跳至主要內容

Electron 與 V8 記憶體隔離區

·7 分鐘閱讀

Electron 21 及更高版本將啟用 V8 記憶體隔離區,這對某些原生模組有影響。


更新 (2022/11/01)

若要追蹤有關 Electron 21+ 中原生模組使用情況的持續討論,請參閱 electron/electron#35801

在 Electron 21 中,我們將啟用 Electron 中的V8 沙盒指標,這與 Chrome 在 Chrome 103 中做出相同決策的行為一致。這對原生模組有一些影響。此外,我們先前在 Electron 14 中啟用了一項相關技術,指標壓縮。當時我們沒有談論太多,但指標壓縮對 V8 堆積大小上限有影響。

啟用這兩項技術時,對安全性、效能和記憶體使用率有顯著的好處。但是,啟用它們也有一些缺點。

啟用沙盒指標的主要缺點是不再允許指向外部(「堆外」)記憶體的 ArrayBuffer。這表示依賴 V8 中此功能的原生模組需要重構,才能在 Electron 20 及更高版本中繼續運作。

啟用指標壓縮的主要缺點是V8 堆積的上限大小限制為 4GB。確切的詳細資訊有點複雜,例如,ArrayBuffer 與 V8 堆積的其餘部分分開計算,但有其自己的限制

Electron 升級工作小組認為,指標壓縮和 V8 記憶體隔離區的優點大於缺點。這樣做主要有三個原因

  1. 它讓 Electron 更接近 Chromium。在 V8 設定等複雜內部詳細資訊方面,Electron 與 Chromium 的差異越少,我們就越不容易意外引入錯誤或安全性漏洞。Chromium 的安全性團隊非常強大,我們希望確保我們正在利用他們的工作。此外,如果錯誤只影響 Chromium 中未使用的設定,修復它可能不是 Chromium 團隊的優先事項。
  2. 它的效能更好。指標壓縮可將 V8 堆積大小減少高達 40%,並將 CPU 和 GC 效能提高 5%–10%。對於絕大多數不會遇到 4GB 堆積大小限制,且不使用需要外部緩衝區的原生模組的 Electron 應用程式而言,這些都是顯著的效能提升。
  3. 它更安全。某些 Electron 應用程式會執行不受信任的 JavaScript(希望遵循我們的安全性建議!),對於這些應用程式而言,啟用 V8 記憶體隔離區可保護它們免受大量惡意的 V8 漏洞侵害。

最後,對於確實需要更大堆積大小的應用程式,還有一些解決方法。例如,可以將 Node.js 的副本與應用程式一起包含,Node.js 的副本是建置時停用了指標壓縮,並將記憶體密集的工作移至子程序。雖然有點複雜,但如果決定為您的特定使用案例選擇不同的權衡,也可以建置停用了指標壓縮的自訂 Electron 版本。最後,在不久的將來,wasm64 將允許在 Web 和 Electron 上使用 WebAssembly 建置的應用程式使用明顯超過 4GB 的記憶體。


常見問題

我如何知道我的應用程式是否受到此變更的影響?

嘗試使用 ArrayBuffer 包裝外部記憶體會在 Electron 20+ 中於執行階段發生當機。

如果您的應用程式中沒有使用任何原生 Node 模組,您是安全的,從純 JS 無法觸發此當機。此變更只會影響在 V8 堆積外配置記憶體 (例如,使用 mallocnew),然後使用 ArrayBuffer 包裝外部記憶體的原生 Node 模組。這是一個相當罕見的使用案例,但有些模組確實使用此技術,而這些模組需要重構,才能與 Electron 20+ 相容。

我該如何測量我的應用程式使用了多少 V8 堆積記憶體,以了解是否接近 4GB 的限制?

在渲染器進程中,您可以使用 performance.memory.usedJSHeapSize,它會以位元組為單位回傳 V8 堆積的使用量。在主進程中,您可以使用 process.memoryUsage().heapUsed,兩者是可比較的。

什麼是 V8 記憶體籠?

有些文件將其稱為「V8 沙箱」,但這個詞很容易與 Chromium 中發生的其他種類的沙箱混淆,所以我會堅持使用「記憶體籠」這個詞。

有一種相當常見的 V8 漏洞利用方式,大致如下:

  1. 在 V8 的 JIT 引擎中找到一個錯誤。JIT 引擎會分析程式碼,以便能夠省略慢速的執行階段型別檢查並產生快速的機器碼。有時邏輯錯誤意味著它的分析會出錯,並省略了實際需要的型別檢查 — 例如,它認為 x 是一個字串,但實際上它是一個物件。
  2. 濫用這種混淆來覆寫 V8 堆積中的一些記憶體位元,例如,指向 ArrayBuffer 開頭的指標。
  3. 現在您有一個 ArrayBuffer 指向您喜歡的任何地方,因此您可以讀寫進程中的任何記憶體,甚至是 V8 通常無法存取的記憶體。

V8 記憶體籠是一種旨在完全防止此類攻擊的技術。實現此目的的方式是不在 V8 堆積中儲存任何指標。相反,對 V8 堆積內其他記憶體的所有引用都以從某個保留區域開頭的偏移量儲存。這樣,即使攻擊者設法損壞了 ArrayBuffer 的基底位址,例如通過利用 V8 中的型別混淆錯誤,他們所能做的最糟糕的事情就是讀寫籠內的記憶體,而他們很可能已經可以這樣做了。關於 V8 記憶體籠如何運作還有很多可讀的內容,因此我不會在此處深入探討 — 最好的起點可能是 Chromium 團隊的高階設計文件

我想重構一個 Node 原生模組以支援 Electron 21+。我該如何做?

有兩種方法可以重構原生模組以使其與 V8 記憶體籠相容。第一種方法是在將外部建立的緩衝區傳遞給 JavaScript 之前,將它們複製到 V8 記憶體籠中。這通常是一個簡單的重構,但當緩衝區很大時,速度可能會很慢。另一種方法是使用 V8 的記憶體配置器來配置您打算最終傳遞給 JavaScript 的記憶體。這有點複雜,但可以讓您避免複製,這意味著大型緩衝區可以獲得更好的效能。

為了使這個更具體,這是一個使用外部陣列緩衝區的 N-API 模組範例:

// Create some externally-allocated buffer.
// |create_external_resource| allocates memory via malloc().
size_t length = 0;
void* data = create_external_resource(&length);
// Wrap it in a Buffer--will fail if the memory cage is enabled!
napi_value result;
napi_create_external_buffer(
env, length, data,
finalize_external_resource, NULL, &result);

當啟用記憶體籠時,這將會崩潰,因為資料是配置在籠外的。重構為將資料複製到籠內,我們得到:

size_t length = 0;
void* data = create_external_resource(&length);
// Create a new Buffer by copying the data into V8-allocated memory
napi_value result;
void* copied_data = NULL;
napi_create_buffer_copy(env, length, data, &copied_data, &result);
// If you need to access the new copy, |copied_data| is a pointer
// to it!

這會將資料複製到 V8 記憶體籠內新配置的記憶體區域中。或者,N-API 也可以提供指向新複製資料的指標,以防您需要在事後修改或參考它。

使用 V8 的記憶體配置器進行重構稍微複雜一些,因為它需要修改 create_external_resource 函式,以使用 V8 配置的記憶體,而不是使用 malloc。這可能或多或少是可行的,具體取決於您是否控制 create_external_resource 的定義。其想法是首先使用 V8 建立緩衝區,例如使用 napi_create_buffer,然後將資源初始化到 V8 配置的記憶體中。重要的是要保留對 Buffer 物件的 napi_ref,以便資源的生命週期,否則 V8 可能會對 Buffer 進行垃圾回收,並可能導致使用後釋放的錯誤。