Skip to main content

Electron 与 V8 内存笼

· 13 min read

Electron 21 及更高版本将启用 V8 内存笼,这将对某些原生模块产生影响。

🌐 Electron 21 and later will have the V8 Memory Cage enabled, with implications for some native modules.


更新 (2022/11/01)

要跟踪关于在 Electron 21 及以上版本中使用本地模块的持续讨论,请参见 electron/electron#35801

🌐 To track ongoing discussion about native module usage in Electron 21+, see electron/electron#35801.

在 Electron 21 中,我们将启用 Electron 中的 V8 沙箱指针,这与 Chrome 在 Chrome 103 中做出的 相同行动 一致。这对原生模块有一些影响。此外,我们之前在 Electron 14 中启用了一个相关技术,指针压缩。当时我们并没有多加讨论,但指针压缩会影响 V8 堆的最大大小。

🌐 In Electron 21, we will be enabling V8 sandboxed pointers in Electron, following Chrome's decision to do the same in Chrome 103. This has some implications for native modules. Also, we previously enabled a related technology, pointer compression, in Electron 14. We didn't talk about it much then, but pointer compression has implications for the maximum V8 heap size.

启用这两项技术在安全性、性能和内存使用方面都有显著的好处。然而,启用它们也有一些缺点。

🌐 These two technologies, when enabled, are significantly beneficial for security, performance and memory usage. However, there are some downsides to enabling them, too.

启用沙箱指针的主要缺点是 不再允许指向外部(“堆外”)内存的 ArrayBuffers。这意味着依赖 V8 中此功能的原生模块需要进行重构,以便在 Electron 20 及更高版本中继续使用。

🌐 The main downside of enabling sandboxed pointers is that ArrayBuffers which point to external ("off-heap") memory are no longer allowed. This means that native modules which rely on this functionality in V8 will need to be refactored to continue working in Electron 20 and later.

启用指针压缩的主要缺点是 V8 堆的最大大小被限制为 4GB。具体细节有些复杂——例如,ArrayBuffers 是单独计算的,不包括在 V8 堆的其他部分,但它们有自己的限制

🌐 The main downside of enabling pointer compression is that the V8 heap is limited to a maximum size of 4GB. The exact details of this are a little complicated—for example, ArrayBuffers are counted separately from the rest of the V8 heap, but have their own limits.

Electron 升级工作组认为指针压缩和 V8 内存隔离的好处大于其缺点。这样做有三个主要原因:

🌐 The Electron Upgrades Working Group believes that the benefits of pointer compression and the V8 memory cage outweigh the downsides. There are three main reasons for doing so:

  1. 它让 Electron 更接近 Chromium。Electron 在 V8 配置等复杂内部细节上与 Chromium 的偏差越小,我们意外引入漏洞或安全问题的可能性就越低。Chromium 的安全团队非常强大,我们希望确保充分利用他们的成果。此外,如果一个漏洞只影响 Chromium 不使用的配置,Chromium 团队修复它的优先级可能不会很高。
  2. 它的性能更好。指针压缩可以将 V8 堆大小减少最多 40%,并将 CPU 和 GC 性能提高 5%–10%。对于绝大多数不会达到 4GB 堆大小限制且不使用需要外部缓冲区的本地模块的 Electron 应用来说,这些都是显著的性能提升。
  3. 它更安全。一些 Electron 应用会运行不受信任的 JavaScript(希望遵循我们的安全建议!),对于这些应用,启用 V8 内存隔离可以保护它们免受大量恶意 V8 漏洞的影响。

最后,对于确实需要更大堆大小的应用,有一些变通方法。例如,可以在你的应用中包含一个禁用了指针压缩的 Node.js 版本,并将内存密集型的工作移到子进程中。虽然有些复杂,但如果你决定针对特定用例进行不同的权衡,也可以构建一个禁用了指针压缩的自定义 Electron 版本。最后,在不久的将来,wasm64 将允许使用 WebAssembly 构建的应用,无论是在网页上还是在 Electron 中,都能使用远超 4GB 的内存。

🌐 Lastly, there are workarounds for apps that really need a larger heap size. For example, it is possible to include a copy of Node.js with your app, which is built with pointer compression disabled, and move the memory-intensive work to a child process. Though somewhat complicated, it is also possible to build a custom version of Electron with pointer compression disabled, if you decide you want a different trade-off for your particular use case. And lastly, in the not-too-distant future, wasm64 will allow apps built with WebAssembly both on the Web and in Electron to use significantly more than 4GB of memory.


常见问题

🌐 FAQ

我如何知道我的应用是否受到此变更的影响?

🌐 How will I know if my app is impacted by this change?

在 Electron 20+ 版本中,尝试用 ArrayBuffer 封装外部内存会导致运行时崩溃。

🌐 Attempting to wrap external memory with an ArrayBuffer will crash at runtime in Electron 20+.

如果你的应用中没有使用任何本地 Node 模块,那么你是安全的——纯 JS 无法触发这种崩溃。此更改仅影响那些在 V8 堆外分配内存(例如使用 mallocnew)并随后使用 ArrayBuffer 封装外部内存的本地 Node 模块。这是一种相对少见的用例,但确实有一些模块使用这种技术,这类模块需要重构才能与 Electron 20 及以上版本兼容。

🌐 If you don't use any native Node modules in your app, you're safe—there's no way to trigger this crash from pure JS. This change only affects native Node modules which allocate memory outside of the V8 heap (e.g. using malloc or new) and then wrap the external memory with an ArrayBuffer. This is a fairly rare use case, but some modules do use this technique, and such modules will need to be refactored in order to be compatible with Electron 20+.

如何测量我的应用使用了多少 V8 堆内存,以了解是否接近 4GB 的限制?

🌐 How can I measure how much V8 heap memory my app is using to know if I'm close to the 4GB limit?

在渲染进程中,你可以使用 performance.memory.usedJSHeapSize,它将返回 V8 堆的使用情况(以字节为单位)。在主进程中,你可以使用 process.memoryUsage().heapUsed,其功能相当。

🌐 In the renderer process, you can use performance.memory.usedJSHeapSize, which will return the V8 heap usage in bytes. In the main process, you can use process.memoryUsage().heapUsed, which is comparable.

什么是 V8 内存笼?

🌐 What is the V8 memory cage?

有些文档将其称为“V8沙箱”,但这个术语容易与在Chromium中发生的其他类型的沙箱混淆,所以我还是使用“内存笼”这个术语。

🌐 Some documents refer to it as the "V8 sandbox", but that term is easily confusable with other kinds of sandboxing that happen in Chromium, so I'll stick to the term "memory cage".

有一种相当常见的 V8 漏洞利用方式如下:

🌐 There's a fairly common kind of V8 exploit that goes something like this:

  1. 在 V8 的 JIT 引擎中找到一个漏洞。JIT 引擎会分析代码,以便能够省略缓慢的运行时类型检查并生成快速的机器码。有时逻辑错误会导致它分析错误,从而省略了实际需要的类型检查——比如,它认为 x 是一个字符串,但实际上它是一个对象。
  2. 利用这种混淆来覆盖 V8 堆中的部分内存,例如,指向 ArrayBuffer 开头的指针。
  3. 现在你有了一个指向任意位置的 ArrayBuffer,因此你可以读取和写入进程中的任何内存,甚至包括 V8 通常无法访问的内存。

V8 内存笼是一种旨在完全防止此类攻击的技术。实现方式是_不在 V8 堆中存储任何指针_。相反,所有对 V8 堆中其他内存的引用都以某个保留区域起始位置的偏移量存储。这样,即使攻击者设法篡改了 ArrayBuffer 的基址,例如通过利用 V8 中的类型混淆错误,最糟糕的情况也只是读取和写入内存笼内的内存,而这些操作他们很可能本来就能做到。
关于 V8 内存笼的工作原理,还有很多资料可以阅读,所以我在这里就不再详细说明——开始阅读的最佳地点可能是 Chromium 团队的高级设计文档

🌐 The V8 memory cage is a technique designed to categorically prevent this kind of attack. The way this is accomplished is by not storing any pointers in the V8 heap. Instead, all references to other memory inside the V8 heap are stored as offsets from the beginning of some reserved region. Then, even if an attacker manages to corrupt the base address of an ArrayBuffer, for instance by exploiting a type confusion error in V8, the worst they can do is read and write memory inside the cage, which they could likely already do anyway. There's a lot more available to read on how the V8 memory cage works, so I won't go into further detail here—the best place to start reading is probably the high-level design doc from the Chromium team.

我想重构一个 Node 原生模块以支持 Electron 21 及以上版本。我该如何做?

🌐 I want to refactor a Node native module to support Electron 21+. How do I do that?

重构原生模块以兼容 V8 内存隔离有两种方法。第一种是在将外部创建的缓冲区传递给 JavaScript 之前,将其复制到 V8 内存隔离中。这通常是一个简单的重构,但当缓冲区很大时,可能会比较慢。另一种方法是使用 V8 的内存分配器来分配你打算最终传给 JavaScript 的内存。这方法稍微复杂一些,但可以避免复制操作,从而在处理大缓冲区时获得更好的性能。

🌐 There are two ways to go about refactoring a native module to be compatible with the V8 memory cage. The first is to copy externally-created buffers into the V8 memory cage before passing them to JavaScript. This is generally a simple refactor, but it can be slow when the buffers are large. The other approach is to use V8's memory allocator to allocate memory which you intend to eventually pass to JavaScript. This is a bit more involved, but will allow you to avoid the copy, meaning better performance for large buffers.

为了更具体说明,以下是一个使用外部数组缓冲区的 N-API 模块示例:

🌐 To make this more concrete, here's an example N-API module that uses external array buffers:

// 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);

当启用内存笼时,这将会崩溃,因为数据是在笼子之外分配的。通过重构将数据复制到笼子中,我们得到:

🌐 This will crash when the memory cage is enabled, because data is allocated outside the cage. Refactoring to instead copy the data into the cage, we get:

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 也可以提供指向新复制数据的指针,以防你之后需要修改或引用它。

🌐 This will copy the data into a newly-allocated memory region that is inside the V8 memory cage. Optionally, N-API can also provide a pointer to the newly-copied data, in case you need to modify or reference it after the fact.

重构以使用 V8 的内存分配器要复杂一些,因为这需要修改 create_external_resource 函数以使用 V8 分配的内存,而不是使用 malloc。这在多大程度上可行取决于你是否能够控制 create_external_resource 的定义。思路是先使用 V8 创建缓冲区,例如通过 napi_create_buffer,然后将资源初始化到 V8 分配的内存中。在资源的生命周期内保留对 Buffer 对象的 napi_ref 是很重要的,否则 V8 可能会回收该 Buffer,从而可能导致使用后释放的错误。

🌐 Refactoring to use V8's memory allocator is a little more complicated, because it requires modifying the create_external_resource function to use memory allocated by V8, instead of using malloc. This may be more or less feasible, depending on whether or not you control the definition of create_external_resource. The idea is to first create the buffer using V8, e.g. with napi_create_buffer, and then initialize the resource into the memory that has been allocated by V8. It is important to retain a napi_ref to the Buffer object for the lifetime of the resource, otherwise V8 may garbage-collect the Buffer and potentially result in use-after-free errors.