在现代系统中,最明显的罪魁祸首可能是地址空间布局随机化,但即使在 ASLR 被广泛实施之前,堆栈框架布局的可变性对于漏洞利用开发也是有问题的。这在 AlephOne 令人尊敬的“Smashing the Stack for Fun and Profit”中提到:
当试图溢出另一个程序的缓冲区时,我们面临的问题是试图找出缓冲区(以及我们的代码)的地址。答案是对于每个程序,堆栈将从相同的地址开始。大多数程序在任何时候都不会将超过几百或几千个字节推入堆栈。因此,通过知道堆栈从哪里开始,我们可以尝试猜测我们试图溢出的缓冲区在哪里。
即使知道堆栈的开始位置,也几乎不可能猜测偏移量。我们最多需要一百次尝试,最坏需要几千次。问题是我们需要准确猜测我们代码的地址从哪里开始。如果我们或多或少地偏离了一个字节,我们只会得到一个分段违规或一条无效指令。增加机会的一种方法是用 NOP 指令填充溢出缓冲区的前面。
所以即使没有启用 ASLR,这也是一个问题。造成这种情况的原因包括以下(据我所知):
编译器、ABI 和堆栈帧布局
有趣的是,管理运行时堆栈的代码是由编译器生成的,编译器无法知道程序运行时堆栈帧的绝对位置是什么:
虽然在编译时无法预测堆栈帧的位置(编译器通常无法判断堆栈中可能已经存在哪些其他帧),但通常可以静态确定帧内对象的偏移量。此外,编译器可以(在调用序列或序言中)安排一个特定的寄存器,称为帧指针,以始终指向当前子例程的帧内的已知位置。需要访问当前帧中的局部变量或调用帧顶部附近的参数的代码可以通过向帧指针中的值添加预定偏移量来实现。1
为堆栈帧分配和释放内存的方式可能因编译器以及同一编译器工具链的不同版本而异。System V i386 架构处理器补充资料(第 36 页)中提到了为什么会出现这种情况:
堆栈是字对齐的。尽管该体系结构不需要任何堆栈对齐,但软件约定和操作系统要求堆栈在字边界上对齐。
默认情况下,GCC 将堆栈与 i386 机器 (x86) 上的 16 字节边界对齐:
-mpreferred-stack-boundary=num
尝试将堆栈边界与 2 提升到num
字节边界对齐。如果-mpreferred-stack-boundary
未指定,则默认值为 4(16 字节或 128 位)。
这意味着即使函数的局部变量或参数长度为 4 个字节(例如int
),也会在该函数的堆栈帧上分配至少 16 个字节的空间。由于这是 的命令行参数gcc
,因此不用说这是可以更改的。
其他方面取决于编译器和正在编译的代码。标准调用序列不定义最大堆栈帧大小,也不限制语言系统如何使用标准堆栈帧的“未指定”区域。
堆栈帧的这个“未指定”区域包括除返回地址和前一帧保存的基指针之外的所有帧:
写入该区域的数据可以包括保存的寄存器、局部变量、临时变量和下一个函数2 的参数。GCC 将分配将这些数据存储在一个帧中所需的内存加上保持堆栈对齐到二进制文件编译时指定的边界所需的空间:
以下是所有这些联系在一起的方式:
无法在运行时确定堆栈上数据的绝对虚拟内存地址是什么,因此使用间接寻址(例如,使用%ebp
和%esp
计算偏移量)。但是,要控制 EIP,必须将可执行指令的绝对内存地址写入 EIP。所以现在我们必须弄清楚这个地址是什么,而无需在反汇编二进制文件时使用绝对地址
堆栈帧的精确布局,以及运行时堆栈的扩展,由用于创建试图利用的二进制文件的编译器决定。
- 这意味着分配给诸如缓冲区之类的变量的空间量至少取决于编译器对齐堆栈帧的边界(4 字节?8 字节?16 字节?等等)。
- 这可能会导致堆栈帧中的“空闲空间”,没有与局部变量相关的值被写入(包含垃圾数据)。
- 除此之外,编译器还确定变量在堆栈帧中的排列顺序。
- 优化编译可能会对特定架构的 ABI 中指定的调用约定以及堆栈帧的布局方式产生不利影响
- 带有堆栈保护的编译将进一步影响编译器如何在堆栈帧内排列数据
总之,溢出的缓冲区和目标返回地址位置之间的偏移量可能会因编译器工具链(GCC、TCC、MSVC 等)、编译器版本(3.x、4.x 等)和用于编译二进制文件(对齐值、优化级别、堆栈保护等):
由于不同的编译器版本和不同的优化标志,返回地址和 [buffer] 开始之间的确切距离可能会发生变化。只要缓冲区的开头与堆栈上的 DWORD 对齐,就可以通过简单地多次重复返回地址来解释这种可变性。这样,至少有一个实例会覆盖返回地址,即使它由于编译器优化而移动。3
运行时环境
其他地方有人建议堆栈布局不确定性可能是由环境变量和程序参数引起的:
如果您没有充分考虑将不确定性引入调试过程的因素,漏洞利用开发可能会导致严重的问题。特别是,调试器中的堆栈地址可能与正常执行期间的地址不匹配。出现此工件是因为操作系统加载程序将环境变量和程序参数放在堆栈开始之前
如果我们查看堆栈在虚拟内存中的布局,这是有道理的:
如果环境或参数的执行之间存在差异,则堆栈基址的位置也可能发生变化。
为了说明这不是简单的无稽之谈,我们可以看一个具体的例子。首先,我们像这样禁用 ASLR:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
然后我们用C写一个快速的程序:
#include <stdio.h>
int test(int a, int b)
{
printf("%p\n", &a);
return a*a + b*b;
}
int main(void)
{
return test(3, 5);
}
当编译并运行几次并且参数长度稳步增加时,可以观察到其中一个局部变量在堆栈上的位置在虚拟内存中变得越来越低:
$ gcc -m32 simple.c -o simple
$ ./simple
0xffffd210
$ ./simple AAAAAAAA
0xffffd200
$ ./simple AAAAAAAAAAAAAAAA
0xffffd200
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1f0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1f0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1e0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1e0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1d0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1d0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1c0
这表明更改传递给的参数的大小execve
也会更改运行时堆栈在虚拟内存中的位置。这个极其微不足道的例子的弱点在于它没有准确反映现实世界程序的复杂性,也没有捕捉到运行时堆栈在更复杂进程的执行过程中是如何变化的。
影响运行时堆栈布局的另一个变量是二进制文件是否动态链接。如果是动态链接,则进程启动由glibc
. 在 x86 Linux 系统上,C 运行时库函数调用序列类似于以下内容:
这将取决于glibc
链接的二进制文件的版本,并且运行时环境会因机器而异。这意味着进程启动期间的函数调用顺序也会因系统而异。
也可以看看:
1. Scott, Michael L.编程语言语用学。第 3 版。第 117 页
2. 布莱恩特,奥哈拉伦。计算机系统:程序员的观点。第二版。第 220 页
3. 埃里克森,乔恩。黑客:剥削的艺术。第二版。第 136 页