CC6反序列化链
CC6:不受 JDK 版本限制的入口替换链
CC6 可以看成 CC1 的入口替换版。后半段的触发、串联和执行能力基本原样保留,变化点集中在反序列化入口:由谁在readObject()阶段把调用送进攻击者可控的Map。
1. 适用条件
JDK版本:不受版本限制
组件版本:commons-collections 3.1 ~ 3.2.1
2. JDK 更新限制了 AnnotationInvocationHandler 的自动调用能力
CC1 链分为写时触发链和读时触发链,入口集中在同一个位置:sun.reflect.annotation.AnnotationInvocationHandler.readObject()(以下简称 AIH)。
两条链的区分在触发动作:写时触发链的入口动作是一次写入(entry.setValue(...)),读时触发链的入口动作是一次读取(LazyMap.get())。
AIH 的readObject()方法会遍历一张"注解成员名 -> 成员值"的表,并据此接入 CC1 的两条链:
- 读时触发链:
memberValues.entrySet()-> 触发 map 代理类的 AIHinvoke()->LazyMap.get()->ChainedTransformer.transform() - 写时触发链:成员值类型不匹配时调用
entry.setValue(...)->TransformedMap.checkSetValue()->valueTransformer.transform()
这个方法在 JDK 8u71 被整体重写,两个版本对比如下。
JDK 7u21 中的实现:
s.defaultReadObject();...for(Map.Entry<String,Object>memberValue:memberValues.entrySet()){// ▶ 遍历攻击者可控的 memberValuesStringname=memberValue.getKey();Class<?>memberType=memberTypes.get(name);if(memberType!=null){Objectvalue=memberValue.getValue();if(!(memberType.isInstance(value)||valueinstanceofExceptionProxy)){memberValue.setValue(// ▶ 写时链触发点newAnnotationTypeMismatchExceptionProxy(value.getClass()+"["+value+"]").setMember(annotationType.members().get(name)));}}}JDK 8u71 中的实现:
ObjectInputStream.GetFieldfields=s.readFields();...Map<String,Object>streamVals=(Map<String,Object>)fields.get("memberValues",null);...Map<String,Object>mv=newLinkedHashMap<>();for(Map.Entry<String,Object>memberValue:streamVals.entrySet()){// ▶ 遍历仍在Stringname=memberValue.getKey();Objectvalue=null;Class<?>memberType=memberTypes.get(name);if(memberType!=null){value=memberValue.getValue();if(!(memberType.isInstance(value)||valueinstanceofExceptionProxy)){value=newAnnotationTypeMismatchExceptionProxy(...)...;}}mv.put(name,value);}UnsafeAccessor.setMemberValues(this,mv);区别不只是少了一行setValue(...)。
旧版readObject直接在攻击者可控的memberValues上原地修值;新版readObject把成员值先复制进一张新建的LinkedHashMap,最后通过Unsafe写回字段。
写时触发链所依赖的那次自动setValue(...)调用,自此消失。
这次重写到底拦了什么、没拦什么?
它拦的是: readObject() 阶段那次"原地修值"的自动调用(setValue 已移除) 它没拦的是: Commons Collections 包里的任何类—— LazyMap、ChainedTransformer、InvokerTransformer 均未受影响 它留下的是: streamVals.entrySet() 这次遍历仍以某种形式存在, 但已经只是 JDK 内部类的实现细节对应出处:
- AnnotationInvocationHandler.java(jdk7u21-b11)
- AnnotationInvocationHandler.java(jdk8u71-b15)
3. 链上还保留着触发、串联和执行能力
入口被限制,不等于整条链子都失效了。
JDK 修改的是自己包里的一个内部类,Commons Collections 包中的组件并未受影响,CC1 积累下来的能力大部分仍然成立:
| 类 | 承担的能力 |
|---|---|
LazyMap | 缺失 key 时把get()接进transform()(触发能力) |
ChainedTransformer | 把多个Transformer串成一条调用链(串联能力) |
ConstantTransformer | 给链一个固定起点 |
InvokerTransformer | 单步方法调用,后半段执行手段 |
后半段原样可用:
LazyMap.get(key) -> ChainedTransformer.transform(key) -> ConstantTransformer(Runtime.class) -> InvokerTransformer("getMethod") -> InvokerTransformer("invoke") -> InvokerTransformer("exec")从LazyMap.get()到Runtime.exec()这一整段都不需要重新分析。
只需要补齐自动调用的入口,就可以把链串起来。
4. 链的前半段缺少把调用接进可控 Map 的入口能力
反序列化阶段,缺的是一次自动发生的方法调用:作用在攻击者可控的对象上,并最终落到LazyMap.get()。
缺口可以分成两个半块:
1. 自动调用点:某个 Serializable 类的 readObject() 里, 自动对攻击者可控对象调用一个方法 2. 桥:存在一个对象,把这次方法调用的内部 转成 map.get(key)CC1 中这两块分别由AnnotationInvocationHandler.readObject()和"动态代理 + 按方法名取值"承担。
第一块已不可靠;第二块本身未被修改,但它是为了承接entrySet()这次特定调用而引入的——调用点发生变化,桥接也需要随之更换。
还有一个更隐蔽的要求。
这次要找的自动调用,不能再是"反序列化之后附加的修正动作"——这类动作一次修补即可删除。
它应当是某个数据结构在重建时不可省略的步骤:删掉它,这个类自身便不再成立。
5. 反序列化期的自动 hashCode 调用成为新的入口方向
沿"不可省略的步骤"往下找,候选范围收窄到容器类。
哈希容器在readObject()中的工作,本质是把流中的 key-value 重新摆回桶中。
摆放位置由 key 的哈希值决定,因此对每个 key 调用一次hashCode(),不是附加动作,而是重建哈希表的必经步骤。
只要这个类仍是哈希表,这一步就在,任何版本都无法删除。
HashMap正是如此。JDK 8 中它的readObject()末尾:
for(inti=0;i<mappings;i++){Kkey=(K)s.readObject();Vvalue=(V)s.readObject();putVal(hash(key),key,value,false,false);}hash(key)的第一步就是调用key.hashCode():
staticfinalinthash(Objectkey){inth;return(key==null)?0:(h=key.hashCode())^(h>>>16);}JDK 7 写法不同,动作相同:putForCreate()中先计算hash(key.hashCode())。
版本之间实现细节屡有变化,这次调用始终存在。
这一判断可以逐版本核实:JDK 17 中的hash()与putVal()和 JDK 8 完全一致,HashMap、HashSet自 JDK 1.2 引入,重算哈希是重建自身的固有步骤,不存在被修补删除的理由。
入口一侧,CC6 不受 JDK 版本限制。
第一块支点至此确定:HashMap这类反序列化时会重建哈希结构的容器。
尚缺第二块:一个hashCode()内部自带map.get(key)的对象,把这次哈希计算桥接进LazyMap。
对应出处:
- HashMap.java(jdk8u)
- HashMap.java(jdk17u)
6. 替代支点开始收敛出来
先补第二块:把hashCode()桥接到map.get(key)[读时触发链LazyMap.get()]的对象。
符合这个条件的是org.apache.commons.collections.keyvalue.TiedMapEntry:它把一张Map和一个 key 绑在一起,充当这张表的一个条目。关键在两处方法:
publicObjectgetValue(){returnmap.get(key);}publicinthashCode(){Objectvalue=getValue();return(getKey()==null?0:getKey().hashCode())^(value==null?0:value.hashCode());}hashCode()为了计算条目哈希,需要先取得条目的值;取值的方式就是map.get(key):
TiedMapEntry.hashCode() -> getValue() -> map.get(key) // map 换成 LazyMap,就是 LazyMap.get(key)构造时将LazyMap和一个不存在的 key 绑入,之后任何一次对该条目的哈希计算,都会进入LazyMap的懒加载分支,ChainedTransformer被带起。
此外,TiedMapEntry的equals()与toString()内部同样调用getValue(),可桥接的调用不止hashCode()一种;CC5 走的是toString()一路。
出处:commons-collections-3.2.1-sources.jar中的TiedMapEntry.java
第一块支点上一节已经确定。ysoserial 在HashMap外再包一层HashSet:HashSet本身不存数据,内部委托给一张HashMap,其readObject()逐个读出元素后执行map.put(e, PRESENT),同样落到HashMap.put() -> hash(key) -> key.hashCode()。
对应出处:
- HashSet.java(jdk8u)
两块支点都确定后,拼合时会遇到一个新问题:链在构造时就会先执行一遍。
组装 payload 时,总要执行一步map.add(entry)或map.put(entry, ...)。向哈希容器中放入元素,当场就要计算entry.hashCode()——这次计算发生在攻击者自己的机器上,后果有两个:
1. 链提前触发:命令在构造 payload 的进程中被先执行 2. 链在目标侧失效:LazyMap.get() 会把生成的值回填进 innerMap, 反序列化时再 get 同一个 key,命中缓存, 懒加载分支不会再走因此构造时的核心要求是:组装过程中的任何时刻,都不能真正对TiedMapEntry执行哈希计算。
ysoserial 的做法是把"放入"和"替换"拆成两步:先向HashSet中放入一个无害的占位对象,再通过反射沿内部结构把占位 key 原地换成TiedMapEntry(多版本兼容的 try/catch 分支略):
TiedMapEntryentry=newTiedMapEntry(lazyMap,"foo");HashSetmap=newHashSet(1);// 变量名沿用 ysoserial 原文,类型为 HashSetmap.add("foo");// 先放无害占位 key,String 的 hashCode 与链无关// 反射:HashSet.map -> HashMap.table -> 节点.keyFieldf=HashSet.class.getDeclaredField("map");...HashMapinnimpl=(HashMap)f.get(map);Fieldf2=HashMap.class.getDeclaredField("table");...Object[]array=(Object[])f2.get(innimpl);Objectnode=array[0]==null?array[1]:array[0];FieldkeyField=node.getClass().getDeclaredField("key");...keyField.set(node,entry);// 占位 key 原地换成 TiedMapEntry整个构造过程中,TiedMapEntry.hashCode()一次也未执行;序列化时HashMap按桶序写出键值,反序列化时重建流程重新对每个 key 计算哈希——链只在目标侧执行。
出处:ysoserial CommonsCollections6.java
另一种常见写法思路相同,只是替换的位置不同:先塞一个空的ChainedTransformer到LazyMap,让构造时那次hashCode()空转,序列化前再通过反射设置一个带payload的 transformer 数组,并清除回填进innerMap的 key。
7. CC6 由此重新成立
把入口、桥接和 CC1 留下的后半段接起来,CC6 的完整链路:
不包HashSet、直接使用HashMap.readObject() -> putForCreate / putVal的版本,链路结构一致。
CC1 和 CC6 的前半段对比:
CC6 保留 CC1 的触发、串联和执行能力,把入口从
AnnotationInvocationHandler的版本相关行为,换成哈希容器重建结构时对 key 的必然hashCode()调用,再用TiedMapEntry把这次调用桥进LazyMap.get()。
