Linux C/C++ 堆栈溢出:原理、利用与防护深度指南
1. 基础概念:内存与堆栈
要理解堆栈溢出,首先必须掌握 Linux 进程的内存布局以及函数调用的底层机制。
1.1 Linux 进程内存布局
一个标准的 Linux 进程(32位环境)的虚拟地址空间从低到高通常包含以下段:
- Text Segment (.text): 存放只读的代码指令。
- Data Segment (.data / .bss): 存放已初始化和未初始化的全局变量/静态变量。
- Heap (堆) : 用于动态内存分配 (
malloc/new),从低地址向高地址增长。 - Stack (栈): 用于函数调用(局部变量、返回地址等),从高地址向低地址增长。
- Kernel Space: 高端地址(如 0xC0000000 以上)留给操作系统内核。

1.2 栈帧 (Stack Frame) 与寄存器
栈(Stack)是利用 LIFO(后进先出)特性的数据结构。每个函数调用都会在栈上创建一个"栈帧"。
关键寄存器 (IA-32):
- ESP (Extended Stack Pointer): 栈顶指针,始终指向栈顶元素。
- EBP (Extended Base Pointer): 栈底指针(帧指针),指向当前栈帧的底部,用于定位局部变量和参数。
- EIP (Extended Instruction Pointer): 指令指针,存放 CPU 下一条要执行的指令地址。
函数调用过程 (Function Prologue) :
当 main 调用 func 时:
- 参数入栈 :
push arg2,push arg1 - 返回地址入栈 :
call func指令会将下一条指令地址 (ret_addr) 压入栈顶。 - 保存旧 EBP :
push ebp(保存调用者的栈底) - 更新 EBP :
mov ebp, esp(当前栈顶变成新栈底) - 分配局部变量 :
sub esp, N(为局部变量腾出空间)
函数销毁过程 (Function Epilogue):
leave(等价于mov esp, ebp; pop ebp):恢复栈顶和旧栈底。ret:从栈顶弹出返回地址到 EIP,CPU 跳转回调用者继续执行。
2. 溢出原理分析
2.1 什么是栈溢出?
栈溢出(Stack Buffer Overflow)发生在向栈上的缓冲区写入数据时,超过了缓冲区本身的容量,导致数据覆盖了相邻的内存区域。最危险的情况是覆盖了返回地址 (Return Address)。
2.2 漏洞代码演示
我们使用一个经典的易受攻击程序 stack_overflow_demo.c:
c
#include <stdio.h>
#include <string.h>
void vulnerable_function(char *str) {
char buffer[64];
// 危险!strcpy 不检查源字符串长度
strcpy(buffer, str);
printf("Input: %s\n", buffer);
}
int main(int argc, char **argv) {
vulnerable_function(argv[1]);
return 0;
}
编译指令(关闭保护):
bash
gcc -fno-stack-protector -z execstack -no-pie -g -m32 stack_overflow_demo.c -o stack_overflow_demo
-fno-stack-protector: 关闭 Stack Canary。-z execstack: 允许栈上执行代码(关闭 NX)。-no-pie: 关闭地址随机化(PIE)。-m32: 编译为 32 位程序(便于教学演示)。
2.3 溢出过程图解

正常状态 :
buffer 只有 64 字节。后面紧跟着 Saved EBP (4字节) 和 Return Address (4字节)。
溢出状态 :
如果输入 72 个 'A'(64 + 4 + 4):
- 前 64 个 'A' 填满
buffer。 - 接下来的 4 个 'A' 覆盖
Saved EBP。 - 最后的 4 个 'A' (0x41414141) 覆盖
Return Address。
当函数执行 ret 时,CPU 会将 0x41414141 弹入 EIP。由于这是一个无效地址,程序会崩溃(Segmentation Fault)。但如果我们将这个地址改成攻击代码的地址呢?
2.4 GDB 调试验证
bash
$ gdb -q ./stack_overflow_demo
(gdb) run $(python -c 'print "A"*72')
Starting program: /path/to/stack_overflow_demo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Program received signal SIGSEGV, Segmentation fault.
0x41414141 in ?? ()
可以看到,EIP 已经被成功修改为 0x41414141。
3. 利用技术详解
3.1 Shellcode 注入
如果栈是可执行的(没有 NX 保护),我们可以:
- 将 Shellcode(一段启动
/bin/sh的机器码)放入缓冲区。 - 将返回地址覆盖为 buffer 的起始地址。
Payload 结构 :
[ NOP Sled ] [ Shellcode ] [ Padding ] [ Address of Buffer ]
- NOP Sled : 一串
0x90指令。只要 EIP 跳到 NOP 区域,CPU 就会滑行到 Shellcode 执行。
3.2 Return-to-libc 攻击 (绕过 NX)
现代系统通常开启 NX (No-Execute),栈上无法执行代码。此时我们需要利用 Ret2Libc 技术。
我们不再跳转到栈上,而是跳转到共享库 libc 中的 system() 函数,并传参 "/bin/sh"。

Payload 构造 :
[ Padding (Offset to EIP) ] [ Address of system() ] [ Fake Return Addr (exit) ] [ Address of "/bin/sh" ]
- Padding: 填充缓冲区直到 EIP。
- system() : 覆盖 EIP,让程序跳转到
system函数。 - Fake Ret :
system执行完后的返回地址(通常填exit让程序优雅退出)。 - "/bin/sh" :
system函数的参数。
3.3 ROP (Return Oriented Programming)
如果需要执行复杂操作(如绕过 ASLR),可以使用 ROP。
核心思想 :利用程序或库中已有的代码片段(Gadgets)。每个 Gadget 通常以 ret 结尾。
ROP 链 :
[ Gadget 1 Addr ] [ Gadget 2 Addr ] [ Gadget 3 Addr ] ...
Gadget 查找工具 :ROPgadget
bash
$ ROPgadget --binary ./stack_overflow_demo --only "pop|ret"
0x080484b6 : pop ebx ; ret
0x080484b8 : pop ebp ; ret
4. 防护方案
4.1 编译器防护
-
Stack Canary (栈哨兵)
- 参数 :
-fstack-protector-all - 原理 : 在 EBP 之前插入一个随机值(Canary)。函数返回前检查该值是否被修改。如果溢出覆盖了 Canary,程序立即终止 (
__stack_chk_fail)。
- 参数 :
-
NX / DEP (不可执行栈)
- 参数 :
-z noexecstack(默认开启) - 原理: 标记栈内存页为不可执行。Shellcode 在栈上无法运行。
- 参数 :
-
PIE (位置无关可执行文件)
- 参数 :
-fPIE -pie - 原理: 代码段地址随机化,增加寻找 Gadget 的难度。
- 参数 :
4.2 系统级防护
- ASLR (地址空间布局随机化)
- 配置 :
echo 2 > /proc/sys/kernel/randomize_va_space - 原理: 每次运行程序时,堆、栈、共享库的基地址都是随机的。
- 配置 :
4.3 安全编码规范
- 禁用危险函数 : 永远不要使用
strcpy,gets,sprintf。 - 使用安全替代 : 使用
strncpy,fgets,snprintf并严格指定长度。 - 边界检查: 在读写数组前,务必检查索引是否越界。
5. 总结
堆栈溢出是计算机安全领域的经典漏洞。虽然现代操作系统和编译器提供了层层防护(Canary, NX, ASLR),但 ROP 等高级攻击技术依然证明了"没有绝对的安全"。对于开发者而言,从代码源头杜绝缓冲区越界才是最根本的解决之道。