简介: 以堆栈溢出为代表的缓冲区溢出攻击已经成为一种普遍的安全漏洞和攻击手段。本文首先对编译器层面的堆栈保护技术作简要介绍,然后通过实例来展示 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” 的探测技术来完成对这种破坏的检测。
“Canaries” 探测:
要检测对函数栈的破坏,需要修改函数栈的组织,在缓冲区和控制信息(如 EBP 等)间插入一个 canary word。这样,当缓冲区被溢出时,在返回地址被覆盖之前 canary word 会首先被覆盖。通过检查 canary word 的值是否被修改,就可以判断是否发生了溢出攻击。
常见的 canary word:
目前主要的编译器堆栈保护实现,如 Stack Guard,Stack-smashing Protection(SSP) 均把 Canaries 探测作为主要的保护技术,但是 Canaries 的产生方式各有不同。下面以 GCC 为例,简要介绍堆栈保护技术在 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 还有意将局部变量中的数组放在函数栈的高地址,而将其他变量放在低地址。这样就使得通过溢出一个数组来修改其他变量(比如一个函数指针)变得更为困难。
GCC 4.1 中三个与堆栈保护有关的编译选项
-fstack-protector:
启用堆栈保护,不过只为局部变量中含有 char 数组的函数插入保护代码。
-fstack-protector-all:
启用堆栈保护,为所有函数插入保护代码。
-fno-stack-protector:
禁用堆栈保护。
GCC 中的 Canaries 探测
下面通过一个例子分析 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 分别反汇编 demo_sp,deno_nosp。
(gdb) disas main Dump of assembler code for function main: 0x08048344 <main+0>: lea 0x4(%esp),%ecx 0x08048348 <main+4>: and $0xfffffff0,%esp 0x0804834b <main+7>: pushl 0xfffffffc(%ecx) 0x0804834e <main+10>: push %ebp 0x0804834f <main+11>: mov %esp,%ebp 0x08048351 <main+13>: push %ecx 0x08048352 <main+14>: sub $0x50,%esp 0x08048355 <main+17>: movl $0x1,0xfffffff8(%ebp) 0x0804835c <main+24>: movb $0x61,0xffffffb8(%ebp) 0x08048360 <main+28>: mov $0x0,%eax 0x08048365 <main+33>: add $0x50,%esp 0x08048368 <main+36>: pop %ecx 0x08048369 <main+37>: pop %ebp 0x0804836a <main+38>: lea 0xfffffffc(%ecx),%esp 0x0804836d <main+41>: ret End of assembler dump. |
(gdb) disas main Dump of assembler code for function main: 0x08048394 <main+0>: lea 0x4(%esp),%ecx 0x08048398 <main+4>: and $0xfffffff0,%esp 0x0804839b <main+7>: pushl 0xfffffffc(%ecx) 0x0804839e <main+10>: push %ebp 0x0804839f <main+11>: mov %esp,%ebp 0x080483a1 <main+13>: push %ecx 0x080483a2 <main+14>: sub $0x54,%esp 0x080483a5 <main+17>: mov %gs:0x14,%eax 0x080483ab <main+23>: mov %eax,0xfffffff8(%ebp) 0x080483ae <main+26>: xor %eax,%eax 0x080483b0 <main+28>: movl $0x1,0xffffffb4(%ebp) 0x080483b7 <main+35>: movb $0x61,0xffffffb8(%ebp) 0x080483bb <main+39>: mov $0x0,%eax 0x080483c0 <main+44>: mov 0xfffffff8(%ebp),%edx 0x080483c3 <main+47>: xor %gs:0x14,%edx 0x080483ca <main+54>: je 0x80483d1 <main+61> 0x080483cc <main+56>: call 0x80482fc <__stack_chk_fail@plt> 0x080483d1 <main+61>: add $0x54,%esp 0x080483d4 <main+64>: pop %ecx 0x080483d5 <main+65>: pop %ebp 0x080483d6 <main+66>: lea 0xfffffffc(%ecx),%esp 0x080483d9 <main+69>: 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 <main+17>: mov %gs:0x14,%eax 0x080483ab <main+23>: mov %eax,0xfffffff8(%ebp) 0x080483ae <main+26>: xor %eax,%eax |
在函数返回前又多了 4 条语句:
0x080483c0 <main+44>: mov 0xfffffff8(%ebp),%edx 0x080483c3 <main+47>: xor %gs:0x14,%edx 0x080483ca <main+54>: je 0x80483d1 <main+61> 0x080483cc <main+56>: 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 堆栈保护效果
以上我们从实现的角度分析了 GCC 中的堆栈保护。下面将用一个小程序 overflow_test.c 来验证 GCC 堆栈保护的实际效果。
#include <stdio.h> #include <stdlib.h> 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 |
在上面的测试中,我们看到编译器的插入的保护代码阻止了通常的溢出攻击。实际上,现在编译器堆栈保护技术确实使堆栈溢出攻击变得困难了。
回页首
GCC 堆栈保护的局限
在上面的例子中,我们发现假如攻击者能够购绕过 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 也会对防止溢出攻击起到重要作用。
参考资料