Java Instrumentation

2022-11-15 18:00:18

前言

JDK 1.5 開始,Java新增了 Instrumentation ( Java Agent API )和 JVMTI ( JVM Tool Interface )功能,允許JVM在載入某個 class 檔案之前對其位元組碼進行修改,同時也支援對已載入的 class (類位元組碼)進行重新載入( Retransform )。

在1.6版本新增了attach(附加方式),可以對執行中的Java程序插入Agent,instrumentation包被賦予了更強大的功能:啟動後的 instrument、原生程式碼(native code)instrument,以及動態改變 classpath 等等。這些改變,意味著 Java 具有了更強的動態控制、解釋能力,它使得 Java 語言變得更加靈活多變。

java.lang.instrument包的具體實現,依賴於 JVMTI。JVMTI(Java Virtual Machine Tool Interface)是一套由 Java 虛擬機器器提供的,為 JVM 相關的工具提供的本地程式設計介面集合。JVMTI 是從 Java SE 5 開始引入,整合和取代了以前使用的 Java Virtual Machine Profiler Interface (JVMPI) 和 the Java Virtual Machine Debug Interface (JVMDI),而在 Java SE 6 中,JVMPI 和 JVMDI 已經消失了。JVMTI 提供了一套」代理」程式機制,可以支援第三方工具程式以代理的方式連線和存取 JVM,並利用 JVMTI 提供的豐富的程式設計介面,完成很多跟 JVM 相關的功能

Java Agent可以去實現位元組碼插樁、動態跟蹤分析等,比如RASP產品和Java Agent記憶體馬。

原始碼

java.lang.instrument包位於rt.jar共包含如下類和介面:

1.IllegalClassFormatException 異常類

此異常為非法的位元組碼格式化異常,由ClassFileTransformer.transform 的實現丟擲。

丟擲此異常的原因是由於初始類檔案位元組無效,或者由於以前應用的轉換損壞了位元組碼。

2. UnmodifiableClassException 異常類

當程式無法修改制定的類時,會丟擲該異常。由 Instrumentation.redefineClasses 的實現丟擲。

3. ClassDefinition 繫結/定義類

public final class ClassDefinition {
    /**
     *  要重定義的類
     */
    private final Class<?> mClass;

    /**
     *  用於替換的本地 class ,為 byte 陣列
     */
    private final byte[]   mClassFile;

    /**
     *  構造方法,使用提供的類和類檔案位元組建立一個新的 ClassDefinition 繫結
     */
    public ClassDefinition( Class<?> theClass, byte[]  theClassFile) {
        if (theClass == null || theClassFile == null) {
            throw new NullPointerException();
        }
        mClass      = theClass;
        mClassFile  = theClassFile;
    }

    /**
     * 以下為 getter 方法
     */
    public Class<?>  getDefinitionClass() {
        return mClass;
    }

    public byte[] getDefinitionClassFile() {
        return mClassFile;
    }
}

4. ClassFileTransformer 介面

此介面為轉換類檔案的代理介面。提供了 transform() 方法用於修改原類的注入。
我們可以在獲取到 Instrumentation物件後通過 addTransformer() 方法新增自定義類檔案轉換器。

public interface ClassFileTransformer {


    /**
     * 類檔案轉換方法,重寫transform方法可獲取到待載入的類相關資訊
     *
     * @param loader              定義要轉換的類載入器;如果是引導載入器,則為 null
     * @param className           類名,如:java/lang/Runtime
     * @param classBeingRedefined 如果是被重定義或重轉換觸發,則為重定義或重轉換的類;如果是類載入,則為 null
     * @param protectionDomain    要定義或重定義的類的保護域
     * @param classfileBuffer     類檔案格式的輸入位元組緩衝區(不得修改)
     * @return 返回一個通過ASM修改後新增了防禦程式碼的位元組碼byte陣列。
     */
        byte[] transform(  ClassLoader loader, 
                String className,
                Class<?> classBeingRedefined,
                ProtectionDomain protectionDomain,
                byte[] classfileBuffer)
        throws IllegalClassFormatException;
}

重寫 transform() 方法需要注意以下事項:

  1. ClassLoader 如果是被 Bootstrap ClassLoader (引導類載入器)所載入那麼 loader 引數的值是空。
  2. 修改類位元組碼時需要特別注意插入的程式碼在對應的 ClassLoader 中可以正確的獲取到,否則會報 ClassNotFoundException ,比如修改 java.io.FileInputStream (該類由 Bootstrap ClassLoader 載入)時插入了我們檢測程式碼,那麼我們將必須保證 FileInputStream 能夠獲取到我們的檢測程式碼類。
  3. JVM類名的書寫方式路徑方式:java/lang/String 而不是我們常用的類名方式:java.lang.String
  4. 類位元組必須符合 JVM 校驗要求,如果無法驗證類位元組碼會導致 JVM 崩潰或者 VerifyError (類驗證錯誤)。
  5. 如果修改的是 retransform 類(修改已被 JVM 載入的類),修改後的類位元組碼不得新增方法、修改方法引數、類成員變數。
  6. addTransformer 時如果沒有傳入 retransform 引數(預設是 false ),就算 MANIFEST.MF 中設定了 Can-Redefine-Classes: true 而且手動呼叫了retransformClasses()方法也一樣無法retransform。
  7. 解除安裝 transform 時需要使用建立時的 Instrumentation 範例。

在以下三種情形下 ClassFileTransformer.transform() 會被執行:

  1. 新的 class 被載入。
  2. Instrumentation.redefineClasses 顯式呼叫。
  3. addTransformer 第二個引數為 true 時,Instrumentation.retransformClasses 顯式呼叫。

5. Instrumentation 介面

java.lang.instrument.Instrumentation 是 Java 提供的監測執行在 JVM 程式的 API 。利用 Instrumentation 我們可以實現如下功能:

類方法 功能
void addTransformer(ClassFileTransformer transformer, boolean canRetransform) 新增一個 Transformer,是否允許 reTransformer
void addTransformer(ClassFileTransformer transformer) 新增一個 Transformer
boolean removeTransformer(ClassFileTransformer transformer) 移除一個 Transformer
boolean isRetransformClassesSupported() 檢測是否允許 reTransformer
void retransformClasses(Class<?>... classes) 過載入(retransform)類
boolean isModifiableClass(Class<?> theClass) 確定一個類是否可以被 retransformation 或 redefinition 修改
Class[] getAllLoadedClasses() 獲取 JVM 當前載入的所有類
Class[] getInitiatedClasses(ClassLoader loader) 獲取指定類載入器下所有已經初始化的類
long getObjectSize(Object objectToSize) 返回指定物件大小
void appendToBootstrapClassLoaderSearch(JarFile jarfile) 新增到 BootstrapClassLoader 搜尋
void appendToSystemClassLoaderSearch(JarFile jarfile) 新增到 SystemClassLoader 搜尋
boolean isNativeMethodPrefixSupported() 是否支援設定 native 方法 Prefix
void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix) 通過允許重試,將字首應用到名稱,此方法修改本機方法解析的失敗處理
boolean isRedefineClassesSupported() 是否支援類 redefine
void redefineClasses(ClassDefinition... definitions) 重定義(redefine)類

原理

這部分由於參考作者 throwable 總結較好,直接參照。

instrument 的底層實現依賴於 JVMTI ,也就是 JVM Tool Interface ,它是 JVM 暴露出來的一些供使用者擴充套件的介面集合, JVMTI 是基於事件驅動的, JVM 每執行到一定的邏輯就會呼叫一些事件的回撥介面(如果有的話),這些介面可以供開發者去擴充套件自己的邏輯。 JVMTIAgent 是一個利用 JVMTI 暴露出來的介面提供了代理啟動時載入(agent on load)、代理通過 attach 形式載入(agent on attach)和代理解除安裝(agent on unload)功能的動態庫。而 instrument agent 可以理解為一類 JVMTIAgent 動態庫,別名是 JPLISAgent (Java Programming Language Instrumentation Services Agent),也就是專門為 Java 語言編寫的插樁服務提供支援的代理

使用

Java agent的使用方式有兩種:
實現premain方法,在JVM啟動前載入。
實現agentmain方法,在JVM啟動後載入
premainagentmain函數宣告如下,方法名相同情況下,擁有Instrumentation inst引數的方法優先順序更高:

public static void agentmain(String agentArgs, Instrumentation inst) {
    ...
}

public static void agentmain(String agentArgs) {
    ...
}

public static void premain(String agentArgs, Instrumentation inst) {
    ...
}

public static void premain(String agentArgs) {
    ...
}

JVM 會優先載入帶 Instrumentation 簽名的方法,載入成功忽略第二種;如果第一種沒有,則載入第二種方法。

  • 第一個引數String agentArgs就是Java agent的引數。

  • Inst 是一個 java.lang.instrument.Instrumentation 的範例,可以用來類定義的轉換和操作等等。

premain方式

JVM啟動時 會先執行 premain 方法,大部分類載入都會通過該方法,注意:是大部分,不是所有。遺漏的主要是系統類,因為很多系統類先於 agent 執行,而使用者類的載入肯定是會被攔截的。也就是說,這個方法是在 main 方法啟動前攔截大部分類的載入活動,既然可以攔截類的載入,就可以結合第三方的位元組碼編譯工具,比如ASM,javassist,cglib等等來改寫實現類。

大致分為以下邏輯:

  1. 編寫一個Agent類,其中定義premain方法並呼叫Instrumentation#addTransformer方法新增一個自定義的Transformer
  2. 自定義一個Transformer類,實現Instrumentation介面,在transform方法中寫入自己想要的AOP邏輯
  3. 建立MANIFEST.MF檔案,可以手動寫也可以通過Maven的外掛(pom.xml)
  4. 打包Agent的jar包
  5. 在需要使用JavaAgent的專案新增JVM啟動引數-javaagent並指定我們打包好的jar

這裡需要2個專案,1個為javaagent的jar包,另1個為被javaagent代理的類。最終在被代理類的main方法執行前先執行我們Agent中的premain方法

編寫javaagent相關程式碼
建立一個Maven專案,其中建立一個Premain類,裡面需要包含premain方法

package org.gk0d;

import java.lang.instrument.Instrumentation;

public class Premain {
    public static void premain(String agentArgs, Instrumentation inst){
        System.out.println("agentArgs"+agentArgs);
        inst.addTransformer(new DefineTransformer(),true);//呼叫addTransformer新增一個Transformer
    }

}

建立DefineTransformer類,實現ClassFileTransformer介面

package org.gk0d;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("premain load class"+className); //列印載入的類
        return new byte[0];
    }
}

建立MANIFEST.MF檔案
此時專案如果打成jar包,缺少入口main檔案,所以需要自己定義一個MANIFEST.MF檔案,用於指明premain的入口在哪裡:

手動建立的話需要在resources/META-INF目錄下建立MANIFEST.MF檔案,內容如下:注意多留一行空行

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.gk0d.Premain

或者通過pom.xml中呼叫Maven的外掛去建立該檔案

   <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <archive>
                        <!--自動新增META-INF/MANIFEST.MF -->
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Premain-Class>org.gk0d.Premain</Premain-Class>
                            <Agent-Class>org.gk0d.Premain</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>6</source>
                    <target>6</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

以下是MANIFEST.MF的其他選項

Premain-Class :包含 premain 方法的類(類的全路徑名)
Agent-Class :包含 agentmain 方法的類(類的全路徑名)
Boot-Class-Path :設定引導類載入器搜尋的路徑列表。查詢類的特定於平臺的機制失敗後,引導類載入器會搜尋這些路徑。按列出的順序搜尋路徑。列表中的路徑由一個或多個空格分開。路徑使用分層 URI 的路徑元件語法。如果該路徑以斜槓字元(「/」)開頭,則為絕對路徑,否則為相對路徑。相對路徑根據代理 JAR 檔案的絕對路徑解析。忽略格式不正確的路徑和不存在的路徑。如果代理是在 VM 啟動之後某一時刻啟動的,則忽略不表示 JAR 檔案的路徑。(可選)
Can-Redefine-Classes :true表示能重定義此代理所需的類,預設值為 false(可選)
Can-Retransform-Classes :true 表示能重轉換此代理所需的類,預設值為 false (可選)
Can-Set-Native-Method-Prefix: true表示能設定此代理所需的本機方法字首,預設值為 false(可選)

打包
設定完後然後使用 maven 構建agent jar 包:mvn clean install
編寫測試類
為了更直觀,起了個新專案

public class Test {
    public static void main(String[] args) {
        System.out.println("main Method");
    }
}

-javaagent模式啟動
JVM啟動引數新增,這裡使用絕對路徑,否則容易出問題
-javaagent:D:/vul/agent/target/agent-1.0-SNAPSHOT.jar

執行main方法之前會載入所有的類,包括系統類和自定義類。而在ClassFileTransformer中會去攔截系統類和自己實現的類物件,邏輯則是在ClassFileTransformer實現類的transform方法中定義。
而在這裡transform感覺是類似於一個filter會去攔截/遍歷一些要在JVM中載入的類,而在transform方法中我們可以定義一些邏輯,比如if className== xxx時走入一個邏輯去實現AOP。而其中就可以利用如javassist技術修改位元組碼並作為transform方法的返回值,這樣就在該類在JVM中載入前(-javaagent模式)修改了位元組碼

使用javassist修改位元組碼

這裡在之前Test類中新新增一個方法,並在Agent裡我們自定義的Transformerttransform新增一個邏輯,使用javassist去修改我們Test類中新新增的方法

Test類中新加一個call方法

public class Test {
    public static void main(String[] args) {
        System.out.println("main Method");
        call();
    }
    public static void call(){
        System.out.println("say hello ...");
    }
}

DefineTransformer 類

package org.gk0d;

import javassist.*;

import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

public class DefineTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(final ClassLoader loader, final String className, final Class<?> classBeingRedefined,final ProtectionDomain protectionDomain, final byte[] classfileBuffer) {
        // System.out.println("premain load class"+className); //列印載入的類
        if ("org/example/Test".equals(className)) {
            try {
                // 從ClassPool獲得CtClass物件
                 ClassPool classPool = ClassPool.getDefault();
                 CtClass ctclass = classPool.get("org.example.Test");
                CtMethod call= ctclass.getDeclaredMethod("call");
                // 列印後加了一個彈計算器的操作
                String MethodBody = "{System.out.println(\"say hello ...\");" +
                        "java.lang.Runtime.getRuntime().exec(\"calc.exe\");}";
                call.setBody(MethodBody);
                byte[] bytes = ctclass.toBytecode();

                // 返回位元組碼,並且detachCtClass物件
                byte[] byteCode = ctclass.toBytecode();
                //detach的意思是將記憶體中曾經被javassist載入過的Test物件移除,如果下次有需要在記憶體中找不到會重新走javassist載入
                ctclass.detach();
                return byteCode;
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
        // 如果返回null則位元組碼不會被修改
        return null;
    }
}

permain方式總結


這種方法存在一定的侷限性——只能在啟動時使用-javaagent引數指定。在實際環境中,目標的JVM通常都是已經啟動的狀態,無法預先載入premain。相比之下,agentmain更加實用。

agentmain

JDK 1.6 新增了attach (附加方式)方式,可以對執行中的 Java 程序附加 Agent 。

這就是我們說的 agentmain ,使用方式和 permain 十分相似,包括編寫 MANIFEST.MF 和生成代理 Jar 包。但是,它並不需要通過-javaagent 命令列形式引入代理 Jar ,而是在執行時通過 attach 工具啟用指定代理即可
premain函數一樣, 開發者可以編寫一個含有agentmain函數的 Java 類:

//採用attach機制,被代理的目標程式VM有可能很早之前已經啟動,當然其所有類已經被載入完成,這個時候需要藉助Instrumentation#retransformClasses(Class<?>... classes)讓對應的類可以重新轉換,從而啟用重新轉換的類執行ClassFileTransformer列表中的回撥
public static void agentmain (String agentArgs, Instrumentation inst)

public static void agentmain (String agentArgs)

同樣,agentmain 方法中帶Instrumentation引數的方法也比不帶優先順序更高。開發者必須在 manifest 檔案裡面設定「Agent-Class」來指定包含 agentmain 函數的類。
在Java6 以後實現啟動後載入的新實現是Attach apiAttach API 很簡單,只有 2 個主要的類,都在 com.sun.tools.attach 包裡面(需要手動匯入):

  1. VirtualMachine 字面意義表示一個Java 虛擬機器器,也就是程式需要監控的目標虛擬機器器,提供了獲取系統資訊(比如獲取記憶體dump、執行緒dump,類資訊統計(比如已載入的類以及範例個數等), loadAgent,Attach 和 Detach (Attach 動作的相反行為,從 JVM 上面解除一個代理)等方法,可以實現的功能可以說非常之強大 。該類允許我們通過給attach方法傳入一個jvm的pid(程序id),遠端連線到jvm上 。
    代理類注入操作只是它眾多功能中的一個,通過loadAgent方法向jvm註冊一個代理程式agent,在該agent的代理程式中會得到一個Instrumentation範例,該範例可以 在class載入前改變class的位元組碼,也可以在class載入後重新載入。在呼叫Instrumentation範例的方法時,這些方法會使用ClassFileTransformer介面中提供的方法進行處理。

  2. VirtualMachineDescriptor 則是一個描述虛擬機器器的容器類,配合 VirtualMachine 類完成各種功能。

attach 實現注入的原理如下:
通過VirtualMachine類的attach(pid)方法,便可以attach到一個執行中的java程序上,之後便可以通過loadAgent(agentJarPath)來將agent的jar包注入到對應的程序,然後對應的程序會呼叫agentmain方法

Attach模式使用

AgentMain類

package org.gk0d;

import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

import static java.lang.Class.forName;

public class AgentMain {
    public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException,
            ClassNotFoundException {
        inst.addTransformer(new DefineTransformer(), true);
        inst.retransformClasses(Class.forName("org.example.Test"));
    }

}

和 premain 的區別在於,我們在 addTransformer 的引數中指定了 true,而且使用了 retransformClasses 重新載入了指定的類。

新建一個自定義的Transformer

package org.gk0d
import javassist.*;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class AgentMainTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

        if ("org.gk0d.Test".equals(className)) {
            try {
                ClassPool classPool = ClassPool.getDefault();
                CtClass ctClass = classPool.get("org.gk0d.Test");
                CtMethod call = ctClass.getDeclaredMethod("call");
                // 列印後加了一個彈計算器的操作
                String MethodBody = "{java.lang.Runtime.getRuntime().exec(\"calc.exe\");" +
                        "System.out.println(\"say hello ...\");}";
                call.setBody(MethodBody);
                byte[] bytes = ctClass.toBytecode();
                return bytes;
                //detach的意思是將記憶體中曾經被javassist載入過的Test物件移除,如果下次有需要在記憶體中找不到會重新走javassist載入
                //  ctClass.detach();


            } catch (Exception e) {
                e.printStackTrace();
                return classfileBuffer;
            }
        }else {
            return classfileBuffer;
        }


    }


}

測試AgentMainTest類
將jar通過jvm pid注入進來,使其修改Test類中call方法的位元組碼

package org.gk0d;

import com.sun.tools.attach.*;

import java.io.IOException;
import java.util.List;

public class AgentMainTest {
    public static void main(String[] args) {
        System.out.println("running JVM start ");
        List<VirtualMachineDescriptor> list = VirtualMachine.list(); // 尋找當前系統中所有執行著的JVM程序
        for (VirtualMachineDescriptor vmd : list) {
            //如果虛擬機器器的名稱為 xxx 則 該虛擬機器器為目標虛擬機器器,獲取該虛擬機器器的 pid
            //然後載入 agent.jar 傳送給該虛擬機器器
            System.out.println(vmd.displayName()); //vmd.displayName()看到當前系統都有哪些JVM程序在執行
            if (vmd.displayName().endsWith("org.gk0d.AgentMainTest")) {
                VirtualMachine virtualMachine = null;
                try {
                    virtualMachine = VirtualMachine.attach(vmd.id());
                    virtualMachine.loadAgent("D:/vul/agent/agent-1.0-SNAPSHOT.jar");

                    virtualMachine.detach();

                } catch (AttachNotSupportedException e) {
                    e.printStackTrace();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (AgentLoadException e) {
                    e.printStackTrace();
                } catch (AgentInitializationException e) {
                    e.printStackTrace();
                }

            }
        }
    }

}