发布时间09十月2023·标记与安全
控制流完整性(CFI)是一种旨在防止攻击者劫持控制流的安全特性。其思想是,即使攻击者设法破坏进程的内存,附加的完整性检查也可以防止他们执行任意代码。在这篇博客文章中,我们想讨论我们在V8中启用CFI的工作。
背景
Chrome的流行使其成为0天攻击的重要目标,我们看到的大多数野生漏洞都针对V8来获得初始代码执行。V8漏洞利用通常遵循类似的模式:初始错误导致内存损坏,但通常初始损坏是有限的,攻击者必须找到一种方法来任意读取/写入整个地址空间。这使得他们能够劫持控制流并运行shellcode,从而执行漏洞利用链的下一步,试图突破Chrome沙箱。
为了防止攻击者将内存损坏转化为shellcode执行,我们在V8中实现了控制流完整性。这在JIT编译器存在的情况下尤其具有挑战性。如果您在运行时将数据转换为机器代码,则现在需要确保损坏的数据不会转换为恶意代码。幸运的是,现代硬件功能为我们提供了构建块来设计JIT编译器,即使在处理损坏的内存时也是健壮的。
接下来,我们将把这个问题分为三个独立的部分:
- Forward-EdgeCFI验证间接控制流传输(如函数指针或vtable调用)的完整性。
- 后向边缘CFI需要确保从堆栈读取的返回地址有效。
- JIT内存完整性验证在运行时写入可执行内存的所有数据。
前沿CFI
我们希望使用两个硬件功能来保护间接调用和跳转:着陆垫和指针验证。
Landing Pads(着陆焊盘)
着陆垫是可用于标记有效分支目标的特殊指令。如果启用,则间接分支只能跳转到着陆垫指令,其他任何操作都将引发异常。
例如,在ARM 64上,着陆垫具有Armv 8.5-A中引入的分支目标识别(BTI)功能。BTI支持在V8中已经启用。
在x64上,着陆垫与控制流实施技术(CET)特性的间接分支跟踪(IBT)部分一起引入。
然而,在间接分支的所有潜在目标上添加着陆垫只能为我们提供粗粒度的控制流完整性,仍然给攻击者很多自由。我们可以通过添加函数签名检查(调用位置的参数和返回类型必须与被调用函数匹配)以及在运行时动态删除不需要的着陆垫指令来进一步收紧限制。
这些功能是最近FineIBT提案的一部分,我们希望它能得到操作系统的采用。
指针验证
Armv8.3-A引入了指针认证(PAC),可用于将签名嵌入指针的上部未使用位。由于签名在使用指针之前已经过验证,攻击者将无法提供指向间接分支的任意伪造指针。
后边缘CFI
为了保护返回地址,我们还希望使用两个独立的硬件功能:影子堆栈和PAC。
影子栈
利用英特尔CET的影子堆栈(shadow stacks)和Armv9.4-A中的保护控制堆栈(GCS),我们可以拥有一个单独的堆栈,仅用于具有硬件保护的返回地址,以防止恶意写入。这些功能提供了一些非常强大的保护,防止返回地址覆盖,但我们需要处理合法修改返回堆栈的情况,例如在优化/反优化和异常处理期间。
指针认证(PAC-RET)
与间接分支类似,指针身份验证可用于在将返回地址推入堆栈之前对其进行签名。这已经在ARM64 CPU上的V8中启用。
对前向边缘和后向边缘CFI使用硬件支持的一个副作用是,它将允许我们将性能影响保持在最低限度。
JIT内存完整性
JIT编译器中CFI的一个独特挑战是,我们需要在运行时将机器代码写入可执行内存。我们需要以一种允许JIT编译器写入内存的方式来保护内存,但攻击者的内存写入原语不能。一种简单的方法是临时更改页面权限以添加/删除写访问权限。但这本质上是非法的,因为我们需要假设攻击者可以从第二个线程并发触发任意写操作。
每线程内存管理
在现代CPU上,我们可以有不同的内存权限视图,这些权限只应用于当前线程,并且可以在用户态中快速更改。
在x64 CPU上,这可以通过内存保护密钥(pkeys)来实现,并且ARM在Armv8.9-A中宣布了权限覆盖扩展。
这允许我们细粒度地切换对可执行内存的写访问,例如通过使用单独的pkey标记它。
JIT页面现在不再是攻击者可写的了,但是JIT编译器仍然需要将生成的代码写入其中。在V8中,生成的代码位于堆上的AssemblerBuffers中,而攻击者可以破坏它。我们也可以用同样的方式保护AssemblerBuffers,但这只是转移了问题。例如,我们还需要保护AssemblerBuffer指针所在的内存。
事实上,任何允许对这种受保护的内存进行写访问的代码都构成了CFI攻击面,需要进行非常防御性的编码。例如,任何对来自未受保护内存的指针的写入都是游戏结束,因为攻击者可以使用它来破坏可执行内存。因此,我们的设计目标是尽可能少地使用这些关键部分,并保持代码简短和自包含。
控制流验证
如果我们不想保护所有编译器数据,那么我们可以从CFI的角度假设它不可信。在将任何内容写入可执行内存之前,我们需要验证它不会导致任意控制流。例如,这包括编写的代码不执行任何系统调用指令,或者它不跳转到任意代码。当然,我们还需要检查它不会改变当前线程的pkey权限。请注意,我们并不试图阻止代码破坏任意内存,因为如果代码被破坏,我们可以假设攻击者已经拥有这种能力。
为了安全地执行这种验证,我们还需要将所需的元数据保存在受保护的内存中,并保护堆栈上的局部变量。
我们运行了一些初步测试来评估这种验证对性能的影响。幸运的是,验证并没有发生在性能关键的代码路径中,我们没有在jetstream或speedometer基准测试中观察到任何回归。
评价
攻击性安全研究是任何缓解设计的重要组成部分,我们一直在努力寻找新的方法来绕过我们的保护。这里有一些我们认为可能发生的攻击的例子,以及解决这些问题的想法。
损坏的Syscall参数
如前所述,我们假设攻击者可以同时触发其他正在运行的线程的内存写原语。如果另一个线程执行系统调用,那么其中一些参数如果是从内存中读取的,就可能受到攻击者的控制。Chrome使用限制性的系统调用过滤器运行,但仍有一些系统调用可用于绕过CFI保护。
例如,signaction是一个注册信号处理程序的系统调用。在我们的研究过程中,我们发现Chrome中的sigaction调用可以以符合CFI的方式访问。由于参数是在内存中传递的,因此攻击者可以触发此代码路径并将信号处理程序函数指向任意代码。幸运的是,我们可以很容易地解决这个问题:要么阻塞sigaction调用的路径,要么在初始化后使用syscall过滤器阻塞它。
其他有趣的例子是内存管理系统调用。例如,如果一个线程在一个损坏的指针上调用munmap,攻击者就可以取消对只读页面的映射,并且一个连续的mmap调用可以重用这个地址,从而有效地向页面添加写权限。
一些操作系统已经通过内存密封提供了针对这种攻击的保护:Apple平台提供了VM_FLAGS_PERMANENT标志,OpenBSD有一个mimmutable系统调用。
信号帧损坏
当内核执行一个信号处理程序时,它会将当前的CPU状态保存在用户栈上。第二个线程可能会破坏保存的状态,然后由内核恢复。
如果信号帧数据是不可信的,则在用户空间中对此进行保护似乎是困难的。在该点处,将必须总是退出信号帧或用已知的保存状态重写信号帧以返回。
一种更有前途的方法是使用每个线程的内存权限来保护信号堆栈。例如,一个pkey标记的sigaltstack可以防止恶意覆盖,但它需要内核在保存CPU状态时临时允许写权限。
v8CTF
这些只是我们正在努力解决的潜在攻击的几个例子,我们还希望从安全社区了解更多信息。如果您对此感兴趣,请尝试最近推出的v8 CTF!利用V8并获得奖励,针对n天漏洞的漏洞明确在范围内!