栈溢出 (Stack Overflow / Stack Buffer Overflow) 是二进制安全中最基础、最经典,也是最核心的内存破坏漏洞。它是所有 ROP、BROP、Ret2Reg 等高级攻击技术的根基。
简单来说,它的原理是:向栈上的缓冲区写入了超过其容量的数据,导致数据“溢出”覆盖了栈上相邻的关键信息(如函数的返回地址),从而劫持程序控制流。
1. 预备知识:函数调用栈的布局
要理解栈溢出,必须先理解内存中栈(Stack)的结构。
- 生长方向:栈在内存中是从高地址向低地址生长的。
- 填充方向:当我们向缓冲区(数组)写入数据时,是从低地址向高地址写的(索引增加)。
- 栈帧(Stack Frame):每个函数调用时都会在栈上开辟一块区域。一个典型的栈帧包含以下部分(从高地址到低地址排列):
| 内存方向 | 内容 | 说明 |
|---|---|---|
| 高地址 | 参数 (Parameters) | 函数调用传递的参数 |
| $\downarrow$ | 返回地址 (Return Address) | 攻击的核心目标。函数执行完后,CPU 应该跳转回去执行的地方。 |
| Saved EBP/RBP | 保存调用者的栈基址指针 | |
| 低地址 | 局部变量 (Local Variables) | 函数内部定义的变量、数组(缓冲区) |
2. 漏洞原理
根本原因
使用了不安全的数据拷贝函数(如 strcpy, gets, strcat, scanf 等),这些函数不检查源数据的长度,直接将数据写入目标缓冲区。
发生过程
-
正常情况:用户输入的数据长度小于缓冲区大小。数据乖乖待在局部变量区,函数执行完毕,通过
Saved EBP恢复栈,取出Return Address跳回主程序。 -
溢出情况:
- 攻击者输入了超长的数据。
- 数据填满了局部变量(缓冲区)。
- 数据继续向高地址写入,覆盖了 Saved EBP。
- 数据继续写入,覆盖了 返回地址 (Return Address)。
- (如果数据够长,还可以覆盖参数)。
-
劫持控制流:
- 当函数执行
ret(return) 指令时,CPU 会从栈上弹出一个值赋给指令指针寄存器 (EIP/RIP)。 - 由于这个位置已经被攻击者修改为恶意的地址(比如 Shellcode 的地址或某个 ROP Gadget 的地址),CPU 就会跳转去执行恶意代码。
- 当函数执行
3. 一个具体的例子
漏洞代码 (vuln.c)
#include <stdio.h>
#include <string.h>
void success() {
printf("Hacked! You got the shell.\n");
// system("/bin/sh"); // 实际攻击中通常是想执行这个
}
void vulnerable_function(char *str) {
char buffer[10]; // 只有 10 字节的缓冲区
// 危险!strcpy 不检查 str 的长度
strcpy(buffer, str);
}
int main(int argc, char **argv) {
if (argc > 1) {
vulnerable_function(argv[1]);
}
return 0;
}
内存布局分析
假设编译为 32 位程序,当我们调用 vulnerable_function 时,栈的布局大致如下(地址仅为示意):
[ 高地址 0xffff0014 ] Return Address (返回到 main 的地址)
[ 0xffff0010 ] Saved EBP (main 函数的 EBP)
[ 0xffff0000 ] Buffer[0] ~ Buffer[9] (10字节空间 + 对齐填充)
[ 低地址 ]
攻击 Paylaod 构造
我们需要计算从 buffer 开始,需要填充多少垃圾数据才能刚好触碰到 Return Address。
- Buffer 区域:10 字节(通常编译器会进行内存对齐,可能会变成 12 或 16 字节,假设这里加上对齐一共需要填充 12 字节)。
- EBP 区域:4 字节。
- 目标:覆盖接下来的 4 字节(返回地址)。
构造 Payload:
[ 垃圾数据 (12 + 4 = 16 字节) ] + [ success 函数的地址 ]
假设 success 函数的地址是 0x08048456。
输入字符串:"AAAAAAAAAAAA" + "BBBB" + "\x56\x84\x04\x08"
执行流程
strcpy开始拷贝。buffer被 ‘A’ 填满。Saved EBP被 ‘B’ 覆盖(导致函数返回后上一级函数的栈帧错乱,但这通常不影响我们拿 Shell)。Return Address被0x08048456覆盖。vulnerable_function执行结束,运行ret指令。- CPU 弹出栈顶的
0x08048456,赋值给 EIP。 - 程序跳转到
success函数执行,输出 “Hacked!”。
4. 栈溢出的利用方式
一旦能控制返回地址,攻击者可以做什么?
-
执行 Shellcode (Ret2Shellcode):
- 将返回地址指向栈上的缓冲区地址。
- 在缓冲区里放入机器码(Shellcode)。
- 前提:栈必须有可执行权限(现在通常被 NX 保护防御)。
-
执行程序已有的函数 (Ret2Libc):
- 将返回地址指向标准库(libc)中的
system函数。 - 在栈上布置好参数(指向 “/bin/sh” 字符串)。
- 用于绕过 NX 保护。
- 将返回地址指向标准库(libc)中的
-
代码复用攻击 (ROP/JOP):
- 正如前面介绍的 BROP、ROP,利用程序现有的代码片段(Gadgets)拼凑出攻击逻辑。
- 用于绕过 NX 和 ASLR。
5. 常见的防御机制
为了防止栈溢出,现代操作系统和编译器引入了多种保护措施:
-
Canary (栈哨兵):
- 原理:在
Saved EBP和Local Variables之间插入一个随机值(Canary)。 - 检测:函数返回前,检查这个值是否被修改。如果发生溢出,Canary 必然先被覆盖,程序检测到变化后立即终止报错(Stack Smashing Detected)。
- 绕过方法:泄露 Canary(如利用格式化字符串漏洞),或爆破(BROP)。
- 原理:在
-
NX / DEP (No-Execute / Data Execution Prevention):
- 原理:将栈内存标记为“不可执行”。
- 效果:即使跳到了栈上的 Shellcode,CPU 也会抛出异常,拒绝执行。
- 绕过方法:ROP / Ret2Libc。
-
ASLR (Address Space Layout Randomization):
- 原理:每次程序运行时,栈、堆、libc 的基地址都是随机的。
- 效果:攻击者无法硬编码返回地址(不知道
system函数或 Shellcode 的确切地址)。 - 绕过方法:信息泄露(Information Leak)配合偏移量计算。
总结
栈溢出是由于缺乏边界检查导致的内存越界写入。虽然现代防御机制让直接的栈溢出攻击变得困难,但理解其原理(内存布局、函数调用约定、控制流劫持)是学习所有二进制漏洞利用技术的必经之路。