缓冲区溢出漏洞,作为一个历史悠久的计算机安全顽疾,早在1970年代就已存在。直到1980年代,Morris蠕虫事件才让这一漏洞首次大规模暴露在公众视野中。进入1990年代,Aleph1的经典文章《Smashing the Stack for Fun and Profit》及其配套攻击代码开始在互联网上广泛传播,从此,缓冲区溢出的“潘多拉魔盒”被彻底打开,成为黑客攻击与安全防御的核心战场。
本文作为系列文章的开篇,将深入剖析本地溢出这一最基础的漏洞类型,并手把手指导读者编写利用代码。我们的目标是讲透原理,让读者真正理解漏洞背后的机制,而不仅仅是复制粘贴攻击代码。
为了顺利跟上后续内容,你需要具备一定的C语言和汇编基础,同时了解虚拟内存、进程的内存布局方式,以及setuid二进制文件的基本概念。当然,你还需要熟练使用UNIX系统,最好具备gdb/cc调试与编译的经验。本系列主要针对Linux/ix86环境展开,不同操作系统或架构的细节会存在差异。在后续文章中,我们将逐步介绍更高级的溢出技术和shellcode编写技巧。
什么是溢出?
如果你写过C语言,一定对字符数组不陌生。数组中的元素类型相同,比如int、char、float。与其他数据结构类似,数组也分为“静态”和“动态”两类。静态变量存放在程序的数据段,而动态变量则在程序运行时的堆栈段中分配和释放。“基于堆栈”的溢出正是发生在堆栈段——当我们向数据结构中写入超过其容量的数据时,例如向一个只能存储12字节的数组强行塞入20字节,超出界限的数据就会覆盖掉后续的重要数据。简单来说,溢出就是这么一回事。
一个Linux ELF格式的可执行文件,其内存布局相当复杂。尤其是在ELF(可执行与可链接格式,详情可搜索“Executable and Linkable Format”)和共享库引入之后,结构变得更加复杂。但无论如何,每个进程在运行时都包含三个基本段:
1. 文本段(Text Segment):只读区域,存放所有程序指令。例如下面这段C代码对应的指令就会存放在这里。
2. 数据段(Data Segment):存放已初始化和未初始化的数据(也称为BSS段)。例如:
如果你写 int i; ,这个变量是未初始化的,会被放到数据段的“未初始化变量”部分(BSS)。
如果你写 int j = 5; ,这个变量是已初始化的,空间就会分配在数据段的“已初始化变量”部分。
3. 堆栈段(Stack Segment):动态变量(C语言中称为自动变量)在这里分配和释放,同时用于临时存储函数返回地址。例如下面的函数中,变量 i 就是在堆栈上产生的,函数返回后它就被销毁了。
如果用符号表示堆栈的内存布局,大致如下:
0xBFFFFFFF ---------------------
| |
| . |
| . |
| . |
| . |
| etc |
| env/argv pointer. |
| argc |
|-------------------|
| |
| stack |
| |
| | |
| | |
| V |
/ /
\ \
| |
| ^ |
| | |
| | |
| |
| heap |
|-------------------|
| bss |
|-------------------|
| initialized data |
|-------------------|
| text |
|-------------------|
| shared libraries |
| etc. |
0x8000000 |-------------------|
堆栈(Stack)
从数据结构课程中我们知道,堆栈是一种后进先出(LIFO)的数据结构。CPU通过PUSH和POP等特殊指令直接控制堆栈。你压入一些数据,再弹出一些数据。后进入的数据先被弹出。专业地说,第一个被弹出堆栈的,是最后一个被压入的数据。
CPU中的SP寄存器(堆栈指针)保存着即将从堆栈中弹出的数据的地址。至于SP是指向最后的数据,还是指向最后数据后面的地址,这取决于具体的CPU架构。在ix86架构下,SP指向的是堆栈中最后数据的地址。在ix86保护模式(32位/双字)下,PUSH和POP指令按4字节单元操作。另一个重要细节:堆栈是向下增长的。也就是说,如果SP当前为0xFF,执行PUSH EAX之后,SP会变成0xFC,而EAX的值会被存放到0xFC这个地址中。
更具体地说,PUSH指令会从ESP(对照上图)减去4个字节,然后将一个双字压入堆栈,放到ESP寄存器所指的地址。POP指令则读取ESP寄存器中的地址,弹出该地址指向的值,然后ESP加4。假设ESP初始化为0x1000,我们来看一段汇编代码的执行过程:
PUSH dword1 ; dword1的值是1,执行后ESP = 0xFFC (0x1000 - 4) PUSH dword2 ; dword2的值是2,执行后ESP = 0xFF8 (0xFFC - 4) PUSH dword3 ; dword3的值是3,执行后ESP = 0xFF4 (0xFF8 - 4) POP EAX ; EAX = 3,执行后ESP = 0xFF8 (0xFF4 + 4) POP EBX ; EBX = 2,执行后ESP = 0xFFC (0xFF8 + 4) POP ECX ; ECX = 1,执行后ESP = 0x1000 (0xFFC + 4)
堆栈除了用作动态变量的临时存储,还用于保存函数调用时的临时变量地址,以及在函数之间传递参数。当然,这也是漏洞滋生的温床。
EIP寄存器,CALL和RET指令
在每个机器周期中,CPU都会查询指令指针寄存器(在ix86 32位保护模式下称为EIP——扩展指令指针)中保存的内容,以确定下一步要执行什么指令。EIP中存放的是下一条将要执行的指令的地址。通常情况下,地址是连续的——下一条指令在内存中比当前指令靠后几个字节。CPU会根据当前指令的长度计算出“靠后的字节数”,然后加到当前地址上。举例来说:假设当前指令地址是0x8048438,这个值就写在EIP中。CPU执行地址0x8048438处的指令,比如是一条PUSH指令。CPU知道这条PUSH指令是1字节长的,所以下一条指令就在0x8048439,可能是MOV指令。执行完PUSH后,CPU就会把MOV指令的地址放到EIP中。
