首页 / 资讯中心 / 文章详情

手搓Java反序列化Gadget链:从CC2到CC5的漏洞排查实战

手搓Java反序列化Gadget链:从CC2到CC5的漏洞排查实战 ★ FEATURED ARTICLE
反序列化漏洞的排查我做了不少但真正让我对 Java 安全体系有整体认知的还是手搓 Gadget 链那段时间。当时公司一个内部系统做上线前评估代码审计扫出一处ObjectInputStream.readObject()的调用点输入流直接对接了外网请求。一开始团队的想法很简单加个 WAF 规则、黑掉几个危险类就收工。结果我拿着 ysoserial 一把梭发现绕不绕得过全靠运气。真正把这些 Payload 解构了一遍、自己照着反射、成员变量、类传入这些机制手写了一遍 CC2、CC4、CC5 之后才明白一个道理不懂链子是怎么搭起来的防御永远是被动的。这篇内容适合三类人正在学 Java 反序列化原理的安全工程师想把 CC 链从“会用工具”变成“能自己写”的 CTF 选手以及想搞清楚readObject()为什么这么危险的 Java 开发者。我会直接从攻击链路的核心机制讲起把手搓代码的细节一层层拆开告诉你 CC2、CC4、CC5 各自的入口、中转、触发条件以及我在复现过程中踩过的版本坑和 JDK 差异坑。1. 反序列化与Gadget链readObject背后藏着一座冰山1.1 Java序列化与反序列化的本质要理解 Gadget 链得先搞清楚 Java 序列化到底做了什么。序列化就是把对象变成字节流反序列化就是把字节流还原成对象看起来只是个持久化/传输机制核心方法就两个ObjectOutputStream.writeObject()和ObjectInputStream.readObject()。问题出在一个细节上Java 反序列化不仅会还原字段值还会在适当的时候自动调用对象的readObject()方法而readObject()是开发者可以自己重写的。很多类在重写readObject()时会对反序列化得到的字段数据做一些“初始化”动作。比如PriorityQueue会在readObject()里重新构建堆结构过程中会调用比较器对元素做排序比较HashMap会在readObject()里重新计算哈希过程中会调用hashCode()。这些看似正常的“重建对象”逻辑如果里面流转的数据被攻击者完全控制就会变成一条通往任意代码执行的通道。我在实际排查中最深的感受是反序列化漏洞的本质不是“对象还原”这个动作本身危险而是对象还原触发的后续调用不可控。攻击者只需要精心构造一个对象图让readObject()在恢复过程中自动触发一系列方法调用最终落到Runtime.exec()、TemplatesImpl.newTransformer()这类危险方法上。这一系列自动触发的方法调用就是所谓的“调用链”。1.2 Gadget链把“可控数据”翻译成“危险动作”的编译器Gadget 这个词直译是“小工具”但在 Java 安全的语境里它指的是一条完整的、从入口函数到危险函数的顺序调用关系。你可以把整条链子想象成一个多米诺骨牌阵readObject()推倒第一张牌PriorityQueue的堆排序方法推倒第二张TransformingComparator.compare()推倒第三张最后InvokerTransformer.transform()反射调用了exec()整条链子结束命令也执行了。手搓链子的过程就是在公共库代码里找到这些能“接得上”的牌。为什么 Apache Commons Collections 里的Transformer系列这么出名因为InvokerTransformer这个类本身就能通过反射调用任意方法它天然就是链子最后那一张能打到危险函数的牌而ChainedTransformer能把多个 Transformer 串起来让链子有多个“换手”的节点。加上 Java 本身的多态特性——接口的实现类可以在运行时被替换攻击者就能把原本人畜无害的排序比较、日志记录动作替换成恶意 Transformer 的执行路径。这里我要特别说明一下“类传入”这个概念标题里这个词很容易被忽略。手写链子的时候你不是在写一段普通的 Java 代码而是在设计一个将来会由readObject()“自己执行”的对象图。这意味着你没办法像正常编程那样直接new一个Runtime对象然后调用方法而是要巧妙地让某个类的构造函数接受你的恶意类对象再把恶意类对象“传入”后续调用链的上下文里。比如ConstantTransformer(TrAXFilter.class)就是把TrAXFilter的 Class 对象传入链子InstantiateTransformer接收的ctorArgs里塞进一个TemplatesImpl实例——这些都是“类传入”的具体手法。2. 手搓链的必修课反射、成员变量与动态代理2.1 反射调用的三个核心APIGadget 链里反复出现反射调用很多人把它理解成“通过字符串拿到方法再 invoke”但实际操作中细节远比这句话多。我用得最多的三个反射 API 是Class.forName()或obj.getClass().getName()拿到 Class 对象这是反射的起点。Method.invoke()真正触发方法调用。关键点是静态方法调用时第一个参数传 null非静态方法要传目标对象实例。Field.setAccessible(true)绕过 Java 访问控制让你能修改private字段的值。这三者组合起来就构成了标题里“反射调用”和“成员变量”两个关键词的核心操作。以 CC 链里最经典的InvokerTransformer为例它的transform()方法内部就是一行反射调用逻辑根据构造时传入的方法名、参数类型、参数值对任意对象调用任意方法。攻击者只需要让前一个 Transformer 产出一个Runtime对象后面的InvokerTransformer就能反射调用getRuntime()、invoke()、exec()三步操作直接执行系统命令。2.2 为什么必须修改成员变量这是手搓链子最容易卡住的地方。许多类在构造时并不会接收攻击者的恶意对象作为参数或者即使接收了在构造函数里也会做类型检查、初始化之类的事情导致你没法直接把恶意对象塞进去。解决方案就是反射修改成员变量。举个例子CC5 链的入口BadAttributeValueExpException它的构造函数只接收一个 String 类型的val参数正常情况下根本没办法把一个TiedMapEntry后续链条的关键对象传进去。但它的readObject()方法会把val字段当成 Object 来调用toString()。我手写链子的时候就是用Field field BadAttributeValueExpException.class.getDeclaredField(val); field.setAccessible(true); field.set(obj, tiedMapEntry);把恶意对象强行塞进了私有字段里。类似的还有TiedMapEntry的map和key字段LazyMap的factory字段。手写任何一条链子之前先要把目标对象的字段清单列出来确认哪些字段是构造时可以传的、哪些必须靠反射改。这一步做扎实了后面链路才能接得上。2.3 动态代理链子里的“隐身中转站”CC 链里还有一个关键机制是动态代理尤其 CC1、CC3 里会出现AnnotationInvocationHandler配合Proxy的操作。动态代理的本质是运行时生成一个实现指定接口的代理类所有接口方法调用都会被转发到InvocationHandler.invoke()方法上。攻击者可以利用这一点把一个本来调用 A 方法的动作暗中替换成 B 方法甚至整条 Transformer 链的执行。不过 CC2、CC4、CC5 这三条链子相对 CC1 来说没那么依赖动态代理核心动作集中在 PriorityQueue 的排序和 BadAttributeValueExpException 的toString()上。但理解动态代理对读懂完整 CC 家族非常重要因为很多现代反序列化链比如 CommonsBeanutils 链都要靠代理对象来“桥接”不同的接口调用。我在复现 CC5 时曾经卡了很久就是因为不清楚BadAttributeValueExpException.readObject()里的valObj.toString()是怎么一步步跳到LazyMap.get()的后来画完调用关系才发现中间是TiedMapEntry.toString()做了中转。3. CC2链从零手搓PriorityQueue如何成为“入口”3.1 链路全景入口、比较器、Transformer 的分工CC2 链在实战中频繁出现一个原因是它适配 CommonsCollections 4.0 及更高版本另一个原因是它的入口类 PriorityQueue 在 Java 标准库中非常常见黑名单往往不会拦。完整的调用链是这样走的PriorityQueue.readObject()反序列化恢复队列结构。PriorityQueue.heapify()重建堆时对元素排序。PriorityQueue.siftDownUsingComparator()调用比较器执行比较。TransformingComparator.compare()比较两个元素时对元素执行transform()。ChainedTransformer.transform()依次执行内部 Transformer 数组。InvokerTransformer.transform()反射调用Runtime.exec()。这里的关键在于TransformingComparator。它是 Commons Collections 4.0 提供的比较器构造时接收一个 Transformercompare()方法里会把两个比较对象先交给 Transformer 处理再比较结果。在 CC2 中这个 Transformer 是被我精心构造的ChainedTransformer所以“比较两个元素”这个动作就变成了“执行一串恶意代码”。我画了下链路中每个节点的触发条件PriorityQueue 的比较器在反序列化时必须非空数组中必须至少有两个元素否则堆不用调整比较器也不会被触发。这个细节直接决定了手搓 PoC 时该怎么构造队列。我在最初复现时就栽过跟头只放了单个元素进队列结果流式反序列化一点动静都没有。3.2 手写CC2 PoC的关键代码手搓 PoC 的核心思路是先构造一个 ChainTransformer让它能完成 Runtime 的获取、方法调用和命令执行再把它包装成 TransformingComparator最后塞进 PriorityQueue。以下是可直接运行的创建逻辑public static byte[] createCC2Payload(String command) throws Exception { // 构造恶意Transformer链 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[] { command }) }; ChainedTransformer chain new ChainedTransformer(transformers); // 将Transformer包装成比较器 TransformingComparator comparator new TransformingComparator(chain); // 构造优先级队列加入两个元素以触发比较器 PriorityQueue queue new PriorityQueue(2, comparator); queue.add(1); queue.add(2); // 序列化输出payload ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos); oos.writeObject(queue); oos.close(); return baos.toByteArray(); }三个容易被忽略的细节我一个个说清楚。第一ConstantTransformer(Runtime.class)不是ConstantTransformer(Runtime.getRuntime())。前者传的是 Runtime 类的 Class 对象后者在这里拿到的 Runtime 实例会在序列化时因为无法序列化而报错。链子的任务是把 Class 对象传到后面的 InvokerTransformer再由反射调用getMethod去拿真实的静态方法。第二InvokerTransformer第二个 InvokerTransformer 调invoke时第一个参数传 null这是Method.invoke()要求——因为getRuntime是静态方法静态方法调用不需要对象实例。很多新手在这里传了Runtime.class反而导致参数类型对不上。第三队列元素选择上new PriorityQueue(2, comparator)的初始容量设为 2add两次确保数组里有真实数据。不要往里面传字符串对象因为TransformingComparator.compare()的比较结果还要参与后续堆排序逻辑传字符串在 JDK 里可能出现无法转换 Comparator 结果的错。3.3 序列化过程中链路何时被真正激活手搓完 PoC你最关心的问题一定是链子到底在哪个瞬间被激活要回答这个问题必须同时看两个阶段。第一阶段是序列化时的“预触发”。当你调用queue.add(1)和queue.add(2)时PriorityQueue 会立即执行一次siftUp/siftDown操作此时TransformingComparator.compare()就会被调用Transformer 链已经开始执行了。这会造成什么影响如果你在构造阶段就放入了exec命令在本地生成 payload 的时候命令就已经执行了一次。所以 ysoserial 的 PoC 里往往会先放一个无害的参数占位等到反序列化目标机器上才真正触发。这个“构造即执行”的特性在调试时会给你非常大的干扰要留意区分当前执行到底是本地的还是目标机器的。第二阶段才是反序列化时的真正触发。目标机器调用readObject()PriorityQueue 读取到队列元素和比较器字段随后调用heapify()调整堆结构一路调用到compare()。此时链子完整执行命令在目标环境落地。我把这两个阶段分开强调是因为排查问题时“到底哪一段报错”会直接影响你的判断方向——构造阶段报错一般是序列化兼容问题反序列化阶段报错才是目标环境问题这是两个完全不同的排查域别混在一起。4. CC4、CC5变体换入口、换中转为什么需要这么多版本4.1 CC4黑名单逼出来的“无InvokerTransformer”链CC2 链好用是好用但它依赖InvokerTransformer这个核心类。实际攻防中防守方很容易把InvokerTransformer、chainedTransformer、TransformingComparator这类特征类加进黑名单。黑名单一旦生效CC2 就废了。CC4 的本质就是换掉最后的“执行引擎”继续复用 PriorityQueue 这个入口。CC4 的完整链路是这样PriorityQueue.readObject()入口不变。TransformingComparator.compare()中转逻辑不变。ChainedTransformer.transform()不再直接连 InvokerTransformer而是换成InstantiateTransformer。InstantiateTransformer.newInstance()反射创建TrAXFilter对象传入构造函数的参数是Templates。TrAXFilter构造函数内部会调用Templates.newTransformer()加载字节码并执行。所以 CC4 用到的核心技巧是“类传入”——把TrAXFilter.class通过ConstantTransformer传入把恶意的TemplatesImpl实例通过InstantiateTransformer的ctorArgs传入。TemplatesImpl内部存储了字节码newTransformer()会动态加载并执行其中的静态代码块。这意味着攻击者不必依赖InvokerTransformer的通用反射能力而是通过“实例化一个特定恶意类”来完成代码执行。CC4 在复现时有几个新坑要特别注意。第一TemplatesImpl的_bytecodes字段必须用反射修改因为它的构造函数根本不接收字节码参数。第二_name字段不能为空否则newTransformer()会提前返回 null。第三字节码的静态初始化逻辑要写在类的static {}块里因为TemplatesImpl加载类后会实例化对象静态块最先执行。我用 Java 编译器把命令执行逻辑编译成 class 字节码再 Base64 编码后塞进_bytecodes字段整个过程很像是在手写一个微型类加载器。4.2 CC5把入口换到 BadAttributeValueExpException如果说 CC4 是在“执行引擎”上换牌那 CC5 就是在“入口”上换牌。CC5 不再使用 PriorityQueue而是用javax.management.BadAttributeValueExpException作为反序列化入口适用于 Commons Collections 3.1 到 3.2.1 这个区间在某些 JDK 版本主要 JDK 8u71 之前下非常好用。CC5 的链路可以这样拆BadAttributeValueExpException.readObject()反序列化时读取私有字段val并调用其toString()。TiedMapEntry.toString()这个类的toString()方法会调用get()方法从内部持有的 Map 里取值。TiedMapEntry.get()内部调用了map.get(key)。LazyMap.get()commons-collections 的 LazyMap 在 get 不到值时会调用factory.transform(key)去“生产”一个值。ChainedTransformer.transform()执行 Transformer 数组最终落到命令执行。这个链路可以看出 CC 链的精髓所在恶意动作并不是从一个高危方法直接跳到一个危险方法而是通过“toString - get - transform”这种普通业务方法层层接力完成的。每个节点单独看都是无辜的连在一起就成了武器。CC5 的关键难点在于把TiedMapEntry塞进BadAttributeValueExpException的val字段。BadAttributeValueExpException的字段是私有且没有对应的 setter构造函数只接收字符串。我手写时的操作是先new BadAttributeValueExpException(null)创建对象再用反射拿到val字段并setAccessible(true)最后field.set(exp, tiedMapEntry)。用到的就是标题里说的“反射调用”和“成员变量”双管齐下。4.3 三链对比与版本适配速查表我整理了一张速查表把 CC2、CC4、CC5 的核心差异列了出来实际编码前对着它选型能节省大量调试时间。对比维度CC2CC4CC5反序列化入口PriorityQueue.readObjectPriorityQueue.readObjectBadAttributeValueExpException.readObject入口触发方式堆排序调用 compare堆排序调用 compare恢复字段后调用 toString关键中转类TransformingComparatorTransformingComparatorTiedMapEntry - LazyMap最终触发类InvokerTransformerInstantiateTransformer TrAXFilterChainedTransformer依赖的 CommonsCollections 版本4.04.03.1 至 3.2.1典型适用 JDK7/8 通用7/8 通用JDK 8u71 之前是否依赖 TemplatesImpl否是否黑名单绕过价值一般可绕InvokerTransformer黑名单可绕 PriorityQueue 黑名单特别注意 CC5 的版本区间。commons-collections 3.x 中BadAttributeValueExpException的readObject()会直接调用val.toString()但是 JDK 8u71 之后官方修补了这个入口逻辑会限制只允许String、Date等少数类型进入toString()导致这条链在较新的 JDK 上直接失效。我在 JDK 8u202 环境复现 CC5 时就是因为没注意到这个版本差异花了很长时间才发现根本不是代码写错而是运行时环境改变了入口行为。5. 复现翻车实录手搓Gadget链常见的坑5.1 serialVersionUID不匹配手搓链子经常遇到InvalidClassException: local class incompatible典型的报错内容是serialVersionUID不一致。这是因为 Java 序列化会在字节流里记录类的serialVersionUID反序列化时如果本地类的版本号和字节流里记录的不一致就会直接拒绝还原。排查方向有两个一是确认你引用的 commons-collections 版本和目标环境一致3.x 和 4.x 的TransformingComparator等类的 UID 完全不一样二是用serialver工具比对本地类必要时在调试代码里手动System.out.println(Serializable.class...)打印 UID 做对照。这个坑最迷惑人的地方在于异常可能发生在链子深处的某个类上而不是入口类上所以要看完整异常堆栈不要只看第一行。5.2 transient修饰符导致链路断点Java 序列化机制有一个规则transient修饰的字段不会参与序列化。很多新手手搓链子时忽略了这个规则把一个只在内存中构建好的恶意字段放进去结果反序列化时发现字段是 null链路在这一步直接断掉。最典型的就是TemplatesImpl内部的_bytecodes、_name等字段并不是 transient 的可以正常序列化但某些版本的LazyMap或者TransformingComparator内部字段可能带 transient导致你构造好的对象在传输后丢失关键状态。我的习惯是在写 payload 之前先给链路里每个关键的类加一段反射代码把字段清单全部打印出来逐个检查Modifier.isTransient(field.getModifiers())。这一步看着繁琐实际排查时能省掉几小时的盲试时间。5.3 JDK模块化与依赖版本差异Commons Collections 4.x 和 3.x 的包结构不同4.x 把类的包名从org.apache.commons.collections改成了org.apache.commons.collections4这意味着你引用类名的方式也可能不同。我在第一次手写 CC2 时误用了 3.x 的import结果编译都过不了。更麻烦的是JDK 9 之后引入了模块系统某些javax.management包下的类比如 CC5 用的BadAttributeValueExpException会被限制访问运行时报java.lang.reflect.InaccessibleObjectException。遇到这类问题最直接的解法是在 JVM 启动参数里加--add-exports和--add-opens把对应的包开放给反射访问而不是硬改链子结构。5.4 本地验证环境的搭建建议复现反序列化链子环境隔离是底线。我自己的做法是在一台独立的虚拟机上装一个干净的 JDK 8再用 Maven 指定 Commons Collections 的精确版本全部依赖锁定不做成对外的服务端口。不要在业务服务器上直接跑 PoC 验证代码命令执行的后果是你无法控制的。在本地验证链子时优先把最终的exec命令参数替换成无害的touch或写文件操作确认执行成功后再对特定的命令做一次完整验证并保留每次验证的日志。另一个实用建议是把InvokerTransformer链路的执行点替换成Runtime.printStackTrace这类无副作用的方法先确定链路激活了再换成真正的命令执行这样能很清晰地分离“链路通不通”和“命令执行对不对”两个独立问题。最后分享一个我自己的经验手写 Gadget 链最值得投入精力的部分不是背下来某条链子的代码而是把入口类的readObject()行为、关键是哪些字段会在反序列化时被自动使用、哪些方法调用会产生“受害者可控的上下文”搞清楚。CC2、CC4、CC5 只是这个思考方式在 Commons Collections 上的三个落地案例。我把这个思路用在防御侧也很有效——现在看任何一个 Java Web 系统的readObject()调用点我第一反应不是去搜已知链子而是去链路上找“有没有攻击者可控的字段、有没有能自动触发的重写方法、有没有最终能弹到危险方法上的反射调用”。把这三个问题回答清楚比背一百条已知 Payload 更有用。别把精力浪费在我踩过的坑上希望这份手记能帮你在 Java 安全的路上少绕几圈。
阅读完成 · 觉得有帮助?
咨询建站