Java反序列化利用链:一条readObject从aced0005到Runtime.exec,CommonsCollections六条链逐层解剖

admin 2026-08-27 05:32:55 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入解析Java反序列化利用链的构造逻辑,从入口、跳板到sink的三段式结构,以CommonsCollections链为例,详细剖析CC1至CC7的差异与适用条件,指出CC6是实战最通用的链。同时探讨sink替换技术如TemplatesImpl字节码加载和JNDI注入。提供面对新目标的三步排查法:确认入口、摸清类路径、选链。核心结论是理解链的构造逻辑比记忆payload更重要。 综合评分: 100 文章分类: 漏洞分析,web安全,红队,渗透测试,ctf


Java反序列化利用链:一条readObject从aced 0005到Runtime.exec,CommonsCollections六条链逐层解剖

原创

Z0安全 Z0安全

Z0安全

2026年8月26日 16:20 山东

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

0x0 前言

Java 反序列化这个话题快被写烂了,但每次真到实战——审计一个老系统的接口、翻一个中间件的补丁、打 CTF 里那道 deserialize 题——大多数人还是卡在同一个地方:知道有洞,不知道链怎么串。

原因不复杂。Java 反序列化不是一个漏洞,它是一类漏洞的构造方法。漏洞本身往往只是某个入口调了一次 readObject(),真正决定能不能打到 RCE 的,是手里有没有一条能把“反序列化”这个动作翻译成“命令执行”的通路,也就是利用链。

这篇文章不打算重复贴 ysoserial 的完整 payload,那些网上都是。我想讲的是利用链的构造逻辑:一条链是怎么被设计出来的,从 sink 反推到入口,CommonsCollections 那几条经典链每一条为什么长那样、绕的是什么、什么时候会失效。看完你最好能自己对着一个新目标,判断它到底能不能打、要打哪条链。

0x1 反序列化到底在触发什么

先把机制钉死,后面所有链都建立在这上面。

Java 对象序列化后是一段字节流,开头两个字节是魔数 0xAC ED,接着两个字节是版本 00 05。所以流量里看到 aced 0005 开头的数据,基本就能断定这是一段 Java 序列化对象——这也是 WAF 检测 Java 反序列化攻击最常用的特征,防御那节还会细说。

反序列化是把这段字节流重新还原成内存里的对象。关键点在于:这个还原过程会触发对象自己定义的一些方法。ObjectInputStream.readObject() 在还原对象时,如果这个对象的类重写了 readObject(),就会调它;同理还有 readResolve()readExternal()finalize(),以及 hashCode()equals()compareTo() 这些可能被间接触发的普通方法。

这些被自动调用的方法,就是利用链的入口。攻击者要做的,是找一个重写了入口方法的类,塞进序列化流里,让反序列化过程一路调下去,最终抵达执行命令的地方。

所以 Java 反序列化攻击的本质就一句话:用一条对象引用链,把入口方法和危险方法串起来。入口往往无害,比如一个 Map 的 readObject;危险方法才是目标,比如 Runtime.exec、TemplatesImpl 的字节码加载、JNDI 的 lookup。中间那段跳板,才是整个利用链技术的全部精髓。

0x2 一条链的骨架

任何一条 Java 反序列化利用链,都能拆成三段:入口、跳板、sink。

入口是一个在反序列化时会被自动调用、且会去调别的方法的类。HashMap.readObject() 里会对每个 key 调 hashCode()HashSet.readObject() 会调 map.put()PriorityQueue.readObject() 会调 heapify() 进而调 compareTo()。这些类 JDK 自带,不可控的部分少,但它们“调用下一个方法”这个动作,就是整条链的发动机。

跳板是核心。入口类调用 hashCode(),你没法让它直接变成 Runtime.exec(),因为 hashCode() 是写死的。但你可以让 hashCode() 指向一个你自己控制的对象的 hashCode()——这个对象可以是任何类。攻击者的自由发挥空间全在这里:通过动态代理、反射调用、比较器、Transformer 这些机制,把“一个普通方法调用”改写成“任意方法调用”。

sink 是链的终点,真正造成危害的那一次调用。最常见的是 Runtime.getRuntime().exec(cmd),进阶一点是 ProcessBuilder,再进阶是 TemplatesImpl.newTransformer()——直接加载攻击者构造的字节码,全程不碰 Runtime——以及 InitialContext.lookup() 这种 JNDI 注入。

理解了这个三段式,再看任何一条链都是同一个套路,只是入口和跳板换了花样。下面拿最经典的 CommonsCollections 链开刀。

0x3 CommonsCollections:一次“合法API”的连环翻车

CommonsCollections 是 Apache 出的一个很老的 Java 工具库,提供了 Transformer、LazyMap、TransformedMap 这些装饰器类。这些类设计初衷是简化集合操作,结果每一个都成了利用链里现成的跳板零件。

先看最要命的一个类,InvokerTransformer。它的作用是:给一个对象,反射调用它的某个方法。

public Object transform(Object input) {     // 反射调用 input 的 methodName 方法,参数 iArgs     Method m = input.getClass().getMethod(this.iMethodName, ...);     return m.invoke(input, this.iArgs); }

单看这个类没什么,但它把“方法调用”这个动作对象化了。平时你要执行命令得写 Runtime.getRuntime().exec(cmd),有了它,你可以把这一步拆成一个个 Transformer,像流水线一样串起来。

Runtime.getRuntime().exec(cmd) 这句话,用反射完整写出来是这样:

Runtime.class     .getMethod("getRuntime").invoke(null)      // 拿到 Runtime 单例     .getClass().getMethod("exec", String.class).invoke(instance, cmd)

用 Transformer 流水线表达,就是四个零件:

ConstantTransformer.transform() 无视输入、永远返回固定值 Runtime.class,它是整条链的引信。ChainedTransformer 把前一个的输出喂给后一个的输入。四个零件一拼,Runtime.exec 就从一个写死的静态调用,变成了一段可以被搬进反序列化流程里的数据。

链拼好了,还需要一个“在反序列化时自动调 transform”的触发者,LazyMap 干的就是这个。

LazyMap 包装一个真实的 Map,当取一个不存在的 key 时,会调构造时传入的 factory.transform(key)。把 factory 设成上面的 ChainedTransformer,那么只要有人对这张 LazyMap 调一次 get() 并且 key 不在里面,整条命令执行链就点燃了。

到这里,问题收敛成一个更小的点:怎么让反序列化过程对这张 LazyMap 调一次 get()

答案藏在 AnnotationInvocationHandler 里,这是 JDK 内部处理注解的类。它的 readObject() 在反序列化时,会对注解里的成员 Map 调 entrySet()

private void readObject(ObjectInputStream s) { &nbsp; &nbsp; // ... &nbsp; &nbsp; Map<String, Class<?>> memberTypes = annotationType.memberTypes(); &nbsp; &nbsp; for (Map.Entry<String, Object> member : memberValues.entrySet()) { &nbsp; &nbsp; &nbsp; &nbsp; // ... &nbsp; &nbsp; } }

注意 memberValues.entrySet() 这一步。如果 memberValues 不是普通 Map,而是一个动态代理对象——代理 Map 接口——那这个 entrySet() 调用就会被代理的 InvocationHandler 截获。把这个 Handler 的 invoke() 引导到对 LazyMap 的 get() 调用,entrySet 这个方法名本身就可以当作那个“不存在的 key”传进去。LazyMap 里当然没有 entrySet 这个 key,于是 factory.transform("entrySet") 被触发,整条链点火。

把上面串起来:AnnotationInvocationHandler.readObject → 代理截获 entrySet → LazyMap.get("entrySet") → ChainedTransformer → Runtime.exec。这就是 CommonsCollections1,也就是 CC1,2015 年被公开后引爆了整个 Java 反序列化攻击的研究浪潮。它可怕的地方在于:用的全是 JDK 和 CommonsCollections 里合法存在的 API,没有一处是漏洞代码,但拼在一起就是 RCE。

CC1 依赖两个前提:JDK 里 AnnotationInvocationHandler 会调 entrySet(),以及 CommonsCollections 3.x 的 InvokerTransformer 没做任何限制。这两条哪条断了,链就废。

2016 年 1 月,Oracle 在 JDK 8u71 里把 AnnotationInvocationHandler.readObject() 改掉了,不再遍历 memberValues.entrySet()。依赖这个入口的 CC1 直接失效。安全圈的反应不是算了,而是开始找不依赖这个入口的新跳板,于是 CC2 到 CC7 一条条冒出来。

这些链的差异,本质上就是入口和跳板换了什么零件:

  • CC1

    :AnnotationInvocationHandler 入口,LazyMap + ChainedTransformer 跳板,依赖 CC 3.x。JDK 8u71 封了 readObject 入口后失效。

  • CC2

    :PriorityQueue 入口,TransformingComparator 跳板,依赖 CC 4.x。环境里没 CC4 就废。

  • CC3

    :入口同 CC1,但 sink 换成 TemplatesImpl 字节码加载,依赖 CC 3.x。入口被禁后换 CC2 的入口能复活。

  • CC4

    :PriorityQueue 入口,CC2 跳板加字节码,依赖 CC 4.x。

  • CC5

    :BadAttributeValueExpException 入口,TiedMapEntry + LazyMap 跳板,依赖 CC 3.x,8u71 后仍可用。

  • CC6

    :HashSet 入口,TiedMapEntry + LazyMap 跳板,依赖 CC 3.x,JDK 8 全版本通吃,目前实战最通用。

  • CC7

    :Hashtable 入口,靠哈希冲突触发 LazyMap,依赖 CC 3.x,触发条件苛刻,少见。

CC6 值得单独说,因为它是实战里用得最多的。它不碰 AnnotationInvocationHandler,入口换成 HashSet.readObject()——HashSet 反序列化时会把元素塞进内部 HashMap,而 HashMap 会对 key 调 hashCode()。中间用 TiedMapEntry 把 hashCode() 转发成 getValue(),进而转发成 LazyMap.get()。于是:

HashSet、HashMap、TiedMapEntry(CommonsCollections 3.x 的类)都不受 JDK 8u71 影响,所以 CC6 是 JDK 8 通吃的通用链。实战里遇到一个不确定环境版本的目标,第一反应就该试 CC6。

0x4 换个姿势执行命令

前面所有链的终点都是 Runtime.exec,但实战里这个终点经常被卡。Runtime 是 WAF 和 RASP 重点监控的对象,黑名单里躺得最多的就是它。所以利用链研究的下半场,全在 sink 的替换上。

TemplatesImpl 是 JAXP 里的 XSLT 模板类,有个特性:它的 newTransformer() 方法会加载并实例化类内部字段 _bytecodes 里保存的字节码。也就是说,只要能在序列化对象里塞进一个自制恶意类的字节码,再触发 TemplatesImpl.newTransformer(),就能加载任意类——恶意类的静态初始化块或构造器里写的命令执行代码,会在类加载那一刻跑起来。

这条路的好处是:整条链从入口到终点,没有一次 Runtime 调用,黑名单完全失效。CC3 就是拿这个 sink 替换掉 CC1 里 Runtime.exec 那段。代价是攻击者得先自己编译一个恶意类、把 class 字节塞进 payload,payload 体积会膨胀不少。

Runtime.exec 还有个更隐蔽的问题:它不是 shell。传 exec("ls -la") 这种带参数的命令,在一些场景下会被当成一个整体文件名去执行,直接报错。要执行带参数的命令得用 exec(new String[]{"/bin/bash","-c","ls -la"}) 这种数组形式,或者干脆换成 ProcessBuilder。ProcessBuilder 接受字符串列表,处理参数更干净,黑名单命中率也比 Runtime 低一截。ysoserial 里多数字段型的链,sink 处都提供了 ProcessBuilder 的变体。

InitialContext.lookup(url) 是另一类 sink。它把控制流引到 JNDI 服务,历史上配合 RMI/LDAP 服务端返回的恶意对象引用,实现远程加载类式的 RCE,Log4j2 的 JNDI 注入就是这条路的极端放大。这类 sink 不需要本地有可利用的 gadget,但依赖出网、依赖远程服务,JDK 高版本又加了 trustURLCodebase=false 限制,所以攻击门槛和适用面一直在变。它是 Fastjson、Jackson 这类 JSON 库反序列化漏洞的常见终点,和原生序列化链是两条平行线,但 sink 思想完全一致。

0x5 对着新目标找链

看懂经典链只是第一步,实战价值在于拿到一个疑似有反序列化入口的目标时,能自己判断和找链。思路固定,三步。

第一步,确认入口。目标到底调的是 readObject(),还是只反序列化了 JSON/XML。原生 Java 反序列化看 ObjectInputStream.readObject,Fastjson 看 JSON.parseObject,XStream 看 fromXML,Jackson 看 readValue。入口不同,能用的链库完全不同,别混着找。

第二步,摸清类路径。一条链能不能通,取决于环境里有没有那几颗跳板零件。CommonsCollections 的版本(3.x 还是 4.x)、有没有 commons-beanutils、有没有 Spring、JDK 版本多少。审计本地环境直接看依赖清单;打远程目标,往往靠报错信息、指纹、或者干脆一条条试。ysoserial 的每个 payload 都标注了依赖,先列清单再对号入座。

第三步,sink 反推。这是自己找链的核心方法,别从入口往下猜,从 sink 往上追:假设我要执行命令,哪些方法能当 sink——execnewTransformerlookupProcessBuilder.start、JNDI、甚至写文件的 FileOutputStream;这些 sink 所在的对象,能被哪些方法调用;再往上,这些方法所在的类,哪个能被反序列化自动触发。一路反推到 readObject,中间每一环都必须是环境里真实存在的类。gadgetinspector、JNDIExploit 这些工具干的就是从 sink 反推调用链的自动化,手工找的思路和它们一致。

这个反推过程很枯燥,但它是区分“会用 ysoserial”和“能自己挖链”的分界线。CTF 里给了源码的 Java deserialize 题,标准解法就是对着 jar 反编译,从 exec 反推一条链出来。

0x6 检测与防御

防 Java 反序列化没有银弹,但有几件事做与不做差别很大。

源头就不该反序列化不可信数据,这是唯一治本的办法。对外接口如果一定要接收序列化对象,用 JSON、Protobuf 这类不带魔法方法的格式替代原生 Java 序列化,问题直接消失。

如果必须用原生序列化,就上过滤器。JDK 9 引入了 ObjectInputFilter,可以按类名白名单/黑名单拦截反序列化过程中的类加载。把白名单收窄到业务实际用到的几个类,攻击者塞进来的 InvokerTransformerTemplatesImpl 这些跳板在加载阶段就会被拦下。老版本 JDK 退而求其次,用 readResolve 校验,或者对序列化数据做签名再反序列化。

流量侧,aced 0005 是黄金特征。反序列化攻击的 payload 在 HTTP 请求体、Cookie、甚至是序列化 RPC 协议的字段里,开头基本都是 ac ed 00 05。WAF 规则盯住这几个字节能拦住一大票盲打。但注意,这只是特征匹配,绕过也容易——加密、压缩、自定义协议头都能绕——所以流量检测只能当最后一道线,不能当主力。

纵深上,限制出网、限制类加载。JNDI 注入、远程加载字节码这类 sink 依赖目标出网或加载远程 class,把出网策略收紧、JDK 升到 trustURLCodebase=false 的版本,能从根上废掉一大类利用链。

0x7 参考资源

  • ysoserial:Java 反序列化 payload 生成工具,所有经典链的实现都在里面,读源码是最好的学习材料
  • gadgetinspector:字节码级的 gadget 链自动发现工具,理解从 sink 反推思路的绝佳参考
  • CommonsCollections 3.x / 4.x 源码:InvokerTransformer、LazyMap、TiedMapEntry 这些跳板零件的原始实现
  • JDK 8u71 release notes:AnnotationInvocationHandler 反序列化修复的官方说明
  • Apache Commons Collections 安全公告:官方对反序列化 RCE 的定性——这不是 bug,是特性被滥用
  • Fastjson、Shiro、XStream 的历史安全公告:JNDI sink 在不同框架里的复用脉络

点个三连吧,谢谢师傅~


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Z0安全 Z0安全 Z0安全《Java反序列化利用链:一条readObject从aced 0005到Runtime.exec,CommonsCollections六条链逐层解剖》

评论:0   参与:  0