存储器的越界引用和栈缓冲区溢出 gaunthan Posted on May 14 2016 ? Computer Security ? > 理解本文需要对函数调用机制有所了解,可以阅读[《栈与函数调用》](http://blog.leanote.com/post/gaunthan/%E6%A0%88%E4%B8%8E%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8)和[《过程调用的实现》](http://blog.leanote.com/post/gaunthan/%E8%BF%87%E7%A8%8B%E8%B0%83%E7%94%A8%E7%9A%84%E5%AE%9E%E7%8E%B0)获得一定认识。 ## 概述 我们知道,C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就可能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行`ret`指令时,就会出现很严重的错误。 ## 缓冲区溢出 一种特别常见的状态破坏称为**栈缓冲区溢出**(stack buffer overflow):在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。 下面的这个程序示例就说明了这个问题: ```c /* 一个缓冲区溢出案列 */ char *getline() { char buf[8]; char *result; gets(buf); result = malloc(strlen(buf) + 1); if(result) strcpy(result, buf); return result; } ``` 它的部分反汇编如下:  现在,假设有这样的场景。调用过程getline,返回地址等于0x8048643,寄存器%ebp等于0xbffffc94,寄存器%ebx等于0x1,寄存器%edi等于0x2,而寄存器%esi等于0x3。输入字符串为"012345678901234567890123",程序会因为**段错误**(segmentation fault)而终止。运行GDB,确定错误是在执行getline的ret指令时发生的。 调用gets前后的堆栈快照见下(每一个块都是4字节的): * 调用gets前 ```c stack frame before calling gets : ----------------- | 08 04 86 43 | return address ----------------- | bf ff fc 94 | old %ebp ----------------- <- %ebp | 00 00 00 02 | backup of %edi ----------------- | 00 00 00 03 | backup of %esi ----------------- | 00 00 00 01 | backup of %ebx ----------------- | | buf + 4 ----------------- | | buf ----------------- <- %esi | . | | . | | . | ----------------- <- %esp ``` * 调用gets后 ```c stack frame after calling gets : ----------------- | 08 04 86 00 | broke return address ----------------- | 33 32 31 30 | broke old %ebp ----------------- <- %ebp | 39 38 37 36 | broke backup of %edi ----------------- | 35 34 33 32 | broke backup of %esi ----------------- | 31 30 39 38 | broke backup of %ebx ----------------- | 37 36 35 34 | buf + 4 ----------------- | 33 32 31 30 | buf ----------------- <- %esi | . | | . | | . | ----------------- <- %esp ``` 可以看到,在调用gets后缓冲区溢出了,使得寄存器%ebx、%esi、%edi和%ebp的内容都受到破坏,并且返回地址的最低字节被修改为0了(字符串终止符)。如果将输入的字符串替换为"012345678901234567890123abcd"则将使返回地址被修改为`0x 61 62 63 64` 缓冲区溢出不仅仅可能破坏存储的寄存器和临时变量,还可能使程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为**攻击代码**(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行`ret`指令的效果就是跳转到攻击代码。 一种攻击形式,攻击代码会使用系统调用启动一个外壳程序,给攻击者提供一组操作系统函数。另一种攻击形式是,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行`ret`指令,(表面上)正常返回给调用者。 ## 对抗缓冲区溢出攻击 缓冲区溢出攻击的普遍发生, 给计算机系统造成了许多的麻烦。现代的编译器和操作系统已经实现了很多机制,以避免遭受这样的攻击,限制入侵者通过缓冲区的溢出攻击获得系统控制的方式。 ### 栈随机化 为了在系统中插入攻击代码,攻击者不但要插入代码,还需要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测。对于所有运行通用程序和操作系统版本的系统来说,在不同的机器之间,栈的位置是相当固定的。因此,如果攻击者可以确定一个常见的Web服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的攻击。这种许多系统都容易受到同一种病毒攻击的现象称为**安全单一化**(security monoculture)。 栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。实现的方式是:程序开始时,在栈上分配一段0~n字节之间的随机大小的空间。例如,使用分配函数alloca在栈上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。分配的范围n必须足够大,才能获得多样的栈地址变化,但是又要足够小,不至于浪费程序太多的空间。 有一个简单的方法可以用于确定程序的栈地址: ```c int main(void) { int local; pirntif("local at %p\n", &local); return 0; } ``` 在Linux系统中,栈随机化已经变成了标准行为。它是更大一类技术中的一种,这类技术称为**地址空间布局随机化**(Address-Space Layout Randomization,简称ASLR)。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到存储器的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,它们的地址映射大相径庭。 然而,随机化是可以用蛮力克服的。攻击者可以反复地用不同的地址进行攻击。一种做法是在实际的攻击代码前插入很长的一段nop(no operation)指令。执行这种指令除了对程序计数器加一使其指向下一条指令之外,没有任何的效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序列,到达攻击代码。这个序列常用的术语是**空操作雪橇**(nop sled),意思是程序会“滑过”这个序列。如果建立一个256个字节的nop sled,那么枚举$2^{15} = 32768$个起始地址,就能破解$n = 2^{23}$的随机化。 ### 栈破坏检测 计算机的第二道防线是能够检测到何时栈已经被破坏。破坏通常发生在当超越局部缓冲区的边界时。在C语言中,没有可靠的方法来防止对数组的越界写。但是,我们能够在发生了越界写的时候,在没造成任何有害的结果之前,尝试检测到它。 较新的GCC版本在产生的代码中加入了一种**栈保护者**(stack protector)机制,用来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区和栈状态之间存储一个特殊的**金丝雀**(canary)值。这个金丝雀值,也称为**哨兵值**(guard value),是在程序每次运行时随机产生的,因此,攻击者没有简单的方法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是,那么程序异常终止。 ### 限制可执行代码区域 最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制那些能够存放可执行代码的存储器区域。在典型的程序中,只有保存编译器产生的代码的那部分存储器才需要是可执行的。其他部分可以被限制为只允许读和写。 赏 Wechat Pay Alipay 机器级操作:算术和逻辑操作 数据对齐