一句话定位:利用
HashMap.readObject()反序列化时的哈希桶碰撞,让两个 key 互相equals(),从而把「Java 原生反序列化」入口转接到「equals触发」类 gadget。纯 JDK,无第三方依赖。
- 类别:common 别名:无 优先级:无(未设置)
- 完整 name:
HashMap#readObject()调用equals()方法,注意在调用equals方法之前会调用到包装对象hashCode方法,所以需要保证对象调用hashCode不能出现问题 - 实现:
com.ar3h.chains.common.Gadget(@Deprecated) - 作者:无 依赖:无(仅需目标 JVM 的
java.util.HashMap) - 参数:无
@Param
把下游 gadget 产出的一对对象 {obj1, obj2} 装进一个「被反射篡改过内部结构」的 HashMap。当外层 Payload 对该 HashMap 做 Java 原生序列化再反序列化时,HashMap.readObject() 在重建哈希表的过程中会对两个哈希碰撞的 key 调用 equals(),最终触发 obj1.equals(obj2)(或反向)。它本身不产生危害,只是一个「JavaNativeDeserialize → Equals」的入口跳板。
- 入口
tags:JavaNativeDeserialize—— 本 gadget 是链首,承接 Java 原生反序列化入口(如ObjectInputStream.readObject)。产物是一个HashMap,可直接交给任意支持原生反序列化的 Payload 入口。 - 衔接
nextTags:Equals—— 之后必须接一个「入口tags含Equals」的 gadget,由它提供chain.doCreate返回的Object[]{obj1, obj2},并定义obj1.equals(obj2)被调用后要发生什么(继续toString、getter、JNDI 等)。典型下游:XStringToString1、AudioFileFormat、AudioFormat、RomeEqualsBean1/2。 excludes:无。
反编译源码本体极短,核心构造逻辑在 PayloadHelper.makeMap(位于 chains-common 模块, 未含该包,下方 makeMap 片段由编译产物 chains-common-1.4.4.jar 的字节码还原,逻辑等价)。
@GadgetTags(tags={"JavaNativeDeserialize"}, nextTags={"Equals"})
@Deprecated
public class HashMapEquals implements Gadget {
public Object getObject(Object obj1, Object obj2) throws Exception {
return PayloadHelper.makeMap(obj1, obj2); // ① 把一对对象塞进特制 HashMap
}
@Override
public Object invoke(GadgetContext context, GadgetChain chain) throws Exception {
Object[] objects = (Object[]) chain.doCreate(context); // ② 下游产出 {obj1, obj2}
Object getterObject = context.get(ContextTag.FASTJSON_HANDLE_BYPASS_KEY);
Object resultObject = this.getObject(objects[0], objects[1]); // ③ 构造 HashMap
if (getterObject != null) {
return CommonMethod.listWrap(getterObject, resultObject); // ④ Fastjson 绕过时外套 ArrayList
}
return resultObject;
}
}② chain.doCreate(context):本 gadget 约定下游返回 Object[],且必须恰好含两个元素 objects[0]/objects[1]。这正是 nextTags={"Equals"} 的契约——下游 Equals 类 gadget 负责生成「一旦互相 equals 就会继续引爆」的对象对。
③ 特制 HashMap 的构造(PayloadHelper.makeMap 字节码还原):
public static HashMap makeMap(Object obj1, Object obj2) throws Exception {
HashMap map = new HashMap();
Reflections.setFieldValue(map, "size", 2); // 反射伪造 size=2,让 writeObject 认为有两个条目
Class<?> nodeCls;
try { nodeCls = Class.forName("java.util.HashMap$Node"); } // JDK8+ 的桶节点
catch (ClassNotFoundException e) {
nodeCls = Class.forName("java.util.HashMap$Entry"); // JDK7 回退
}
Constructor<?> ctor = nodeCls.getDeclaredConstructor(int.class, Object.class, Object.class, nodeCls);
Reflections.setAccessible(ctor);
Object table = Array.newInstance(nodeCls, 2); // 长度为 2 的 Node[] 桶数组
Array.set(table, 0, ctor.newInstance(0, obj1, obj1, null)); // 桶[0] = Node(hash=0, key=obj1, value=obj1)
Array.set(table, 1, ctor.newInstance(0, obj2, obj2, null)); // 桶[1] = Node(hash=0, key=obj2, value=obj2)
Reflections.setFieldValue(map, "table", table); // 反射写入 transient 的 table 字段
return map;
}要点:
table与size都是HashMap的transient字段,正常不参与序列化。这里用反射直接写入,是为了让随后的HashMap.writeObject能把两个条目写出去。- 两个
Node的构造hash均写死为0,但这个存储的 hash 在反序列化时不会被使用——readObject会用key.hashCode()重新计算(见下)。存储 hash 只是为了合法地把 key/value 挂到桶数组上,使writeObject能遍历到它们。 key与value都填成同一个对象,序列化时会重复写出,无副作用。
触发原理(在受害端反序列化时发生):外层 Payload 序列化这个 HashMap 时,HashMap.writeObject 按 size=2 遍历 table,依次写出 obj1(key)、obj1(value)、obj2(key)、obj2(value)。受害端 HashMap.readObject 逐条读回并调用 putVal(hash(key), key, value, ...):
hash(key)内部会调用key.hashCode()—— 这就是 name 里那句警告的由来:在equals之前先触发hashCode,故下游对象的hashCode()必须不抛异常。- 只要
obj1与obj2落进同一个桶(下游 gadget 保证二者hashCode()相同 / 桶索引相同),第二次putVal遍历该桶链时命中判断p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))), 于是执行key.equals(k),即obj2.equals(obj1)(或反向),链条继续向Equals下游引爆。
④ Fastjson 绕过分支:当 GadgetContext 中存在 FASTJSON_HANDLE_BYPASS_KEY 时,CommonMethod.listWrap(getterObject, resultObject) 把「getter 对象」与「HashMap」按顺序放入一个 ArrayList 返回(字节码还原:new ArrayList(); add(getterObject); add(resultObject);)。这是为在 Fastjson 等场景下先满足某个 getter 调用、再落到 HashMap 触发点的兼容处理;标准 Java 原生反序列化路径下 getterObject == null,直接返回 HashMap。
本 gadget 无 @Param,无用户可配字段。所有语义(要触发谁、equals 之后做什么)都由下游 Equals gadget 决定并通过 chain.doCreate 注入。
| 字段 | 名称 | 说明 | 默认 | 可选值 |
|---|---|---|---|---|
| —— | —— | 无独立可配参数 | —— | —— |
- JDK 适配:
java.util.HashMap$Node为 JDK8+ 命名,JDK7 桶节点为HashMap$Entry;makeMap已try/catch兼容两者,因此覆盖主流 JDK 版本。触发点是HashMap本身,无版本白名单顾虑。 - 无外部依赖:
dependencies为空,只要求目标 JVM 存在标准HashMap(恒成立)。这使它成为极通用的equals跳板,等价于 ysoserialCommonsCollections/URLDNS中经典的「HashMap 哈希碰撞触发 equals」技巧。 @Deprecated:类被标注为过时。同族的 HashTableEquals、ConcurrentHashMapEquals、HotSTSourceEquals 提供等价或更隐蔽的触发容器;实际生成器可能优先选用它们,但HashMapEquals逻辑最简单、最通用。- 关键约束(下游需保证):
obj1与obj2的hashCode()必须 (a) 不抛异常、(b) 使二者落入同一桶,equals才会被调用。若下游对象hashCode()依赖尚未初始化的字段,会在受害端提前抛错导致链失败——这正是 name 中反复强调「保证对象调用 hashCode 不能出现问题」的原因。
usedInChains 为空——自由构件,未直接出现在 51 条预设链中,供按 tags/nextTags 与 Equals 类 gadget 自由组合。
- 同族(同为
JavaNativeDeserialize→Equals容器跳板):HashTableEquals、ConcurrentHashMapEquals、HotSTSourceEquals(后者额外用HotSwappableTargetSource包一层并支持HessianDeserialize)。 - 下游(
nextTags=Equals,提供{obj1,obj2}对):XStringToString1、AudioFileFormat、AudioFormat、XalanXStringToString1/2/3、RomeEqualsBean1/2等——它们把equals再转接到toString/getter,从而衔接后续 sink。 - 构造辅助:
com.ar3h.chains.common.util.PayloadHelper#makeMap、Reflections#setFieldValue/setAccessible、CommonMethod#listWrap。
- 序列化指纹:反序列化流中出现一个
java.util.HashMap,其内部两个 key 对象的hashCode()相等且类型为已知equalsgadget(如XString、AudioFileFormat$Type、AudioFormat$Encoding、rome 的EqualsBean/ObjectBean)时高度可疑。 - 调用栈特征:受害端异常/审计栈中出现
HashMap.readObject → HashMap.putVal → hash → <gadget>.hashCode / <gadget>.equals,即命中本模式。 - 加固建议:
- 使用带类型白名单的反序列化过滤(JEP 290
ObjectInputFilter),拒绝业务无关的equals触发类(com.sun.org.apache.xpath.internal.objects.XString、javax.sound.sampled.*$Type/$Encoding、com.rometools.rome.feed.impl.EqualsBean等)。 - 避免对不可信数据做 Java 原生反序列化;如必须,改用无多态的白名单编解码。
HashMap本身无法禁用,防线应放在「equals/hashCode会引爆的下游类」以及入口过滤上。
- 使用带类型白名单的反序列化过滤(JEP 290