为什么Linux不随机化可执行代码段的地址?

信息安全 linux 硬化 aslr
2021-08-21 00:35:21

我最近一直在学习 ASLR(地址空间随机化)如何在 Linux 上工作。至少在 Fedora 和 Red Hat Enterprise Linux 上,有两种可执行程序:

  • 与位置无关的可执行文件 (PIE) 接收强地址随机化。显然,所有内容的位置都是随机的,分别针对每个程序。显然,面向网络的守护进程应该编译为 PIE(使用-pie -fpie编译器标志),以确保它们接收到全强度随机化。

  • 其他可执行文件接收部分地址随机化。可执行代码段不是随机的——它位于一个固定的、可预测的地址,对于所有 Linux 系统都是相同的。相反,共享库是随机的:它们被加载在一个随机位置,对于系统上的所有此类程序来说都是相同的。

我想我理解为什么非 PIE 可执行文件对共享库具有较弱的随机化形式(这对于预链接是必要的,它可以加快可执行文件的链接和加载)。我也认为我理解为什么非 PIE 可执行文件根本没有随机化其可执行段:看起来这是因为程序必须编译为 PIE,才能随机化可执行代码段的位置。

尽管如此,使可执行代码段的位置保持非随机化可能会带来安全风险(例如,它使 ROP 攻击更容易),因此最好了解为所有二进制文件提供完全随机化是否可行。

那么,是否有理由不将所有内容编译为 PIE?编译为 PIE 是否有性能开销?如果是这样,不同架构的性能开销是多少,特别是在 x86_64 上,地址随机化最有效?


参考:

3个回答

尽管架构之间的细节差异很大,但我在这里所说的同样适用于 32 位 x86、64 位 x86,以及 ARM 和 PowerPC:面对相同的问题,几乎所有架构设计人员都使用了类似的解决方案。


在汇编级别有(粗略地说)四种“访问”,它们与“位置无关”系统相关:有函数调用call操作码)和数据访问,两者都可以针对同一实体中的任何一个实体对象(其中对象是“共享对象”,即 DLL 或可执行文件本身)或在另一个对象中。对堆栈变量的数据访问在这里无关紧要;我说的是对全局变量或静态常量数据的数据访问(特别是在源代码级别显示为文字字符串的内容)。在 C++ 上下文中,虚方法在内部由特殊表(称为“vtables”)中的函数指针引用;数据访问也是如此,即使方法是代码。

call操作码使用作为目标地址相对:它是一个偏移量计算出的电流指令指针之间(在技术上,该参数后的第一个字节call码)和呼叫目标地址。这意味着同一对象内的函数调用可以在(静态)链接时完全解决;它们不会出现在动态符号表中,并且它们是“与位置无关的”。另一方面,对其他对象的函数调用(跨 DLL 调用,或从可执行文件到 DLL 的调用)必须通过一些由动态链接器处理的间接。call操作码仍必须跳“的地方”,并且动态连接要动态地进行调整。该格式试图实现两个特征:

  • 惰性链接:调用目标仅在第一次使用时才被查找和解析。
  • 共享页面:内存中的结构应尽可能与可执行文件中的相应字节保持相同,以促进多个调用之间的共享(如果两个进程加载相同的 DLL,则代码应仅在 RAM 中出现一次)和更容易分页(当 RAM 紧张时,作为文件中数据块的未修改副本的页面可以从物理 RAM 中逐出,因为它可以随意重新加载)。

由于共享是基于每页的,这意味着应该避免动态更改call参数(操作码之后的几个字节)。call相反,编译后的代码使用一个全局偏移表(或几个——我稍微简化一下)。基本上,call跳转到执行实际调用的一小段代码,并受到动态链接器的修改。对于给定的对象,所有这些小包装器都存储在动态链接器将修改的页面中;这些页面与代码的偏移量是固定的,因此参数call是在静态链接时计算的,不需要从源文件中修改。首次加载对象时,所有包装器都指向一个动态链接器函数,该函数在第一次调用时进行链接;该函数修改包装器本身以指向已解析的目标,以供后续调用。装配级别的杂耍很复杂,但效果很好。

数据访问遵循类似的模式,但它们没有相对寻址。也就是说,数据访问将使用绝对地址该地址将在寄存器中计算,然后用于访问。CPU的x86行可以直接将绝对地址作为操作码的一部分;对于具有固定大小操作码的 RISC 体系结构,地址将作为两个或三个连续指令加载。

在非 PIE 可执行文件中,静态链接器知道数据元素的目标地址,后者可以直接在执行访问的操作码中对其进行硬编码。在 PIE 可执行文件或 DLL 中,这是不可能的,因为在执行之前目标地址是未知的(它取决于将加载到 RAM 中的其他对象,也取决于 ASLR)。相反,二进制代码必须再次使用 GOT。GOT 地址被动态计算到基址寄存器中。在 32 位 x86 上,基址寄存器是常规%ebx的,以下代码是典型的:

    call nextaddress
nextaddress:
    popl %ebx
    addl somefixedvalue, %ebx

第一个call简单地跳转到下一个操作码(所以这里的相对地址只是一个零);因为这是 a call,所以它将返回地址(也是popl操作码的地址)压入堆栈,然后popl提取它。此时,%ebx包含 的地址popl,因此一个简单的加法修改该值以指向 GOT 的开头。然后可以相对于 进行数据访问%ebx


那么通过将可执行文件编译为 PIE 会改变什么?其实不多。“PIE 可执行文件”意味着使主可执行文件成为 DLL,并像任何其他 DLL 一样加载和链接它。这意味着以下内容:

  • 函数调用未修改。
  • 从主可执行文件中的代码到主可执行文件中的数据元素的数据访问会产生一些额外的开销。所有其他数据访问均未更改。

数据访问的开销是由于使用传统寄存器指向 GOT:一个额外的间接寻址,一个用于此功能的寄存器(这会影响寄存器匮乏的架构,如 32 位 x86),以及一些额外的代码来重新计算指向 GOT 的指针。

但是,与对局部变量的访问相比,数据访问已经有些“慢”,因此编译后的代码已经尽可能缓存了此类访问(变量值保存在寄存器中,仅在需要时刷新;即使刷新,变量地址也保存在寄存器中)。由于全局变量在线程之间共享,因此更是如此,因此大多数使用此类全局数据的应用程序代码仅以只读方式使用它(执行写入时,它们是在互斥锁的保护下完成的,并且无论如何抓住互斥体都会产生更大的成本)。大多数 CPU 密集型代码将在寄存器和堆栈变量上工作,并且不会因使代码与位置无关而受到影响。

最多,将代码编译为 PIE 将意味着典型代码的大小开销约为 2%,而对代码效率没有可衡量的影响,所以这几乎不是问题(我从与参与 OpenBSD 开发的人讨论中得到这个数字;在尝试将准系统系统安装在引导软盘上的非常特殊的情况下,“+2%”对他们来说是个问题)。


不过,非 C/C++ 代码可能会遇到 PIE 问题。在生成编译代码时,编译器必须“知道”它是针对 DLL 还是针对静态可执行文件,以包含找到 GOT 的代码块。Linux 操作系统中不会有很多可能会导致问题的包,但 Emacs 将成为麻烦的候选者,它具有 Lisp 转储和重新加载功能。

请注意,Python、Java、C#/.NET、Ruby...中的代码完全超出了这一切的范围。PIE 用于 C 或 C++ 中的“传统”代码。

一些 Linux 发行版可能不愿将所有可执行文件编译为与位置无关的可执行文件 (PIE),因此可执行代码是随机的,原因之一是出于对性能的担忧。关于性能问题的事情是,有时人们会担心性能,即使它不是问题。因此,最好对实际成本进行详细测量。

幸运的是,以下论文介绍了将可执行文件编译为 PIE 的成本测量:

本文分析了在一组 CPU 密集型程序(即 SPEC CPU2006 基准测试)上启用 PIE 的性能开销。由于我们预计此类可执行文件会显示由于 PIE 导致的最差性能开销,因此这给出了对潜在性能估计的保守、最坏情况估计。

总结论文的主要发现:

  • 在 32 位 x86 架构上,性能开销可能很大:对于 SPEC CPU2006 基准测试(CPU 密集型程序),性能开销平均下降约 10%,而对于一些程式。

  • 在 64 位 x64 架构上,性能开销要小得多:在 CPU 密集型程序上平均降低约 3%。对于人们使用的许多程序来说,性能开销可能会更少(因为许多程序不是 CPU 密集型的)。

这表明为 64 位架构上的所有可执行文件启用 PIE 将是安全的合理步骤,并且对性能的影响非常小。但是,为 32 位架构上的所有可执行文件启用 PIE 成本太高。

很明显为什么依赖于位置的可执行文件不是随机的。

“位置相关”仅仅意味着至少有一些地址是硬编码的。特别是,这可能适用于分支地址。移动可执行段的基地址也会移动所有的分支目标。

这种硬编码地址有两种选择:要么用 IP 相对地址替换它们(这样 CPU 可以在运行时确定绝对地址),要么在加载时修复它们(当基地址已知时)。

您当然需要一个可以生成此类可执行文件的编译器。