在 JDK 24 宣布永久停用 SecurityManager 安全管理器之後,如何攔截特定的 Java 平台 API 呼叫就變成了重要的研究課題。實務上,我們有時會需要攔截非預期的 System::exit 呼叫,以確保應用程式不會被輕易地終止、或是執行任務收尾與資源釋放的優雅關機、或是避免程式跳脫原定的生命週期管理。
本文說明為何在停用安全管理器後仍需要 API 攔截的動機與場景,接著會聚焦在沒有安全管理器的前提下,如何以代理程式和動態位元碼重寫的方式去實作 API 攔截。
前言
SecurityManager 安全管理器的主要目的,是為了要「限制大多數能力」並僅給予程式「最小權限」。尤其是在當年 Java Applet 流行且需要存取本地端檔案系統的場景中,為了要確保從網路上下載的任意 Java Apple 都會受到控管,並且僅在使用者信任的情況下才能存取本機環境與資源。
因此,為了相容於安全管理器模式,JDK 內部導入了大量的權限檢查與權限提升程式碼,形成了巨大的維護負擔。然而,長期以來它在實務中的使用率極低,並造成了許多無謂的維護資源浪費。
雖然很少有程式真正使用它的安全策略,不過仍有一些程式會利用安全管理器來「攔截」平台 API 呼叫。這也說明了我們需要阻擋或監聽特定的關鍵呼叫,以維持系統的可控性與可觀察性。例如,攔截 System::exit 呼叫能避免受控程序被過早終止,保證交易一致性、回收流程、監控與告警的最後一哩,並降低營運風險。
思維轉換
OpenJDK 團隊的立場是:不在整個 JDK 內部維護不定數量且需求分散的攔截點。取而代之的是,他們提供了更恰當的替代途徑,包含原始碼修改、靜態分析與重寫、以及以代理為基礎的「類別載入時」動態重寫。這些方法跳脫了 JVM 的安全模型,改以工程化手段建立治理能力,將「攔截」視為部署管線與執行時的可插拔能力,而非語言規格的一部分。
因此,對需要 API 攔截的團隊來說,關鍵在於如何將治理能力融合到工具鏈中:
這種「分層治理」的方式,比把希望寄託在安全管理器來說,會更清晰、可測試且可持續演進。
實作方式
代理程式與位元碼重寫
代理程式(Agent)是一種 Java 程式,可以在應用程式運行時修改其中的程式碼,它的運作方式如下:
- 當類別被載入時,代理程式可以轉換其中的方法位元碼:類別從
classpath或 module path 載入時,以transformer搜尋並改寫敏感呼叫 - 當類別已經被載入後,代理程式仍可重新定義這些類別:在支援的 JVM 上,對已載入的類別進行
retransform或redefine
阻止 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 - 將呼叫指令替換為建立
RuntimeException並athrow的序列 - 僅在有實際修改時回傳新位元碼,否則回傳
null以節省成本
建置與執行:
- 使用
--enable-preview搭配--release 23編譯代理 - 在 manifest 指定
Premain-Class與Can-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 檔 - 以元素流過濾與轉換指令層級元素,實作針對性改寫
- 使用 JDK 23 的 Class-File API,自
- 相容性與風險控管
- 預覽 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 的靜態分析與重寫,再以執行時代理作為補強,同步納入可觀察性與審計機制,形成可持續進化的標準化流程。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!


發佈:
更新:
瀏覽:
分類:
標籤:


