V8 沙盒
V8 沙盒
前言
自最初的设计文档发布以来,历经近三年,期间提交了数百个CL(兼容性请求),V8 Sandbox——一个轻量级的进程内V8沙箱——如今已发展到不再被视为实验性安全功能的阶段。从今天起, V8 Sandbox正式加入Chrome的漏洞奖励计划(VRP)。尽管在它成为一道强大的安全屏障之前,仍有一些问题需要解决,但加入VRP无疑是朝着这个方向迈出的重要一步。因此,Chrome 123可以被视为该沙箱的“测试版”。本文将借此机会探讨沙箱背后的动机,阐述它如何防止V8内存损坏在宿主进程中扩散,并最终解释为什么它是实现内存安全的必要步骤。
动机
内存安全仍然是一个重要问题:过去三年(2021-2023 年)所有在实际环境中发现的 Chrome 漏洞都源于 Chrome 渲染进程中的内存损坏漏洞,该漏洞被利用来实现远程代码执行 (RCE)。其中,60% 的漏洞存在于 V8 内核中。然而,问题在于:V8 的漏洞很少是“经典”的内存损坏漏洞(例如释放后使用、越界访问等),而是更微妙的逻辑问题,这些问题反过来又可以被利用来破坏内存。因此,现有的内存安全解决方案大多不适用于 V8。特别是,无论是切换到像 Rust 这样的内存安全语言,还是使用当前或未来的硬件内存安全特性(例如内存标记),都无法解决 V8 目前面临的安全挑战。
为了理解原因,我们考虑一个高度简化的、假设的 JavaScript 引擎漏洞:fizz 函数的实现JSArray::fizzbuzz()会将数组中能被 3 整除的值替换为“fizz”,能被 5 整除的值替换为“buzz”,能同时被 3 和 5 整除的值替换为“fizzbuzz”。以下是该函数的 C++ 实现。fizz``JSArray::buffer_可以被视为一个 Array JSValue*,即指向 JavaScript 值数组的指针,并且JSArray::length_包含该缓冲区的当前大小。
1 | 1. for (int index = 0; index < length_; index++) { |
看起来很简单?然而,这里存在一个比较隐蔽的漏洞:ToNumber第 3 行的转换可能会产生副作用,因为它可能会调用用户自定义的 JavaScript 回调函数。这样的回调函数可能会缩小数组,从而导致后续的越界写入。以下 JavaScript 代码很可能会导致内存损坏:
1 | let array = new Array(100); |
请注意,这种漏洞既可能出现在手写的运行时代码中(如上例所示),也可能出现在由即时 (JIT) 编译器在运行时生成的机器代码中(如果该函数是用 JavaScript 实现的)。在前一种情况下,程序员会认为由于刚刚访问过该索引,因此无需对存储操作进行显式的边界检查。在后一种情况下,编译器会在其某个优化过程中(例如消除冗余或消除边界检查)得出同样的错误结论,因为它没有正确地模拟副作用ToNumber()。
虽然这是一个人为简化的漏洞(由于模糊测试工具的改进、开发者意识的提高以及研究人员的关注,这种特定的漏洞模式现在基本已经绝迹),但理解为什么现代 JavaScript 引擎中的漏洞难以以通用方式缓解仍然十分重要。考虑使用像 Rust 这样的内存安全语言,在这种语言中,保证内存安全是编译器的责任。在上面的例子中,内存安全语言很可能可以防止解释器使用的手写运行时代码中出现此漏洞。然而,它无法防止任何即时编译器中的漏洞,因为那里的漏洞是逻辑问题,而不是“经典”的内存损坏漏洞。只有编译器生成的代码才会真正导致内存损坏。从根本上讲,问题在于如果编译器直接成为攻击面的一部分,那么编译器就无法保证内存安全。
同样,禁用 JIT 编译器也只是部分解决方案:历史上,V8 中发现和利用的漏洞大约有一半影响到其编译器,而其余的则存在于其他组件中,例如运行时函数、解释器、垃圾回收器或解析器。为这些组件使用内存安全语言并移除 JIT 编译器或许可行,但这会显著降低引擎的性能(根据工作负载类型的不同,对于计算密集型任务,性能可能会降低 1.5 到 10 倍甚至更多)。
现在我们来考虑一下常用的硬件安全机制,特别是内存标记。内存标记同样无法有效解决问题,原因有很多。例如,CPU侧信道攻击很容易被JavaScript利用,攻击者可以利用这些攻击泄露标记值,从而绕过安全措施。此外,由于指针压缩,V8的指针中目前没有足够的空间来存储标记位。因此,整个堆区域都必须使用相同的标记,这使得检测对象间的损坏变得不可能。因此,虽然内存标记在某些攻击面上非常有效,但对于JavaScript引擎而言,它不太可能对攻击者构成多大的阻碍。
总而言之,现代 JavaScript 引擎往往包含复杂的二阶逻辑漏洞,这些漏洞提供了强大的利用方式。传统的内存损坏漏洞防护技术无法有效保护这些漏洞。然而,如今在 V8 中发现和利用的几乎所有漏洞都有一个共同点:最终的内存损坏必然发生在 V8 堆内存中,因为编译器和运行时(几乎)完全基于 V8HeapObject实例运行。而这正是沙箱发挥作用的地方。
V8(堆)沙盒
沙箱背后的基本思想是隔离 V8 的(堆)内存,这样任何内存损坏都不会“扩散”到进程内存的其他部分。
以现代操作系统中用户空间和内核空间的分离为例,可以更好地理解沙箱设计。过去,所有应用程序和操作系统内核共享同一(物理)内存地址空间。因此,用户应用程序中的任何内存错误都可能导致整个系统崩溃,例如,损坏内核内存。而现代操作系统则不同,每个用户空间应用程序都拥有自己专用的(虚拟)地址空间。因此,任何内存错误都仅限于应用程序本身,系统的其他部分则受到保护。换句话说,一个有缺陷的应用程序可能会崩溃,但不会影响系统的其他部分。类似地,V8 沙箱旨在隔离 V8 执行的不受信任的 JavaScript/WebAssembly 代码,从而避免 V8 中的错误影响宿主进程的其他部分。
原则上,沙箱可以通过硬件支持来实现:类似于用户空间和内核分离,V8 会在进入或离开沙箱代码时执行一些模式切换指令,从而使 CPU 无法访问沙箱外的内存。但实际上,目前还没有合适的硬件特性,因此当前的沙箱完全由软件实现。
软件沙箱的基本思想是将所有能够访问沙箱外内存的数据类型替换为“沙箱兼容”的替代类型。具体来说,所有指针(无论是指向 V8 堆上的对象还是内存中其他位置的对象)以及 64 位大小的数据都必须移除,因为攻击者可能会破坏它们,从而进而访问其他内存。这意味着像栈这样的内存区域不能位于沙箱内,因为受硬件和操作系统限制,它们必须包含指针(例如返回地址)。因此,在软件沙箱中,只有 V8 堆位于沙箱内,其整体结构与WebAssembly 使用的沙箱模型非常相似。
为了理解这在实践中是如何运作的,有必要了解漏洞利用程序在破坏内存后必须执行的步骤。远程代码执行 (RCE) 漏洞利用的目标通常是执行权限提升攻击,例如执行 shellcode 或执行返回导向编程 (ROP) 式攻击。对于这两种攻击,漏洞利用程序首先需要具备读写任意内存的能力,例如破坏函数指针或在内存中的某个位置放置 ROP 有效载荷并以此为跳板。假设存在一个破坏 V8 堆内存的漏洞,攻击者会寻找类似以下的对象:
1 | class JSArrayBuffer: public JSObject { |
鉴于此,攻击者随后会篡改缓冲区指针或大小值,从而构造任意读/写原语。这正是沙箱机制旨在阻止的步骤。具体来说,启用沙箱后,假设引用的缓冲区位于沙箱内部,则上述对象现在将变为:
1 | class JSArrayBuffer: public JSObject { |
其中sandbox_ptr_t,$\begin{bright}$ 是沙箱底部的 40 位偏移量(以 1TB 沙箱为例)。类似地,$\begin{bright}$sandbox_size_t是“沙箱兼容”的大小,目前限制为 32GB。
或者,如果引用的缓冲区位于沙箱外部,则该对象将变为:
1 | class JSArrayBuffer: public JSObject { |
这里,aexternal_ptr_t通过指针表间接引用缓冲区(及其大小)(类似于Unix 内核的文件描述符表或WebAssembly.Table),从而提供内存安全保证。
无论哪种情况,攻击者都无法“突破”沙箱的限制,访问地址空间的其他部分。他们首先需要利用另一个漏洞:V8 沙箱绕过漏洞。下图概述了沙箱的高级设计,感兴趣的读者可以在链接的设计文档中找到更多关于沙箱的技术细节src/sandbox/README.md。
沙箱设计的高级示意图
对于像 V8 这样复杂的应用程序来说,仅仅将指针和大小转换为不同的表示形式是不够的,还有许多其他问题需要解决。例如,引入沙箱机制后,类似以下的代码突然变得有问题:
1 | std::vector<std::string> JSObject::GetPropertyNames() { |
这段代码做出了一个(合理的)假设:直接存储在 JSObject 中的属性数量必须小于该对象的属性总数。然而,假设这些数字只是以整数的形式存储在 JSObject 的某个地方,攻击者可以篡改其中一个数字来破坏这个不变性。随后,对(沙箱外的)访问std::vector就会越界。添加显式的边界检查,例如使用 if 语句SBXCHECK,就可以解决这个问题。
令人鼓舞的是,目前发现的几乎所有“沙箱违规”都属于此类:一些轻微的(一级)内存损坏漏洞,例如释放后使用或由于缺乏边界检查而导致的越界访问。与 V8 中常见的二级漏洞不同,这些沙箱漏洞实际上可以通过前面讨论的方法进行预防或缓解。事实上,由于Chrome 对 libc++ 的加固,上述特定漏洞目前已经得到缓解。因此,我们希望从长远来看,沙箱能够成为比 V8 本身更强大的安全边界。虽然目前可用的沙箱漏洞数据集非常有限,但今天发布的 VRP 集成有望帮助我们更清晰地了解沙箱攻击面上遇到的漏洞类型。
表现
这种方法的一大优势在于其极低的成本:沙箱带来的开销主要来自外部对象的指针表间接寻址(大约需要一次额外的内存加载),其次是使用偏移量而非原始指针(主要只需要一次移位加法运算,开销非常低)。因此,在典型工作负载下(使用 Speedometer和JetStream基准测试套件测量),沙箱的当前开销仅为 1% 或更低。这使得 V8 沙箱可以在兼容平台上默认启用。
测试
任何安全边界都应具备可测试性这一理想特性:即能够手动和自动测试所承诺的安全保证在实践中是否真正有效。这需要一个清晰的攻击者模型、一种“模拟”攻击者的方法,以及理想情况下能够自动判断安全边界何时失效的方法。V8 沙箱满足所有这些要求:
- 清晰的攻击者模型:假设攻击者可以在 V8 沙箱内任意读写数据。目标是防止沙箱外部的内存损坏。
- 模拟攻击者的一种方法: V8 在构建时使用特定
v8_enable_memory_corruption_api = true标志提供了一个“内存损坏 API”。这模拟了从典型的 V8 漏洞中获取的基本操作,尤其是在沙箱内提供完整的读写访问权限。 - 检测“沙箱违规”的方法: V8 提供了一个“沙箱测试”模式(可通过
--sandbox-testing或启用--sandbox-fuzzing),该模式会安装一个信号处理程序,用于确定诸如之类的信号是否SIGSEGV表示违反了沙箱的安全保证。
最终,这使得沙箱能够集成到 Chrome 的 VRP 程序中,并由专门的模糊测试工具进行模糊测试。
用法
V8 沙箱必须在构建时使用构建标志启用/禁用v8_enable_sandbox。由于技术原因,无法在运行时启用/禁用沙箱。V8 沙箱需要 64 位系统,因为它需要预留大量的虚拟地址空间,目前为 1 TB。
在过去两年左右的时间里,Android、ChromeOS、Linux、macOS 和 Windows 平台上的 64 位(特别是 x64 和 arm64)Chrome 浏览器默认启用了 V8 沙箱。尽管沙箱功能并不完善(现在仍然如此),但这样做主要是为了确保它不会导致稳定性问题,并收集实际性能统计数据。因此,最近出现的 V8 漏洞利用程序必须先绕过沙箱,从而为评估其安全性提供了宝贵的早期反馈。
结论
V8 沙箱是一种新的安全机制,旨在防止 V8 中的内存损坏影响其他内存。沙箱的出现源于这样一个事实:当前的内存安全技术大多无法应用于优化 JavaScript 引擎。虽然这些技术无法防止 V8 本身的内存损坏,但它们实际上可以保护 V8 沙箱的攻击面。因此,沙箱是实现内存安全的必要步骤。



