编译器优化可能会引入安全问题
编译器生成高效的代码是不够的,它们还必须生成安全的代码。尽管在开发编译器和优化过程中进行了大量的测试和正确性认证,但它们可能会无意中将信息泄露引入程序,或删除程序员在源代码中编写的安全关键操作。
下图显示了CWE-733的一个示例,这是一个编译器优化删除或修改安全关键代码的漏洞。在本例中,程序员编写的源代码通过将先前持有加密密钥的变量设置为0来对其进行清理。这是重要的一步,如果程序员不清理变量,则攻击者稍后可能会恢复密钥。但是,当编译此代码时,清理操作可能会被称为死存储消除(Dead store elimination) 的编译器优化过程删除。
这个过程通过消除它认为不必要的变量赋值操作来优化程序。它假定分配给变量的值如果不在程序中稍后使用,则它们是不必要的,不幸的是,其中包括我们的清理代码。
编译器优化删除或修改安全关键代码 (CWE-733)
此示例是编译器优化无意中将安全漏洞引入程序的几个有据可查的实例之一。最近,有研究人员发表了一项关于编译器设计选择如何影响二进制文件的另一个安全属性的广泛研究——恶意代码可重用性。他们发现编译器代码生成和优化行为一般不考虑恶意复用性。结果,它们生成的二进制文件通常比必要的更容易被攻击者重用。
构建强大的代码重用利用
攻击者使用代码重用利用技术,例如面向返回和跳转的编程(ROP和JOP),来逃避恶意代码注入防御。使用这些技术的攻击者不是注入恶意代码,而是重用易受攻击的程序可执行代码的片段(称为gadget)来编写他们的攻击有效负载。
gadget由一个或多个二进制指令组成,这些二进制指令执行一个有用的计算任务,然后是终止gadget的间接分支指令(返回、间接跳转、间接调用)。最后的控制流指令用来将一个或多个gadget连接在一起。gadget可以被认为是可以用来编写利用程序的单独指令。
由于gadget是易受攻击的程序的一部分,防止被注入代码被执行的防御并不能阻止利用。从攻击者的角度来看,缺点是他们的利用程序可能受到可用gadget的限制。最终,攻击者可用的gadget(以及它们的有用程度)是编译器代码生成和优化行为的函数,因为是编译器生成程序的二进制代码。
ROP Gadget链示例
在这项研究中,研究人员分析了用GCC和clang编译的20个不同程序的1000多个变体,以确定优化行为如何影响输出二进制文件中可用的gadget。我们使用了一个静态分析工具GSA来度量gadget大小、实用程序和可组合性在应用优化选项之前和之后的变化。从高层开始,我们首先发现,在大约85%的情况下,优化增加了gadget的大小、实用性或可组合性。
之后,研究人员又对几个程序变体进行了差分二进制分析,以确定这些影响的根本原因。目前研究人员已经确定了一些直接或间接导致这个问题的编译器行为。两种行为最有影响力:复制间接分支指令和代码布局更改。
复制间接分支指令
将gadget链接在一起以创建利用程序依赖于每个gadget末尾的控制流指令。由于每个gadget都必须以这些指令中的一个结束,因此一个程序的间接分支传输越多,它就越有可能拥有大量唯一且有用的gadget。许多编译器优化通过有选择地复制这些指令来提高性能,从而增加gadget的大小和实用程序。
这种行为在GCC omit frame pointer优化中最为明显,它消除了帧指针设置,并在不需要的函数的开头和结尾恢复指令。在许多情况下,如下图所示,消除函数末尾的指针恢复指令会创造一个机会,通过在函数末尾复制间接控制流指令(retn)来进一步优化。虽然这种二次优化稍微减少了代码大小和执行时间,但它创建了retn指令的一个或多个副本。反过来,这又为程序引入了更多的gadget,包括可能对攻击者有用的gadget。
通过 GCC Omit Frame Pointer 优化重复 retn 指令
更改二进制布局
通常,优化行为以改变代码块和函数大小的方式插入、删除或更改指令。这会导致块和函数最终以二进制格式布局的方式发生变化,这反过来又需要更改整个程序中控制流指令中使用的位移。
在某些情况下,新的位移包含间接分支指令的二进制编码,如下图所示。这样在未优化代码中具有短 1 字节位移的条件跳转指令更改为具有近4 字节位移是优化引起的布局更改的副产品。这个新的位移编码了 retn(即 0xC3)间接分支指令。这个新的置换编码retn(即0xC3)间接转移指令。即使这个位移不是一个指令,它也可以在利用期间被解码为一个指令,因为x86_64使用了未对齐的变长指令。如果在间接分支指令编码之前的字节序列恰好编码了有效的指令(考虑到x86_64 ISA的密度,这很有可能),则可以将其解码为一个gadget。这些gadget称为“意外”或“未对齐的”gadget。
导致引入的gadget终止指令编码的二进制布局更改
缓解办法
迄今为止发现的各种行为都有一个共同的属性:它们是次要的或独立于所需的优化。这意味着我们可以在不完全牺牲性能的情况下撤消引入gadget的行为。
理想情况下,编译器会修复它们的优化以删除这些行为。不幸的是,指令复制行为在许多不同的优化过程中都是常见的,而通过位移变化引入的gadget编码在优化过程中无法检测到,因为二进制布局发生的时间要晚得多。
幸运的是,像Egalito这样的二进制重新编译器非常适合解决这个问题。Egalito允许我们以一种与布局无关的方式转换程序的二进制文件,而不管生成二进制文件的编译器是什么。这样做有许多好处。首先,我们可以实现重新编译器传递来撤销一次Egalito的恶意行为,而不是在每个编译器中都进行优化。此外,我们可以在不访问源代码或特殊编译器的情况下撤销程序中的恶意行为!
实用的二进制安全优化
研究人员为Egalito构建了一个由5个二进制优化传递组成的初始集合,用来消除二进制文件中被编译器随意引入的gadget:
返回合并:将一个函数中的所有返回指令合并到一个实例中;
间接跳转合并:将函数中针对同一寄存器的所有间接跳转指令合并到一个实例中;
指令障碍扩大:消除跨越连续预期指令的非预期专用gadget;
偏移/位移:消除植根于跳跃位移的gadget;
函数重新排序:消除基于调用偏移量的gadget;
接下来,通过将这些优化传递应用于几个研究二进制文件,研究人员评估了这些优化传递对gadget和性能的影响。结果如下:
平均消除 31.8% 的有用gadget;
在 78% 的变体中降低了gadget的整体效用;
在 75% 的变体中消除了一种或多种特殊用途的gadget类型(例如,系统调用小工具);
对执行速度没有影响;
代码大小平均只增加了 6.1 kB;
总结
编译器代码生成和优化行为对二进制gadget有巨大的影响。但是,由于缺乏对潜在安全属性的关注,这些行为通常会创建具有gadget的二进制文件,这些gadget更容易被攻击者在利用中重用。造成这种情况的根本原因有很多,但是,可以通过不牺牲性能的简单代码转换来缓解和删除恶意行为。
参考及来源:https://blog.trailofbits.com/2022/03/25/towards-practical-security-optimizations-for-binaries/