迈向实用的二进制安全优化 - Trail of Bits博客
仅仅存在还不够,必须安全地存在。(《麦克白》3:1)
编译器生成高效代码是不够的,还必须生成安全代码。尽管编译器及其优化通道在开发过程中经过广泛测试和正确性认证,但它们可能无意中在程序中引入信息泄露,或消除程序员在源代码中编写的安全关键操作。让我们来看一个例子。
图1展示了CWE-733的一个示例,这是一种编译器优化移除或修改安全关键代码的弱点。在这种情况下,程序员编写的源代码通过将先前保存加密密钥的变量设置为零来清理它。这是一个重要步骤!如果程序员不清理该变量,攻击者之后可能恢复该密钥。然而,当这段代码被编译时,清理操作很可能被一个称为“死存储消除”的编译器优化通道移除。
该通道通过消除它认为不必要的变量赋值操作来优化程序。它假设如果分配给变量的值在程序后续未被使用,那么这些值就是不必要的,不幸的是,这包括了我们的清理代码。
图1:编译器优化移除或修改安全关键代码(CWE-733)
这个例子是编译器优化无意中给程序引入安全弱点的几个有据可查的实例之一。最近,我在佐治亚理工学院的同事和我发表了一项广泛研究,探讨编译器设计选择如何影响二进制文件的另一个安全属性:恶意代码可重用性。我们发现编译器代码生成和优化行为通常不考虑恶意可重用性。因此,它们产生的二进制文件通常比必要的更容易被攻击者重用。
构建强大的代码重用攻击
攻击者使用代码重用攻击技术,如返回导向编程和跳转导向编程(ROP和JOP),来规避恶意代码注入防御。使用这些技术的攻击者不是注入恶意代码,而是重用易受攻击程序的可执行代码片段(称为gadgets)来编写他们的攻击载荷。
Gadgets由一个或多个执行有用计算任务的二进制指令组成,后跟一个间接分支指令(返回、间接跳转、间接调用)来终止gadget。最终的控制流指令用于将一个或多个gadgets链接在一起。Gadgets可以被视为可用于编写攻击程序的独立指令。
由于gadgets是易受攻击程序的一部分,防止注入代码执行的防御措施无法阻止攻击。从攻击者的角度来看,缺点是他们在攻击编程中可能受到可用gadgets的限制。最终,攻击者可用的gadgets(以及它们的用处)是编译器代码生成和优化行为的函数,因为是编译器产生了程序的二进制代码。
图2描述了一个简单的ROP载荷示例。载荷由一系列gadget地址(即攻击)和一些必要的数据(即攻击的输入)交织组成。攻击者首先利用内存损坏漏洞(如基于栈的缓冲区溢出,CWE-121)将载荷放置在栈上,使得栈上的返回地址被链中第一个gadget的地址覆盖。这将程序执行重定向到链中的第一个gadget。
图2:示例ROP Gadget链
研究结果摘要
在我们的研究中,我们分析了用GCC和clang编译的20个不同程序的1000多个变体,以确定优化行为如何影响输出二进制文件中的gadget集合。我们使用静态分析工具GSA来测量应用优化选项前后gadget集合大小、实用性和可组合性的变化。从高层次开始,我们首先发现优化在大约85%的情况下增加了gadget集合的大小、实用性和/或可组合性。
深入挖掘,我们对几个程序变体进行了差异二进制分析,以确定这些影响的根本原因。我们确定了几个直接和间接导致这个问题的编译器行为。两个行为最为突出:复制间接分支指令和代码布局更改。
复制间接分支指令
将gadgets链接在一起创建攻击程序依赖于每个gadget末端的控制流指令。由于每个gadget必须以这些指令之一结束,程序拥有的间接分支转移越多,就越有可能拥有大量独特且有用的gadgets。许多编译器优化通过有选择地复制这些指令来提高性能,从而导致gadget集合大小和实用性的增加。
这种行为在GCC的省略帧指针优化中最为明显,该优化消除了不需要帧指针的函数开头和结尾的帧指针设置和恢复指令。在许多情况下,如图3所示,消除函数末端的指针恢复指令创造了通过复制函数末端的间接控制流指令(retn)来进一步优化的机会。虽然这种次要优化略微减少了代码大小和执行时间,但它创建了一个或多个retn指令的副本。反过来,这给程序引入了更多gadgets,包括可能对攻击者有用的gadgets。
图3:GCC省略帧指针优化复制retn指令
二进制布局更改
通常,优化行为以改变代码块和函数大小的方式插入、移除或更改指令。这导致块和函数最终在二进制格式中的布局方式发生变化,进而需要更改整个程序中控制流指令使用的位移。
在某些情况下,新的位移包含间接分支指令的二进制编码,如图4所示的示例。这里,未优化代码中具有短1字节位移的条件跳转指令变为具有近4字节位移的等效条件跳转指令,这是优化引起的布局变化的副产品。这个新的位移编码了retn(即0xC3)间接分支指令。尽管位移本意不是指令,但在攻击过程中可以被解码为指令,因为x86_64使用未对齐的变长指令。如果间接分支指令编码前面的字节序列恰好编码了有效指令(鉴于x86_64 ISA的密度,这很可能),它们可以被解码为一个gadget。这些gadgets被称为“意外”或“未对齐”gadgets。
图4:二进制布局更改导致引入gadget终止指令编码
我们能做些什么来解决这个问题?
我们发现的各种行为都有一个共同属性:它们是所需优化的次要或独立行为。这意味着我们可以在不完全牺牲性能的情况下撤销引入gadget的行为。
理想情况下,编译器会修补它们的优化以移除这些行为。不幸的是,指令复制行为在许多不同的优化通道中都很常见,并且通过位移更改引入的gadget编码在优化期间无法检测到,因为二进制布局发生得晚得多。
幸运的是,像Egalito这样的二进制重编译器非常适合解决这个问题。Egalito允许我们以与布局无关的方式转换程序二进制文件,无论用于生成二进制文件的编译器是什么。这为手头的问题提供了许多优势。首先,我们可以为Egalito实现重编译器通道来一次撤销负面行为,而不是为每个编译器中的每个有问题的优化这样做。此外,我们可以在没有源代码或特殊编译器的情况下撤销程序中的负面行为!
实用的二进制安全优化
我们为Egalito构建了初始的五组二进制优化通道,以消除编译器中粗心引入的二进制文件中的gadgets:
- 返回合并:将函数中的所有返回指令合并到单个实例。
- 间接跳转合并:将函数中针对同一寄存器的所有间接跳转指令合并到单个实例。
- 指令屏障拓宽:消除跨越连续预期指令的意外特殊用途gadgets。
- 偏移/位移滑行:消除根植于跳转位移的gadgets。
- 函数重排序:消除根植于调用偏移的gadgets。
接下来,我们通过将这些优化通道应用到我们研究中的几个二进制文件来评估它们对gadget集合和性能的影响。我们发现我们的通道:
- 平均消除了31.8%的有用gadgets
- 在78%的变体中减少了gadget集合的整体实用性
- 在75%的变体中消除了一种或多种特殊用途gadget类型(例如,系统调用gadgets)
- 对执行速度没有影响
- 平均仅增加6.1 kB的代码大小
结论
编译器代码生成和优化行为对二进制gadget集合有巨大影响。但由于对潜在安全属性的缺乏关注,这些行为通常创建的二进制文件具有更容易被攻击者在攻击中重用的gadget集合。这有许多根本原因,然而,可以通过不牺牲性能的简单代码转换来减轻和撤销负面行为。
虽然我们对此问题的初步研究产生了一些有希望的结果,但在处理有问题的编译器行为方面并不详尽。在未来一年,我将致力于其他转换来解决其他问题,如有问题的寄存器分配。此外,我将研究这些优化如何可能带来次要好处,例如减少采用其他代码重用防御(如控制流完整性,CFI)的性能成本。
致谢
这项研究是与我在佐治亚理工学院和佐治亚理工学院研究所的合著者进行的:Matthew Pruett、Robert Bigelow、Girish Mururu和Santosh Pande。
如果你喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News