将栈冲突保护带入 Clang/X86——开源的方式
背景
栈冲突攻击可追溯到 2017 年,当时 Qualys 研究团队发布了一份带有联合博客文章的公告。它利用了大型栈分配(大于PAGE_SIZE
),这会导致栈读写不会触发由 Linux 内核分配的栈保护页。
在公告发布后不久,GCC 提供了由-fstack-clash-protection
激活的对策,它主要通过将大型分配分割成PAGE_SIZE
大小的块,并在每个块中进行探测来触发内核栈保护页。
从那时起,这成为了 GCC 和 Clang 之间的重大安全差异。它甚至被 Fedora 识别为阻碍其从 GCC 切换到 Clang 作为某些项目的编译器的因素,这些项目已在 upstream 完成了切换,导致打包人员进行额外维护。
对该标志的支持在 2020 年被添加到 Clang 中,仅适用于 X86、SystemZ 和 PowerPC。它的实现是 LLVM、Firefox 和 Rust 开发者之间卓有成效合作的结果。
Rust 已经在其运行时实现了一种对策,以执行栈探测。随着 LLVM 的赶超,使用更轻量级的方法在 Rust 中进行了调查。
对策描述
X86 的 Clang 实现源自 GCC 的实现,但有一些区别。核心思想是
-
由于 X86 调用约定,我们在每个调用点获得一个免费探测,这意味着每个函数都从一个已探测的栈开始
-
当在函数序言中探测栈时,我们不会探测分配的尾部。换句话说,如果栈大小是
PAGE_SIZE + PAGE_SIZE/2
,我们只希望探测一次。这对于限制探测次数很重要:如果栈大小低于PAGE_SIZE
,则不需要探测 -
由于信号可以在任何时候中断执行流程,因此在任何时候我们都不应该有两个(低于
PAGE_SIZE
的)栈分配而不进行探测。
根据栈分配的大小,栈分配的探测策略会有所不同。如果它小于PAGE_SIZE
,那么由于(2),不需要进行探测。如果它低于PAGE_SIZE
的小倍数,那么探测循环可以展开。否则,探测循环会交替进行PAGE_SIZE
字节的栈分配和探测,从分配开始,这得益于(1)。
作为(2)的副作用,当执行动态分配时,我们需要在更新栈之前进行探测,否则我们的保护就会出现漏洞。由于(3),即使使用偏移量,也不能在栈更新之后进行探测。否则,我们会遇到类似于在GCC中发现的错误。
以下方案试图总结静态和动态分配之间的分配和探测交互
+ ----- <- ------------ <- ------------- <- ------------ +
| |
[free probe] -> [page alloc] -> [alloc probe] -> [tail alloc] + -> [dyn probe] -> [page alloc] -> [dyn probe] -> [tail alloc] +
| |
+ <- ----------- <- ------------ <- ----------- <- ------------ +
使用 Firefox 进行验证
Firefox 提供了一个绝佳的测试平台来评估编译器更改的影响。事实上,它有超过 1200 万行 C/C++ 代码和 300 万行 Rust 代码,使用 PGO/LTO 和XLTO构建,涵盖了大多数重要情况。
此外,Firefox 在大量操作系统和架构上得到支持,因此它成为在各种配置集上测试栈冲突保护的绝佳方式。
这项工作在bug 1588710中进行了详细说明。
功能测试
为了确保 Firefox 的性能符合预期,我们利用了庞大的测试套件来验证该产品在启用此选项后仍然能够按预期工作。
我们使用了try auto
,这是一种新命令,在开发阶段将运行最适合此类更改的测试集。然后,一旦补丁被添加到 Mozilla-central(Firefox nightly)中,整个测试套件就会被执行,大约需要 9000 个任务的 29 天机器时间。
由于有了这种基础设施,我们发现了一个alloca(0)
导致机器码出现错误的问题。幸运的是,修复已在 LLVM 的主干版本中。我们在我们的自定义 Clang 构建中 cherry-picked 了修复程序,解决了我们的问题。
性能测试
多年来,Mozilla 开发了一些工具来评估更改的性能影响,从微基准测试到页面加载。这些工具一直是提高 Firefox 整体性能的关键,也评估了几年前迁移到 Clang对所有平台的影响。
评估性能改进/回归的通常步骤是
-
运行两个带有基准测试的构建。一个没有补丁,另一个有补丁。
-
利用工具重新运行基准测试(通常 5 到 20 次)以限制噪音。
-
比较各种基准测试,查看是否可以识别出重大回归。
在这个项目中,我们运行了通常对 C++ 更改敏感的基准测试,我们没有发现性能方面的回归。
当前状态
从 2021 年 1 月 8 日起,Linux 上的 Firefox nightly 现在使用栈冲突选项进行编译。我们没有发现任何回归。如果一切顺利,此更改应该包含在 Firefox 86 中(计划在 2021 年 2 月中旬发布)。
使用 Rust 进行验证
Rust 长期以来一直支持 LLVM probe-stack
属性的回调风格,使用其自己的编译器内置库中定义的__rust_probestack
函数。在 Rust 的安全性精神中,此属性被添加到所有函数中,让 LLVM 决定哪些函数实际上需要探测。但是,将这样的调用强制到具有大型栈帧的每个函数中对于性能并不理想,尤其是在可以使用少量展开的内联探测的情况下。此外,Rust 仅在其一级(最受支持)的目标(即 i686 和 x86_64)上实现了此回调,到目前为止,其他架构没有得到保护。因此,让 LLVM 生成内联栈探测对于避免调用的性能和扩展架构支持都是有益的。
由于 Rust 编译器本身是用 Rust 编写的,并且默认情况下启用了栈探测,因此它成为了任何新代码生成功能的绝佳功能测试。编译器分阶段引导,首先使用先前版本进行构建,然后使用第一阶段结果进行重新构建。如果编译器在重新构建期间崩溃,代码生成问题通常会显现出来,而内联栈探测的实验也不例外,导致在D82867和D90216中修复。这两个都是简单的错误,在现有的 FileCheck 测试中并不明显,这表明实际执行生成的代码的重要性。
一个问题也让人意识到,在 GCC 和 LLVM 的-fstack-clash-protector
实现中存在更普遍的错误,导致 LLVM 端出现新的补丁集。基本上,观察到的行为如下
对齐要求与分配在栈方面的行为类似:它们(可能)会导致栈增长。例如,char foo[4096] __attribute__((aligned(2048)));
的栈分配是通过
and rsp, -2048
sub rsp, 6024
and
和 sub
实际上都会更新栈!为了考虑这种影响,LLVM 补丁将and rsp, -2048
视为sub rsp, 2048
来计算探测距离,这意味着考虑最坏的情况。
在 Rust 端的未来工作中,内联栈探测将很快在Rust pr77885中替换 i686 和 x86_64 上的__rust_probestack
,这将包括性能结果以监控效果。之后,其他架构也可以进行功能测试并启用内联栈探测,从而扩大 Rust 内存安全性的覆盖范围。
使用二进制跟踪器进行验证
以上验证都没有验证保护的安全方面。为了对实际探测方案实现更有信心,我们基于(很棒的)QBDI动态二进制检测框架实现了一个二进制跟踪器。此概念验证 (POC) 可在 GitHub 上获得:stack-clash-tracer
此工具会检测运行二进制文件的栈分配和内存访问,记录它们并检查没有任何栈分配大于PAGE_SIZE
,以及我们在两个分配之间进行了实际探测。
这是一个展示大型栈分配问题的示例会话
$ cat main.c
#include <alloca.h>
#include <string.h>
int main(int argc, char**argv) {
char buffer[5000];
strcpy(buffer, argv[0]);
char* dynbuffer = alloca(argc * 1000);
strcpy(dynbuffer, argv[0]);
return buffer[argc] + dynbuffer[argc];
}
$ gcc main.c -o main
$ LD_PRELOAD=./libstack_clash_tracer.so ./main 1
[sct][error] stack allocation is too big (5024)
$ LD_PRELOAD=./libstack_clash_tracer.so ./main 1 2 3 4 5
[sct][error] stack allocation is too big (5024)
[sct][error] stack allocation is too big (6016)
使用-fstack-clash-protection
编译的相同代码更安全(除了使用strcpy
很愚蠢之外)。
$ gcc main.c -fstack-clash-protection -o main
$ LD_PRELOAD=./libstack_clash_tracer.so ./main 1
$ LD_PRELOAD=./libstack_clash_tracer.so ./main 1 2 3 4 5
这种与编译器无关方法的小额外优势:我们可以验证 GCC 和 Clang 的实现:-)
$ clang main.c -fstack-clash-protection -o main
$ LD_PRELOAD=./libstack_clash_tracer.so ./main 1
$ LD_PRELOAD=./libstack_clash_tracer.so ./main 1 2 3 4 5
回到 Firefox 测试用例,在我们添加更改之前,我们可以看到
$ LD_PRELOAD=./libstack_clash_tracer.so firefox-bin
[sct][error] stack allocation is too big (4168)
一旦 Firefox nightly 发布了栈冲突保护,此警告就会消失。
结论
除了对策的技术方面之外,值得注意的是,其 Clang 实现源自 GCC 实现,但导致在 GCC 代码库中报告了一个问题。Clang 生成的代码由 Firefox 人员进行验证,由 Rust 人员进行测试,他们报告了几个错误,其中一些影响了 Clang 和 GCC 的实现,循环闭合了!