Electron 与 V8 内存笼
Electron 21 及更高版本将启用 V8 内存笼,这对一些原生模块有影响。
关于 Electron 21+ 中原生模块使用的持续讨论,请参阅 electron/electron#35801。
在 Electron 21 中,我们将启用 V8 沙盒指针,遵循 Chrome 在 Chrome 103 中 做出同样决定的。 这对原生模块有一些影响。 此外,我们之前已经在 Electron 14 中启用了相关技术,指针压缩。 当时我们没有过多讨论,但指针压缩对 V8 堆的最大大小有影响。
这两种技术在启用后,在安全性、性能和内存使用方面都有显著的好处。然而,启用它们也有一些缺点。
启用沙盒指针的主要缺点是,指向外部(“堆外”)内存的 ArrayBuffers 不再被允许。这意味着依赖 V8 中此功能的原生模块需要重构才能在 Electron 20 及更高版本中继续工作。
启用指针压缩的主要缺点是 V8 堆的最大大小限制为 4GB。 细节有点复杂——例如,ArrayBuffers 与 V8 堆的其余部分分开计算,但有其 自己的限制。
Electron 升级工作组 (Electron Upgrades Working Group) 认为指针压缩和 V8 内存笼的优势大于缺点。 这样做有三个主要原因
- 它使 Electron 更接近 Chromium。Electron 在 V8 配置等复杂内部细节上与 Chromium 的差异越小,我们意外引入错误或安全漏洞的可能性就越小。Chromium 的安全团队实力强大,我们希望确保我们能利用他们的工作。此外,如果一个错误只影响 Chromium 未使用的配置,那么修复它可能不是 Chromium 团队的优先事项。
- 性能更好。 指针压缩可以将 V8 堆大小减少高达 40%,并提高 CPU 和 GC 性能 5%–10%。 对于绝大多数不会达到 4GB 堆大小限制且不使用需要外部缓冲区的原生模块的 Electron 应用程序,这些都是显著的性能提升。
- 更安全。 一些 Electron 应用程序运行不受信任的 JavaScript(希望遵循我们的 安全建议!),对于这些应用程序,启用 V8 内存笼可以保护它们免受一大类恶意的 V8 漏洞的影响。
最后,对于确实需要更大堆大小的应用程序,有一些解决方法。 例如,可以将不启用指针压缩的 Node.js 副本包含在您的应用程序中,并将耗内存的工作转移到子进程中。 虽然有点复杂,但也可以构建一个禁用指针压缩的自定义 Electron 版本,如果您决定为您的特定用例想要不同的权衡,也是可以的。 最后,在不久的将来,wasm64 将允许使用 WebAssembly 构建的应用程序,无论是在 Web 上还是在 Electron 中,都可以使用超过 4GB 的内存。
常见问题解答
我如何知道我的应用程序是否受到此更改的影响?
尝试使用 ArrayBuffer 包装外部内存将在 Electron 20+ 中导致运行时崩溃。
如果您的应用程序中没有使用任何原生 Node 模块,那么您是安全的——无法从纯 JS 触发此崩溃。此更改仅影响那些在 V8 堆之外分配内存(例如使用 malloc 或 new),然后用 ArrayBuffer 包装外部内存的原生 Node 模块。这是一种相当罕见的用例,但有些模块确实使用了这种技术,并且此类模块需要重构才能与 Electron 20+ 兼容。
我如何测量我的应用程序正在使用的 V8 堆内存量,以了解我是否接近 4GB 的限制?
在渲染进程中,您可以使用 performance.memory.usedJSHeapSize,它将以字节为单位返回 V8 堆的使用量。 在主进程中,您可以使用 process.memoryUsage().heapUsed,它是可比的。
什么是 V8 内存笼?
有些文档将其称为“V8 沙盒”,但该术语很容易与 Chromium 中发生的 其他类型的沙盒 混淆,所以我坚持使用“内存笼”这个术语。
有一种相当常见的 V8 漏洞利用方式,大致如下:
- 在 V8 的 JIT 引擎中发现一个错误。JIT 引擎分析代码,以便能够省略缓慢的运行时类型检查并生成快速机器码。有时逻辑错误意味着它错误地进行了这种分析,并省略了它实际需要的类型检查——例如,它认为 x 是一个字符串,但实际上它是一个对象。
- 利用这种混淆来覆盖 V8 堆中的某些内存位,例如 ArrayBuffer 开始位置的指针。
- 现在您拥有一个指向任意位置的 ArrayBuffer,因此您可以读取和写入进程中的任何内存,即使是 V8 通常无法访问的内存。
V8 内存笼是一种旨在从根本上防止这种攻击的技术。 实现方法是不在 V8 堆中存储任何指针。 相反,V8 堆内的所有其他内存的引用都存储为从某个保留区域的开头偏移量。 然后,即使攻击者设法破坏了 ArrayBuffer 的基地址,例如通过利用 V8 中的类型混淆错误,他们所能做的就是读取和写入笼子内的内存,而他们可能已经能够这样做。 关于 V8 内存笼的工作原理还有更多内容可供阅读,因此我不会在此处深入探讨——最好的起点可能是 Chromium 团队的 高级设计文档。
我想重构一个 Node 原生模块以支持 Electron 21+。 我该怎么做?
有两种方法可以重构原生模块以使其与 V8 内存笼兼容。第一种方法是,在将外部创建的缓冲区传递给 JavaScript 之前,将其**复制**到 V8 内存笼中。这通常是一个简单的重构,但当缓冲区很大时可能会很慢。另一种方法是**使用 V8 的内存分配器**来分配您打算最终传递给 JavaScript 的内存。这有点复杂,但可以避免复制,这意味着对于大缓冲区来说性能更好。
为了使这一点更具体,这里有一个使用外部 ArrayBuffer 的 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 分配的内存中。 重要的是要保留对 资源生命周期 的 napi_ref 到 Buffer 对象,否则 V8 可能会垃圾回收 Buffer 并可能导致使用已释放内存的错误。
