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