发布时间04四月2024·标记为安全
在最初的设计文档和数百个CL发布近三年后,V8 Sandbox -一个轻量级的V8进程内沙箱-现在已经发展到不再被认为是实验性安全功能的地步。从今天开始,V8 Sandbox包含在Chrome的漏洞奖励计划(VRP)中。虽然在它成为强大的安全边界之前还有许多问题需要解决,但VRP的加入是朝着这个方向迈出的重要一步。因此,Chrome 123可以被认为是沙盒的一种“beta”版本。这篇博客文章利用这个机会讨论沙箱背后的动机,展示它如何防止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_包含该缓冲区的当前大小。
1. for (int index = 0; index < length_; index++) {
2. JSValue js_value = buffer_[index];
3. int value = ToNumber(js_value).int_value();
4. if (value % 15 == 0)
5. buffer_[index] = JSString("fizzbuzz");
6. else if (value % 5 == 0)
7. buffer_[index] = JSString("buzz");
8. else if (value % 3 == 0)
9. buffer_[index] = JSString("fizz");
10. }
看起来够简单了吗然而,这里有一个微妙的bug:第3行中的ToNumber转换可能会产生副作用,因为它可能会调用用户定义的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中发现和利用的bug中,大约有一半会影响其中一个编译器,而其余的则在其他组件中,如运行时函数,解释器,垃圾收集器或解析器。为这些组件使用内存安全语言并删除JIT编译器可以工作,但会显着降低引擎的性能(范围取决于工作负载类型,从1.5-10倍或更高的计算密集型任务)。
现在考虑流行的硬件安全机制,特别是内存标记。有很多原因可以解释为什么记忆标记同样不是一个有效的解决方案。例如,CPU侧通道很容易被JavaScript利用,可能会被滥用以泄漏标记值,从而允许攻击者绕过缓解措施。此外,由于指针压缩,V8的指针中目前没有标记位的空间。因此,整个堆区域必须使用相同的标记进行标记,从而无法检测对象间损坏。因此,虽然内存标记在某些攻击表面上可能非常有效,但在JavaScript引擎的情况下,它不太可能成为攻击者的障碍。
总之,现代JavaScript引擎往往包含复杂的二阶逻辑错误,这些错误提供了强大的利用原语。这些漏洞无法通过用于典型内存损坏漏洞的相同技术进行有效保护。然而,今天在V8中发现和利用的几乎所有漏洞都有一个共同点:最终的内存损坏必然发生在V8堆中,因为编译器和运行时(几乎)只在V8 HeapObject实例上运行。这就是沙盒发挥作用的地方。
V8(堆)沙盒
沙箱背后的基本思想是隔离V8的(堆)内存,这样任何内存损坏都不会“传播”到进程内存的其他部分。
作为沙箱设计的一个激励性例子,考虑现代操作系统中用户空间和内核空间的分离。从历史上看,所有应用程序和操作系统内核都将共享相同的(物理)内存地址空间。因此,用户应用程序中的任何内存错误都可能导致整个系统崩溃,例如,损坏内核内存。另一方面,在现代操作系统中,每个用户态应用程序都有自己的专用(虚拟)地址空间。因此,任何内存错误都仅限于应用程序本身,并且系统的其余部分都受到保护。换句话说,一个错误的应用程序可能会崩溃,但不会影响系统的其余部分。类似地,V8 Sandbox尝试隔离由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位偏移量(在1 TB沙箱的情况下)。类似地,sandbox_size_t是一个“沙箱兼容”的大小,目前限制为32GB。
或者,如果引用的缓冲区位于沙箱之外,则对象将变为:
class JSArrayBuffer: public JSObject {
private:
external_ptr_t buffer_;
};
在这里,external_ptr_t通过指针表间接引用缓冲区(及其大小)(与Unix内核的文件描述符表或WebAssembly.Table不同),它提供了内存安全保证。
在这两种情况下,攻击者会发现自己无法“伸出”沙箱进入地址空间的其他部分。相反,他们首先需要一个额外的漏洞:V8 Sandbox旁路。下图总结了高级设计,感兴趣的读者可以在设计文档中找到更多关于沙箱的技术细节,链接来自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 Sandbox在兼容平台上默认启用。
测试
任何安全边界的一个理想特性是可测试性:手动和自动测试承诺的安全保证在实践中实际保持的能力。这需要一个明确的攻击者模型,一种“模拟”攻击者的方法,以及一种自动确定安全边界何时失败的理想方法。V8 Sandbox满足所有这些要求:
- 一个清晰的攻击者模型:假设攻击者可以在V8 Sandbox中任意读写。目标是防止沙箱之外的内存损坏。
- 一种模拟攻击者的方法:V8在使用
v8_enable_memory_corruption_api = true标志构建时提供了“内存损坏API”。这模拟了从典型的V8漏洞中获得的原语,特别是在沙箱中提供了完全的读写访问。 - 一种检测“沙箱违规”的方法:V8提供了一种“沙箱测试”模式(通过
--sandbox-testing或--sandbox-fuzzing启用),该模式安装了一个信号处理程序,用于确定诸如SIGSEGV之类的信号是否表示违反了沙箱的安全保证。
最终,这允许沙箱集成到Chrome的VRP程序中,并由专门的模糊器进行模糊化。
使用
必须在构建时使用v8_enable_sandbox构建标志启用/禁用V8沙箱。由于技术原因,不可能在运行时启用/禁用沙箱。V8 Sandbox需要一个64位系统,因为它需要保留大量的虚拟地址空间,目前为1 TB。
V8 Sandbox已经在Android、ChromeOS、Linux、macOS和Windows上默认启用了64位(特别是x64和arm64)版本的Chrome。尽管沙箱的功能并不完整,但这主要是为了确保它不会导致稳定性问题,并收集真实世界的性能统计数据。因此,最近的V8漏洞已经不得不通过沙箱,提供有关其安全属性的有用的早期反馈。
结论
V8 Sandbox是一种新的安全机制,旨在防止V8中的内存损坏影响进程中的其他内存。沙箱的动机是当前的内存安全技术在很大程度上不适用于优化JavaScript引擎。虽然这些技术无法防止V8本身的内存损坏,但它们实际上可以保护V8 Sandbox攻击面。因此,沙箱是实现内存安全的必要步骤。