跳转至

缓冲区溢出

基础知识介绍

缓冲区


那么,什么是缓冲区呢?简单来说,缓冲区就是程序运行时所使用的内存空间或场所。这些内存空间用于存储程序当前使用的临时数据。因此,如果我们有一个简单的程序,它要求用户输入自己的名字,并将其存放到一个名为username的变量中,然后,该程序会输出“Hello username”形式的消息。这样,如果我们运行该程序,并输入用户名“Rick”,那么,“Rick”一词会首先存放到缓冲区中,直到程序执行print命令的时候,该程序才会从缓冲区中取出给定的用户名“Rick”,并输出结果:“Hello Rick”。

我们的示例代码是用c语言编写,具体如下所示:

C
#include <stdio.h>

int main () {
   char username[20];

   printf("Enter your name: ");
   scanf("%s", username);

   printf("Hello %s\n", username);

   return(0);
}

示例代码详解


int main() 定义主函数 char username [20] 这里是为变量规定名称的地方,但这一行最重要的是char .... [20],其作用是为该变量指定了相应的缓冲区,具体大小为存放20个字符所需内存空间

其余代码的作用是接收用户的输入,然后将其打印输出。

Text Only
printf("Enter your name: "); 
scanf("%s", username); 
printf("Hello %s\n, username");

那么,当我们编译并运行这个程序时,能否得到预期的输出结果呢?

img

在讨论缓冲区溢出之前,我们需要先来了解一下应用程序的内存是如何工作的。

内存/栈以及内存地址


那么,应用程序的内存到底长啥样呢?堆栈又是什么呢?实际上,堆栈就是一个内存缓冲区,用来存储程序的函数和局部变量。为了便于演示,我们为读者画了一个示意图,具体如下所示。

img

首先,最下面部分存放的是代码,即程序的源代码编译后的代码,它们是程序的主要指令。

其次,从下往上数第二部分是用于存储全局变量的缓冲区,

局部变量和全局变量之间的区别在于,局部变量被限定在一个特定的函数中:如果在一个函数中定义了一个局部变量,那么,该变量就只能在这个函数中调用。但是,全局变量既可以在主函数中定义,也可以在函数外部定义,并且,这种类型的变量可以在任何地方调用。

然后,再往上就是栈(stack)了,这是内存的重要组成部分,因为缓冲区溢出就是在这里发生的。这是存储局部变量和函数调用的地方。

最后,也是最上面的一部分,即堆(Heap),这是动态内存分配区。

现在,我们已经知道了应用程序的内存是什么样子,以及什么是栈,但是,内存地址又是什么呢? 基本上,当程序被编译和执行时,程序的所有指令位于应用程序对应的内存空间中,并且,并为它们分配一个地址。该地址通常采用十六进制字节的格式。

因此,当我们反汇编一个程序,并查看其代码时,就会看到内存地址,类似这样:

img

为什么会发生缓冲区溢出呢?


现在,我们已经知道什么是缓冲区,同时,对内存构造也进行了深入探索。接下来,您很可能想要知道问什么会发生缓冲区溢出,以及什么时候会发生溢出。简单来说,当输入的数据的长度超过缓冲区的上限时,就会发生缓冲区溢出,这会导致程序在为其分配的缓冲区之外写入数据,这样,就可能会覆盖程序用来保存数据的某些内存空间,从而使数据不可用,并最终导致程序崩溃。为了演示这一点,让我们回顾一下第一个例子。

C
#include <stdio.h>

int main () {
   char username[20];

   printf("Enter your name: ");
   scanf("%s", username);

   printf("Hello %s\n", username);
   printf("Program exited normally");
   return(0);
}

处于演示的目的,这里又添加了一行代码,用来输出“program exited noramlly”。

现在,程序运行时会询问我们的用户名,接着打印“Hello username”,然后输出“program exited noramlly”并退出。其中,用于保存变量username的值的缓冲区的长度被设置为20个字符,所以,用户名的长度最好小于20个字符。但是,如果输入的数据的长度超过了20个字符,程序将会崩溃,因为缓冲区外面的某些数据将被覆盖,致使程序的某些部分被破坏掉了。在我们的例子中,被破坏的部分将是打印“program exited noramlly”的那一部分。

首先,让我们运行程序,并输入Rick。

img

这时,该程序将正常退出。

现在,让我们再次运行它,并输入30个字母A,来为变量username赋值。

img

这时,我们看到程序会打印“Hello aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”,但是,之后并没有看到它输出“program exited normally”,相反,我们看到的是一个段错误。这是因为我们额外输入了10个字符,而程序只需要20个字符,或更少。这些额外的字符,即“aaaaaaaaa”超过了20个字符的缓冲区的容量,会写入缓冲区边界之外的内存中,并覆盖其他数据(即打印“Program exit normal”的打印指令),从而引发段错误,因为程序已被破坏。

借助GDB考察缓冲区溢出


下面,让我们借助GDB(GNU调试器)来更加深入地考察溢出是如何发生的。 我们将编写另一个程序,该程序会创建一个名为“whatever”的变量,然后,它会拷贝我们提供的内容,并将其放入该变量中。我们将该变量的缓冲区的长度设置为20个字节。

C
#include <stdio.h>
#include <string.h>

int main(int argc, char** argv)
{
    char whatever[20];
    strcpy(whatever, argv[1]);

    return 0;
}

代码详解


int main(int argc,char ** argv) 定义main函数及其参数

char whatever[20]; 创建变量,为其指定名称“whatever”,并将其缓冲区长度指定为20个字节

strcpy(whatever, argv[1]); 将我们输入的字符拷贝到变量“whatever”中

return 0; 这是我们的返回地址

现在,让我们利用gdb运行该程序,以进行相应的测试。

img

上面输入的内容为aaaaa,长度小于20个字符,所以,程序会正常退出,说明一切正常。

现在,让我们输入20个以上的字符。

img

我们将看到段错误,这时因为我们的返回地址被覆盖了,程序无法继续运行。

为了说明这些地址是如何被覆盖的,让我们输入一个任意十六进制值,例如\x12,并连续输入50次。然后,让我们来看看寄存器的情况。

img

我们看到,大多数内存地址都被12覆盖了。

缓冲区溢出攻击的类型

缓冲区溢出攻击指利用缓冲区溢出漏洞所进行的攻击行动。通过向程序的缓冲区写入超出其长度的内容,造成缓冲区的溢出,从而破坏程序的堆栈,造成程序崩溃、系统关机或使程序执行其它指令,以达到攻击的目的。缓冲区溢出攻击有很多类型,主要可以分为以下几类:

  • 栈溢出:栈溢出是指在程序执行中,如果在栈上分配的内存超过了栈的大小,就会发生栈溢出。栈是一种后进先出(LIFO)的数据结构,用于存储程序执行过程中的临时变量。当栈溢出时,程序会立即停止执行,并显示栈溢出错误信息。栈溢出攻击是最常见的缓冲区溢出攻击类型,发生栈溢出的基本前提是程序必须向栈上写入数据且写入的数据大小没有被控制。
  • 堆溢出:堆溢出是指程序在动态分配内存时,分配的内存超出了堆的大小。堆是一种先进先出(FIFO)的数据结构,用于存储程序运行时长期需要的数据。当堆溢出时,程序可能不会立即停止执行,但会导致程序的不稳定,甚至崩溃。在恶意攻击中,攻击者可能会利用堆缓冲区溢出来执行任意代码或获取敏感信息。
  • 格式字符串溢出:格式字符串指在编程语言中,涉及使用格式化字符串函数来打印字符串时,如果格式串由用户定制,攻击者就可以任意伪造格式串,利用 *printf() 系列函数的特性就可以窥探堆栈空间的内容,超长输入可以引发传统的缓冲区溢出,或是用“%n”覆盖指针、返回地址等。
  • 整数溢出: 计算机语言中整数类型都有一个取值范围,两个整数进行运算时,若结果大于最大值(上溢)或小于最小值(下溢),就是溢出。例如,最大值为a,在最大值与最小值之间发生以下计算:a+1=0或0-1=a,此时会发生溢出,其中a+1=0会发生上溢,0-1=a会发生下溢。利用整数的范围和符号等问题触发安全漏洞,大多数整形溢出不能直接利用,但如果该整形变量决定内存分配等操作,作为漏洞被间接利用。
  • Unicode 溢出:Unicode 溢出通过将 Unicode 字符插入需要 ASCII 字符的输入中来创建缓冲区溢出。ASCII 和 Unicode 是使计算机表达文本的编码标准。由于 unicode 中有更多可用的字符,所以许多 unicode 字符大于最大的 ASCII 字符。当出现Unicode 溢出,可改变程序的工作方式,出现进一步的安全问题。

攻击者如何利用缓冲区溢出

攻击者可以将精心制作的数据输入程序,程序尝试将该输入数据存储在缓冲区中,输入数据会覆盖连接到缓冲区空间的部分内存。如果程序的内存布局定义明确,则攻击者可故意覆盖已知包含可执行代码的区域,然后用自己的可执行代码替换这些代码,改变程序的工作方式。通常,缓冲区溢出攻击都是按照如下步骤进行:

  1. 注入攻击代码。
  2. 跳转到攻击代码。
  3. 执行攻击代码。

攻击者可利用的方法较多,下面介绍两种攻击者常用的缓冲区溢出攻击:

  • 利用栈溢出攻击破坏栈数据

可能被攻击者利用缓冲区溢出漏洞进行破坏的对象包括栈数据中的ARG(函数调用时的实参)、RETADDR(下一条要执行的操作指令在内存中的地址)、EBP(调用该函数前的栈帧状态值)和LOCVAR(该函数中的本地变量)。

常见的栈溢出攻击的利用方式是改变RETADDR的值,将已经注入到栈中的攻击代码的地址或代码区中某些具有特权的系统函数地址存放至RETADDR。若完成修改RETADDR的值,结束调用该函数后,程序就跳转到攻击者设计好的地址去执行攻击者希望被执行的指令,进而获得系统控制权限,导致严重的后果。EBP也常常作为被攻击的对象。攻击者通过构建一个RETADDR指向攻击代码的虚拟栈帧,再溢出当前栈帧EBP的值,溢出后的EBP值是构造的虚拟栈帧的地址。最终通过构造的虚拟堆栈的承接,执行完当前栈帧则执行虚拟栈帧,执行完虚拟栈帧则跳转到虚拟栈帧的RETADDR值所指向的位置,也使得程序最终跳转到攻击者设计好的地址去执行攻击指令。

  • 利用堆溢出攻击破坏堆数据

由于堆中是不连续地动态分配内存,攻击者预测地址的难度提高,堆溢出攻击比栈溢出攻击更困难,但依然有技术可以利用堆溢出实现攻击。

Dword Shoot攻击:Dword Shoot指能够向内存任意位置写入任意数据,1WORD=4个bytes,即通过执行程序将4bytes的数据写入4bytes地址中,从而实现某种恶意操作。Dword Shoot攻击指利用Dword Shoot进行的恶意操作。Linux系统和windows系统对堆的管理方式都是双向链表方式,每一个分配的内存块由3部分组成:头指针(head)、尾指针(tail)、内存数据(data)。针对堆内存的管理主要有分配和释放两部分。在释放堆内存M时,会将M从链表上摘除,会执行M→head→tail=M→tail操作,如果攻击者通过溢出M临近的内存,将M的头指针、尾指针修改,让M的头指针指向攻击者设计好的虚拟节点,让M的尾指针指向攻击者设计好的位置,比如Shellcode,那么当执行完M→head→tail=M→tail操作时,该虚拟节点的尾指针就指向Shellcode,调用该虚拟节点的尾指针就会转向Shellcode。摘链时的另一操作M→tail→head=M→head,利用同样的原理,也可实现攻击。

Heap Spray攻击:Heap Spray是在Shellcode前面加上大量的slide code(滑板指令,指不会影响程序执行,但占据内存空间,使真正的攻击指令得到执行的无意义指令),组成一个注入代码段。之后向系统申请大量内存,并且反复注入代码段来填充,然后结合其他的漏洞攻击技术控制程序流,使得程序跳转执行到堆上,使Shellcode得到执行,Shellcode中的核心攻击指令得到执行,进而获得系统控制权限,达到攻击目的。

如何防止缓冲区溢出攻击

缓冲区溢出攻击占了远程网络攻击的绝大多数,这种攻击可以使得一个匿名的Internet用户有机会获得一台主机的部分或全部的控制权。如果能有效地消除缓冲区溢出的漏洞,则很大一部分的安全威胁可以得到解决。有几种基本的方法保护缓冲区免受缓冲区溢出的攻击和影响,具体方法如下:

  • 完整性检查:在程序指针失效前进行完整性检查。
  • 随机化地址空间: 关键数据区的地址空间位置随机排列。通常,缓冲区溢出攻击需要知道可执行代码的位置,而随机化地址空间使这几乎不可能。
  • 防止数据执行:标记内存的某些区域为可执行或不可执行,从而阻止在不可执行区域运行代码的攻击。
  • 编写安全的代码:使用能够帮助识别不安全函数或错误的编译器,利用编译器的边界检查来实现缓冲区的保护。避免使用不进行缓冲区检查的函数(例如,在C语言中,用 fgets() 代替 gets())。使用内置保护的语言编写或在其代码中使用特殊的安全性程序,来预防缓冲区溢出漏洞。

尽管存在上述预防措施,但仍能发现新的缓冲区溢出漏洞。当发现新漏洞时,工程师需要修补受影响的软件,并确保该软件的用户可以及时获取补丁。