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>
2 |
在控制模块的Article.java文件中加上了如下代码: |
@RequestMapping("/json")
public Object json(String id){
return JSON.parse(id);
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()。
在这里进入到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。
在这里进入到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后,也是直接向上进行了返回。
并在DefaultJsonParser.parse函数中进入到parseObject函数里面。
parseObject函数从205到595行,感觉不太好把这么多内容复制进来,这里就针对代码,使用图片把关键需要展示的截下来。
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函数,我们继续跟进。
在跟进到这个if中,在这里又一次调用了lexer.isEnabled函数这次传的参数的mask值为64,将64和989进行了&运算并且和!0运算返回为true。进入到了while,但是此时的ch值为”,直接退出当前的if。同时在这里任然可以进行添加英文格式的逗号进行绕过,继续往下看。
在这里if,判断ch值是否为”,然后进入到if语句中,这里通过lexer对象调用了scanSymbol函数。该函数的在JSONLexerBase.java中,代码是从603到803行。
先看看前面的三个if语句。
在通过参考上面绕过waf文章的内容,可以确定的就是在这里,对我们传入的”后面的数据进行了处理。第一个if就如果还是”,那么就达成了闭合,直接跳出当前循环,第二个就是如果字符就是u001a,直接进行报错。主要就是第三个if语句了如果是开头,那么就进入到这个if进行处理。
这里为了方便测试,我将poc改成了:
{“x40u0074u0079u0070u0065″:”java.net.Inet4Address”,”val”:”dvbkxehibj.dgrh3.cn”}
@使用十六进制转换,后面的type则是使用unicode进行转换。
可以看见,将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也是如此。
在这里读取unicode的值,然后转为ascii的十进制,然后执行putChar函数。
在通过scanSymbol函数获取到””包裹的值后,返回到DefaultJSONParser.java下的parseObject函数内,并将获取的值传给key。
可以看见在这里又一次调用了lexer. skipWhitespace函数。
这里为了测试这个skipWhitespace函数,我又将写入的数据改了下:
{“x40u0074u0079u0070u0065″ :”java.net.Inet4Address”,”val”:”dvbkxehibj.dgrh3.cn”}
在”和:之间加了个空格。测试可见也是可以执行的。
接着往下进行,直接到了:
在上个图中可以看见这个isObjectKey被设置为了false,这里做!运算,导致结果为true。然后在这里又执行了skipWhitespace函数。接着往下走,到resetStringPosition这个函数,这里将当前对象的sp值重置为0。接着看下面:
在这个if语句中,上面得到的key的值为@type,if返回true,第一行就直接获取了@type的值,scanSymbol函数已经跟过了,所以不重复跟进。可以知道的是,scanSymbol函数内的参数是可以转十六进制或者是unicode编码的。
继续跟进在当前if语句下,大部分的if都没有经过,在获取到@type的值后,直接进行了反序列化。
第一个ObjectDeserializer deserializer = config.getDeserializer(clazz);这个就是返回我们的类名称,主要是下面这个deserializer.deserialze函数,我们跟进看看。
前面都是些赋值的操作,在标点处进入到了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函数中。
在这里可以看见我们的ch值为”,这个应该是poc里面的那个逗号后面的第一个字符。
通过if语句进入到了scanString函数中:该函数和scanSymbol类似。
在匹配到的时候,在匹配关键词x和u,然后分别进行转码,所以poc的部分也可以使用unicode和十六进制编码进行绕过。其次就是我看了下相关的代码:
case 't': // 9
putChar('t');
break;
case 'n': // 10
putChar('n');
break;
case 'v': // 11
putChar('u000B');
break;
case 'f': // 12
case 'F':
putChar('f');
返回到parser.accept函数里面,进行如下的判断:
在前面已经将lexer的token值设置为了状态4,进入到当前的if语句中,这里指定了我们的参数的名称必须要为val,否则就要报错。然后就进入到了lexer.nextToken函数中,在这个时候ch的值为:,即”val”:的:,在nextToken中有。
if (ch == '/') {
skipComment();
continue;
}
所以这地方也可以尝试加入相关的数据。
在这里传入的数据并没有相关的数据,所以没经过该函数后续可以再测测。
这里就是经过了如上代码,并将token设置为了COLON。然后向上返回,回到deserialze函数中。
执行了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的数据进行访问。
该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*/
Poc成功被解析。
后续换了几个版本的fastjson发现,该绕过方式在1.24.47,1.2.68,1.2.83版本都可以成功的执行。所以该poc应该在fatsjson的1.0系列都可以成功的针对部分waf进行绕过。
这里主要讲的是针对实战环境下的一些场景,在我们直接打poc/exp的时候,可能被waf拦截的可能性,这里主要针对就是fastjson源码这块有可能绕过的规则和其绕过的相关原理。
原文始发于微信公众号(中尔安全实验室):Java反序列化安全 | Fastjson反序列化waf绕过