Java反序列化漏洞这话题,圈子里聊了快十年,可直到今天,它依然是Java安全里最有讨论度的一条主线。无论是RMI还是消息中间件,不管是老牌Web框架还是云原生环境里的对象存储,只要开发为了方便把对象直接序列化后塞进网络、缓存或文件,反序列化就可能变成入口。而研究这块绕不开的“教材”,就是Apache Commons Collections下的CC链。从CC1开始,安全社区一路迭代出CC2、CC3、CC4、CC5、CC6、CC7等多条链,每条链的诞生,要么是因为上一个入口被封堵,要么是为了适配不同的依赖版本和JDK版本。
这篇文章我想从攻防全景的角度,把这七条链背后的触发逻辑、版本对应关系、实战适用场景和防御思路完整拆开讲一遍。如果你是安全工程师,正在排查线上有没有反序列化风险,这篇可以当作梳理CC链的参考手册;如果你是后端开发,想知道为什么依赖里一个看似普通的工具库会成为漏洞温床,这篇也会给你答案。我尽量不堆理论,直接把关键代码思路、调用路径、版本坑和加固方案都摆到台面上。
1. 反序列化漏洞:Java攻防的核心战场
1.1 先搞懂序列化与反序列化的底层机制
Java的序列化机制本质上是把内存中的对象转成字节流,方便持久化或传输;反序列化则是把这个字节流重新还原成Java对象。这个过程靠的是ObjectOutputStream.writeObject()和ObjectInputStream.readObject()两个方法。表面上看,你只需要让类实现Serializable接口,剩下的交给JDK处理,但背后做的事情比大多数人想象的复杂得多。
当对象被序列化时,写入的不只是字段值,还包括类的元信息、对象图结构、以及serialVersionUID。反序列化时,readObject()会先读取这些元数据,然后通过resolveClass()加载对应的类,再递归地把每个字段恢复出来。也就是说,整张对象图是“可控数据”驱动着“类加载”和“方法调用”的。序列化和反序列化的对端如果有一端不可信,整个过程就变成了一个远程代码执行的潜在入口。
这里有个很容易被忽略的点:readObject()并不仅仅是“还原字段”那么简单。对于实现了readObject()方法的类,JVM在反序列化时会直接调用它。这个方法是程序员自己写的,里面可以有任意逻辑。一个看似无害的类,如果在readObject()里调用了HashMap.containsKey()、Map.get()、toString()这类方法,而方法参数又是反序列化出来的可控对象,那数据流就会顺着这些调用往下游走。这就是所有反序列化漏洞的地基。
我经常用快递来打比方:反序列化收到的字节流就像一个“看似正常的包裹”,但包裹里某个盒子的产品说明书(类定义)背后藏了一个机关。你按照说明书把盒子装好(readObject),装的过程中不小心碰了某个按钮(触发方法),然后整个屋子里的一系列连锁机关就依次启动了。链条长,不代表它不能实现;链条简单,也不代表它好防。核心只在于:不可信数据能否控制某个类的某个字段,进而控制后续的方法调用参数。
1.2 为什么readObject会成为代码执行的入口
反序列化漏洞和普通逻辑漏洞最大的不同在于,普通漏洞通常发生在某个具体业务功能上,攻击者能控制URL、参数、文件路径;而反序列化漏洞发生在“基础能力”层面,攻击者控制的是整个对象图。对象图里可以包含各种类的实例,这些类实例的字段值也都由攻击者构造。于是问题就变成了:在Java庞大的标准库和第三方库里,哪些类在反序列化过程中会调用可控对象的方法?
答案是一大堆。java.util.HashMap在readObject后会重建哈希表,期间要计算每个key的hashCode();java.util.Hashtable在readObject后重建时会调用key.equals();java.util.PriorityQueue在readObject后要恢复堆结构,会调用比较器的compare();JDK里面管理MBean的BadAttributeValueExpException在readObject里会把成员变量转成字符串,触发toString()。这些方法调用看似平平无奇,但只要你把目标对象替换成一个精心构造的类,让它在这些方法里做危险操作,逻辑漏洞就出来了。
真正让问题升级的是Commons Collections这类库。它本身是给开发者做集合增强用的,里面的Transformer接口设计得非常优雅,能实现对集合元素的动态转换。但其中有个InvokerTransformer,可以实现“反射调用任意对象的任意方法”。你再看看LazyMap,它允许在Map.get()时自动执行Transformer链。把这两个机制和前面那些JDK类的反序列化钩子连起来,一条完整的利用链就形成了。
所以要理解CC链,必须抓住两大Things:一个是“入口”,也就是JDK或第三方库里那些反序列化时会主动调用hashCode、equals、toString、compare的类;另一个是“链中段”,也就是Commons Collections里那些能通过方法调用触发后续调用的Transformer组件。前者决定这条链能被谁触发,后者决定触发后往哪里走。
1.3 一条利用链的三要素:入口、链中段与落地执行
搞安全研究的人都喜欢把利用链拆成三段看:入口(entry point)、链中段(gadget chain)、落地执行(sink)。对CC链来说,入口是整个反序列化过程触发第一声枪响的地方,通常是某个类的readObject()方法。链中段则是从入口到最终危险方法之间的一系列调用跳板,典型的如TiedMapEntry、LazyMap、TransformingComparator,它们的共同特点是在看似普通的方法调用中夹带了后续的转换逻辑。落地执行则是真正达到目的的地方,对CC链来说一般有两种:一种是InvokerTransformer通过反射直接调用Runtime.exec()执行系统命令,另一种是通过TemplatesImpl动态加载字节码。
这里有个容易混淆的概念:三段并不是固定由某个库里的类独揽的。入口可能来自JDK,比如AnnotationInvocationHandler、BadAttributeValueExpException;链中段可能来自Commons Collections,也可能来自JDK内部的TiedMapEntry(它在java.util里);落地执行既可以用Commons Collections的Transformer,也可以用JDK自带的TemplatesImpl。所以CC链并不等于“Commons Collections漏洞”,而是“Commons Collections为链中段提供了关键组件”的简称。
理解了这三要素,你再看网上各种链的调用路径就不会发懵。比如CC1写的是AnnotationInvocationHandler.readObject() -> entrySet() -> LazyMap.get() -> ChainedTransformer.transform(),本质就是用入口类的反序列化钩子,通过代理机制把方法调用导向LazyMap,再让LazyMap触发Transformer链,最后落地到命令执行。链有多长不重要,重要的是每一段调用都在可控路径上。后面所有CC2到CC7,无非是把这三段里的某一环替换成更稳定、更通用、或者能绕过过滤器的组合。
2. CC1到CC7:七条利用链的演进逻辑
2.1 CC1:最经典的入口与Transformer体系开端
CC1是第一波被系统性公开的Commons Collections利用链,它回答了“如何在反序列化中获得命令执行”这个问题。核心组件有四个:AnnotationInvocationHandler、LazyMap、ChainedTransformer和InvokerTransformer。
先看触发方式。AnnotationInvocationHandler是JDK里处理注解的类,它实现了InvocationHandler接口,而且自己实现了readObject()。在早期JDK版本里,readObject()反序列化时会读取一个Map类型的成员变量,然后调用entrySet();由于该类本身是个动态代理处理器,这个类的实例可以被代理包装后使用,而代理的方法调用会统一进入它的invoke()方法。攻击者构造一个动态代理,让代理的处理器指向AnnotationInvocationHandler,代理的接口是Map,这样反序列化时调用entrySet()就会进入invoke(),进而触发被包装的LazyMap.get()。
LazyMap的特点是当你调用get(key)且key不存在时,会执行factory.transform(key)。这个factory就是攻击者塞进去的Transformer链。Transformer链的关键是InvokerTransformer,它能通过反射调用任意对象的任意方法,方法和参数都写在Transformer的字段里。ConstantTransformer负责提供固定对象,比如先返回Runtime.class,然后用InvokerTransformer调用它的getRuntime方法,再调用exec。把三步拼成一个数组,包进ChainedTransformer,就完成了从“触发transform”到“命令执行”的全程。
CC1的经典不止在于它是第一条链,更在于它展示了两个关键技术:动态代理和Transformer链。动态代理让攻击者可以“截获”任何接口的方法调用,Transformer链则提供了一个任意方法反射调用的通用通道。这两个技术思想在后续所有版本里被反复使用。操作前提是要有一个可控的InvocationHandler实例被反序列化,而JDK自身的AnnotationInvocationHandler正好满足条件,于是Commons Collections 3.x和旧版JDK组合出了这条经典链。
它的局限也很明显:AnnotationInvocationHandler在JDK 8u71之后的版本里被修改了,不再直接从成员变量取值并调用entrySet(),导致入口失效。想在一个JDK 8u202的项目上用CC1,基本没戏。这也是为什么后面会出现那么多替代链。
2.2 CC2到CC4:入口更换与字节码加载的两条分支
CC2这条链代表了另一个思路:既然JDK内部的入口类被封堵了,那就用Commons Collections 4.x里自带的东西做入口。CC2的核心是PriorityQueue。这个类非常有意思,它的readObject()在反序列化时会恢复二叉堆结构,每调整一次堆都要调用比较器的compare()。攻击者把比较器替换成TransformingComparator,这个类在compare()里会对传入的两个对象执行transform()。于是入口从“代理截获”变成了“堆排序时触发比较器”,链条一下简单了很多。
CC2的落地执行依然可以用InvokerTransformer,因为它面向的是Commons Collections 4.x,而4.x在很长一段时间里没有对InvokerTransformer做限制。CC2的好处是它的入口不依赖JDK内部类,所以在各个JDK版本上都能跑;坏处是它要求项目引入的是Commons Collections 4.x,而不是经典的3.x。很多老项目用的是3.x系列,这就逼出了后面的分支。
CC3是另一个方向,目标是把落地执行换掉。InvokerTransformer这类反射调用很容易被安全产品通过类名和方法名特征拦掉,于是研究者找到了TemplatesImpl。这是JDK内部用于把字节码转换成模板类的类,它的newTransformer()内部会加载字节码并实例化类。如果攻击者能把一段恶意的字节码塞进TemplatesImpl的字段里,再想办法让它调用到newTransformer(),就能实现任意类加载。
CC3的巧妙之处在于用TrAXFilter做了一个“构造器触发”。TrAXFilter在实例化时会调用传入的Templates对象的newTransformer()方法。也就是说,你不一定要调用某个方法,你只要让某段逻辑去new TrAXFilter(templatesImpl),代码就自己跑起来了。前面再用InstantiateTransformer来通过反射执行这个构造器。放到整条链里看,CC3就是“LazyMap触发 -> InstantiateTransformer实例化TrAXFilter -> TrAXFilter构造器调用TemplatesImpl.newTransformer -> 字节码被加载执行”。
CC4则是对CC2和CC3的混合:入口用PriorityQueue加TransformingComparator,落地执行用TrAXFilter和TemplatesImpl。它和CC2的区别只是把路径从“用InvokerTransformer反射调用exec”换成“用字节码加载执行任意类”。听起来差别不大,但在绕过过滤时往往很关键,因为很多防护产品对Runtime.exec的特征字符串特别敏感,却很少对TemplatesImpl做深入检测。
这里总结一下前四条链:CC1是入门教科书,CC2是不依赖JDK入口的替代品,CC3是字节码加载的新执行方式,CC4是CC2与CC3的杂交。你如果把这四条链都跑通,基本就掌握了CC链的两个核心变量:入口怎么触发,落地怎么执行。
2.3 CC5到CC7:面向新JDK的稳定化迭代
CC5到CC7出现的背景是:JDK 8u71封了CC1,JDK内部类要么被修要么不稳定;Commons Collections 3.x又是主流依赖,不能只靠CC2。于是研究者开始寻找更多JDK自带的、反序列化时会触发方法调用的类。
CC5用的是BadAttributeValueExpException。这个类在反序列化时会对成员变量调用toString()。听上去很普通,但配合TiedMapEntry就很不一样了。TiedMapEntry是JDKjava.util里的类,它实现了Map.Entry接口,toString()方法会调用getKey()和getValue(),而getValue()会直接调用绑定的Map对象的get(key)。这里再塞一个LazyMap,就实现了“反序列化 -> 触发toString -> 触发TiedMapEntry.getValue -> 触发LazyMap.get -> 触发Transformer链 -> 落地执行”的完整路径。
CC5的兼容性比CC1好很多,但也不是全版本通用:BadAttributeValueExpException在后续JDK版本里修改了readObject()的实现,不再无条件调用toString(),所以它只能在某个JDK版本区间内使用。这就有了CC6。
CC6可以称得上是最通用的CC链,也是我在实际验证场景里最常用的一条。它的入口是HashMap。HashMap的readObject()在恢复键值对时会调用hash(key),而hash()会调用key的hashCode()。攻击者把key设成TiedMapEntry对象,把value设成一个无关值。TiedMapEntry.hashCode()会调getValue(),getValue()会调LazyMap.get(),LazyMap.get()触发Transformer链。于是整个触发过程完全由JDK的HashMap和TiedMapEntry完成,不依赖任何夏季补丁的JDK内部类,所以从JDK 7到JDK 17都能稳定复现,只是高版本JDK需要配合调整某些细节。
CC7则是另一种思路,利用Hashtable的反序列化恢复机制。Hashtable.readObject()里有个reconstitutionPut()方法,它会先计算key的hashCode(),然后在已有槽位里对相同索引的key调用equals()。攻击者构造两个key,一个普通字符串一个LazyMap对象,让它们的hashCode一致,反序列化时就会触发hashtableKey.equals(lazyMapKey)。这里的关键是让equals()的执行路径进入LazyMap的逻辑,最简单的办法是把字符串key去掉,直接用两个不同的LazyMap实例做碰撞,让前一个LazyMap作为equals的调用方,在它的equals里调用get()取另一个LazyMap的值,从而触发Transformer链。CC7比CC6更隐蔽,因为它是通过equals而不是hashCode起爆,在排查和流量检测上更难被泛化规则匹配。
到这里,七条链各自的特征就清楚了:CC1和CC3属于旧版JDK经典组合,适合理解原理;CC2和CC4属于Commons Collections 4.x分支,适合新库环境;CC5和CC6属于JDK新版本稳定分支,CC6是实战最常用;CC7是equals触发的偏门分支,适合给检测规则“添堵”。
2.4 七条链的横向对比速查
整理一份横向对比表,方便你平时参考:
| 链 | 适用依赖 | 入口类 | 关键触发点 | 落地执行 | 稳定性 |
|---|---|---|---|---|---|
| CC1 | CC 3.x + JDK 7/8u71前 | AnnotationInvocationHandler | 动态代理 + LazyMap.get | InvokerTransformer | 低,受JDK版本限制 |
| CC2 | CC 4.x | PriorityQueue | TransformingComparator.compare | InvokerTransformer | 中,不依赖JDK修改变量 |
| CC3 | CC 3.x + JDK 7/8u71前 | AnnotationInvocationHandler | TrAXFilter构造器 + TemplatesImpl | 字节码加载 | 低,但要配稳定JDK |
| CC4 | CC 4.x | PriorityQueue | TransformingComparator + TrAXFilter | 字节码加载 | 中 |
| CC5 | CC 3.x + JDK 8u76前 | BadAttributeValueExpException | TiedMapEntry.toString + LazyMap.get | InvokerTransformer | 中 |
| CC6 | CC 3.x/4.x + 主流JDK | HashMap/HashSet | TiedMapEntry.hashCode + LazyMap.get | InvokerTransformer | 高,实战最常用 |
| CC7 | CC 3.x/4.x + 主流JDK | Hashtable | reconstitutionPut中equals触发 | InvokerTransformer | 高,偏门但稳定 |
这份表引出一个重要观点:没有哪条链是“万能钥匙”。你要看项目里Commons Collections的版本、JDK的版本以及可能存在的过滤规则,才能决定用哪条链最合适。做防御的人也要反过来想:项目里如果同时存在多条链的可行条件,就不能只封一条特征。
3. 实操环节:从环境搭建到调试验证
3.1 环境准备:JDK、库版本与依赖配置
复现CC链的第一步是把环境搭对,版本不对,你会浪费大量时间还找不到原因。我建议用Maven工程来搭建隔离的验证环境,核心就是准确控制JDK版本和Commons Collections版本。
需要准备两套环境。一套跑CC1、CC3,用JDK 8u66左右,或者更保险的JDK 7,配合Commons Collections 3.1或3.2.1。另一套跑CC2、CC4,用普通JDK 8或11,把Commons Collections换成4.0。CC6和CC7则可以用JDK 8到17的任意版本,配合Commons Collections 3.2.1或4.0都行。这里有个容易搞错的点:Commons Collections 3.x的包名是org.apache.commons.collections.*,4.x的包名是org.apache.commons.collections4.*,两者不兼容,同时放在classpath里极易出问题。我用过一次两个都引入的工程,结果TransformedMap和LazyMap出现了两个同名类,编译能过但运行时类加载全靠运气。
pom.xml里最核心的配置就两行:
<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency>要测4.x就换成:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-collections4</artifactId> <version>4.0</version> </dependency>我建议用Docker来跑老JDK环境,省的本地装一堆老版本影响其他项目。拉一个JDK 8u66的基础镜像就能轻松复现CC1和CC3。如果团队内部没有现成镜像,用centos:7自己加JDK也很快。
3.2 Transformer链机制:先理解链的中段执行逻辑
正式开始复现前,我建议先做一个笨但有效的小实验:单独验证Transformer链的执行机制,不掺和反序列化。这样你能清楚地看到ChainedTransformer到底是怎么把一个个Transformer串起来的。
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 TransformerDemo { public static void main(String[] args) throws Exception { Transformer[] transformers = new Transformer[] { new ConstantTransformer("hello"), new InvokerTransformer("toUpperCase", new Class[0], new Object[0]) }; ChainedTransformer chain = new ChainedTransformer(transformers); Object result = chain.transform(null); System.out.println(result); } }这段代码的输出是HELLO。它做了什么事情?首先ConstantTransformer.transform()无论入参是什么都返回固定对象"hello",然后toUpperCase被InvokerTransformer通过反射调用,返回值成了新的结果。这个结果又被当作下一次transform()的入参。所以ChainedTransformer的语义就是:把上一次的输出作为下一次的输入,依次执行数组里的所有Transformer。
理解这段代码,你就理解了CC链的“中段发动机”。真正的攻击里,ConstantTransformer返回的可能是Runtime.class,InvokerTransformer调用的可能是getRuntime和exec,但机制完全一样。这里我只是演示机制,不演示攻击效果。请在隔离环境里学习,别拿生产环境玩。
这个演示的另一个价值是帮你理解“为什么InvokerTransformer这么危险”。它接收三个参数:方法名、参数类型数组、参数值数组。这三个参数在反序列化场景下都是由攻击者控制的。也就是说,攻击者可以说:“把所有对象当作java.lang.Runtime,调用它的exec方法,参数为/bin/sh -c ...”。如果不对此做限制,任何反序列化出来的对象只要有机会被transform到,就可能被执行危险方法。
3.3 验证与调试:断点追调用栈的实操方法
真正复现CC链时,最大的坑是“你觉得自己构造对了,但触发点没走到最终的危险方法”。这时候靠看日志往往什么都看不出来,最好的办法是打断点。
我个人的习惯是在这几个关键位置设置断点:
LazyMap.get(),这是判断Transformer链是否被触发的关键。如果断点没进来,说明前面入口没生效。TransformingComparator.compare(),测CC2和CC4时优先看这里。TiedMapEntry.hashCode()和TiedMapEntry.toString(),测CC5和CC6时各看一眼。Hashtable.reconstitutionPut(),测CC7时用。InvokerTransformer.transform(),看落地执行是否真的走到了反射调用环节。
用IDE跑起调试后,一旦命中断点,就能切到调用栈面板,从下往上逐层检查是谁在调谁。你会非常直观地看到一条链:readObject -> HashMap.readObject -> hash -> TiedMapEntry.hashCode -> LazyMap.get -> ChainedTransformer.transform -> InvokerTransformer.transform。这个栈面板就是最好的师傅,比看十篇分析文章都有用。
有个小技巧:如果你不想打断点,也可以在InvokerTransformer.transform()里临时加一行System.out.println("hit"),把jar包放到一个单独的项目里手动替换类,这样能在不入栈调试的情况确认触发路径。不过我还是推荐正规断点方式,加日志改jar容易把自己绕晕。
3.4 学习CC链的正确路径建议
每次有新人问我“怎么学CC链”,我都建议不要一上来就背调用链,那玩意儿背下来会忘,忘了还得背。更合理的路径是分五步走。
第一步,跑通3.2中那种Transformer链演示,搞懂transform()的执行逻辑。第二步,在JDK 8u66环境下复现CC1,打断点看完整调用栈,把入口、中段、落地三个位置在源码里找到。第三步,尝试把入口换成PriorityQueue,复现CC2;再把落地换成TemplatesImpl,复现CC3和CC4。第四步,把JDK升到8u202,复现CC6和CC7,体会一下稳定链为什么稳定。第五步,去读一遍ObjectInputStream的源码,搞清楚resolveClass()、readObject()和序列化数据格式之间的关系。
这五步走完,你对反序列化漏洞的理解就不会停留在“用工具一把梭”的层面,而是能看懂网上任何一条新链的原理。到那时候你再去看Spring链、Rome链、Hessian链,会发现它们玩的还是入口、中段、落地这三样东西。
4. 防御加固与线上排查实战指南
4.1 依赖侧修复:版本升级与Transformer机制限制
最直接的一招是把Commons Collections升到3.2.2或4.1.0。3.2.2做了什么?它对InvokerTransformer增加了方法级白名单,禁止通过反射调用Runtime、ProcessBuilder等特定危险类的方法。也就是说,即便攻击者构造出了完整的对象图,到了transform()这一步,方法名和类名会被拦下来,链就断了。4.1.0的修复思路类似。
但这里我要说一个容易被忽略的现实:升级依赖不是万能药。第一,CC链只是反序列化漏洞的“素材库之一”,项目里如果还存在其它可利用的库,比如Spring的AbstractPropertyAccessor、Rome的ObjectBean、或者C3P0的衍生链,只堵CC链是不够的。第二,很多团队的升级不是升级单个JAR那么简单,上游框架把Commons Collections作为传递依赖带入的,你强行覆盖版本可能引起兼容性问题。所以依赖修复要做,但要做全量依赖分析。
评估这件事,可以用Maven插件输出依赖树,搜索commons-collections的来源,确认是被谁引进去的。我遇到过线上是2.5.1这种远古版本的情况,升级到3.2.2需要连同上游组件一起升,最终是靠升级整个框架版本解决的。
4.2 运行时防护:ObjectInputFilter从入门到落地
如果说依赖升级是治标,那运行时防护就是治本的一部分。现代JDK从Java 9开始提供了ObjectInputFilter机制,到Java 17更是通过JEP 415完善了全局序列化过滤。这个机制的核心思想是:在ObjectInputStream反序列化对象之前,可以对即将加载的类做一次过滤,不符合规则的类直接拒绝加载。
最简单的落地方式是使用JVM参数全局配置。比如只允许业务包和标准库的反序列化:
-Djdk.serialFilter=org.example.*;java.base/*;!*这段配置的含义是:白名单放行org.example包和java.base模块下的类,拒绝其他所有类。实际生产里不能写得这么死板,因为中间件、缓存客户端、消息客户端都要反序列化各自的类。我通常建议先把-Djdk.serialFilter设置为一个宽容的白名单加宽泛拒绝的组合,试运行一段时间,观察有没有正常业务触发的InvalidClassException,再逐步收紧。
在代码层面,你还可以给单个ObjectInputStream显式设置过滤器:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "org.example.*;java.base/*;!*" ); try (ObjectInputStream ois = new ObjectInputStream(input)) { ois.setObjectInputFilter(filter); Object obj = ois.readObject(); }这样在阅读代码时就能明确看到目标输入流被过滤了。比全局配置更可控。再进一步,还可以在resolveClass()里做自定义校验——但那是没有办法修改ObjectInputStream时兜底用的方案,能用过滤器的环境还是优先用过滤器。
另一个经常被问到的点是黑名单和白名单怎么选。黑名单省事,但你永远不知道下一个利用链长什么样;白名单累,但长远看安全系数高得多。混合使用更现实:系统自带类按模块放行,业务类按包前缀放行,第三方组件按组件限定放行,剩下的通通拒绝。
4.3 从攻击面视角做自查:找出不可信反序列化点
做反序列化漏洞排查,最怕的是代码库太大、查不完。我的经验是不要漫无目的地扫,而是逐项排查以下场景:
- RMI相关调用,特别是
java.rmi.server.UnicastRemoteObject的传输流。 - JMS消息队列,消息体如果直接传序列化对象,要重点看。
- JMX相关接口,很多老系统用JMX管理,传参里藏着ObjectInputStream。
- Redis等缓存里存对象,一旦缓存被污染,读取时就触发反序列化。
- Session持久化,常见的
session.serialize()、Tomcat的会话反序列化。 - HTTP请求体里有二进制对象的接口,尤其老版本Dubbo、Hessian这类RPC协议。
- 日志系统或任务调度框架里的对象传输。
排查时先在代码仓库里搜ObjectInputStream的实例化点和readObject()调用,这是最忠诚的路径。找到后逐个看上游:这个输入流的数据源是用户直接可控、还是经过签名校验、还是来自可信内部组件?三选一里只有“经过强校验”可以稍微松一口气,另外两种都应该当成不可信反序列化处理。如果拿不准,写个脚本在测试环境发个无害的探测对象,观察有没有异常日志。
我还会建议团队在网关层面对已知利用链的类名做检测,比如org.apache.commons.collections.functors.InvokerTransformer这类。这不是万全的方案,攻击者换一条链就绕过了,但它能把低垂的果实摘掉,提升攻击成本。
4.4 高频问题与排查速查表
把我在实际调试和防御过程中遇到的高频问题整理成一张速查表,方便你直接查阅:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 反序列化时抛出ClassNotFoundException | 目标环境缺少构造payload时用到的类,或者类路径版本不对 | 用mvn dependency:tree查依赖,确认CC版本;换用与目标一致的依赖版本构造链 |
| InvalidClassException serialVersionUID不一致 | 序列化端与反序列化端的类版本不一致 | 排查双方的JAR包版本,最好统一;不建议在实体类上为了兼容而手动固定serialVersionUID |
| 引用Commons Collections 3和4都报编译错或运行错 | 两个版本的包名不同,同时存在时类名冲突 | 去掉其中一个依赖,至少不要同时引入两个大版本 |
| CC1、CC3复现永远收不到效果 | JDK版本高于u71,AnnotationInvocationHandler逻辑已变 | 换CC6或CC7这类JDK版本无关的链,或降JDK版本到复现区间 |
| 验证时命令似乎没执行 | 无回显环境,命令执行结果无法反馈 | 通过延时、DNS查询等带外方式确认;作为蓝队验证时,优先看安全设备或网络层日志 |
| 过滤规则拦截了InvokerTransformer | 安全产品对反射调用做了检测 | 考虑升级依赖或运行时过滤落地,别再尝试绕过滤规则做对抗 |
这张表解决的是“环境问题”而不是“攻击技巧问题”。我在做安全评估时,经常发现团队花大量时间在调试环境版本,却忽略了更本质的问题:为什么这里会有不可信反序列化。环境版本可以靠经验快速解决,但架构层面不修,今天堵了CC链,明天还有别的链。
在这方面,绕不开的一个建议是:不要信任任何从外部输入流直接创建的ObjectInputStream.readObject()。线上系统如果确实要接收对象流,必须有来源校验和类白名单双重保障。安全这个事,永远是在前端多想一步,比后端多修十个补丁都管用。
我自己在处理这类问题时,最大的感受是:CC链的攻防本质上是一场“动态博弈”。入口类被修了,就有新入口类被找到;落地手段被拦了,就有新的字节码加载姿势出现。作为防御方,与其盯着某一条链的特征,不如把精力花在“能不能让不可信数据进入readObject”和“即使进入了,能不能加载非白名单类”这两个核心问题上。只要这两个口子把握住,不管后续冒出CC8还是CC9,都不会太慌。