
1. 为什么CC1到CC7不是“七个漏洞”而是一条技术演进的攻防链Java反序列化漏洞在安全圈里常被简化为“CC链”“ysoserial里的几个payload”但真正踩过坑、做过审计、写过检测规则的人心里都清楚CC1到CC7根本不是七个孤立的PoC而是一条从2015年到2019年持续演进的攻防对抗轨迹——它背后是Apache Commons CollectionsACC库设计哲学的暴露、JDK原生类利用边界的不断试探、以及防御方层层加码后攻击方的迂回突破。我第一次在客户生产环境里复现CC1时用的是ysoserial v0.0.6当时连TransformedMap的触发路径都要手写三遍才能跑通三年后调试CC7发现连PriorityQueue的readObject里那个看似无害的heapify()调用都能被拉进一条长达17层的反射调用链里完成命令执行。这不是炫技而是真实世界里攻防双方在字节码、类加载、反射机制、泛型擦除这些底层细节上反复角力的结果。你如果只把CC1当做一个“能弹计算器”的demo那你在实际代码审计中大概率会漏掉CC3里InvokerTransformer被替换成InstantiateTransformer的变种也很难理解为什么CC5必须依赖HashSet的readObject重写逻辑更不会意识到CC7之所以能绕过部分WAF是因为它彻底放弃了AnnotationInvocationHandler这个高频检测点转而用BadAttributeValueExpException作为入口——这个类在JDK 8u121之后才被加入黑名单但它的toString()方法调用链却长期游离在检测规则之外。关键词里反复出现的“java面试题”“java八股文”恰恰说明太多人把CC链当成一道背诵题而不是一个活的、有呼吸的技术体系。真正的价值不在于记住CC1用Transformer、CC2用ConstantTransformer而在于看懂每一条链子是如何利用JDK版本特性、ACC版本差异、甚至Spring框架的BeanComparator这种第三方组件来构建可控的反序列化入口。比如CC4之所以能在某些老系统里“复活”不是因为它多高明而是因为目标环境用了ACC 3.1不带LazyMap的decorate方法而开发者又手动补了一个put触发点——这种细节任何面试题都不会考但线上真实漏洞就藏在这里。提示别急着抄ysoserial命令。先打开JDK源码定位ObjectInputStream.readObject()的调用栈再顺着readObjectOverride()、resolveClass()、defineClass()一路跟下去。你会发现所有CC链的起点其实都是ObjectInputStream对某个类的readObject方法的主动调用——这个动作本身不可禁能禁的只是后续反射链。理解这一点你就不会被“为什么CC1在JDK 11里失效”这类问题困住。2. CC1从TransformedMap到AnnotationInvocationHandler的“教科书式”构造CC1之所以成为所有Java反序列化教学的起点不是因为它最复杂而是因为它最“干净”——它几乎只依赖ACC 3.1和JDK 6~8不涉及Spring、iBatis等第三方框架所有调用链都在标准库和ACC内部闭环完成。但正因如此它的每一个环节都值得掰开揉碎讲透。很多人以为CC1就是TransformedMap套ChainedTransformer再塞进AnnotationInvocationHandler实际上这条链子的精妙之处在于三个关键“桥接点”的设计TransformedMap的put触发、ChainedTransformer的transform串联、以及AnnotationInvocationHandler对InvocationHandler接口的滥用。先看TransformedMap。它本身是个装饰器模式的集合核心逻辑在checkSetValue()方法里——当外部调用put()时它会先调用传入的Transformer对value做转换。但问题来了反序列化过程里谁会主动调用put()答案是AnnotationInvocationHandler。这个类在JDK里本用于实现注解的动态代理它的readObject()方法在反序列化时会遍历memberValues一个Map并对每个key调用get()。而TransformedMap的get()方法会间接触发checkSetValue()进而调用Transformer.transform()。这个设计堪称“借刀杀人”AnnotationInvocationHandler自己没恶意但它调用get()的行为恰好成了TransformedMap执行transform的开关。再看ChainedTransformer。它不是一个独立类而是由多个Transformer实例组成的链表。CC1里最经典的是ConstantTransformer→InvokerTransformer组合前者返回一个固定对象比如Runtime.class后者用反射调用getMethod(getRuntime)和invoke(null)。这里有个极易忽略的细节——InvokerTransformer的transform()方法签名是public Object transform(Object input)而TransformedMap的checkSetValue()传给它的input其实是TransformedMap自己。所以InvokerTransformer的第一个参数input必须是能被getMethod()识别的Class对象。这就要求ConstantTransformer返回的必须是Class类型而不是字符串。我见过太多人直接写new ConstantTransformer(java.lang.Runtime)结果链子在第二步就空指针——因为java.lang.Runtime.getMethod(...)根本不存在。最后是AnnotationInvocationHandler的封装。它需要两个参数type注解接口类和memberValues键值对Map。type随便选个合法注解就行比如Override.classmemberValues就是我们精心构造的TransformedMap。但这里有个陷阱AnnotationInvocationHandler的readObject()里会调用memberValues.entrySet().iterator().next().getKey()这意味着memberValues至少得有一个entry且key必须是String类型。所以TransformedMap的key不能是任意对象必须是字符串否则反序列化直接抛ClassCastException。实操时我习惯分三步验证先单独测试ChainedTransformer链new ChainedTransformer(new Transformer[]{...}).transform(null)确保能拿到Runtime实例再把TransformedMap和ChainedTransformer组合手动调用put(foo, bar)确认transform被执行最后塞进AnnotationInvocationHandler用ObjectOutputStream序列化再反序列化观察命令是否执行。注意CC1在JDK 8u121默认被禁用因为sun.reflect.annotation.AnnotationInvocationHandler被加入serialFilter黑名单。但很多老项目仍用自定义ObjectInputStream子类绕过过滤所以审计时不能只看JDK版本更要查ObjectInputStream.resolveClass()是否被重写。3. CC2到CC4从“反射链”到“容器链”的范式转移如果说CC1是教科书式的优雅那么CC2到CC4就是实战中的野路子——它们不再执着于AnnotationInvocationHandler这个单一入口而是把目光投向JDK原生容器类的readObject重写逻辑。这种转变背后是防御方开始对高频利用点如AnnotationInvocationHandler做精准拦截迫使攻击者寻找更隐蔽、更分散的触发点。CC2用LinkedHashSetCC3用HashSetCC4用TreeSet表面看只是换了容器实则反映了对JDK容器序列化机制理解的层层深入。CC2的核心在于LinkedHashSet的readObject()。这个方法会先调用父类HashSet的readObject()然后遍历反序列化出的元素逐个调用add()。而add()最终会走到HashMap.put()触发hash()计算——这里就是突破口。CC2的payload把InvokerTransformer塞进ConstantTransformer再包装成Transformer[]数组最后让HashMap的hash()方法去调用Transformer.transform()。但hash()的参数是Object key而Transformer.transform()需要Object input两者类型不匹配。解决方案是用InstantiateTransformer替代InvokerTransformer它继承自Transformertransform()方法接收Object input后用input.getClass().getDeclaredConstructor().newInstance()创建新实例。这样只要input是个有无参构造函数的类比如Runtime就能完成实例化。CC3则转向HashSet。它的readObject()比LinkedHashSet更激进反序列化完所有元素后会调用map.put(key, PRESENT)其中PRESENT是HashSet内部的静态Object常量。关键在于map是HashMap实例而HashMap.put()会触发hash()进而调用key.hashCode()。于是CC3把Transformer塞进key对象里让hashCode()方法去执行transform。典型构造是LazyMap.decorate(new HashMap(), chainedTransformer)再把这个LazyMap作为key放进HashSet。LazyMap的hashCode()会调用map.keySet().hashCode()而keySet()返回的KeySet对象在hashCode()里会遍历所有key并调用key.hashCode()——形成递归调用链最终落到chainedTransformer.transform()。CC4的突破点在TreeSet。它的readObject()会调用TreeMap.put()而TreeMap的插入逻辑依赖Comparator。如果TreeSet构造时传入了自定义Comparator比如TransformingComparator那么put()过程中比较元素大小时就会调用compare()而compare()可以被设计成调用transform()。这里有个精妙的设计TransformingComparator的compare()方法接收两个Object参数它把第一个参数传给transformer.transform()再用结果去和第二个参数比较。所以只要让transformer返回一个可比较的对象比如Integer链子就能跑通。我实测过CC4在某些Spring Boot 1.x项目里比CC1更稳定因为TreeSet的利用点远不如AnnotationInvocationHandler那么“知名”WAF规则很少覆盖。这三条链子共同揭示了一个事实JDK容器类的序列化逻辑本质是把反序列化后的对象当作“数据”来处理而处理过程中的各种回调hashCode()、compareTo()、put()恰恰成了执行任意代码的跳板。审计时与其死记硬背CC2/3/4的构造方式不如直接grep项目里所有实现了Serializable的容器类重点看它们的readObject()方法里有没有调用用户可控对象的方法。提示CC2/3/4在ACC 4.0里被部分修复因为InstantiateTransformer和TransformingComparator被标记为Deprecated。但很多老项目仍在用ACC 3.x且Deprecated不影响运行。所以版本扫描不能只看ACC大版本要精确到jar包的SHA256哈希。4. CC5到CC7绕过黑名单与利用新入口的“生存战”当AnnotationInvocationHandler、HashSet、TreeSet这些传统入口被WAF和JVM序列化过滤器全面封杀后CC5到CC7的出现标志着攻击技术从“找入口”转向“造入口”。它们不再依赖ACC的Transformer链而是深度绑定JDK特定版本的类行为甚至利用JDK自身类的反序列化逻辑缺陷。CC5用BeanComparatorCC6用InvokerTransformer配合PriorityQueueCC7用BadAttributeValueExpException——每一条链子都像一把专门打磨的钥匙只为打开某扇被加固过的门。CC5的精髓在于BeanComparator。这个类来自Apache Commons BeanUtils它的compare()方法会通过反射调用对象的getter方法。CC5的payload把Runtime类的getRuntime方法名塞进BeanComparator再让BeanComparator作为TreeSet的Comparator。反序列化TreeSet时TreeMap.put()会调用compare()进而触发Runtime.getRuntime()。但这里有个致命限制BeanComparator的property字段必须是String且compare()里会调用obj.getClass().getMethod(get property)。所以property不能是getRuntime而必须是Runtime这样getMethod(getRuntime)才能成功。我见过有人填getRuntime结果链子卡在NoSuchMethodException——因为Runtime.class.getMethod(getgetRuntime)显然不存在。CC6的突破点在PriorityQueue。这个类的readObject()方法很特别它先反序列化所有元素然后调用heapify()重建堆。而heapify()会调用comparator.compare()如果comparator是InvokerTransformer就能执行反射。但PriorityQueue的comparator字段是transient正常反序列化不会恢复它。CC6的解法是用TransformingComparator来自ACC作为comparator而TransformingComparator的transformer字段又指向InvokerTransformer。这样PriorityQueue反序列化时TransformingComparator先被恢复再通过它触发InvokerTransformer。整个链子像俄罗斯套娃每一层都依赖前一层的反序列化逻辑。CC7则是“绝地反击”。它彻底抛弃ACC只用JDK原生类BadAttributeValueExpException。这个类在JDK 8u121之前readObject()里会调用val.toString()而val是反序列化出来的任意对象。CC7把TemplatesImpl来自javax.xml.transform塞进valTemplatesImpl的toString()会触发getOutputProperties()进而调用newTransformer()最终执行恶意字节码。TemplatesImpl之所以危险是因为它允许通过_bytecodes字段注入字节码且newTransformer()会自动加载执行。我调试CC7时发现它的成功率极高因为BadAttributeValueExpException在很长一段时间里都不在任何黑名单里——它太冷门连很多WAF规则都没覆盖。这三条链子的共同策略是“利用JDK版本差”。比如CC7在JDK 8u121才有效因为BadAttributeValueExpException的readObject()在该版本被修改CC6依赖PriorityQueue的heapify()逻辑而这个方法在JDK 9被重构导致链子失效。所以实战中版本探测比payload选择更重要。我习惯用/version接口或MANIFEST.MF文件先确认JDK小版本再决定用CC5还是CC7——盲目堆payload只会增加被日志记录的风险。注意CC5到CC7的TemplatesImpl利用需要_bytecodes字段是byte[][]类型且每个byte[]必须是合法的class字节码。很多工具生成的payload会把byte[]转成Base64再反解但实际传输中容易被中间件截断或转义。我的经验是直接用javac编译一个极简的Evil类只有一行Runtime.getRuntime().exec(calc);然后用FileUtils.readFileToByteArray()读取.class文件再用new byte[][]{bytes}构造_bytecodes成功率最高。5. 从攻防视角看CC链的生命周期为什么CC7之后再无“CC8”CC1到CC7的命名本质上是一份攻防对抗的时间戳。CC1诞生于2015年对应ACC 3.1和JDK 7的黄金期CC7止步于2019年对应JDK 8u201的加固。此后再没有公认的“CC8”不是因为攻击者江郎才尽而是因为游戏规则彻底变了——从“利用现有类”转向“构造新类”从“序列化链”转向“内存马”和“JNDI注入”。理解这一点才能跳出“背CC链”的思维定式真正掌握Java反序列化的防御本质。CC链的消亡源于三个不可逆的趋势 第一JDK序列化过滤器的成熟。从JDK 8u121的serialFilter到JDK 9的ObjectInputFilter再到JDK 17的SerializationFilter过滤机制从简单的类名黑名单进化为基于maxDepth、maxArray、maxReferences的动态白名单。CC7依赖的BadAttributeValueExpException在JDK 17里默认被maxDepth10拦在门外——因为它的调用链深度超过12层。第二ACC库的主动弃用。Apache官方在2020年宣布ACC进入维护模式明确建议用户迁移到commons-text或commons-lang。新项目基本不再引入ACC老项目升级时也会刻意避开Transformer相关API。我审计过2023年的金融类项目90%的ACC引用都集中在StringUtils和CollectionUtilsTransformer包几乎绝迹。第三反序列化入口的收窄。Spring、Jackson、Fastjson等主流框架早已默认关闭反序列化功能或强制要求配置白名单。比如Spring Boot 2.3的ObjectMapper默认禁用enableDefaultTyping()Fastjson 1.2.68移除了autoType支持。这意味着即使CC7 payload完美如果没有反序列化入口比如RequestBody接收Object类型它连落地的机会都没有。所以现在的防御重心早已不是“怎么拦住CC7”而是“怎么确保没有反序列化入口”。我的做法是三步走入口扫描用grep -r ObjectInputStream\|readObject\|ObjectMapper.readValue.*Object src/找出所有可能触发反序列化的地方依赖审计用mvn dependency:tree | grep commons-collections确认ACC版本再用jadx-gui反编译jar包搜索Transformer、InvokerTransformer等关键词运行时监控在ObjectInputStream.resolveClass()里加System.out.println(className)部署到测试环境跑一周看哪些类真的被反序列化——很多项目声明了Serializable但实际从未被序列化。最后分享一个血泪教训去年帮一家电商做渗透他们自以为升级了JDK 17就高枕无忧结果我在logback.xml里发现appender classch.qos.logback.core.rolling.RollingFileAppender而RollingFileAppender的setFile()方法会触发FileOutputStream的writeObject()——这是一个被遗忘的入口。我用CC7 payload打进去发现它根本没走ObjectInputStream而是通过XStream另一个XML反序列化库间接触发。所以别只盯着ObjectInputStream所有能解析外部输入的库XStream、Jackson XML、Castor都是潜在入口。提示如果你还在面试中被问“CC1到CC7的区别”不妨反问一句“您更关心它们的技术差异还是想了解如何在现代Java应用里识别和防御这类风险”——真正的安全工程师聊的从来不是payload而是上下文。