Java 反序列化工具 gadgetinspector 初窺

酷酷的曉得哥發表於2019-09-17

作者:Longofo@知道創宇404實驗室  
時間:2019年9月4日

原文連結:


起因

一開始是聽@Badcode師傅說的這個工具,在Black Hat 2018的一個議題提出來的。這是一個基於位元組碼靜態分析的、利用已知技巧自動查詢從source到sink的反序列化利用鏈工具。看了幾遍作者在Black Hat上的 與 ,想從作者的演講與PPT中獲取更多關於這個工具的原理性的東西,可是有些地方真的很費解。不過作者開源了這個 ,但沒有給出詳細的說明文件,對這個工具的分析文章也很少,看到一篇平安集團對這個工具的 分析,從文中描述來看,他們對這個工具應該有一定的認識並做了一些改進,但是在文章中對某些細節沒有做過多的闡釋。後面嘗試了除錯這個工具,大致理清了這個工具的工作原理,下面是對這個工具的分析過程,以及對未來工作與改進的設想。

關於這個工具

  • 這個工具不是用來尋找漏洞,而是利用已知的source->...->sink鏈或其相似特徵發現分支利用鏈或新的利用鏈。
  • 這個工具是在整個應用的classpath中尋找利用鏈。
  • 這個工具進行了一些合理的預估風險判斷(汙點判斷、汙點傳遞等)。
  • 這個工具會產生誤報不是漏報(其實這裡還是會漏報,這是作者使用的策略決定的,在後面的分析中可以看到)。
  • 這個工具是基於位元組碼分析的,對於Java應用來說,很多時候我們並沒有原始碼,而只有War包、Jar包或class檔案。
  • 這個工具不會生成能直接利用的Payload,具體的利用構造還需要人工參與。

序列化與反序列化

序列化(Serialization)是將物件的狀態資訊轉化為可以儲存或者傳輸形式的過程,轉化後的資訊可以儲存在磁碟上,在網路傳輸過程中,可以是位元組、XML、JSON等格式;而將位元組、XML、JSON等格式的資訊還原成物件這個相反的過程稱為反序列化。

在JAVA中,物件的序列化和反序列化被廣泛的應用到RMI(遠端方法呼叫)及網路傳輸中。

Java中的序列化與反序列化庫

  • JDK(ObjectInputStream)

  • XStream(XML,JSON)

  • Jackson(XML,JSON)

  • Genson(JSON)

  • JSON-IO(JSON)

  • FlexSON(JSON)

  • Fastjson(JSON)

  • ...

不同的反序列化庫在反序列化不同的類時有不同的行為、被反序列化類的不同"魔術方法"會被 自動呼叫,這些被自動呼叫的方法就能夠作為反序列化的入口點(source)。如果這些被自動呼叫的方法又呼叫了其他子方法,那麼在呼叫鏈中某一個子方法也可以作為source,就相當於已知了呼叫鏈的前部分,從某個子方法開始尋找不同的分支。透過方法的層層呼叫,可能到達某些危險的方法(sink)。

  • ObjectInputStream

例如某個類實現了Serializable介面,ObjectInputStream.readobject在反序列化類得到其物件時會自動查詢這個類的readObject、readResolve等方法並呼叫。

例如某個類實現了Externalizable介面,ObjectInputStream.readobject在反序列化類得到其物件時會自動查詢這個類的readExternal等方法並呼叫。

  • Jackson

ObjectMapper.readValue在反序列化類得到其物件時,會自動查詢反序列化類的無參構造方法、包含一個基礎型別引數的構造方法、屬性的setter、屬性的getter等方法並呼叫。

  • ...

在後面的分析中,都使用JDK自帶的ObjectInputStream作為樣例。

控制資料型別=>控制程式碼

作者說,在反序列化漏洞中,如果控制了資料型別,我們就控制了程式碼。這是什麼意思呢?按我的理解,寫了下面的一個例子:

public class TestDeserialization {
    interface Animal {
        public void eat();
    }
    public static class Cat implements Animal,Serializable {
        @Override        public void eat() {
            System.out.println("cat eat fish");
        }
    }
    public static class Dog implements Animal,Serializable {
        @Override        public void eat() {
            try {
                Runtime.getRuntime().exec("calc");
            } catch (IOException e) {
                e.printStackTrace();
            }
            System.out.println("dog eat bone");
        }
    }
    public static class Person implements Serializable {
        private Animal pet;
        public Person(Animal pet){
            this.pet = pet;
        }
        private void readObject(java.io.ObjectInputStream stream)
                throws IOException, ClassNotFoundException {
            pet = (Animal) stream.readObject();
            pet.eat();
        }
    }
    public static void GeneratePayload(Object instance, String file)
            throws Exception {
        //將構造好的payload序列化後寫入檔案中        File f = new File(file);
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));
        out.writeObject(instance);
        out.flush();
        out.close();
    }
    public static void payloadTest(String file) throws Exception {
        //讀取寫入的payload,並進行反序列化        ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));
        Object obj = in.readObject();
        System.out.println(obj);
        in.close();
    }
    public static void main(String[] args) throws Exception {
        Animal animal = new Dog();
        Person person = new Person(animal);
        GeneratePayload(person,"test.ser");
        payloadTest("test.ser");//        Animal animal = new Cat();//        Person person = new Person(animal);//        GeneratePayload(person,"test.ser");//        payloadTest("test.ser");    }}

為了方便我把所有類寫在一個類中進行測試。在Person類中,有一個Animal類的屬性pet,它是Cat和Dog的介面。在序列化時,我們能夠控制Person的pet具體是Cat物件或者Dog物件,因此在反序列化時,在readObject中 pet.eat()具體的走向就不一樣了。如果是pet是Cat類物件,就不會走到執行有害程式碼 Runtime.getRuntime().exec("calc");這一步,但是如果pet是Dog類的物件,就會走到有害程式碼。

即使有時候類屬性在宣告時已經為它賦值了某個具體的物件,但是在Java中透過反射等方式依然能修改。如下:

public class TestDeserialization {
    interface Animal {
        public void eat();
    }
    public static class Cat implements Animal, Serializable {
        @Override
        public void eat() {
            System.out.println("cat eat fish");
        }                           
    }
    public static class Dog implements Animal, Serializable {
        @Override
        public void eat() {
            try {
                Runtime.getRuntime().exec("calc");
            } catch (IOException e) {
                e.printStackTrace();
            }
            System.out.println("dog eat bone");
        }
    }
    public static class Person implements Serializable {
        private Animal pet = new Cat();
        private void readObject(java.io.ObjectInputStream stream)
                throws IOException, ClassNotFoundException {
            pet = (Animal) stream.readObject();
            pet.eat();
        }
    }
    public static void GeneratePayload(Object instance, String file)
            throws Exception {
        //將構造好的payload序列化後寫入檔案中
        File f = new File(file);
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));
        out.writeObject(instance);
        out.flush();
        out.close();
    }
    public static void payloadTest(String file) throws Exception {
        //讀取寫入的payload,並進行反序列化
        ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));
        Object obj = in.readObject();
        System.out.println(obj);
        in.close();
    }
    public static void main(String[] args) throws Exception {
        Animal animal = new Dog();
        Person person = new Person();
        //透過反射修改私有屬性
        Field field = person.getClass().getDeclaredField("pet");
        field.setAccessible(true);
        field.set(person, animal);
        GeneratePayload(person, "test.ser");
        payloadTest("test.ser");
    }
}

在Person類中,不能透過構造器或setter方法或其他方式對pet賦值,屬性在宣告時已經被定義為Cat類的物件,但是透過反射能將pet修改為Dog類的物件,因此在反序列化時依然會走到有害程式碼處。

這只是我自己對作者"控制了資料型別,就控制了程式碼"的理解,在Java反序列化漏洞中,很多時候是利用到了Java的多型特性來控制程式碼走向最後達到惡意執行目的。

魔術方法

在上面的例子中,能看到在反序列化時沒有呼叫Person的readobject方法,它是ObjectInputStream在反序列化物件時自動呼叫的。作者將在反序列化中會自動呼叫的方法稱為"魔術方法"。

使用ObjectInputStream反序列化時幾個常見的魔術方法:

  • Object.readObject()
  • Object.readResolve()
  • Object.finalize()
  • ...

一些可序列化的JDK類實現了上面這些方法並且還自動呼叫了其他方法(可以作為已知的入口點):

  • HashMap
    • Object.hashCode()
    • Object.equals()
  • PriorityQueue
    • Comparator.compare()
    • Comparable.CompareTo()
  • ...

一些sink:

  • Runtime.exec(),這種最為簡單直接,即直接在目標環境中執行命令
  • Method.invoke(),這種需要適當地選擇方法和引數,透過反射執行Java方法
  • RMI/JNDI/JRMP等,透過引用遠端物件,間接實現任意程式碼執行的效果
  • ...

作者給出了一個從Magic Methods(source)->Gadget Chains->Runtime.exec(sink)的例子:

上面的HashMap實現了readObject這個"魔術方法",並且呼叫了hashCode方法。某些類為了比較物件之間是否相等會實現equals方法(一般是equals和hashCode方法同時實現)。從圖中可以看到 AbstractTableModel$ff19274a正好實現了hashCode方法,其中又呼叫了 f.invoke方法,f是IFn物件,並且f能透過屬性 __clojureFnMap獲取到。IFn是一個介面,上面說到,如果控制了資料型別,就控制了程式碼走向。所以如果我們在序列化時,在 __clojureFnMap放置IFn介面的實現類FnCompose的一個物件,那麼就能控制 f.invokeFnCompose.invoke方法,接著控制FnCompose.invoke中的f1、f2為FnConstant就能到達FnEval.invoke了(關於AbstractTableModel$ff19274a.hashcode中的 f.invoke具體選擇IFn的哪個實現類,根據後面對這個工具的測試以及對決策原理的分析,廣度優先會選擇短的路徑,也就是選擇了FnEval.invoke,所以這也是為什麼要人為參與,在後面的樣例分析中也可以看到)。

有了這條鏈,只需要找到觸發這個鏈的漏洞點就行了。Payload使用JSON格式表示如下:

{
    "@class":"java.util.HashMap",
    "members":[
        2,
        {
            "@class":"AbstractTableModel$ff19274a",
            "__clojureFnMap":{
                "hashcode":{
                    "@class":"FnCompose",
                    "f1":{"@class","FnConstant",value:"calc"},
                    "f2":{"@class":"FnEval"}
                }
            }
        }
    ]
}

gadgetinspector工作流程

如作者所說,正好使用了五個步驟:

        // 列舉全部類以及類的所有方法        if (!Files.exists(Paths.get("classes.dat")) || !Files.exists(Paths.get("methods.dat"))
                || !Files.exists(Paths.get("inheritanceMap.dat"))) {
            LOGGER.info("Running method discovery...");
            MethodDiscovery methodDiscovery = new MethodDiscovery();
            methodDiscovery.discover(classResourceEnumerator);
            methodDiscovery.save();
        }
        //生成passthrough資料流        if (!Files.exists(Paths.get("passthrough.dat"))) {
            LOGGER.info("Analyzing methods for passthrough dataflow...");
            PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery();
            passthroughDiscovery.discover(classResourceEnumerator, config);
            passthroughDiscovery.save();
        }
        //生成passthrough呼叫圖        if (!Files.exists(Paths.get("callgraph.dat"))) {
            LOGGER.info("Analyzing methods in order to build a call graph...");
            CallGraphDiscovery callGraphDiscovery = new CallGraphDiscovery();
            callGraphDiscovery.discover(classResourceEnumerator, config);
            callGraphDiscovery.save();
        }
        //搜尋可用的source        if (!Files.exists(Paths.get("sources.dat"))) {
            LOGGER.info("Discovering gadget chain source methods...");
            SourceDiscovery sourceDiscovery = config.getSourceDiscovery();
            sourceDiscovery.discover();
            sourceDiscovery.save();
        }
        //搜尋生成呼叫鏈        {
            LOGGER.info("Searching call graph for gadget chains...");
            GadgetChainDiscovery gadgetChainDiscovery = new GadgetChainDiscovery(config);
            gadgetChainDiscovery.discover();
        }

Step1 列舉全部類以及每個類的所有方法

要進行呼叫鏈的搜尋,首先得有所有類及所有類方法的相關資訊:

public class MethodDiscovery {
    private static final Logger LOGGER = LoggerFactory.getLogger(MethodDiscovery.class);
    private final List<ClassReference> discoveredClasses = new ArrayList<>();//儲存所有類資訊    private final List<MethodReference> discoveredMethods = new ArrayList<>();//儲存所有方法資訊    ...
    ...
    public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception {
        //classResourceEnumerator.getAllClasses()獲取了執行時的所有類(JDK rt.jar)以及要搜尋應用中的所有類        for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
            try (InputStream in = classResource.getInputStream()) {
                ClassReader cr = new ClassReader(in);
                try {
                    cr.accept(new MethodDiscoveryClassVisitor(), ClassReader.EXPAND_FRAMES);//透過ASM框架操作位元組碼並將類資訊儲存到this.discoveredClasses,將方法資訊儲存到discoveredMethods                } catch (Exception e) {
                    LOGGER.error("Exception analyzing: " + classResource.getName(), e);
                }
            }
        }
    }
    ...
    ...
    public void save() throws IOException {
        DataLoader.saveData(Paths.get("classes.dat"), new ClassReference.Factory(), discoveredClasses);//將類資訊儲存到classes.dat        DataLoader.saveData(Paths.get("methods.dat"), new MethodReference.Factory(), discoveredMethods);//將方法資訊儲存到methods.dat
        Map<ClassReference.Handle, ClassReference> classMap = new HashMap<>();
        for (ClassReference clazz : discoveredClasses) {
            classMap.put(clazz.getHandle(), clazz);
        }
        InheritanceDeriver.derive(classMap).save();//查詢所有繼承關係並儲存    }}

來看下classes.dat、methods.dat分別長什麼樣子:

  • classes.dat

找了兩個比較有特徵的

類名 父類名 所有介面 是否是介面 成員
com/sun/deploy/jardiff/JarDiffPatcher java/lang/Object com/sun/deploy/jardiff/JarDiffConstants,com/sun/deploy/jardiff/Patcher false newBytes!2![B
com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl com/sun/corba/se/spi/orbutil/proxy/CompositeInvocationHandlerImpl com/sun/corba/se/spi/orbutil/proxy/LinkedInvocationHandler,java/io/Serializable false stub!130!com/sun/corba/se/spi/presentation/rmi/DynamicStub!this$0!4112!com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl

第一個類com/sun/deploy/jardiff/JarDiffPatcher:

和上面的表格資訊對應一下,是吻合的

  • 類名:com/sun/deploy/jardiff/JarDiffPatcher
  • 父類: java/lang/Object,如果一類沒有顯式繼承其他類,預設隱式繼承java/lang/Object,並且java中不允許多繼承,所以每個類只有一個父類
  • 所有介面:com/sun/deploy/jardiff/JarDiffConstants、com/sun/deploy/jardiff/Patcher
  • 是否是介面:false
  • 成員:newBytes!2![B,newBytes成員,Byte型別。為什麼沒有將static/final型別的成員加進去呢?這裡還沒有研究如何操作位元組碼,所以作者這裡的判斷實現部分暫且跳過。不過猜測應該是這種型別的變數並不能成為 汙點所以忽略了

第二個類com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl:

和上面的表格資訊對應一下,也是吻合的

  • 類名:com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl,是一個內部類
  • 父類: com/sun/corba/se/spi/orbutil/proxy/CompositeInvocationHandlerImpl
  • 所有介面:com/sun/corba/se/spi/orbutil/proxy/LinkedInvocationHandler,java/io/Serializable
  • 是否是介面:false
  • 成員:stub!130!com/sun/corba/se/spi/presentation/rmi/DynamicStub!this$0!4112!com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl,!*!這裡可以暫時理解為分割符,有一個成員stub,型別com/sun/corba/se/spi/presentation/rmi/DynamicStub。因為是內部類,所以多了個this成員,這個this指向的是外部類

  • methods.dat

同樣找幾個比較有特徵的

類名 方法名 方法描述資訊 是否是靜態方法
sun/nio/cs/ext/Big5 newEncoder ()Ljava/nio/charset/CharsetEncoder; false
sun/nio/cs/ext/Big5_HKSCS$Decoder \<init> (Ljava/nio/charset/Charset;Lsun/nio/cs/ext/Big5_HKSCS$1;)V false

sun/nio/cs/ext/Big5#newEncoder:

  • 類名:sun/nio/cs/ext/Big5
  • 方法名: newEncoder
  • 方法描述資訊: ()Ljava/nio/charset/CharsetEncoder; 無參,返回java/nio/charset/CharsetEncoder物件
  • 是否是靜態方法:false

sun/nio/cs/ext/Big5_HKSCS$Decoder#\<init>:

  • 類名:sun/nio/cs/ext/Big5_HKSCS$Decoder
  • 方法名:\<init>
  • 方法描述資訊: (Ljava/nio/charset/Charset;Lsun/nio/cs/ext/Big5_HKSCS 1 ; ) V 1 j a v a / n i o / c h a r s e t / C h a r s e t 2 s u n / n i o / c s / e x t / B i g 5 H K S C S 1;)V引數1是java/nio/charset/Charset型別,引數2是sun/nio/cs/ext/Big5HKSCS1型別,返回值void
  • 是否是靜態方法:false

繼承關係的生成:

繼承關係在後面用來判斷一個類是否能被某個庫序列化、以及搜尋子類方法實現等會用到。

public class InheritanceDeriver {
    private static final Logger LOGGER = LoggerFactory.getLogger(InheritanceDeriver.class);
    public static InheritanceMap derive(Map<ClassReference.Handle, ClassReference> classMap) {
        LOGGER.debug("Calculating inheritance for " + (classMap.size()) + " classes...");
        Map<ClassReference.Handle, Set<ClassReference.Handle>> implicitInheritance = new HashMap<>();
        for (ClassReference classReference : classMap.values()) {
            if (implicitInheritance.containsKey(classReference.getHandle())) {
                throw new IllegalStateException("Already derived implicit classes for " + classReference.getName());
            }
            Set<ClassReference.Handle> allParents = new HashSet<>();
            getAllParents(classReference, classMap, allParents);//獲取當前類的所有父類
            implicitInheritance.put(classReference.getHandle(), allParents);
        }
        return new InheritanceMap(implicitInheritance);
    }
    ...
    ...
    private static void getAllParents(ClassReference classReference, Map<ClassReference.Handle, ClassReference> classMap, Set<ClassReference.Handle> allParents) {
        Set<ClassReference.Handle> parents = new HashSet<>();
        if (classReference.getSuperClass() != null) {
            parents.add(new ClassReference.Handle(classReference.getSuperClass()));//父類        }
        for (String iface : classReference.getInterfaces()) {
            parents.add(new ClassReference.Handle(iface));//介面類        }
        for (ClassReference.Handle immediateParent : parents) {
            //獲取間接父類,以及遞迴獲取間接父類的父類            ClassReference parentClassReference = classMap.get(immediateParent);
            if (parentClassReference == null) {
                LOGGER.debug("No class id for " + immediateParent.getName());
                continue;
            }
            allParents.add(parentClassReference.getHandle());
            getAllParents(parentClassReference, classMap, allParents);
        }
    }
    ...
    ...}

這一步的結果儲存到了inheritanceMap.dat:

直接父類+間接父類
com/sun/javaws/OperaPreferences P r e f e r e n c e S e c t i o n PreferenceSectionPreferenceEntryIterator java/lang/Object、java/util/Iterator
com/sun/java/swing/plaf/windows/WindowsLookAndFeel$XPValue java/lang/Object、javax/swing/UIDefaults$ActiveValue

Step2 生成passthrough資料流

這裡的passthrough資料流指的是每個方法的返回結果與方法引數的關係,這一步生成的資料會在生成passthrough呼叫圖時用到。

以作者給出的demo為例,先從宏觀層面判斷下:

FnConstant.invoke返回值與引數this(引數0,因為序列化時類的所有成員我們都能控制,所以所有成員變數都視為0參)、arg(引數1)的關係:

  • 與this的關係:返回了this.value,即與0參有關係
  • 與arg的關係:返回值與arg沒有任何關係,即與1參沒有關係
  • 結論就是FnConstant.invoke與引數0有關,表示為FnConstant.invoke()->0

Fndefault.invoke返回值與引數this(引數0)、arg(引數1)的關係:

  • 與this的關係:返回條件的第二個分支與this.f有關係,即與0參有關係
  • 與arg的關係:返回條件的第一個分支與arg有關係,即與1參有關係
  • 結論就是FnConstant.invoke與0參,1參都有關係,表示為Fndefault.invoke()->0、Fndefault.invoke()->1

在這一步中,gadgetinspector是利用ASM來進行方法位元組碼的分析,主要邏輯是在類PassthroughDiscovery和TaintTrackingMethodVisitor中。特別是TaintTrackingMethodVisitor,它透過標記追蹤JVM虛擬機器在執行方法時的stack和localvar,並最終得到返回結果是否可以被引數標記汙染。

核心實現程式碼(TaintTrackingMethodVisitor涉及到位元組碼分析,暫時先不看):

public class PassthroughDiscovery {
    private static final Logger LOGGER = LoggerFactory.getLogger(PassthroughDiscovery.class);
    private final Map<MethodReference.Handle, Set<MethodReference.Handle>> methodCalls = new HashMap<>();
    private Map<MethodReference.Handle, Set<Integer>> passthroughDataflow;
    public void discover(final ClassResourceEnumerator classResourceEnumerator, final GIConfig config) throws IOException {
        Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();//load之前儲存的methods.dat        Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();//load之前儲存的classes.dat        InheritanceMap inheritanceMap = InheritanceMap.load();//load之前儲存的inheritanceMap.dat
        Map<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);//查詢一個方法中包含的子方法        List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();//對所有方法構成的圖執行逆拓撲排序        passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods,
                config.getSerializableDecider(methodMap, inheritanceMap));//計算生成passthrough資料流,涉及到位元組碼分析    }
    ...
    ...
    private List<MethodReference.Handle> topologicallySortMethodCalls() {
        Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences = new HashMap<>();
        for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodCalls.entrySet()) {
            MethodReference.Handle method = entry.getKey();
            outgoingReferences.put(method, new HashSet<>(entry.getValue()));
        }
        // 對所有方法構成的圖執行逆拓撲排序        LOGGER.debug("Performing topological sort...");
        Set<MethodReference.Handle> dfsStack = new HashSet<>();
        Set<MethodReference.Handle> visitedNodes = new HashSet<>();
        List<MethodReference.Handle> sortedMethods = new ArrayList<>(outgoingReferences.size());
        for (MethodReference.Handle root : outgoingReferences.keySet()) {
            dfsTsort(outgoingReferences, sortedMethods, visitedNodes, dfsStack, root);
        }
        LOGGER.debug(String.format("Outgoing references %d, sortedMethods %d", outgoingReferences.size(), sortedMethods.size()));
        return sortedMethods;
    }
    ...
    ...
    private static void dfsTsort(Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences,
                                    List<MethodReference.Handle> sortedMethods, Set<MethodReference.Handle> visitedNodes,
                                    Set<MethodReference.Handle> stack, MethodReference.Handle node) {
        if (stack.contains(node)) {//防止在dfs一條方法呼叫鏈中進入迴圈            return;
        }
        if (visitedNodes.contains(node)) {//防止對某個方法及子方法重複排序            return;
        }
        Set<MethodReference.Handle> outgoingRefs = outgoingReferences.get(node);
        if (outgoingRefs == null) {
            return;
        }
        stack.add(node);
        for (MethodReference.Handle child : outgoingRefs) {
            dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, child);
        }
        stack.remove(node);
        visitedNodes.add(node);
        sortedMethods.add(node);
    }}

拓撲排序

有向無環圖(DAG)才有拓撲排序,非 DAG 圖沒有拓撲排序。 當有向無環圖滿足以下條件時:

  • 每一個頂點出現且只出現一次

  • 若A在序列中排在B的前面,則在圖中不存在從B到A的路徑

這樣的圖,是一個拓撲排序的圖。樹結構其實可以轉化為拓撲排序,而拓撲排序 不一定能夠轉化為樹。

以上面的拓撲排序圖為例,用一個字典表示圖結構

 graph = {
     "a": ["b","d"],
     "b": ["c"],
     "d": ["e","c"],
     "e": ["c"],
     "c": [],
 }

程式碼實現

graph = {
    "a": ["b","d"],
    "b": ["c"],
    "d": ["e","c"],
    "e": ["c"],
    "c": [],}def TopologicalSort(graph):
  degrees = dict((u, 0) for u in graph)
  for u in graph:
      for v in graph[u]:
          degrees[v] += 1
  #入度為0的插入佇列  queue = [u for u in graph if degrees[u] == 0]
  res = []
  while queue:
      u = queue.pop()
      res.append(u)
      for v in graph[u]:
          # 移除邊,即將當前元素相關元素的入度-1          degrees[v] -= 1
          if degrees[v] == 0:
              queue.append(v)
  return resprint(TopologicalSort(graph)) # ['a', 'd', 'e', 'b', 'c']

但是在方法的呼叫中,我們希望最後的結果是c、b、e、d、a,這一步需要逆拓撲排序,正向排序使用的BFS,那麼得到相反結果可以使用DFS。為什麼在方法呼叫中需要使用逆拓撲排序呢,這與生成passthrough資料流有關。看下面一個例子:

...
    public String parentMethod(String arg){
        String vul = Obj.childMethod(arg);
        return vul;
    }...

那麼這裡arg與返回值到底有沒有關係呢?假設Obj.childMethod為

...
    public String childMethod(String carg){
        return carg.toString();
    }...

由於childMethod的返回值carg與有關,那麼可以判定parentMethod的返回值與引數arg是有關係的。所以如果存在子方法呼叫並傳遞了父方法引數給子方法時,需要先判斷子方法返回值與子方法引數的關係。因此需要讓子方法的判斷在前面,這就是為什麼要進行逆拓撲排序。

從下圖可以看出outgoingReferences的資料結構為:

{
    method1:(method2,method3,method4),
    method5:(method1,method6),
    ...}

而這個結構正好適合逆拓撲排序

但是上面說拓撲排序時不能形成環,但是在方法呼叫中肯定是會存在環的。作者是如何避免的呢?

在上面的dfsTsort實現程式碼中可以看到使用了stack和visitedNodes,stack保證了在進行逆拓撲排序時不會形成環,visitedNodes避免了重複排序。使用如下一個呼叫圖來演示過程:

從圖中可以看到有環med1->med2->med6->med1,並且有重複的呼叫med3,嚴格來說並不能進行逆拓撲排序,但是透過stack、visited記錄訪問過的方法,就能實現逆拓撲排序。為了方便解釋把上面的圖用一個樹來表示:

對上圖進行逆拓撲排序(DFS方式):

從med1開始,先將med1加入stack中,此時stack、visited、sortedmethods狀態如下:

med1還有子方法?有,繼續深度遍歷。將med2放入stack,此時的狀態:

med2有子方法嗎?有,繼續深度遍歷。將med3放入stack,此時的狀態:

med3有子方法嗎?有,繼續深度遍歷。將med7放入stack,此時的狀態:

med7有子方法嗎?沒有,從stack中彈出med7並加入visited和sortedmethods,此時的狀態:

回溯到上一層,med3還有其他子方法嗎?有,med8,將med8放入stack,此時的狀態:

med8還有子方法嗎?沒有,彈出stack,加入visited與sortedmethods,此時的狀態:

回溯到上一層,med3還有其他子方法嗎?沒有了,彈出stack,加入visited與sortedmethods,此時的狀態:

回溯到上一層,med2還有其他子方法嗎?有,med6,將med6加入stack,此時的狀態:

med6還有子方法嗎?有,med1,med1在stack中?不加入,拋棄。此時狀態和上一步一樣

回溯到上一層,med6還有其他子方法嗎?沒有了,彈出stack,加入visited和sortedmethods,此時的狀態:

回溯到上一層,med2還有其他子方法嗎?沒有了,彈出stack,加入visited和sortedmethods,此時的狀態:

回溯到上一層,med1還有其他子方法嗎?有,med3,med3在visited中?在,拋棄。

回溯到上一層,med1還有其他子方法嗎?有,med4,將med4加入stack,此時的狀態:


med4還有其他子方法嗎?沒有,彈出stack,加入visited和sortedmethods中,此時的狀態:

回溯到上一層,med1還有其他子方法嗎?沒有了,彈出stack,加入visited和sortedmethods中,此時的狀態(即最終狀態):

所以最後的逆拓撲排序結果為:med7、med8、med3、med6、med2、med4、med1。

生成passthrough資料流

在calculatePassthroughDataflow中遍歷了sortedmethods,並透過位元組碼分析,生成了方法返回值與引數關係的passthrough資料流。注意到下面的序列化決定器,作者內建了三種:JDK、Jackson、Xstream,會根據具體的序列化決定器判定決策過程中的類是否符合對應庫的反序列化要求,不符合的就跳過:

  • 對於JDK(ObjectInputStream),類否繼承了Serializable介面
  • 對於Jackson,類是否存在0參構造器
  • 對於Xstream,類名能否作為有效的XML標籤

生成passthrough資料流程式碼:

...
    private static Map<MethodReference.Handle, Set<Integer>> calculatePassthroughDataflow(Map<String, ClassResourceEnumerator.ClassResource> classResourceByName,
                                                                                          Map<ClassReference.Handle, ClassReference> classMap,
                                                                                          InheritanceMap inheritanceMap,
                                                                                          List<MethodReference.Handle> sortedMethods,
                                                                                          SerializableDecider serializableDecider) throws IOException {
        final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>();
        for (MethodReference.Handle method : sortedMethods) {//依次遍歷sortedmethods,並且每個方法的子方法判定總在這個方法之前,這是透過的上面的逆拓撲排序實現的。            if (method.getName().equals("<clinit>")) {
                continue;
            }
            ClassResourceEnumerator.ClassResource classResource = classResourceByName.get(method.getClassReference().getName());
            try (InputStream inputStream = classResource.getInputStream()) {
                ClassReader cr = new ClassReader(inputStream);
                try {
                    PassthroughDataflowClassVisitor cv = new PassthroughDataflowClassVisitor(classMap, inheritanceMap,
                            passthroughDataflow, serializableDecider, Opcodes.ASM6, method);
                    cr.accept(cv, ClassReader.EXPAND_FRAMES);//透過結合classMap、inheritanceMap、已判定出的passthroughDataflow結果、序列化決定器資訊來判定當前method的返回值與引數的關係                    passthroughDataflow.put(method, cv.getReturnTaint());//將判定後的method與有關係的汙染點加入passthroughDataflow                } catch (Exception e) {
                    LOGGER.error("Exception analyzing " + method.getClassReference().getName(), e);
                }
            } catch (IOException e) {
                LOGGER.error("Unable to analyze " + method.getClassReference().getName(), e);
            }
        }
        return passthroughDataflow;
    }...

最後生成了passthrough.dat:

類名 方法名 方法描述 汙點
java/util/Collections$CheckedNavigableSet tailSet (Ljava/lang/Object;)Ljava/util/NavigableSet; 0,1
java/awt/RenderingHints put (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; 0,1,2

Step3 列舉passthrough呼叫圖

這一步和上一步類似,gadgetinspector 會再次掃描全部的Java方法,但檢查的不再是引數與返回結果的關係,而是方法的引數與其所呼叫的子方法的關係,即子方法的引數是否可以被父方法的引數所影響。那麼為什麼要進行上一步的生成passthrough資料流呢?由於這一步的判斷也是在位元組碼分析中,所以這裡只能先進行一些猜測,如下面這個例子:

...
    private MyObject obj;
    public void parentMethod(Object arg){
        ...
        TestObject obj1 = new TestObject();
        Object obj2 = obj1.childMethod1(arg);
        this.obj.childMethod(obj2); 
        ...
    }...

如果不進行生成passthrough資料流操作,就無法判斷TestObject.childMethod1的返回值是否會受到引數1的影響,也就無法繼續判斷parentMethod的arg引數與子方法MyObject.childmethod的引數傳遞關係。

作者給出的例子:

AbstractTableModel$ff19274a.hashcode與子方法IFn.invoke:

  • AbstractTableModel$ff19274a.hashcode的this(0參)傳遞給了IFn.invoke的1參,表示為0->IFn.invoke()@1

  • 由於f是透過this.__clojureFnMap(0參)獲取的,而f又為IFn.invoke()的this(0參),即AbstractTableModel$ff19274a.hashcode的0參傳遞給了IFn.invoke的0參,表示為0->IFn.invoke()@0

FnCompose.invoke與子方法IFn.invoke:

  • FnCompose.invoked的arg(1參)傳遞給了IFn.invoke的1參,表示為1->IFn.invoke()@1
  • f1為FnCompose的屬性(this,0參),被做為了IFn.invoke的this(0引數)傳遞,表示為0->IFn.invoke()@1
  • f1.invoke(arg)做為一個整體被當作1參傳遞給了IFn.invoke,由於f1在序列化時我們可以控制具體是IFn的哪個實現類,所以具體呼叫哪個實現類的invoke也相當於能夠控制,即f1.invoke(arg)這個整體可以視為0引數傳遞給了IFn.invoke的1參(這裡只是進行的簡單猜測,具體實現在位元組碼分析中,可能也體現了作者說的合理的風險判斷吧),表示為0->IFn.invoke()@1

在這一步中,gadgetinspector也是利用ASM來進行位元組碼的分析,主要邏輯是在類CallGraphDiscovery和ModelGeneratorClassVisitor中。在ModelGeneratorClassVisitor中透過標記追蹤JVM虛擬機器在執行方法時的stack和localvar,最終得到方法的引數與其所呼叫的子方法的引數傳遞關係。

生成passthrough呼叫圖程式碼(暫時省略ModelGeneratorClassVisitor的實現,涉及到位元組碼分析):

public class CallGraphDiscovery {
    private static final Logger LOGGER = LoggerFactory.getLogger(CallGraphDiscovery.class);
    private final Set<GraphCall> discoveredCalls = new HashSet<>();
    public void discover(final ClassResourceEnumerator classResourceEnumerator, GIConfig config) throws IOException {
        Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();//載入所有方法        Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();//載入所有類        InheritanceMap inheritanceMap = InheritanceMap.load();//載入繼承圖        Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = PassthroughDiscovery.load();//載入passthrough資料流
        SerializableDecider serializableDecider = config.getSerializableDecider(methodMap, inheritanceMap);//序列化決定器
        for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
            try (InputStream in = classResource.getInputStream()) {
                ClassReader cr = new ClassReader(in);
                try {
                    cr.accept(new ModelGeneratorClassVisitor(classMap, inheritanceMap, passthroughDataflow, serializableDecider, Opcodes.ASM6),
                            ClassReader.EXPAND_FRAMES);//透過結合classMap、inheritanceMap、passthroughDataflow結果、序列化決定器資訊來判定當前method引數與子方法傳遞呼叫關係                } catch (Exception e) {
                    LOGGER.error("Error analyzing: " + classResource.getName(), e);
                }
            }
        }
    }

最後生成了passthrough.dat:

父方法類名 父方法 父方法描述 子方法類名 子方法子 方法描述 父方法第幾參 引數物件的哪個field被傳遞 子方法第幾參
java/io/PrintStream write (Ljava/lang/String;)V java/io/OutputStream flush ()V 0 out 0
javafx/scene/shape/Shape setSmooth (Z)V javafx/scene/shape/Shape smoothProperty ()Ljavafx/beans/property/BooleanProperty; 0
0

Step4 搜尋可用的source

這一步會根據已知的反序列化漏洞的入口,檢查所有可以被觸發的方法。例如,在利用鏈中使用代理時,任何可序列化並且是 java/lang/reflect/InvocationHandler子類的invoke方法都可以視為source。這裡還會根據具體的反序列化庫決定類是否能被序列化。

搜尋可用的source:

public class SimpleSourceDiscovery extends SourceDiscovery {
    @Override    public void discover(Map<ClassReference.Handle, ClassReference> classMap,
                         Map<MethodReference.Handle, MethodReference> methodMap,
                         InheritanceMap inheritanceMap) {
        final SerializableDecider serializableDecider = new SimpleSerializableDecider(inheritanceMap);
        for (MethodReference.Handle method : methodMap.keySet()) {
            if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {
                if (method.getName().equals("finalize") && method.getDesc().equals("()V")) {
                    addDiscoveredSource(new Source(method, 0));
                }
            }
        }
        // 如果類實現了readObject,則傳入的ObjectInputStream被認為是汙染的        for (MethodReference.Handle method : methodMap.keySet()) {
            if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {
                if (method.getName().equals("readObject") && method.getDesc().equals("(Ljava/io/ObjectInputStream;)V")) {
                    addDiscoveredSource(new Source(method, 1));
                }
            }
        }
        // 使用代理技巧時,任何擴充套件了serializable and InvocationHandler的類會受到汙染。        for (ClassReference.Handle clazz : classMap.keySet()) {
            if (Boolean.TRUE.equals(serializableDecider.apply(clazz))
                    && inheritanceMap.isSubclassOf(clazz, new ClassReference.Handle("java/lang/reflect/InvocationHandler"))) {
                MethodReference.Handle method = new MethodReference.Handle(
                        clazz, "invoke", "(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;");
                addDiscoveredSource(new Source(method, 0));
            }
        }
        // hashCode()或equals()是將物件放入HashMap的標準技巧的可訪問入口點        for (MethodReference.Handle method : methodMap.keySet()) {
            if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {
                if (method.getName().equals("hashCode") && method.getDesc().equals("()I")) {
                    addDiscoveredSource(new Source(method, 0));
                }
                if (method.getName().equals("equals") && method.getDesc().equals("(Ljava/lang/Object;)Z")) {
                    addDiscoveredSource(new Source(method, 0));
                    addDiscoveredSource(new Source(method, 1));
                }
            }
        }
        // 使用比較器代理,可以跳轉到任何groovy Closure的call()/doCall()方法,所有的args都被汙染        // (MethodReference.Handle method : methodMap.keySet()) {
            if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))
                    && inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/Closure"))
                    && (method.getName().equals("call") || method.getName().equals("doCall"))) {
                addDiscoveredSource(new Source(method, 0));
                Type[] methodArgs = Type.getArgumentTypes(method.getDesc());
                for (int i = 0; i < methodArgs.length; i++) {
                    addDiscoveredSource(new Source(method, i + 1));
                }
            }
        }
    }...

這一步的結果會儲存在檔案sources.dat中:

方法 方法描述 汙染引數
java/awt/color/ICC_Profile finalize ()V 0
java/lang/Enum readObject (Ljava/io/ObjectInputStream;)V 1

Step5 搜尋生成呼叫鏈

這一步會遍歷全部的source,並在callgraph.dat中遞迴查詢所有可以繼續傳遞汙點引數的子方法呼叫,直至遇到sink中的方法。

搜尋生成呼叫鏈:

public class GadgetChainDiscovery {
    private static final Logger LOGGER = LoggerFactory.getLogger(GadgetChainDiscovery.class);
    private final GIConfig config;
    public GadgetChainDiscovery(GIConfig config) {
        this.config = config;
    }
    public void discover() throws Exception {
        Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
        InheritanceMap inheritanceMap = InheritanceMap.load();
        Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver.getAllMethodImplementations(
                inheritanceMap, methodMap);//得到方法的所有子類方法實現(被子類重寫的方法)
        final ImplementationFinder implementationFinder = config.getImplementationFinder(
                methodMap, methodImplMap, inheritanceMap);
        //將方法的所有子類方法實現儲存到methodimpl.dat        try (Writer writer = Files.newBufferedWriter(Paths.get("methodimpl.dat"))) {
            for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodImplMap.entrySet()) {
                writer.write(entry.getKey().getClassReference().getName());
                writer.write("\t");
                writer.write(entry.getKey().getName());
                writer.write("\t");
                writer.write(entry.getKey().getDesc());
                writer.write("\n");
                for (MethodReference.Handle method : entry.getValue()) {
                    writer.write("\t");
                    writer.write(method.getClassReference().getName());
                    writer.write("\t");
                    writer.write(method.getName());
                    writer.write("\t");
                    writer.write(method.getDesc());
                    writer.write("\n");
                }
            }
        }
        //方法呼叫map,key為父方法,value為子方法與父方法引數傳遞關係        Map<MethodReference.Handle, Set<GraphCall>> graphCallMap = new HashMap<>();
        for (GraphCall graphCall : DataLoader.loadData(Paths.get("callgraph.dat"), new GraphCall.Factory())) {
            MethodReference.Handle caller = graphCall.getCallerMethod();
            if (!graphCallMap.containsKey(caller)) {
                Set<GraphCall> graphCalls = new HashSet<>();
                graphCalls.add(graphCall);
                graphCallMap.put(caller, graphCalls);
            } else {
                graphCallMap.get(caller).add(graphCall);
            }
        }
        //exploredMethods儲存在呼叫鏈從查詢過程中已經訪問過的方法節點,methodsToExplore儲存呼叫鏈        Set<GadgetChainLink> exploredMethods = new HashSet<>();
        LinkedList<GadgetChain> methodsToExplore = new LinkedList<>();
        //載入所有sources,並將每個source作為每條鏈的第一個節點        for (Source source : DataLoader.loadData(Paths.get("sources.dat"), new Source.Factory())) {
            GadgetChainLink srcLink = new GadgetChainLink(source.getSourceMethod(), source.getTaintedArgIndex());
            if (exploredMethods.contains(srcLink)) {
                continue;
            }
            methodsToExplore.add(new GadgetChain(Arrays.asList(srcLink)));
            exploredMethods.add(srcLink);
        }
        long iteration = 0;
        Set<GadgetChain> discoveredGadgets = new HashSet<>();
        //使用廣度優先搜尋所有從source到sink的呼叫鏈        while (methodsToExplore.size() > 0) {
            if ((iteration % 1000) == 0) {
                LOGGER.info("Iteration " + iteration + ", Search space: " + methodsToExplore.size());
            }
            iteration += 1;
            GadgetChain chain = methodsToExplore.pop();//從隊首彈出一條鏈            GadgetChainLink lastLink = chain.links.get(chain.links.size()-1);//取這條鏈最後一個節點
            Set<GraphCall> methodCalls = graphCallMap.get(lastLink.method);//獲取當前節點方法所有子方法與當前節點方法引數傳遞關係            if (methodCalls != null) {
                for (GraphCall graphCall : methodCalls) {
                    if (graphCall.getCallerArgIndex() != lastLink.taintedArgIndex) {
                        //如果當前節點方法的汙染引數與當前子方法受父方法引數影響的Index不一致則跳過                        continue;
                    }
                    Set<MethodReference.Handle> allImpls = implementationFinder.getImplementations(graphCall.getTargetMethod());//獲取子方法所在類的所有子類重寫方法
                    for (MethodReference.Handle methodImpl : allImpls) {
                        GadgetChainLink newLink = new GadgetChainLink(methodImpl, graphCall.getTargetArgIndex());//新方法節點                        if (exploredMethods.contains(newLink)) {
                            //如果新方法已近被訪問過了,則跳過,這裡能減少開銷。但是這一步跳過會使其他鏈/分支鏈經過此節點時,由於已經此節點被訪問過了,鏈會在這裡斷掉。那麼如果這個條件去掉就能實現找到所有鏈了嗎?這裡去掉會遇到環狀問題,造成路徑無限增加...                            continue;
                        }
                        GadgetChain newChain = new GadgetChain(chain, newLink);//新節點與之前的鏈組成新鏈                        if (isSink(methodImpl, graphCall.getTargetArgIndex(), inheritanceMap)) {//如果到達了sink,則加入discoveredGadgets                            discoveredGadgets.add(newChain);
                        } else {
                            //新鏈加入佇列                            methodsToExplore.add(newChain);
                            //新節點加入已訪問集合                            exploredMethods.add(newLink);
                        }
                    }
                }
            }
        }
        //儲存搜尋到的利用鏈到gadget-chains.txt        try (OutputStream outputStream = Files.newOutputStream(Paths.get("gadget-chains.txt"));
             Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
            for (GadgetChain chain : discoveredGadgets) {
                printGadgetChain(writer, chain);
            }
        }
        LOGGER.info("Found {} gadget chains.", discoveredGadgets.size());
    }...

作者給出的sink方法:

private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) {
        if (method.getClassReference().getName().equals("java/io/FileInputStream")
                && method.getName().equals("<init>")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/io/FileOutputStream")
                && method.getName().equals("<init>")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/nio/file/Files")
                && (method.getName().equals("newInputStream")
                || method.getName().equals("newOutputStream")
                || method.getName().equals("newBufferedReader")
                || method.getName().equals("newBufferedWriter"))) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/lang/Runtime")
                && method.getName().equals("exec")) {
            return true;
        }
        /*
        if (method.getClassReference().getName().equals("java/lang/Class")
                && method.getName().equals("forName")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/lang/Class")
                && method.getName().equals("getMethod")) {
            return true;
        }
        */
        // If we can invoke an arbitrary method, that's probably interesting (though this doesn't assert that we        // can control its arguments). Conversely, if we can control the arguments to an invocation but not what        // method is being invoked, we don't mark that as interesting.        if (method.getClassReference().getName().equals("java/lang/reflect/Method")
                && method.getName().equals("invoke") && argIndex == 0) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/net/URLClassLoader")
                && method.getName().equals("newInstance")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/lang/System")
                && method.getName().equals("exit")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/lang/Shutdown")
                && method.getName().equals("exit")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/lang/Runtime")
                && method.getName().equals("exit")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/nio/file/Files")
                && method.getName().equals("newOutputStream")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/lang/ProcessBuilder")
                && method.getName().equals("<init>") && argIndex > 0) {
            return true;
        }
        if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("java/lang/ClassLoader"))
                && method.getName().equals("<init>")) {
            return true;
        }
        if (method.getClassReference().getName().equals("java/net/URL") && method.getName().equals("openStream")) {
            return true;
        }
        // Some groovy-specific sinks        if (method.getClassReference().getName().equals("org/codehaus/groovy/runtime/InvokerHelper")
                && method.getName().equals("invokeMethod") && argIndex == 1) {
            return true;
        }
        if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/MetaClass"))
                && Arrays.asList("invokeMethod", "invokeConstructor", "invokeStaticMethod").contains(method.getName())) {
            return true;
        }
        return false;
    }

對於每個入口節點來說,其全部子方法呼叫、孫子方法呼叫等等遞迴下去,就構成了一棵樹。之前的步驟所做的,就相當於生成了這顆樹,而這一步所做的,就是從根節點出發,找到一條通往葉子節點的道路,使得這個葉子節點正好是我們所期望的sink方法。gadgetinspector對樹的遍歷採用的是廣度優先(BFS),而且對於已經檢查過的節點會直接跳過,這樣減少了執行開銷,避免了環路,但是丟掉了很多其他鏈。

這個過程看起來就像下面這樣:

透過汙點的傳遞,最終找到從source->sink的利用鏈

:targ表示汙染引數的index,0->1這樣的表示父方法的0參傳遞給了子方法的1參

樣例分析

現在根據作者的樣例寫個具體的demo例項來測試下上面這些步驟。

demo如下:

IFn.java:
    package com.demo.ifn;
    import java.io.IOException;
    public interface IFn {
        public Object invokeCall(Object arg) throws IOException;
    }FnEval.java    package com.demo.ifn;
    import java.io.IOException;
    import java.io.Serializable;
    public class FnEval implements IFn, Serializable {
        public FnEval() {
        }
        public Object invokeCall(Object arg) throws IOException {
            return Runtime.getRuntime().exec((String) arg);
        }
    }FnConstant.java:
    package com.demo.ifn;
    import java.io.Serializable;
    public class FnConstant implements IFn , Serializable {
        private Object value;
        public FnConstant(Object value) {
            this.value = value;
        }
        public Object invokeCall(Object arg) {
            return value;
        }
    }FnCompose.java:
    package com.demo.ifn;
    import java.io.IOException;
    import java.io.Serializable;
    public class FnCompose implements IFn, Serializable {
        private IFn f1, f2;
        public FnCompose(IFn f1, IFn f2) {
            this.f1 = f1;
            this.f2 = f2;
        }
        public Object invokeCall(Object arg) throws IOException {
            return f2.invokeCall(f1.invokeCall(arg));
        }
    }TestDemo.java:
    package com.demo.ifn;
    public class TestDemo {
        //測試拓撲排序的正確性        private String test;
        public String pMethod(String arg){
            String vul = cMethod(arg);
            return vul;
        }
        public String cMethod(String arg){
            return arg.toUpperCase();
        }
    }AbstractTableModel.java:
    package com.demo.model;
    import com.demo.ifn.IFn;
    import java.io.IOException;
    import java.io.Serializable;
    import java.util.HashMap;
    public class AbstractTableModel implements Serializable {
        private HashMap<String, IFn> __clojureFnMap;
        public AbstractTableModel(HashMap<String, IFn> clojureFnMap) {
            this.__clojureFnMap = clojureFnMap;
        }
        public int hashCode() {
            IFn f = __clojureFnMap.get("hashCode");
            try {
                f.invokeCall(this);
            } catch (IOException e) {
                e.printStackTrace();
            }
            return this.__clojureFnMap.hashCode() + 1;
        }
    }

:下面截圖中資料的順序做了調換,同時資料也只給出com/demo中的資料

Step1 列舉全部類及每個類所有方法

classes.dat:

methods.dat:

Step2 生成passthrough資料流

passthrough.dat:

可以看到IFn的子類中只有FnConstant的invokeCall在passthrough資料流中,因為其他幾個在靜態分析中無法判斷返回值與引數的關係。同時TestDemo的cMethod與pMethod都在passthrough資料流中,這也說明了拓撲排序那一步的必要性和正確性。

Step3 列舉passthrough呼叫圖

callgraph.dat:

Step4 搜尋可用的source

sources.dat:

Step5 搜尋生成呼叫鏈

在gadget-chains.txt中找到了如下鏈:

com/demo/model/AbstractTableModel.hashCode()I (0)
  com/demo/ifn/FnEval.invokeCall(Ljava/lang/Object;)Ljava/lang/Object; (1)
  java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process; (1)

可以看到選擇的確實是找了一條最短的路徑,並沒有經過FnCompose、FnConstant路徑。

環路造成路徑爆炸

上面流程分析第五步中說到,如果去掉已訪問過節點的判斷會怎麼樣呢,能不能生成經過FnCompose、FnConstant的呼叫鏈呢?

陷入了爆炸狀態,Search space無限增加,其中必定存在環路。作者使用的策略是訪問過的節點就不再訪問了,這樣解決的環路問題,但是丟失了其他鏈。

比如上面的FnCompose類:

public class Fncompose implements IFn{
    private IFn f1,f2;
    public Object invoke(Object arg){
        return f2.invoke(f1.invoke(arg));
    }}

由於IFn是介面,所以在呼叫鏈生成中會查詢是它的子類,假如f1,f2都是FnCompose類的物件,這樣形成了環路。

隱式呼叫

測試隱式呼叫看工具能否發現,將FnEval.java做一些修改:

FnEval.java    package com.demo.ifn;
    import java.io.IOException;
    import java.io.Serializable;
    public class FnEval implements IFn, Serializable {
        private String cmd;
        public FnEval() {
        }
        @Override        public String toString() {
            try {
                Runtime.getRuntime().exec(this.cmd);
            } catch (IOException e) {
                e.printStackTrace();
            }
            return "FnEval{}";
        }
        public Object invokeCall(Object arg) throws IOException {
            this.cmd = (String) arg;
            return this + " test";
        }
    }

結果:

com/demo/model/AbstractTableModel.hashCode()I (0)
  com/demo/ifn/FnEval.invokeCall(Ljava/lang/Object;)Ljava/lang/Object; (0)
  java/lang/StringBuilder.append(Ljava/lang/Object;)Ljava/lang/StringBuilder; (1)
  java/lang/String.valueOf(Ljava/lang/Object;)Ljava/lang/String; (0)
  com/demo/ifn/FnEval.toString()Ljava/lang/String; (0)
  java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process; (1)

隱式呼叫了tostring方法,說明在位元組碼分析中做了查詢隱式呼叫這一步。

不遵循反射呼叫

在github的工具說明中,作者也說到了在靜態分析中這個工具的盲點,像下面這中 FnEval.class.getMethod("exec", String.class).invoke(null, arg)寫法是不遵循反射呼叫的,將FnEval.java修改:

FnEval.java    package com.demo.ifn;import java.io.IOException;import java.io.Serializable;import java.lang.reflect.InvocationTargetException;public class FnEval implements IFn, Serializable {
    public FnEval() {
    }
    public static void exec(String arg) throws IOException {
        Runtime.getRuntime().exec(arg);
    }
    public Object invokeCall(Object arg) throws IOException {
        try {
            return FnEval.class.getMethod("exec", String.class).invoke(null, arg);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        return null;
    }}

經過測試,確實沒有發現。但是將 FnEval.class.getMethod("exec", String.class).invoke(null, arg)改為 this.getClass().getMethod("exec", String.class).invoke(null, arg)這種寫法卻是可以發現的。

特殊語法

測試一下比較特殊的語法呢,比如lambda語法?將FnEval.java做一些修改:

FnEval.java:
    package com.demo.ifn;
    import java.io.IOException;
    import java.io.Serializable;
    public class FnEval implements IFn, Serializable {
        public FnEval() {
        }
        interface ExecCmd {
            public Object exec(String cmd) throws IOException;
        }
        public Object invokeCall(Object arg) throws IOException {
            ExecCmd execCmd = cmd -> {
                return Runtime.getRuntime().exec(cmd);
            };
            return execCmd.exec((String) arg);
        }
    }

經過測試,沒有檢測到這條利用鏈。說明目前語法分析那一塊還沒有對特殊語法分析。

匿名內部類

測試匿名內部類,將FnEval.java做一些修改:

FnEval.java:
    package com.demo.ifn;
    import java.io.IOException;
    import java.io.Serializable;
    public class FnEval implements IFn, Serializable {
        public FnEval() {
        }
        interface ExecCmd {
            public Object exec(String cmd) throws IOException;
        }
        public Object callExec(ExecCmd execCmd, String cmd) throws IOException {
            return execCmd.exec(cmd);
        }
        public Object invokeCall(Object arg) throws IOException {
            return callExec(new ExecCmd() {
                @Override                public Object exec(String cmd) throws IOException {
                    return Runtime.getRuntime().exec(cmd);
                }
            }, (String) arg);
        }
    }

經過測試,沒有檢測到這條利用鏈。說明目前語法分析那一塊還沒有對匿名內部類的分析。

sink->source?

既然能source->sink,那麼能不能sink->source呢?因為搜尋source->sink時,source和sink都是已知的,如果搜尋sink->source時,sink與soure也是已知的,那麼source->sink與sink->source好像沒有什麼區別?如果能將source總結為引數可控的一類特徵,那麼sink->source這種方式是一種非常好的方式,不僅能用在反序列化漏洞中,還能用在其他漏洞中(例如模板注入)。但是這裡也還有一些問題,比如反序列化是將this以及類的屬性都當作了0參,因為反序列化時這些都是可控的,但是在其他漏洞中這些就不一定可控了。

目前還不知道具體如何實現以及會有哪些問題,暫時先不寫。

缺陷

目前還沒有做過大量測試,只是從宏觀層面分析了這個工具的大致原理。結合平安集團 分析文章以及上面的測試目前可以總結出一下幾個缺點(不止這些缺陷):

  • callgraph生成不完整
  • 呼叫鏈搜尋結果不完整,這是由於查詢策略導致的
  • 一些特殊語法、匿名內部類還不支援
  • ...

設想與改進

  • 對以上幾個缺陷進行改進

  • 結合已知的利用鏈(如ysoserial等)不斷測試

  • 儘可能列出所有鏈並結合人工篩選判斷,而作者使用的策略是隻要經過這個節點有一條鏈,其他鏈經過這個節點時就不再繼續尋找下去。主要解決的就是最後那個呼叫鏈環路問題,目前看到幾種方式:

  • DFS+最大深度限制

  • 繼續使用BFS,人工檢查生成的呼叫鏈,把無效的callgraph去掉,重複執行
  • 呼叫鏈快取(這一個暫時還沒明白具體怎麼解決環路的,只是看到了這個方法)

我的想法是在每條鏈中維持一個黑名單,每次都檢查是否出現了環路,如果在這條鏈中出現了環路,將造成環路的節點加入黑名單,繼續使其走下去。當然雖然沒有了環,也能會出現路徑無限增長的情況,所以還是需要加入路徑長度限制。

  • 嘗試sink->source的實現

  • 多執行緒同時搜尋多條利用鏈加快速度

  • ...

最後

在原理分析的時候,忽略了位元組碼分析的細節,有的地方只是暫時猜測與測試得出的結果,所以可能存在一些錯誤。位元組碼分析那一塊是很重要的一環,它對汙點的判斷、汙點的傳遞呼叫等起著很重要的作用,如果這些部分出現了問題,整個搜尋過程就會出現問題。由於ASM框架對使用人員要求較高,所以需要要掌握JVM相關的知識才能較好使用ASM框架,所以接下來的就是開始學習JVM相關的東西。這篇文章只是從宏觀層面分析這個工具的原理,也算是給自己增加些信心,至少明白這個工具不是無法理解和無法改進的,同時後面再接觸這個工具進行改進時也會間隔一段時間,回顧起來也方便,其他人如果對這個工具感興趣也可以參考。等以後熟悉並能操縱Java位元組碼了,在回頭來更新這篇文章並改正可能有錯誤的地方。

如果這些設想與改進真的實現並且進行了驗證,那麼這個工具真的是一個得力幫手。但是這些東西要實現還有較長的一段路要走,還沒開始實現就預想到了那麼多問題,在實現的時候會遇到更多問題。不過好在有一個大致的方向了,接下來就是對各個環節逐一解決了。

參考


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912109/viewspace-2657316/,如需轉載,請註明出處,否則將追究法律責任。

相關文章