将堆栈冲突保护引入Clang / x86

2021-01-31 20:22:25

堆栈冲突是一种可追溯到2017年的攻击,当时Qualys Research Team通过联合博客发布了一份咨询。它基本上利用了较大的堆栈分配(大于PAGE_SIZE),这可能导致堆栈读/写不触发LinuxKernel分配的堆栈保护页。

该通报发布后不久,GCC提供了一种由-fstack-clash-protection激活的措施,该措施主要包括将大分配分成PAGE_SIZE个块,每个块中都有一个探针以触发内核堆栈保护页。

从那以后,这一直是GCC和Clang之间的主要安全差异。 Fedora甚至将其从GCC转移到Clang,作为某些已经向上游转移的项目的编译器,从而导致打包程序的额外维护。

支持此标志[2020年登陆Clang] [CLANGStackClashProtection],仅适用于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中找到

以下方案尝试总结静态分配和动态分配之间的分配和探测交互:

+ -----<-------------<--------------<---------- -+ | | [免费探针]-> [页面分配]-> [分配探针]-> [tail alloc] +-> [dyn探针]-> [页面分配]-> [dyn探针]-> [tail alloc] + | | +<------------<-------------<------------<---- --------- +

Firefox提供了一个惊人的测试平台来评估编译器更改的影响。实际上,使用PGO / LTO和XLTO构建的C / C ++超过12MLOC,Rust超过3MLOC,涵盖了大多数重要情况。

此外,Firefox已在大量操作系统和体系结构上受支持,这是在各种配置集上测试堆栈冲突保护的好方法。

为了确保Firefox能够按预期方式运行,我们利用庞大的测试套件来验证该产品是否仍可以按预期运行。

我们使用了try auto这个新命令,它将在开发阶段针对此类更改运行最合适的测试集。然后,一旦补丁程序进入Mozilla-central(每晚Firefox),将执行整体测试套件,展示29天的机器时间可处理约9000个任务。

由于有了这个基础架构,我们发现了alloca(0)生成有问题的机器代码的问题,幸运的是,该修复程序已经在LLVM的主干版本中。我们从定制的Clang构建中挑选了修复程序来解决了这个问题。

多年来,Mozilla开发了一些工具来评估从微观基准到页面加载的变化对性能的影响。这些工具是提高Firefox整体性能的关键,而且还评估了几年前迁移到Clang对所有平台的影响。

利用工具重新运行基准测试(通常5到20次)以限制噪声。

在此项目的背景下,我们运行了对C ++更改敏感的常规基准测试,但尚未发现性能方面的任何下降。

从2021年1月8日起,Linux上的Firefox夜间版本现在已使用stack-clash-option进行编译。自从登陆以来,我们尚未发现任何回归。如果一切顺利,此更改应随Firefox 86一起发布(计划于2021年2月中旬发布)。

长期以来,Rust使用在其自己的内置库中定义的函数__rust_probestack来支持LLVM probe-stack属性的回调样式。本着Rust的安全精神,将此属性添加到所有功能中,让LLVM整理出实际需要探测的位置。但是,强制这样的使用大堆栈框架的每个函数调用对于性能而言并不是理想的选择,尤其是对于那些仅可以使用几个展开的内联探针的情况。此外,Rust仅为其第1层(最受支持)的目标实现了此回调,即i686和x86_64,到目前为止,其他架构都没有受到保护。因此,让LLVM生成内联堆栈探针对于避免调用的性能和增加的体系结构支持都是有益的。

由于Rust编译器是用Rust本身编写的,默认情况下启用了堆栈探测,因此它对任何新的代码生成功能都进行了很好的功能测试。编译器分阶段启动,首先使用先前版本进行构建,然后使用该结果进行重建第一阶段。如果编译器在重建期间崩溃,则通常会暴露出Codegen问题,并且行内堆栈探针中的实验也没有不同,从而导致D82867和D90216中的修复。这两个都是简单的错误,在现有的FileCheck测试中并不明显,这表明实际执行生成的代码的重要性。

一个问题还导致人们意识到,有一个更一般的错误同时影响-fstack-clash-protector的GCC和LLVM的实现,从而导致在LLVM端设置了新的补丁程序,基本上,观察到的行为如下:

对齐要求的行为类似于针对堆栈的分配:它们(可能)使之增长。例如,对于char foo [4096] __attribute __((aligned(2048)));的堆栈分配;通过以下方式完成:

和和sub实际上都更新了堆栈!为了考虑到这种影响,LLVM补丁程序在计算探测距离时将and rsp,-2048视为子rsp,2048,这意味着要考虑最坏的情况。

为了将来在Rust方面的工作,内联堆栈探针将很快在Rustpr77885中替换i686和x86_64上的__rust_probestack,并将包括性能结果以监视效果。之后,还可以对其他架构进行功能测试并启用内联堆栈探针,从而扩大了Rust的内存安全性。

上述验证均不能验证保护的安全性。为了对实际的探测方案实现更有信心,我们基于(出色的)QBDIDynamic Binary Instrumentation框架实现了二进制跟踪程序。这个概念证明(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] [错误]堆栈分配太大(5024)$ LD_PRELOAD =。 /libstack_clash_tracer.so ./main 1 2 3 4 5 [sct] [错误]堆栈分配太大(5024)[sct] [错误]堆栈分配太大(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

$ 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测试用例,在我们进行更改之前,我们可以看到:

除了对策的技术方面,有趣的是,它的Clang实现是从GCC实现派生的,但存在GCC代码库中报告的一个问题。 Clang生成的代码已由Firefox People验证,并经过Rust人员的测试,该人员报告了一些错误,有些错误同时影响了Clang和GCC的实现,因此圈子很完整!