聊到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的setValuesetValue把新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方法会调用getValuegetValue会调用LazyMap.getLazyMap.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 HashMapObject, Object map new HashMap(); map.put(entry, bar); // 序列化前移除占位key确保反序列化时触发 lazyMap.remove(foo);反序列化时HashMap重算key哈希会调用TiedMapEntry.hashCodeLazyMap.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 -Dincludescommons-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入手它对环境的要求最低最适合拿来建立对利用链整体的手感。