从最初的设计文档发布到现在,经过了近三年的时间,在此期间,V8 沙盒(V8 的轻量级进程内沙盒)已经发展到不再被视为实验性安全功能的地步。从今天开始,V8 沙盒被纳入 Chrome 的漏洞奖励计划(VRP)。虽然在它成为强大的安全边界之前仍有许多问题需要解决,但 VRP 的纳入是朝着这个方向迈出的重要一步。因此,Chrome 123 可以被视为沙盒的一种“测试版”。这篇博文利用这个机会讨论了沙盒背后的动机,展示了它如何防止 V8 中的内存损坏在主机进程内传播,并最终解释了为什么它是实现内存安全的必要步骤。
动机
内存安全仍然是一个相关问题:过去三年(2021 年至 2023 年)发现的所有 Chrome 漏洞都是从 Chrome 渲染器进程中的内存损坏漏洞开始的,该漏洞被用于远程代码执行 (RCE)。其中 60% 是 V8 中的漏洞。然而,有一个问题:V8 漏洞很少是“经典”的内存损坏错误(释放后使用、越界访问等),而是微妙的逻辑问题,反过来可以利用这些问题来破坏内存。因此,现有的内存安全解决方案在很大程度上不适用于 V8。特别是,无论是切换到内存安全的语言(例如 Rust),还是使用当前或未来的硬件内存安全功能(例如内存标记),都无法帮助解决 V8 当前面临的安全挑战。
要理解原因,请考虑一个高度简化的假设 JavaScript 引擎漏洞:的实现JSArray::fizzbuzz(),它将数组中可被 3 整除的值替换为“fizz”,可被 5 整除的值替换为“buzz”,可被 3 和 5 整除的值替换为“fizzbuzz”。下面是该函数在 C++ 中的实现。JSArray::buffer_可以将其视为一个JSValue*,即指向 JavaScript 值数组的指针,并JSArray::length_包含该缓冲区的当前大小。
for (int index = 0; index < length_; index++) {
JSValue js_value = buffer_[index];
int value = ToNumber(js_value).int_value();
if (value % 15 == 0)
buffer_[index] = JSString("fizzbuzz");
else if (value % 5 == 0)
buffer_[index] = JSString("buzz");
else if (value % 3 == 0)
buffer_[index] = JSString("fizz");
}
看起来够简单了吧?然而,这里有一个微妙的错误:ToNumber第 3 行中的转换可能会产生副作用,因为它可能会调用用户定义的 JavaScript 回调。这样的回调可能会缩小数组,从而导致之后出现越界写入。以下 JavaScript 代码可能会导致内存损坏:
let array = new Array(100);
let evil = { [Symbol.toPrimitive]() { array.length = 1; return 15; } };
array.push(evil);
// At index 100, the @@toPrimitive callback of |evil| is invoked in
// line 3 above, shrinking the array to length 1 and reallocating its
// backing buffer. The subsequent write (line 5) goes out-of-bounds.
array.fizzbuzz();
请注意,此漏洞既可能出现在手写的运行时代码中(如上例所示),也可能出现在由优化即时 (JIT) 编译器在运行时生成的机器代码中(如果该函数是用 JavaScript 实现的)。在前一种情况下,程序员会得出结论,由于该索引刚刚被访问过,因此存储操作的显式边界检查是不必要的。在后一种情况下,编译器会在它的某个优化过程中得出相同的错误结论(例如冗余消除或边界检查消除),因为它没有正确模拟副作用ToNumber()。
虽然这是一个人为的简单错误(由于模糊测试器的改进、开发人员意识和研究人员的关注,这种特定的错误模式现在已基本消失),但了解为什么现代 JavaScript 引擎中的漏洞难以以通用方式缓解仍然很有用。考虑使用内存安全语言(如 Rust)的方法,其中编译器负责保证内存安全。在上面的例子中,内存安全语言可能会防止解释器使用的手写运行时代码中的这个错误。但是,它不会阻止任何即时编译器中的错误,因为那里的错误是一个逻辑问题,而不是“经典”的内存损坏漏洞。只有编译器生成的代码才会真正导致任何内存损坏。从根本上讲,问题是如果编译器直接成为攻击面的一部分,则编译器无法保证内存安全。
同样,禁用 JIT 编译器也只能解决部分问题:从历史上看,V8 中发现和利用的漏洞中大约有一半会影响其编译器之一,而其余的漏洞则影响其他组件,例如运行时函数、解释器、垃圾收集器或解析器。对这些组件使用内存安全的语言并删除 JIT 编译器可能会奏效,但会显著降低引擎的性能(根据工作负载的类型,对于计算密集型任务,性能会降低 1.5 到 10 倍或更多)。
现在考虑流行的硬件安全机制,特别是内存标记。有许多原因可以说明为什么内存标记同样不是一种有效的解决方案。例如,很容易被JavaScript 利用的CPU 侧通道可能会被滥用来泄露标记值,从而允许攻击者绕过缓解措施。此外,由于指针压缩,V8 指针中目前没有空间容纳标记位。因此,整个堆区域都必须用相同的标记进行标记,从而无法检测到对象间损坏。因此,虽然内存标记在某些攻击面上非常有效,但对于 JavaScript 引擎而言,它不太可能对攻击者构成太大的障碍。
总之,现代 JavaScript 引擎往往包含复杂的二阶逻辑错误,这些错误提供了强大的利用原语。这些错误无法通过用于典型内存损坏漏洞的相同技术得到有效保护。然而,当今在 V8 中发现和利用的几乎所有漏洞都有一个共同点:最终的内存损坏必然发生在 V8 堆内,因为编译器和运行时(几乎)只在 V8HeapObject实例上运行。这就是沙盒发挥作用的地方。
V8(堆)沙箱
沙箱背后的基本思想是隔离 V8(堆)内存,使得任何内存损坏都不会“扩散”到进程内存的其他部分。
作为沙盒设计的一个激励示例,请考虑现代操作系统中用户空间和内核空间的分离。从历史上看,所有应用程序和操作系统的内核都共享相同的(物理)内存地址空间。因此,用户应用程序中的任何内存错误都可能导致整个系统崩溃,例如,破坏内核内存。另一方面,在现代操作系统中,每个用户空间应用程序都有自己专用的(虚拟)地址空间。因此,任何内存错误都仅限于应用程序本身,系统的其余部分受到保护。换句话说,有故障的应用程序可能会崩溃,但不会影响系统的其余部分。同样,V8 沙盒试图隔离 V8 执行的不受信任的 JavaScript/WebAssembly 代码,以便 V8 中的错误不会影响托管进程的其余部分。
原则上,沙盒可以通过硬件支持来实现:与用户空间-内核分离类似,V8 在进入或离开沙盒代码时会执行一些模式切换指令,这会导致 CPU 无法访问沙盒外的内存。实际上,目前没有合适的硬件功能可用,因此当前的沙盒纯粹是用软件实现的。
基于软件的沙盒背后的基本思想是将所有可以访问沙盒外内存的数据类型替换为“与沙盒兼容”的替代方案。特别是,必须删除所有指针(指向 V8 堆或内存中其他地方的对象)和 64 位大小,因为攻击者可能会破坏它们,随后访问进程中的其他内存。这意味着由于硬件和操作系统的限制,堆栈等内存区域不能位于沙盒内,因为它们必须包含指针(例如返回地址)。因此,对于基于软件的沙盒,只有 V8 堆位于沙盒内,因此整体构造与WebAssembly 使用的沙盒模型并无不同。
要了解这在实践中是如何工作的,查看漏洞利用在破坏内存后必须执行的步骤很有用。RCE 漏洞利用的目标通常是执行权限提升攻击,例如通过执行 shellcode 或执行面向返回编程 (ROP) 式攻击。对于这两种攻击,漏洞利用首先需要能够在进程中读取和写入任意内存,例如然后破坏函数指针或将 ROP 有效负载放置在内存中的某个位置并转向它。给定一个破坏 V8 堆内存的错误,攻击者会因此寻找如下对象:
class JSArrayBuffer: public JSObject {
private:
byte* buffer_;
size_t size_;
};
鉴于此,攻击者可以破坏缓冲区指针或大小值以构造任意读/写原语。这是沙箱旨在阻止的步骤。具体来说,在启用沙箱的情况下,假设引用的缓冲区位于沙箱内,上述对象现在将变为:
class JSArrayBuffer: public JSObject {
private:
sandbox_ptr_t buffer_;
sandbox_size_t size_;
};
其中sandbox_ptr_t是距离沙箱基数 40 位的偏移量(对于 1TB 的沙箱)。同样,sandbox_size_t是“沙箱兼容”的大小,目前限制为 32GB。
或者,如果引用的缓冲区位于沙箱之外,则对象将变为:
class JSArrayBuffer: public JSObject {
private:
external_ptr_t buffer_;
};
这里,通过指针表间接引用缓冲区(及其大小)(与unix 内核或WebAssembly.Tableexternal_ptr_t的文件描述符表不同),从而提供内存安全保障。
在这两种情况下,攻击者都会发现自己无法“突破”沙盒进入地址空间的其他部分。相反,他们首先需要一个额外的漏洞:V8 沙盒绕过。下图总结了高级设计,感兴趣的读者可以在链接的设计文档中找到有关沙盒的更多技术细节src/sandbox/README.md。
沙盒设计的高级图表
对于像 V8 这样复杂的应用程序来说,仅仅将指针和大小转换为不同的表示形式是不够的,还有许多其他问题需要解决。例如,随着沙箱的引入,以下代码突然出现问题:
std::vector<std::string> JSObject::GetPropertyNames() {
int num_properties = TotalNumberOfProperties();
std::vector<std::string> properties(num_properties);
for (int i = 0; i < NumberOfInObjectProperties(); i++) {
properties[i] = GetNameOfInObjectProperty(i);
}
// Deal with the other types of properties
// ...
此代码做出了(合理的)假设,即直接存储在 JSObject 中的属性数量必须小于该对象的属性总数。但是,假设这些数字只是作为整数存储在 JSObject 的某个位置,攻击者可以破坏其中一个数字以打破此不变量。随后,对(沙盒外)的访问std::vector将超出范围。添加显式边界检查(例如使用SBXCHECK)可以解决此问题。
令人鼓舞的是,到目前为止发现的几乎所有“沙盒违规”都是这样的:由于缺乏边界检查,导致使用后释放或越界访问等微不足道的(第一阶)内存损坏错误。与 V8 中常见的第二阶漏洞相反,这些沙盒错误实际上可以通过前面讨论的方法预防或缓解。事实上,由于Chrome 的 libc++ 强化,上述特定错误今天已经得到缓解。因此,希望从长远来看,沙盒成为比 V8 本身更易于防御的安全边界。虽然目前可用的沙盒错误数据集非常有限,但今天启动的 VRP 集成有望帮助更清楚地了解沙盒攻击面上遇到的漏洞类型。
表现
这种方法的一个主要优点是它从根本上来说成本很低:沙盒造成的开销主要来自外部对象的指针表间接性(大约需要一次额外的内存加载),较小程度上来自使用偏移而不是原始指针(主要只需要一次移位+添加操作,成本非常低)。因此,沙盒的当前开销在典型工作负载上仅为 1% 左右或更低(使用 Speedometer和JetStream基准测试套件测量)。这允许在兼容平台上默认启用 V8 沙盒。
测试
任何安全边界都应具备的一个特性是可测试性:能够手动和自动测试承诺的安全保证在实践中是否有效。这需要明确的攻击者模型、“模拟”攻击者的方法,以及理想情况下自动确定安全边界何时失效的方法。V8 沙盒满足以下所有要求:
-
清晰的攻击者模型:假设攻击者可以在 V8 沙箱内任意读写。目标是防止沙箱外的内存损坏。
-
模拟攻击者的方法:V8 在使用该标志构建时提供“内存损坏 API” v8_enable_memory_corruption_api = true。这模拟了从典型的 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 沙盒的攻击面。因此,沙盒是实现内存安全的必要步骤。
https://v8.dev/blog/sandbox
原文始发于微信公众号(Ots安全):V8 沙盒
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论