如何在 Java 24 後使用動態程式碼攔截 System::exit 呼叫

Foggy highway scene with exit sign and vehicles in twilight ambiance. System::exit Systemexit

JDK 24 宣布永久停用 SecurityManager 安全管理器之後,如何攔截特定的 Java 平台 API 呼叫就變成了重要的研究課題。實務上,我們有時會需要攔截非預期的 System::exit 呼叫,以確保應用程式不會被輕易地終止、或是執行任務收尾與資源釋放的優雅關機、或是避免程式跳脫原定的生命週期管理。

本文說明為何在停用安全管理器後仍需要 API 攔截的動機與場景,接著會聚焦在沒有安全管理器的前提下,如何以代理程式和動態位元碼重寫的方式去實作 API 攔截。

前言

SecurityManager 安全管理器的主要目的,是為了要「限制大多數能力」並僅給予程式「最小權限」。尤其是在當年 Java Applet 流行且需要存取本地端檔案系統的場景中,為了要確保從網路上下載的任意 Java Apple 都會受到控管,並且僅在使用者信任的情況下才能存取本機環境與資源。

因此,為了相容於安全管理器模式,JDK 內部導入了大量的權限檢查與權限提升程式碼,形成了巨大的維護負擔。然而,長期以來它在實務中的使用率極低,並造成了許多無謂的維護資源浪費。

雖然很少有程式真正使用它的安全策略,不過仍有一些程式會利用安全管理器來「攔截」平台 API 呼叫。這也說明了我們需要阻擋或監聽特定的關鍵呼叫,以維持系統的可控性與可觀察性。例如,攔截 System::exit 呼叫能避免受控程序被過早終止,保證交易一致性、回收流程、監控與告警的最後一哩,並降低營運風險。

思維轉換

OpenJDK 團隊的立場是:不在整個 JDK 內部維護不定數量且需求分散的攔截點。取而代之的是,他們提供了更恰當的替代途徑,包含原始碼修改、靜態分析與重寫、以及以代理為基礎的「類別載入時」動態重寫。這些方法跳脫了 JVM 的安全模型,改以工程化手段建立治理能力,將「攔截」視為部署管線與執行時的可插拔能力,而非語言規格的一部分。

因此,對需要 API 攔截的團隊來說,關鍵在於如何將治理能力融合到工具鏈中:

  • 在 CI 以靜態重寫或拒絕策略卡住高風險呼叫
  • 執行程式時以 -javaagent 插入動態轉換器(transformer
  • 搭配容器、cgroupsseccomp 等 OS 級沙箱完成邊界與資源限制

這種「分層治理」的方式,比把希望寄託在安全管理器來說,會更清晰、可測試且可持續演進。

實作方式

代理程式與位元碼重寫

代理程式(Agent)是一種 Java 程式,可以在應用程式運行時修改其中的程式碼,它的運作方式如下:

  • 當類別被載入時,代理程式可以轉換其中的方法位元碼:類別從 classpath 或 module path 載入時,以 transformer 搜尋並改寫敏感呼叫
  • 當類別已經被載入後,代理程式仍可重新定義這些類別:在支援的 JVM 上,對已載入的類別進行 retransformredefine

阻止 System::exit 的代理程式

目標是將所有 invokestatic System.exit(int) 改寫成丟出 RuntimeException,避免程序被任意終止。其作法是在 premain() 方法中註冊 ClassFileTransformer 去掃描每個方法的位元碼,如果遇到對 System::exit 的靜態呼叫的話,就以拋例外序列取代:

  • 適用範圍:來自應用 classloader 的類別,避免污染平台類別
  • 啟用方式:以 -javaagent 指定代理 JAR;若使用 JDK 23 的 Class-File API,需開啟 preview 並正確宣告模組匯入[8]

實作要點:

  • 僅在非平台類別載入器下進行改寫,降低風險
  • 以位元碼走訪判斷 INVOKESTATIC java/lang/System.exit 的簽名 (I)V
  • 將呼叫指令替換為建立 RuntimeExceptionathrow 的序列
  • 僅在有實際修改時回傳新位元碼,否則回傳 null 以節省成本

建置與執行:

  • 使用 --enable-preview 搭配 --release 23 編譯代理
  • 在 manifest 指定 Premain-ClassCan-Retransform-Classes
  • 以 jar 建立代理 JAR,並以 java --enable-preview -javaagent:... -jar app.jar 啟動目標應用

以下是一個代理程式,它可以阻止程式呼叫 System::exit

  • 代理程式的 premain 方法會在應用程式的 main 方法執行之前,先由 JVM 執行。
  • premain 方法的主要任務是註冊一個轉換器,該轉換器會攔截從 類別路徑 (class path) 或 模組路徑 (module path) 載入的類別。
  • 轉換器的運作方式:
    • 尋找所有 System.exit(int) 呼叫
    • 將其改寫為 throw new RuntimeException("System.exit not allowed"),以阻止應用程式退出

技術細節

  • 位元碼處理
    • 使用 JDK 23 的 Class-File API,自 java.lang.classfile 解析與生成 class 檔
    • 以元素流過濾與轉換指令層級元素,實作針對性改寫
  • 相容性與風險控管
    • 預覽 API 需與 JDK 版本對齊,建議以工具鏈封裝並於 CI 做版本鎖定
    • 對第三方程式碼的指令改寫需搭配測試矩陣與煙囪測試,避免引入行為差異
  • 可觀察性
    • 在改寫處植入統計與診斷鉤子,將攔截事件回報到記錄或度量系統,利於稽核與追蹤
  • 類別檔轉換(Class File Transformation)透過 Class-File API 來讀取和寫入 .class 檔案的 bytecode
  • Class-File API 是 JDK 23 的預覽功能,可在 java.lang.classfile 套件中找到詳細資訊
  • 代理程式的原始碼會使用 模組匯入宣告(module import declarations)來匯入 Class-File API 和其他 Java API
import module java.base;
import module java.instrument;

public class BlockSystemExitAgent {
    // 在應用程式啟動前註冊類別檔案的轉換器
    public static void premain(String agentArgs, Instrumentation inst) {
        var transformer = new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader      loader,
                                    String           className,
                                    Class<?>         classBeingRedefined,
                                    ProtectionDomain protectionDomain,
                                    byte[]           classBytes) {
                if (loader != null && loader != ClassLoader.getPlatformClassLoader()) {
                    return blockSystemExit(classBytes);
                }
                return null;
            }
        };
        inst.addTransformer(transformer, true);
    }

    // 重寫對 System::exit 的呼叫並丟出 RuntimeException
    private static byte[] blockSystemExit(byte[] classBytes) {
        var modified = new AtomicBoolean();
        ClassFile cf = ClassFile.of(ClassFile.DebugElementsOption.DROP_DEBUG);
        ClassModel classModel = cf.parse(classBytes);
        Predicate<MethodModel> invokesSystemExit =
            methodModel -> methodModel.code()
                                      .map(codeModel ->
                                             codeModel.elementStream()
                                                      .anyMatch(BlockSystemExitAgent::isInvocationOfSystemExit))
                                      .orElse(false);

        CodeTransform rewriteSystemExit =
            (codeBuilder, codeElement) -> {
                if (isInvocationOfSystemExit(codeElement)) {
                    var runtimeException = ClassDesc.of("java.lang.RuntimeException");
                    codeBuilder.new_(runtimeException)                    
                               .dup()
                               .ldc("System.exit not allowed")
                               .invokespecial(runtimeException,
                                   "<init>",
                                   MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V"),
                                   false)
                               .athrow();
                    modified.set(true);
                } else {
                    codeBuilder.with(codeElement);
                }
            };

        ClassTransform ct = ClassTransform.transformingMethodBodies(invokesSystemExit, rewriteSystemExit);
        return modified.get() ? cf.transform(classModel, ct) : null;
    }

    private static boolean isInvocationOfSystemExit(CodeElement codeElement) {
        return codeElement instanceof InvokeInstruction i
                && i.opcode() == Opcode.INVOKESTATIC
                && "java/lang/System".equals(i.owner().asInternalName())
                && "exit".equals(i.name().stringValue())
                && "(I)V".equals(i.type().stringValue());
    }
}

我們必須將 agent 包在 JAR 檔之中,並在啟動應用程式時加上 -javaagent 選項去指定它:

# Compile the agent into the agentclasses directory, enabling preview features for JDK 23
$ javac --enable-preview --release 23 -d agentclasses BlockSystemExitAgent.java

# Create JAR file manifest in agent.mf
$ cat > agent.mf << EOF
Premain-Class: BlockSystemExitAgent
Can-Retransform-Classes: true
EOF

# Create the agent JAR (Note there is a period after -C agentclasses)
$ jar --create --file=BlockSystemExitAgent.jar --manifest=agent.mf -C agentclasses .

# Run application with the agent JAR, enabling preview features for JDK 23
$ java --enable-preview -javaagent:BlockSystemExitAgent.jar -jar app.jar

總結

安全管理器的退場並不代表「攔截」需求消失,而是將責任轉移到更合適的層次:以代理與位元碼重寫提供語義級的控制點,以容器與作業系統提供資源與邊界級的保護。工程化地把這兩者組合,能更精準地對應現代威脅模型與治理需求。

若你的場景只需要攔截少數敏感呼叫(例如 System::exit),以 -javaagent 加上針對性的位元碼改寫即可快速落地;若牽涉廣泛 API 或複雜治理策略,則應將規則前移至 CI 的靜態分析與重寫,再以執行時代理作為補強,同步納入可觀察性與審計機制,形成可持續進化的標準化流程。

本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

five − four =

返回頂端