☰
Java反序列化漏洞与CommonsCollections利用链原理及防御
2026/10/11 2:41:08 网站建设 项目流程

聊到Java服务端安全,反序列化漏洞永远是绕不开的话题,而CommonsCollections(下面简称CC)利用链,差不多是这条赛道上所有从业者的必修课。做攻防验证的人要理解它,做防线的人更要理解它,因为这套库的历史包袱实在太重,大量框架和中间件在某几代依赖里都会间接带上它,导致一条很老的利用链到现在还有机会打通。这篇文章我不会只贴payload,而是把几个关键环节拆开讲透:序列化协议的信任边界、CC库里的Transformer机制、两条主流链路CC1和CC6的构造思路,以及防御侧真正有效的加固手段。如果你是刚接触这块的开发者或新入行的安全从业者,看完至少能理解“为什么一个集合工具库会成为远程命令执行的跳板”,也能在自己负责的项目里知道该从哪里下手排查。

1. 反序列化漏洞的根子在“对象重放”

1.1 先看Java原生序列化协议的几个关键事实

Java的原生序列化从JDK 1.1就有了,设计目标是把Java对象树完整地变成字节流,再在另一端恢复成一个活对象。ObjectOutputStream.writeObject负责序列化,ObjectInputStream.readObject负责反序列化。注意它不是简单地把字段顺序写进去,而是把类描述、类名、字段名、字段类型、对象图关系全部写进流中,底层有TC_OBJECT、TC_CLASSDESC这套标记。网上常有人搜索的aced0005,就是Java序列化流的十六进制魔数,相当于“文件头”,许多IDS/WAF就是靠识别它来抓Java反序列化流量。

这个协议有个重要特性:反序列化时并不会走类的构造函数,而是直接从字节流中重建字段值。你以为在写一个“数据交换格式”,实际上等于允许对方远程把一张对象图原样“复活”在你的JVM里。字段值从哪儿来?全来自对方发来的字节流。换句话说,只要你有一个接口在反序列化外部数据,就等于把一堆类型的成员字段的赋值权交给了外部输入。安全模型的边界,在这一步就悄悄被刺穿了。

1.2 readObject被调用的那一刻,信任链条就断了

如果一个类实现了Serializable且自定义了readObject方法,那么反序列化时ObjectInputStream会自动调用这个私有方法,不需要你显式调用。它本意是给开发者一个钩子,用来在恢复对象后补充一些逻辑,比如重新初始化临时字段。但这个钩子一旦落进不可信输入,就成了攻击者的“入口点”。

我习惯用自动拆包机器人来理解:你给它一个包裹,它会按包裹里的说明书把物品复原并自动执行一些组装动作。正常情况下说明书是你自己的,但Java反序列化相当于“任何人都可以往包裹里塞一份说明书,机器人照样执行”。攻击者只要能在目标classpath里找到某个类的readObject方法,能在其中触发危险操作,并且能控制传给这个类字段的值,那么只需要构造一个对象图,让这个对象的嵌套字段层层指向那个“危险类”,一次readObject就足以执行任意代码。这就是后来所有反序列化利用链的基本图景。

还有一点值得强调:Java反序列化对“类是什么”基本不做校验。ObjectInputStream在读到类名后会直接从本地classpath加载对应类,哪怕这个类是网络传来指定的。这意味着凡是classpath里存在的类,都可能在一次反序列化里被实例化。这也解释了为什么库依赖越多、越老,攻击面往往越大。

2. 为什么CC库能成为“万能齿轮箱”

2.1 三个Transformer就组成了远程命令执行的最小引擎

Apache Commons Collections最初并不是为了安全而生的,它只是给集合操作提供便捷的装饰器。里面有一套Transform模式:Transformer接口只有一个方法transform(Object input),职责是对输入对象做一次转换。典型的几个实现:

  • ConstantTransformer:不管输入是什么,永远返回构造时指定的那个常量。
  • InvokerTransformer:通过反射,在输入对象上调用指定方法,方法名、参数类型、参数值都在构造函数里写死。
  • ChainedTransformer:把一个Transformer数组串联起来,前一个的输出作为后一个的输入。

这三个东西单独看都很无害,但组合起来就形成了一个“迷你引擎”。ConstantTransformer可以先把类对象(比如Runtime.class)送到引擎开头,InvokerTransformer可以在类对象上调用getRuntime(),再让下一个InvokerTransformer调用exec()。ChainedTransformer恰好负责把这几步串成流水线。任何一个接受Transformer的地方,如果会对外部提供的Transformer执行transform,就等于把这个引擎接到了自己的逻辑里。这个设计在业务代码里是合理的,但在反序列化场景下就成了最理想的武器零件。

2.2 触发点藏在Map装饰器里

只靠Transformer引擎还不够,因为反序列化入口不会直接调用你的transform。Commons Collections里还有一类Map装饰器,它们会在集合元素的增删改查过程中,自动调用Transformer。

  • TransformedMap.decorate(map, keyTransformer, valueTransformer):每次put元素时,对key和value分别执行transform,再放进原map;在setValue时也会有类似检查。
  • LazyMap.decorate(map, factory):每次get一个不存在的key时,会调用factory.transform(key)生成value,然后放入map。

这就是典型的“触发点”。如果某段逻辑会遍历Map的entry并调用setValue,或者会在某些对象计算哈希时触发get,那就可能触发到Transformer链。利用链的本质,就是找到一条路径,从readObject出发,经过若干个方法调用,最终落到map.get或者entry.setValue之类的操作上。理解了这一点,后面看CC1和CC6就都不会觉得乱。

2.3 这条链为什么能在无数框架里存活

Commons Collections 3.x时代,版本流行程度非常高,大量框架、中间件在依赖树里都会间接引入它,甚至很多老项目到现在还在跑着10年前的依赖锁文件。老的软件恰恰最容易被这类机制波及,因为它们常年不升级,且承载着核心业务。可以这么说,CC库在当时几乎成了Java生态的“通用零件”,任何一个项目,只要在某处用ObjectInputStream处理了外部不可信字节流,而classpath里又存在commons-collections,就具备了被利用的前提条件。

开发者在设计Commons Collections时并没有恶意,但“强大且通用的转换能力”加上“自动被Map触发”,再加上“整个库都可以随数据一起被反序列化”,这三个条件叠加,就是教科书级别的危险。这也是为什么后来很多序列化过滤方案都直接把org.apache.commons.collections放进黑名单。老依赖的清理通常不是一天能完成的,所以认清风险面是第一步。

3. 主流CC利用链的构造思路与实操拆解

3.1 一条利用链的四个必备环节

一条可用的利用链通常包含四个环节:序列化入口、中间跳板、触发对象、最终的“危险动作”。序列化入口是readObject所在的那个类;中间跳板负责把readObject里看似正常的逻辑引到触发对象上;触发对象往往是某类Map或集合,它会在特定时机调用Transformer;最后的危险动作则是我们构造的ChainedTransformer执行命令。

打个比方,入口类负责“开机”,中间跳板类负责“把电源接到电机上”,Map装饰器是“继电器”,Transformer链是“马达”。少任何一环,命令都执行不进去。需要先说清楚,以下所有验证思路都在本地隔离环境完成,不针对任何在网目标;如果你要做类似测试,请先确认拥有相应授权。研究利用链的根本目的是理解攻击面,不是用来制造破坏,这个边界必须守住。

3.2 CC1原理解读:从AnnotationInvocationHandler到setValue

这里以老牌CC1链为例。目标很简单:让ObjectInputStream在反序列化某个对象时,最终对一个TransformedMap的entry调用setValue。setValue内部会触发valueTransformer的transform,然后命令执行。

专门的入口类是JDK自带的AnnotationInvocationHandler。它是一个代理处理器,内部维护着memberValues字段,这个字段是个Map。它的readObject方法在恢复对象时会遍历这个Map,并对每个entry调用setValue方法。这一点非常关键,因为普通Map的setValue不干多余的事,但如果你把TransformedMap放到memberValues里,setValue就会把value交给Transformer链处理。

构造上,我们把命令定为一个无害验证动作,比如创建临时文件touch /tmp/cc1_test。Transformer链可以这样组织:

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[]{"touch /tmp/cc1_test"}) }; ChainedTransformer chain = new ChainedTransformer(transformers); Map innerMap = new HashMap(); Map transformedMap = TransformedMap.decorate(innerMap, null, chain);

反序列化时,AnnotationInvocationHandler.readObject遍历memberValues,调用每个entry的setValue,setValue把新value交给chain.transform,于是从Runtime.class一路执行到exec。这里面有个容易忽略的点:AnnotationInvocationHandler在反序列化时本身会对注解类型做校验,不同JDK版本的行为差异很大。这也是为什么很多人按老教程复现时发现不走读链,后来大家更常用CC6。

3.3 CC6的迂回路线:HashMap、TiedMapEntry与LazyMap的组合

CC6是一条兼容性更广的链。思路是绕开对JDK内部类行为的依赖,改用安全的普通集合类作为入口:HashMap。ObjectInputStream在反序列化HashMap时,会重建键值对并重算每个key的hashCode。如果我们让key是一个TiedMapEntry,同时它的hashCode方法会调用getValue,getValue会调用LazyMap.get,LazyMap.get由于key不存在会调用factory.transform——最终就触发了链子。

TiedMapEntry是Commons Collections里的一个Map.Entry实现,持有某个Map引用和key。它的hashCode实现大致是getValue().hashCode(),而getValue实现是map.get(key)。于是链子就串起来了:HashMap.readObject -> key.hashCode -> TiedMapEntry.hashCode -> getValue -> LazyMap.get -> ChainedTransformer.transform。

构造时需要注意一个细节:LazyMap.get在key存在时不会触发factory.transform,所以我们构造完LazyMap之后,要先手动put一个key进去,避免在构造阶段就触发链子;等到序列化完成后,再把这个测试key移除,确保反序列化时第一次get一个不存在的key,从而触发transform。示意代码如下:

TiedMapEntry entry = new TiedMapEntry(lazyMap, "foo"); lazyMap.put("foo", "dummy"); // 防止构造阶段触发transform HashMap<Object, Object> map = new HashMap<>(); map.put(entry, "bar"); // 序列化前移除占位key,确保反序列化时触发 lazyMap.remove("foo");

反序列化时,HashMap重算key哈希,会调用TiedMapEntry.hashCode,LazyMap.get没找到key,调用factory.transform,也就是那条ChainedTransformer,命令执行。CC6不依赖AnnotationInvocationHandler,所以在大量JDK版本上都能稳定工作,这也是我日常检验依赖树时最先排查的链路之一。

3.4 版本选型与测试环境的搭建要点

用哪条链,不是越新越好,而是取决于目标环境里的Commons Collections版本和JDK版本。同一个payload换个依赖版本就可能完全失效,这是反序列化调试里最让人头疼的事。我踩过这样的坑:本地测试CC1运行得好好的,部署环境一换JDK小版本就静默失败,最后逐帧查看反序列化流程才发现是入口类的行为判断变了。所以版本问题一定得摆在最前面。

依赖情况优先考虑的链路
Commons Collections 3.x + 旧JDKCC1、CC3、CC6都能尝试
Commons Collections 3.x + 较新JDKCC6更稳妥
Commons Collections 4.x看类签名变化,CC6部分可用,CC2/CC4更匹配
未知依赖树先用依赖检测工具扫classpath

这个表格只是经验结论,不一定精确到每一版,所以实际测试要用SerializationDumper或者动态调试来确认。我在本地复现时会搭一个最小的骨架项目,故意引入commons-collections老版本,再写一个入口触发ObjectInputStream.readObject,这样每一次测试都能在IDE断点里看到完整调用栈。测试命令建议用touch /tmp/xxx或者写入一个临时目录,不要一上来就弹计算器或者发起网络连接,否则在本地安全工具或沙箱里容易误报,干扰验证过程。

4. 防御视角:怎么把反序列化风险堵回去

4.1 流量侧与代码侧:先定位“谁在反序列化不可信数据”

防御的第一步不是研究利用链,而是找“入口”。最容易出问题的地方通常是远程调用接口的入参处理、分布式缓存里的对象存储、消息队列的消息体、以及某些框架内置的session序列化逻辑。在代码仓库里搜ObjectInputStream、readObject、readUnshared、XMLDecoder等关键字,一个都不能放过。

流量层面,Java原生序列化流的特征很固定:以aced0005开头。写一个简单的流量检测规则,把请求体或响应体开头这几字节匹配出来并不难,再对后续内容做类名解析,只要出现org.apache.commons.collections.functors.ChainedTransformer或者InvokerTransformer这一类特征,就基本可以认定有利用尝试。这里需要强调:黑名单是脆弱的,但胜在便宜。它可以作为第一层防线,把绝大多数已知副本挡在门外。

4.2 JEP 290、ObjectInputFilter与白名单

JDK层面提供了ObjectInputFilter机制,允许对反序列化的类做过滤。JDK 8u121之后部分后移,JDK 9开始成为标准能力。过滤规则支持包名白名单和黑名单。以下是一个示例过滤配置,拒绝加载commons-collections、commons-beanutils等风险组件,同时放行业务包:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "com.example.biz.**;java.**;javax.**;!org.apache.commons.collections.**;!org.apache.commons.beanutils.**" ); ObjectInputFilter.Config.setSerialFilter(filter);

注意规则顺序很重要,分号分隔,通配符匹配包路径;带感叹号表示拒绝。配置后可以再次触发一遍原有利用链,观察readObject是否抛出SecurityException,确认拦截生效。也可以对不同的入口接口设置不同的过滤器,而不是一刀切放行全局。单纯依赖ObjectInputFilter还不够,攻击者可能会寻找过滤器规则没有覆盖到的其他链;如果过滤规则太宽,放过了容器自身类,仍然有绕过面。所以它更适合作为纵深防御的一环,而不是唯一的救命稻草。

4.3 更彻底的收敛方案:换个承载方式

如果业务允许,最稳的方案是彻底放弃Java原生反序列化来交换外部数据。对外接口尽量用JSON、Protocol Buffers这类格式;内部缓存里的对象如果必须跨进程传递,可以改成字符串、字节数组加签名,让远端只能读到数据,不能触发任何对象重建逻辑。

同时要维护好依赖树。用mvn dependency:tree -Dincludes=commons-collections或者对应构建工具的命令,看看项目里到底有没有引入老版本CC库。很多项目其实不是直接依赖,而是被某个中间依赖捎带进来的。知道它在哪,才有机会通过升级或排除把它拿掉。但我不推荐“只靠升级”当答案,因为同类的Gadget库还有很多,Commons Collections只是名气最大的那个,防御视野得放宽到整个反序列化风险类别。

4.4 加固后的验证:确保攻击链真正失效

加固后的验证不能只是“我觉得应该没问题了”。我一般会在本地环境跑一遍前面构造的CC1和CC6测试payload,分别验证:直接反序列化是否成功、加上ObjectInputFilter是否被拦截、升级或移除依赖后是否因为找不到类而失败。三个条件里至少有一个生效,才算真正把这条路堵住。

也可以留一个小的自动化测试用例,用反序列化过滤器拒绝风险类后,断言反序列化抛异常。这样以后依赖更新时,回归测试能顺手发现风险。防御工作做到这一步,才算真正形成了闭环。

5. 常见问题与调试排错经验

5.1 序列化版本不一致是第一步要排的雷

场景:本地构造序列化流后,放到目标环境反序列化时报InvalidClassException,核心信息是serialVersionUID不匹配。出现这种情况,通常是两端classpath里同一个类的版本不同。Java序列化机制非常敏感,类结构变了就可能导致反序列化失败。调试办法是看异常信息里是哪个类对不齐,再用SerializationDumper解析本地序列化流里的类描述,和远端环境对比。

有一个靠得住的小习惯:每个自定义类都显式声明serialVersionUID,并且让所有运行环境使用同一份构建产物,避免“开发机OK、服务器挂”这种玄学问题。虽然这属于基本功,但反序列化调试里九成的不兼容问题都出在它身上。

5.2 命令没执行?先查入口类和JDK版本

如果构造过程没有报错,但readObject后命令没生效,最常见的原因是链路选择不当。比如在较高版本JDK上仍然使用依赖AnnotationInvocationHandler的旧链,或者被目标环境的其他类加载逻辑拦住了。此时要做的不是换命令反复试,而是先在IDE里对关键方法下断点,比如InvokerTransformer.transform、LazyMap.get、TiedMapEntry.hashCode,看看readObject到底走到了哪一步。

如果断点根本停不下来,说明链路的某个类在目标环境里不存在,或者入口类没有被触发。对照着调用栈一点一点往回找,通常很快能定位。调试这类问题,耐心比技巧重要,因为链路每多一层,失败的可能性就翻一倍。

5.3 Runtime.exec传参踩到的空格问题

很常见的一个坑:在Windows下用Runtime.exec("calc")没问题,但换成"cmd /c ..."这类带空格的命令,会发现命令没有按预期执行,因为Runtime.exec对字符串的处理并不是按照Shell语义来解析的。Linux下执行带参数的命令最好使用字符串数组形式,或者明确调用/bin/sh -c。在ChainedTransformer里可以用InvokerTransformer传Object[]参数,把命令数组完整传进去。这也是很多初学复现者会卡住的地方。

5.4 我的本地调试流程

我个人的调试流程很简单:一个独立的虚拟机或者容器,不带生产环境任何密钥和真实数据;一个最小可运行的服务端,只包含反序列化入口和待测依赖;多个payload文件分别覆盖CC1、CC6、带过滤器的验证场景。测试命令统一用touch创建临时文件或写日志,确认执行后马上清理现场。

这样既能快速验证新发现的利用链,也不会污染工作环境。反序列化这类调试极易出现“本地明明成功,换个环境就失败”的情况,所以环境隔离和包版本管理一定要在开始前就做扎实。

5.5 关于复现环境的一点执念

最后说句实在话。我见过不少同学对着公开的模板按部就班复制代码,但一带到自己的环境就各种失效,于是怀疑模板有误。其实更多时候是版本、触发点、命令参数三者的组合不匹配。把原理里每一环都吃透,比背下一百条payload要有用得多。如果你也想做类似的验证,建议从CC6入手,它对环境的要求最低,最适合拿来建立对利用链整体的手感。

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

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

立即咨询