0. 引言
如果你学的第一门程序语言是C语言,那么下面这段程序很可能是你写出来的第一个有完整的 “输入---处理---输出” 流程的程序:
#include
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
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 :
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
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>
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
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)--> +-------------+
| ret rip |
+-------------+
| ... |
+-------------+
| ... |
+-------------+
rbp即函数的栈帧基指针