Java Native Interface(JNI)長期以來一直是 Java 平台與原生程式碼互動的重要橋樑。然而,老舊過時的互動方式帶來了安全性與完整性的隱憂。為此,JDK 24 推出 JEP 472,目的是為了要限制 JNI 的使用,同時也調整了外部函式和記憶體 API(FFM)的行為。
在去年九月老喬介紹本功能做為 JDK 24 的第一項功能,能幫助我們為未來的 Java 版本做好準備。接下來本文將介紹 JEP 472 的重要變更,並說明這些改變會如何影響我們的工作。
目錄
前言
JNI 自 JDK 1.1 推出以來,一直是 Java 程式碼與原生程式碼(通常是 C 語言)之間交互操作的主要管道。它允許 Java 程式碼呼叫原生程式碼(downcall,下行呼叫),以及原生程式碼呼叫 Java 程式碼(upcall,上行呼叫)。
不幸的是,它們之間的任何交互操作都是有風險的,很可能會損害應用程式和 Java 平台本身的完整性。根據「預設完整性」政策,所有能夠破壞完整性的 JDK 功能都必須獲得開發人員的允許。
一、下行呼叫的風險
Java 程式碼透過 JNI 呼叫原生程式碼的下行呼叫可能會導致任意的未定義行為,甚至包括 JVM 當機。這些問題無法在 Java 執行時期預防,也無法拋出例外讓 Java 程式碼有機會去捕獲並處理。例如,這個 C 函式接收來自 Java 程式碼的 long
值,並將其當作記憶體中的位址來處理,將一個值存入該位址:
void Java_pkg_C_setPointerToThree__J(jlong ptr) {
*(int*)ptr = 3;
}
呼叫這個 C 函式可能會破壞 JVM 使用的記憶體,並導致 JVM 在不可預測的時間崩潰,甚至在 C 函式返回後很長一段時間才發生。這類崩潰和其他未預期的行為難以診斷。
二、無效的直接位元組緩衝區
原生程式碼和 Java 程式碼之間經常透過直接位元組緩衝區(direct byte buffers)來交換資料,而這些緩衝區是垃圾回收器無法觸及的記憶體區域,最終變成了未受控管的灰色地帶。原生程式碼可能會產生一個指向無效記憶體區域的位元組緩衝區,如果在 Java 程式碼中使用它的話幾乎會導致未定義行為。
例如下段 C 程式碼會從位址 0 開始構建一個擁有 10 個元素的位元組緩衝區,並返回給 Java 程式碼。當 Java 程式碼嘗試讀取或寫入這個位元組緩衝區時,JVM 會崩潰:
return (*env)->NewDirectByteBuffer(env, 0, 10);
三、繞過存取檢查
原生程式碼可以用 JNI 繞過 JVM 的存取檢查,像是直接存取欄位與呼叫方法。另外,原生程式碼甚至可以利用 JNI 在 final
欄位初始化很久之後去更改它們的值。因此,呼叫原生程式碼具有破壞 Java 程式碼完整性的風險存在
例如 String
物件為不可變物件,但這段 C 程式碼透過寫入由私有欄位所引用的陣列來變更 String
物件:
jclass clazz = (*env)->FindClass(env, "java/lang/String");
jfieldID fid = (*env)->GetFieldID(env, clazz , "value", "[B");
jbyteArray contents = (jbyteArray)(*env)->GetObjectField(env, str, fid);
jbyte b = 0;
(*env)->SetByteArrayRegion(env, contents, 0, 1, &b);
另一個例子是,陣列被指定為不允許超出邊界的存取,但這段 C 程式碼可以寫入超過陣列結尾的資料:
jbyte *a = (*env)->GetPrimitiveArrayCritical(env, arr, 0);
a[500] = 3; // 可能超出邊界
(*env)->ReleasePrimitiveArrayCritical(env, arr, a, 0);
四、垃圾收集器的不良行為
原生程式碼如果不正確使用某些 JNI 函式,特別是 GetPrimitiveArrayCritical
和 GetStringCritical
,可能導致垃圾收集器出現不良行為,而這種行為可能在程式的任何生命週期階段中發生。例如,存取臨界區域(critical region)但卻未釋放它,導致垃圾收集區無法回收物件
為什麼需要限制 JNI?
在 JDK 22 中作為 JNI 的首選替代方案而引入的 FFM API 一樣具有上述前二項的風險。它採取的應對策略是將可能會破壞完整性和不會破壞完整性的操作區分開來,因此 FFM API 之中有部分被歸類為受到限制的方法。這代表在程式執行時,我們必須明確地使用 java 命令列選項去啟用。JNI 也應該要效仿 FFM API,以實現預設完整性的目標。
JEP 472 屬於 Java 平台預設完整性的長期計畫。其他相關的功能還包括移除 sun.misc.Unsafe
中的記憶體存取方法(JEP 471),以及限制動態載入代理程式(JEP 451)。這些努力將使得 Java 平台能變得更加安全且更高效,並減少開發人員因為不再支援的 API 變更而被困在舊版 JDK 的風險。
JEP 472 概觀
JEP 472 的任務是「限制 JNI 使用」的準備工作,方法是當我們使用 JNI 時,JVM 會發出警告。JDK 22 的外部函式和記憶體 FFM API 也包含在內,因此使用 FFM API 時也會發出一致的警告。
這些警告的目的是為了讓我們為之後的 Java 版本先做好準備,之後 Java 將會統一限制 JNI 和 FFM API 的使用來確保預設完整性。我們在必要時可以選擇性地指定選項來避免產生警告(現在階段)和禁止執行的限制(未來階段)。
本功能的目標:
- 保留 JNI 作為與原生程式碼交互操作的標準方式
- 預設不允許與原生程式碼交互操作,無論是使用 JNI 或 FFM API,除非我們在程式啟動時,明確指定要啟用 JNI 或 FFM API
- 調整 JNI 和 FFM API 的使用,讓函式庫的維護人員能順利轉移,而無需開發人員變更命令列選項
- 並非要棄用 JNI,也並非要從 Java 平台中移除 JNI,同時也沒有要限制 JNI 呼叫原生程式碼
優點
- 提升 Java 平台的安全性與完整性
- 統一 JNI 和 FFM API 的存取限制機制
- 提供更細緻的控制方式,允許選擇性地啟用原生存取
- 協助開發者識別和管理原生程式碼的使用
缺點
- 可能影響現有依賴 JNI 的應用程式(約 7% 的 Maven Central 套件)
- 需要額外的配置步驟來啟用原生存取
- 開發者需要學習新的命令列選項和設定方式
介紹
在 JDK 22 及以後的版本中,我們可以透過 JNI 或 FFM API 呼叫原生程式碼。無論哪種情況,都必須要先載入原生函式庫,並將 Java 結構與該函式庫中的函式連結。
- FFM API 的載入和連結步驟是受到限制的,代表在執行時預設會發出警告。
- JDK 24 對 JNI 中的載入和連結步驟亦進行限制,讓它們在執行時也預設會發出警告。
這項「載入和連結原生函式庫」的限制稱為原生存取限制。在 JDK 24 中,無論是使用 JNI 還是 FFM API,原生存取限制都將統一適用。
未來的 JDK 版本會預設在 Java 程式碼使用 JNI 或 FFM API 載入和連結原生函式庫時拋出例外,而非僅發出警告。這樣做的目的是為了確保應用程式和 Java 平台預設具備完整性,而非要抑制 JNI 或 FFM API 的使用。
啟用原生存取
我們可以在程式啟動時啟用原生存取來避免警告(未來版本中可能會是拋出例外)。啟用原生存取即表示程式需要載入並連結原生函式庫,所以要解除原生存取限制。
如要為所有在類別路徑上的程式碼啟用原生存取,可以使用下列第一條命令列選項;如果要為模組路徑上的特定模組啟用原生存取,則可以傳入以逗號分隔的模組名稱清單:
# 為所有在類別路徑上的程式碼啟用原生存取
java --enable-native-access=ALL-UNNAMED ...
# 為特定模組啟用原生存取
java --enable-native-access=M1,M2,... ...
使用 JNI 的程式碼若滿足以下條件,則會受到原生存取限制影響:
- 呼叫
System::loadLibrary
、System::load
、Runtime::loadLibrary
或Runtime::load
- 宣告原生方法
如果只有呼叫在其他模組中宣告的原生方法的程式碼,則不需要啟用原生存取。
大多數的開發人員會直接在啟動腳本中將 --enable-native-access
參數傳遞給 java 啟動器,但也有下列可用的方式:
- 使用環境變數
JDK_JAVA_OPTIONS
間接將--enable-native-access
傳遞給啟動器 - 將
--enable-native-access
放入參數檔案中,該檔案由腳本或最終使用者傳遞給啟動器,例如java @config
- 將
Enable-Native-Access: ALL-UNNAMED
加入可執行 JAR 檔案的 manifest 中,即通過java -jar
啟動的 JAR 檔案(Enable-Native-Access
唯一允許的值為ALL-UNNAMED
;其他的值則會拋出例外) - 如果已為了程式建立自訂的 Java 執行環境,那麼可以用
--add-options
參數將--enable-native-access
選項傳遞給jlink
,使得結果的執行環境映像啟用原生存取。 - 如果程式碼會動態建立模組,則可以用
ModuleLayer.Controller::enableNativeAccess
方法為它們啟用原生存取,該方法本身是一個受限方法。程式碼可以動態檢查其模組是否啟用了原生存取,方法是通過Module::isNativeAccessEnabled
- JNI 呼叫介面允許原生應用程式將 JVM 嵌入其自身的進程。使用 JNI 呼叫介面的原生應用程式可以在創建 JVM 時傳遞
--enable-native-access
參數來為嵌入式 JVM 中的模組啟用原生存取
選擇性地啟用原生存取
--enable-native-access=ALL-UNNAMED
選項屬於粗粒度的設定,它會解除所有在類別路徑上的類別對 JNI 和 FFM API 的原生存取限制。為了縮小風險並達到更高的完整性,我們建議將使用 JNI 或 FFM API 的 JAR 檔案移至模組路徑。
這樣的話,可以針對特定的 JAR 檔案啟用原生存取,而不是對整個類別路徑啟用。原生 JAR 檔案可以從類別路徑移動到模組路徑,而不需要將其模組化。Java 執行時會將其視為自動模組,其模組名稱會根據其檔案名稱來命名。
控制原生存取限制的影響
如果某個模組未啟用原生存取,那麼該模組中的程式碼執行受限操作將會是非法的。當程式碼嘗試執行這樣的操作時,Java 執行時會根據一個新的命令行選項 --illegal-native-access
來控制其行為,這個選項在精神和形式上與 JDK 9 中 JEP 261 引入的 --illegal-access
選項相似。其運作方式如下:
--illegal-native-access=allow
:允許操作繼續執行--illegal-native-access=warn
:允許操作執行,但會在特定模組中第一次發生非法原生存取時發出警告。每個模組最多發出一次警告。 這個模式是 JDK 24 的預設模式,將在未來的版本中逐步淘汰,最終被移除--illegal-native-access=deny
:對每個非法原生存取操作都會拋出IllegalCallerException
異常。 這個模式將在未來的版本中成為預設模式。
當 deny
成為預設模式後,allow
會被移除,但 warn
將至少在一個版本中繼續支援。為了為未來做好準備,建議在 deny
模式下運行現有程式碼,以識別需要原生存取的程式碼。
調整 FFM API 的行為
在 JDK 24 之前,如果某些模組透過 --enable-native-access
選項啟用了原生存取,那麼如果從其他模組呼叫受限的 FFM 函式,會導致拋出 IllegalCallerException
例外。
為了與 JNI 的行為保持一致,JDK 24 起 FFM API 也將改為相同的處理方式。當發生非法的原生存取時,系統不會拋出例外,而是發出警告。如果要恢復舊有行為,可以使用以下選項組合:
java --enable-native-access=M,... --illegal-native-access=deny ...
載入原生函式庫的警告
在 JNI 中,我們透過 java.lang.Runtime
類別的 load
和 loadLibrary
方法來載入原生函式庫。備註說明:java.lang.System
類別中同名的 load
和 loadLibrary
方法只是簡單地呼叫系統層級的 Runtime
執行個體中的對應方法。
載入原生函式庫具有風險性,因為這可能會導致原生程式碼被執行:
- 如果原生函式庫定義了初始化函式,則作業系統在載入函式庫時就會執行這些函式,而這些函式內含任意的原生程式碼。
- 如果原生函式庫定義了
JNI_OnLoad
函式,Java 執行環境在載入函式庫時就會呼叫它,而這個函式同樣內含任意的原生程式碼。
由於這些風險,在 JDK 24 中,load
和 loadLibrary
方法被視為受限方法,就像 FFM API 中的 SymbolLookup::libraryLookup
方法一樣。當某個模組的原生存取未啟用,卻呼叫了受限方法時,JVM 仍會執行該方法,但預設會發出警告,並標示出呼叫者:
WARNING: A restricted method in java.lang.System has been called
WARNING: System::load has been called by com.foo.Server in module com.foo (file:/path/to/com.foo.jar)
WARNING: Use --enable-native-access=com.foo to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
系統對於每個模組最多只會發出一次這類警告(並輸出到標準錯誤流),且僅在該模組尚未收到任何警告時才會顯示。
連結原生函式庫的警告
當原生方法首次被呼叫時,系統會自動將其綁定(binding)到對應的原生函式庫中的函式。此步驟在 JDK 24 中被視為受限操作,就像在 FFM API 中獲取 downcall 方法句柄(method handle)也是受限操作一樣。
當某個模組的原生存取未啟用,卻首次呼叫該模組內宣告的原生方法時,JVM 仍會執行方法的綁定動作,但預設會發出警告,並標示出呼叫者:
WARNING: A native method in org.baz.services.Controller has been bound
WARNING: Controller::getData in module org.baz has been called by com.foo.Server in an unnamed module (file:/path/to/foo.jar)
WARNING: Use --enable-native-access=org.baz to avoid a warning for native methods declared in org.baz
WARNING: Native methods will be blocked in a future release unless native access is enabled
系統對於每個模組最多只會發出一次這類警告(並輸出到標準錯誤流):
- 只有在原生方法被綁定時(即該方法首次被呼叫時)才會發出警告,而不會在每次呼叫該方法時都發出警告。
- 針對某個模組內的原生方法,當該模組內第一個原生方法被綁定時,系統會發出警告。若該模組已經收到過警告,則後續的綁定不再觸發警告。
識別原生程式碼的使用
- JFR 事件:
jdk.NativeLibraryLoad
和jdk.NativeLibraryUnload
會追蹤原生函式庫的載入與卸載,協助監控應用程式的原生程式碼活動。 - 掃描工具:為了幫助識別使用 JNI 的函式庫,全新的 JDK 工具(暫定名稱為
jnativescan
)可以對指定的模組路徑或類別路徑進行靜態掃描,並回報受限方法的使用情況及原生方法的宣告資訊。
總結
JEP 472 代表了 Java 平台朝向更安全、更可靠方向發展的重要一步。透過這項提案,Java 平台將能更好地保護應用程式的完整性,同時也為未來版本的安全性改進奠定基礎。
對於開發者而言,現在就應該開始為這些改變做準備,包括檢視現有程式碼中的 JNI 使用情況,並考慮是否能改用更安全的 FFM API。建議在測試環境中使用 –illegal-native-access=deny 選項運行應用程式,及早發現潛在的問題。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!