CC6反序列化链 CC6不受 JDK 版本限制的入口替换链CC6 可以看成 CC1 的入口替换版。后半段的触发、串联和执行能力基本原样保留变化点集中在反序列化入口由谁在readObject()阶段把调用送进攻击者可控的Map。1. 适用条件JDK版本不受版本限制组件版本commons-collections 3.1 ~ 3.2.12. 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.EntryString,ObjectmemberValue:memberValues.entrySet()){// ▶ 遍历攻击者可控的 memberValuesStringnamememberValue.getKey();Class?memberTypememberTypes.get(name);if(memberType!null){ObjectvaluememberValue.getValue();if(!(memberType.isInstance(value)||valueinstanceofExceptionProxy)){memberValue.setValue(// ▶ 写时链触发点newAnnotationTypeMismatchExceptionProxy(value.getClass()[value]).setMember(annotationType.members().get(name)));}}}JDK 8u71 中的实现ObjectInputStream.GetFieldfieldss.readFields();...MapString,ObjectstreamVals(MapString,Object)fields.get(memberValues,null);...MapString,ObjectmvnewLinkedHashMap();for(Map.EntryString,ObjectmemberValue:streamVals.entrySet()){// ▶ 遍历仍在StringnamememberValue.getKey();Objectvaluenull;Class?memberTypememberTypes.get(name);if(memberType!null){valuememberValue.getValue();if(!(memberType.isInstance(value)||valueinstanceofExceptionProxy)){valuenewAnnotationTypeMismatchExceptionProxy(...)...;}}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.javajdk7u21-b11AnnotationInvocationHandler.javajdk8u71-b153. 链上还保留着触发、串联和执行能力入口被限制不等于整条链子都失效了。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(inti0;imappings;i){Kkey(K)s.readObject();Vvalue(V)s.readObject();putVal(hash(key),key,value,false,false);}hash(key)的第一步就是调用key.hashCode()staticfinalinthash(Objectkey){inth;return(keynull)?0:(hkey.hashCode())^(h16);}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.javajdk8uHashMap.javajdk17u6. 替代支点开始收敛出来先补第二块把hashCode()桥接到map.get(key)[读时触发链LazyMap.get()]的对象。符合这个条件的是org.apache.commons.collections.keyvalue.TiedMapEntry它把一张Map和一个 key 绑在一起充当这张表的一个条目。关键在两处方法publicObjectgetValue(){returnmap.get(key);}publicinthashCode(){ObjectvaluegetValue();return(getKey()null?0:getKey().hashCode())^(valuenull?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外再包一层HashSetHashSet本身不存数据内部委托给一张HashMap其readObject()逐个读出元素后执行map.put(e, PRESENT)同样落到HashMap.put() - hash(key) - key.hashCode()。对应出处HashSet.javajdk8u两块支点都确定后拼合时会遇到一个新问题链在构造时就会先执行一遍。组装 payload 时总要执行一步map.add(entry)或map.put(entry, ...)。向哈希容器中放入元素当场就要计算entry.hashCode()——这次计算发生在攻击者自己的机器上后果有两个1. 链提前触发命令在构造 payload 的进程中被先执行 2. 链在目标侧失效LazyMap.get() 会把生成的值回填进 innerMap 反序列化时再 get 同一个 key命中缓存 懒加载分支不会再走因此构造时的核心要求是组装过程中的任何时刻都不能真正对TiedMapEntry执行哈希计算。ysoserial 的做法是把放入和替换拆成两步先向HashSet中放入一个无害的占位对象再通过反射沿内部结构把占位 key 原地换成TiedMapEntry多版本兼容的 try/catch 分支略TiedMapEntryentrynewTiedMapEntry(lazyMap,foo);HashSetmapnewHashSet(1);// 变量名沿用 ysoserial 原文类型为 HashSetmap.add(foo);// 先放无害占位 keyString 的 hashCode 与链无关// 反射HashSet.map - HashMap.table - 节点.keyFieldfHashSet.class.getDeclaredField(map);...HashMapinnimpl(HashMap)f.get(map);Fieldf2HashMap.class.getDeclaredField(table);...Object[]array(Object[])f2.get(innimpl);Objectnodearray[0]null?array[1]:array[0];FieldkeyFieldnode.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()。