CSI-S1:程序的机器级表示~缓冲区溢出详解

时间:2022-09-15 06:59:45

前言

         缓冲区溢出是一种非常普遍、非常危险的漏洞,在各个系统、应用软件中都广泛存在。因此,这也是一些hacker喜欢利用缓冲区溢出漏洞进行攻击的原因之一。另一方面,通过缓冲器溢出可以让攻击者很轻易地植入攻击代码并执行,从而获取到被控主机的控制权。当然,各个系统对于缓冲区溢出漏洞也引入了各自的防范机制,这让缓冲区溢出攻击的可能性有所下降,但还并不能完全阻止,其中一部分原因就出自于我们编码者本身。继上一篇<CSI-IV:程序的机器级表示-反汇编基础>,在本篇中,我将通过实例程序来演示缓冲区溢出漏洞,并通过其原理提出如何更好的防范缓冲区溢出。最后,需要说明的是,我们不能原谅任何利用缓冲区溢出漏洞去做任何违法或不道德的事情。

1.  什么是缓冲区溢出

缓冲区溢出简单来说就是当计算机向缓冲区填充数据时超过了缓冲区本身的容量,使得溢出的数据覆盖在合法的数据上,并且通过溢出的数据,可以被用来做一些程序不正当合理的操作。概念或许有点模糊,好,我先举个非常普遍的例子(可能你经常这么写):

void func(char* src)
{
….
char buffer[12];
strcpy(buffer,str);
…..
return ;
}

这是一段潜在存在缓冲区溢出漏洞的代码,函数接受一段字符串,并拷贝给了栈空间的某一个缓冲区buffer。如果输入的字符串长度大于buffer大小,那么就导致了缓冲区溢出。

2. 缓冲区溢出攻击原理

在了解缓冲区溢出攻击的原理之前,我们要了解函数的栈帧结构,这里,我再次引用CSI-IV一篇中的栈帧结构图,关于栈帧结构我不做多的介绍,请参考上一篇内容。这里我主要介绍缓冲区溢出攻击的原理,也就是具有缓冲区溢出漏洞程序的栈帧结构。

                       CSI-S1:程序的机器级表示~缓冲区溢出详解

以第一节的func函数为例,那么当前帧对应的是func的栈帧结构其中栈顶保存了调用者的栈指针ebp,并同时将帧指针指向它。基于帧指针我们可以访问到当前帧中的大多数信息,而栈指针esp是可以移动的,在当前栈帧中存取堆栈数据。在func的调用过程中,需要移动栈指针esp来开辟缓冲区大小,注意栈帧结构的地址是由上向下减小的。因此我们看到的func栈帧结构可能如下:

                                   CSI-S1:程序的机器级表示~缓冲区溢出详解

我们输入的字符串src被保存在buf[0]-buf[11]中,可是一旦超过预期的长度,在我们未察觉的情况下,可能覆盖掉了ebp和返回地址的值,从而可能引发“段错误”,不过对于熟悉这一危险漏洞的黑客来说,就能据此做些程序不可预见的事情。比如,可以覆盖掉返回地址的值使其指向攻击代码的地址,然后再恢复该值。我们知道func执行完成后会跳到返回地址所指定的地址处继续执行,这样,在程序不可知的情况下,攻击代码已经执行。或者,另一种情况,如果缓冲区足够多,也可以将攻击代码直接写入到缓冲区执行,这需要修改返回地址使其指向缓冲区,随后在缓冲区中正常退出,不过这种方式现在来看相对上一种方式有些困难。因为系统可能限制了在堆栈中执行代码,MS的VS和gcc都有其堆栈区的保护机制,一般可能默认禁止了执行指令代码。

           根据上面的情况来看,我们看到缓冲区溢出攻击并不复杂,反而,似乎并不是很困难。为了更清楚的了解缓冲区溢出的整个过程,在下面一节中,我特意做个实例来演示这个过程如何实现。

3. 缓冲区溢出实例分析

本篇的实例基于书中的一个练习题实验,首先我们需要从CS:APP的网站上下载文件bufbomb.c,并编译成可执行文件。其文件内容如下:

#include <stdio.h> 
#include <stdlib.h>
#include <ctype.h>
#include <malloc.h>
/* Like gets, except that characters are typed as pairs of hex digits.
Nondigit characters are ignored. Stops when encounters newline */
char*getxs(char*dest)
{
int c;
int even =1; /* Have read even number of digits */
int otherd =0; /* Other hex digit of pair */
char*sp = dest;
while ((c = getchar()) != EOF && c !='\n') {
if (isxdigit(c)) {
int val;
if ('0'<= c && c <='9')
val = c -'0';
else if ('A'<= c && c <='F')
val = c -'A'+10;
else
val = c -'a'+10;
if (even) {
otherd = val;
even =0;
}
else {
*sp++= otherd *16+ val;
even =1;
}
}
}
*sp++='\0';
return dest;
}

/* $begin getbuf-c */
int getbuf()
{
char buf[12];
getxs(buf);
return 1;
}

void test()
{
int val;
printf("Type Hex string:");
val = getbuf();
printf("getbuf returned 0x%x\n", val);
}
/* $end getbuf-c */

int main()
{

int buf[16];
/* This little hack is an attempt to get the stack to be in a
stable position
*/
int offset = (((int) buf) &0xFFF);
int*space = (int*) alloca(offset);
*space =0; /* So that don't get complaint of unused variable */
test();
getchar();
return 0;
}

其中函数getxs类似于库函数gets,不过它是以十六进制数字的编码方式读入字符。比如给它一个字符串”0123”,用户应该输入字符串”30 31 32 33”(十进制数x的ASCII表示为0x3x),这个函数会忽略掉空格字符。Getbuf函数的代码看上去似乎很明显,无论何时被调用,它都会返回1.看上去就好像调用getxs没有产生效果一样,你的任务是,只简单地对提示符输入一个适当的十六进制字符串,就使getbuf对test返回0xdeadbeef(-559038737)。

下面开始我们的实验:

一、要做到这点,我们实际上两种可行的办法,首先我们在一台Unbuntu 12.04机器下使用gcc  4.6.3的环境下进行测试。在开始之前,我们第一步需要分析getbuf的栈帧结构,我们先保存上面的代码为bomb.c,并使用gcc进行编译生成可执行文件bomb如下:gcc –g –o bomb bomb.c ,然后我们使用gdb 对bomb进行调试分析。

               CSI-S1:程序的机器级表示~缓冲区溢出详解

           根据调试得到的反汇编代码首先做一些简要的说明,在地址0x08048585处我们看到编译器为getbuf栈帧开辟了0x28(40个)字节的栈区空间,随后将gs:14指定的数据写入到基于ebp的-0xc位置。同时在0x08048593地址处,指令lea计算出buf地址的起始位置,在基于ebp的-0x18的位置。根据这些信息,我们构造getbuf的栈帧结构图如下:

                           CSI-S1:程序的机器级表示~缓冲区溢出详解

                                                                                注:地址从上向下递减

在getbuf调用完成后,其返回值被传递给printf函数,所以这里的返回地址就是printf函数的地址,我们要做的就是将返回值1修改为我们要返回的(0xdeadbeef),这里我们还有必要再看下test的反汇编代码。

                  CSI-S1:程序的机器级表示~缓冲区溢出详解

在0x080485ce地址处我们将返回值(eax)存放在当前test帧指针ebp的-0xc的位置,这里的帧指针就保存在getbuf的栈帧中,所以在我们输入buf覆盖时要保证该值不变,这样我们才能在test中正确的访问到想要的数据。再看下test的栈帧结构图:

                                      CSI-S1:程序的机器级表示~缓冲区溢出详解

printf函数接受两个参数分别为val和格式参数,存放在相对esp为4和0的位置。我们采用一种投机的方式来让溢出的值修改val,这样从表面上来看getbuf确实返回了0xdeadbeef,其实真正意义上只是跳过了0x080485ce-0x080485d6之间的代码。

        另一个细节是,我们在getbuf开始的反汇编代码中看到了由段寄存器gs:14指定的数据被存放在栈中,gcc采用随机值的方式来防止可能的缓冲区溢出,这里生成的随机值被存放在gs:14的位置。在代码末尾可以看到使用xor异或指令判断栈中该值是否被改变,如果改变就说明发生了缓冲区溢出,从而引发一个堆栈错误。不过,如果没改变并不能说明就没有发生缓冲区溢出。这里,我们需要绕过该机制来达到修改返回值的目的,这需要我们在每次运行时记录该随机值,然后在我们溢出缓冲区时保证不要覆盖该值。        

根据上面的介绍,我们基本上可以构造出这么一个缓冲区字符序列来达到我们的目的:

          字符序列= 12字节任意字符+ gs:14随机值+ 8字节任意字符+ 被保存的%ebp + printf调用地址+ 格式参数+ 返回值(0xdeadbeef).

接下来我们就根据调试信息构造这个字符序列。我们先在getbuf处设置一个断点然后运行。

              CSI-S1:程序的机器级表示~缓冲区溢出详解

接着单步执行,查看生成的随机值,并进行校验。

               CSI-S1:程序的机器级表示~缓冲区溢出详解

               CSI-S1:程序的机器级表示~缓冲区溢出详解

我们看到生成的随机值为0x7eal4f00(2124500736),并同时打印出了其存储在栈中的内容进行校验。同时,根据打印的寄存器信息,我们也可以看到当前帧指针ebp=0xbfffef98,

可以看到其对应存储在栈中的ebp值为(-1073745967),对应的十六进制值为0xbfffefc8,

还有,我们从之前test反汇编代码中可以看到printf调用地址为0x080485e0,格式参数存放的地址为0x8048741,根据这些我们可以构造出字符序列为:

                00 00 00 00  00 00 0000  00 00 00 00  00 4f a1 7e  00 00 00 00 00 00 00 00 c8 ef ff bf  e0 85 0408  41 87 04 08  ef be ad de

               注:这里需要注意的是,所测试的机器采用小端法的编码方式,同时在输入字符序列时要特别小心,稍有差错就会导致错误发生。

接下来我们将该字符序列输入到缓冲区中,看下返回的结果:

             CSI-S1:程序的机器级表示~缓冲区溢出详解 

二、下来让我们再演示另外一种情况下的缓冲区溢出,这种方式我们通过getbuf返回地址到其内部缓冲区,内部缓冲区里保存了我们的指令,指令的功能就是将返回值0x1修改为0xdeadbeef,并让函数getbuf正常返回。在这之前,我们需要重新编译下bomb程序。

              CSI-S1:程序的机器级表示~缓冲区溢出详解

这次我在编译过程中使用了-fno-stack-protector选项来关闭堆栈的保护机制,这样编译器就不会产生随机的探测值来对堆栈进行缓冲区溢出检查。同前面一样,我们首先打印出getbuf的反汇编代码,同样我们首先利用gdb查看产生的getbuf反汇编代码:

              CSI-S1:程序的机器级表示~缓冲区溢出详解

我们看到确实没有产生探测值,并且此时的缓冲区大小只有0x14字节。然后,我们需要知道getbuf的返回地址,我们通过查看test的反汇编代码可以得到:

              CSI-S1:程序的机器级表示~缓冲区溢出详解

可以看到调用getbuf的下一条地址为0x08048562,这就是在getbuf的返回地址。知道了返回地址,我们就可以构造出我们的指令。如下:

              CSI-S1:程序的机器级表示~缓冲区溢出详解

首先,将0xdeadbeef存放到eax,即getbuf的返回值,然后将返回地址(0x08048562)压入堆栈,然后再执行ret返回(还记得ret指令的功能就是将返回地址弹出,并从该地址开始执行)。最后我们通过objdump得到我们的指令字节码。在缓冲区中我们需要写入这个指令字节串。

          接下来,我们就需要修改getbuf的返回地址为我们的缓冲区地址,这样在getbuf函数返回时就可以执行我们的指令,由于指令给出了正确的返回地址,所以函数真正的返回在我们的指令中执行。让我们首先看下这时getbuf的栈帧结构:

                CSI-S1:程序的机器级表示~缓冲区溢出详解

随后的主要任务就是确定返回地址,即buf缓冲区的地址以及被保存的%ebp(test的帧指针)。可以看到在getbuf反汇编代码中指令=>

             0x08048538 <+6>:    lea    -0x14(%ebp),%eax

该指令就是将相对ebp 0x14字节位置的地址放入%eax,并提供给getxs使用,所以可以确定%eax里保存的就是buf的地址。我们单步执行查看:

              CSI-S1:程序的机器级表示~缓冲区溢出详解

             CSI-S1:程序的机器级表示~缓冲区溢出详解

 我们看到eax=0xbfffef84 = 0xbfffef98(ebp)-0x14,保存的ebp为-1073745976(0xbfffefc8).通过上面的信息我们可以构造出输入的字符:

  11字节指令+ 9字节任意字符 +保存的ebp + getbuf的返回地址(buf地址),其中,11字节指令和9字节任意字符用来填充20(0x14)字节的缓冲区。

           CSI-S1:程序的机器级表示~缓冲区溢出详解

          其实,最后一种方式对于溢出的条件更加苛刻,我们需要允许指定栈的可执行条件,这在一般系统中是默认禁止的,我们之前在介绍虚拟存储器中也介绍过,所以在一开始,我重新编译了bomb程序,并加了-z execstack的条件,使得程序栈具有可执行的权限。我们可以用readlef查看其相关的读写执行权限,其中E就代表了可执行。

4. 如何防止缓冲区溢出

          认识到缓冲区溢出的原理后,我们知道它将会给我们的系统带来严重的危害,因此,在这里,有必要介绍和了解针对缓冲区溢出所做的措施。在很多系统中,缓冲区溢出已经被当做一个系统漏洞,而不是简单的软件的漏洞,针对这个漏洞,各个系统都有提出自己的解决方案,首先从各自的编译器入手,如上面gcc编译器使用的探测机制(生成随机值,随后校验),在编译层限制堆栈执行权限(典型的如VS 编译器的DEP保护)等措施。同时,另一方面,缓冲区溢出的发生跟C/C++语言自身也有很大的关系,C和C++附带有大量的危险函数,比如strcpy,strcat,scanf,gets等等,因此,这就将避免缓冲区溢出的责任推到我们开发人员身上,不过同时,系统也对此提供了一套针对C/C++库的安全方法,比如vs引入了strcpy_s,strcat_s等安全版本的C库函数,这些方法唯一的不同是加入了一个指定缓冲区大小,或者类似提供libsafe开源的安全标准库。其实,这些给我们开发人员带来的启发就是,在使用或者维护一些软件系统时,能够尽量使用类似这些安全的库函数,或者,你也可以自己对缓冲区长度和字符长度进行检查以尽量避免发生缓冲区溢出的可能。

好了,关于缓冲区溢出,本篇只介绍了最基本的原理而并未涉及到任何应用,如果想要了解有关于缓冲区溢出的详细相关技术,你可以参阅下面的几篇文章。

详细参见

1.现代Linux系统上的栈溢出http://www.exploit-db.com/papers/24085/(参考译文:http://my.oschina.net/sincoder/blog/115944)

2.不同版本的Ubuntu系统默认启用的安全机制https://wiki.ubuntu.com/Security/Features

3.Gcc中的编译器堆栈保护技术http://www.ibm.com/developerworks/cn/linux/l-cn-gccstack/

4.Linux下缓冲区溢出攻击的原理http://www.ibm.com/developerworks/cn/linux/l-overflow/