10、缓冲区溢出:栈溢出原理、堆溢出原理、防范措施、安全函数使用
缓冲区溢出,这名字听着挺学术,说白了就是往一个杯子里倒水,水满了还继续倒,结果水流得到处都是。在C语言里,这个「杯子」就是缓冲区,而「流出来的水」就是内存里不该被改写的数据。
我入行第三年的时候,接手过一个嵌入式网关项目。前任工程师留下的代码里,有一个字符串处理函数没做边界检查。设备在实验室跑了两个月都没事,一上线到客户现场,每天凌晨三点准时重启。后来定位到问题,就是缓冲区溢出把返回地址给踩了。嗯,从那以后,我对缓冲区溢出就特别敏感。
10.1 栈溢出原理
栈溢出是最常见的一种。先说说栈的结构。每次函数调用,系统都会在栈上分配一个栈帧。栈帧里存着局部变量、函数参数,还有最重要的——返回地址。
返回地址是什么?就是函数执行完后,CPU该回到哪里继续干活。这个地址保存在栈上。如果你能通过缓冲区溢出把这个地址改了,那CPU就会跳到你指定的地方去执行代码。
看个典型的例子:
#include <string.h>
void vulnerable(char *input) {
char buffer[64]; // 局部缓冲区,在栈上
strcpy(buffer, input); // 危险!没有长度检查
// 函数返回时,会从栈上取返回地址
}
这个buffer只有64字节。如果input的长度超过64,strcpy会一直复制直到遇到\0。多出来的数据就会覆盖栈上相邻的内存——包括返回地址。
我画了一张图,帮你理解栈帧的布局:
你看,buffer在低地址,返回地址在高地址。数据是从低地址往高地址写的。一旦buffer写满了,接下来的数据就会覆盖EBP,再接下来就是返回地址。
10.2 堆溢出原理
堆溢出比栈溢出稍微复杂一点。堆是动态分配的内存区域,用malloc、free来管理。堆溢出发生在堆上的缓冲区写超了。
堆的管理依赖于元数据。每个堆块的前后都有一些管理信息,比如块大小、空闲标志、前后指针等。这些元数据保存在堆块的头尾。
看个例子:
#include <stdlib.h>
#include <string.h>
void heap_vulnerable(char *input) {
char *buffer = (char*)malloc(64); // 堆上分配64字节
strcpy(buffer, input); // 没有检查长度!
// 堆溢出后,相邻堆块的元数据被破坏
free(buffer); // 触发崩溃或利用
}
堆溢出攻击的常见手法是「堆块元数据篡改」。攻击者通过溢出改写相邻空闲堆块的指针,当free执行时,系统会把这些伪造的指针写入特定位置,从而实现任意地址写。
我在一个物联网设备上遇到过堆溢出。设备用malloc分配了一个JSON解析缓冲区,但没检查输入长度。攻击者发送了一个超长的JSON字符串,溢出的数据覆盖了下一个堆块的元数据。结果free的时候,系统把攻击者控制的地址写入了__free_hook。嗯,从那以后,我对所有外部输入都做了长度校验。
- 栈溢出:覆盖返回地址,直接控制执行流
- 堆溢出:篡改堆元数据,通过free/malloc间接控制
- 栈溢出利用难度较低,堆溢出需要更深入理解堆管理算法
10.3 防范措施
防范缓冲区溢出,说白了就两条路:一是编译时加保护,二是写代码时做检查。
10.3.1 编译保护
| 保护机制 | 作用 | 启用方式 |
|---|---|---|
| 栈保护(Stack Canary) | 在返回地址前插入一个随机值(canary),函数返回前检查是否被篡改 | -fstack-protector |
| 地址空间布局随机化(ASLR) | 随机化代码段、堆、栈的基地址,让攻击者猜不到地址 | 操作系统级,编译时加-pie |
| 数据执行保护(NX/DEP) | 标记栈和堆为不可执行,即使溢出也无法执行shellcode | 编译器默认启用,-z noexecstack |
| 地址无关代码(PIE) | 生成位置无关的可执行文件,配合ASLR使用 | -fPIE -pie |
我个人习惯,嵌入式项目编译时一定会加-fstack-protector-strong。这个选项只对包含局部数组的函数插入canary,性能开销很小。
10.3.2 编码规范
编译保护不是万能的。你想想看,如果代码里用了strcpy、sprintf、gets这些不安全的函数,再强的保护也可能被绕过。我总结了几条硬性规则:
- 永远不要用
gets——这个函数没有边界检查,C11标准已经把它删了 - 用
strncpy代替strcpy——但要注意strncpy不会自动加\0 - 用
snprintf代替sprintf——指定最大长度 - 用
fgets代替gets——指定缓冲区大小 - 所有外部输入都要做长度校验——不要相信任何来自网络、文件、用户的数据
snprintf,代码审查时发现有人还是用了sprintf,理由是「这个缓冲区肯定够大」。嗯,后来我直接在代码规范里加了一条:使用不安全函数直接打回重审。
10.4 安全函数使用
C11标准引入了_s后缀的安全函数,比如strcpy_s、strcat_s、sprintf_s。这些函数会检查缓冲区大小,如果发现溢出会调用约束处理函数(默认是abort)。
看个对比:
// 不安全的写法
char buf[10];
strcpy(buf, "这个字符串肯定超过10个字节了");
// 安全的写法(C11)
char buf[10];
errno_t err = strcpy_s(buf, sizeof(buf), "这个字符串肯定超过10个字节了");
if (err != 0) {
// 处理错误,不会发生溢出
}
不过要注意,安全函数不是所有平台都支持。嵌入式环境里,很多编译器没有实现_s系列函数。我一般自己封装一套安全宏:
#ifdef __STDC_LIB_EXT1__
#define SAFE_STRCPY(dst, dstsz, src) strcpy_s(dst, dstsz, src)
#else
// 手动实现安全复制
#define SAFE_STRCPY(dst, dstsz, src) do { \
size_t srclen = strlen(src); \
if (srclen >= dstsz) { \
/* 溢出处理 */ \
memcpy(dst, src, dstsz - 1); \
dst[dstsz - 1] = '\0'; \
} else { \
memcpy(dst, src, srclen + 1); \
} \
} while(0)
#endif
这样写的好处是:平台支持就用标准安全函数,不支持就自己兜底。代码可移植性更好。
strcpy_s(buf, wrong_size, src)。所以,sizeof一定要用对,数组名和指针的sizeof结果不一样。
最后说一句,缓冲区溢出是C语言的老大难问题。编译保护、安全函数、编码规范,这三层缺一不可。我见过太多项目只依赖编译保护,结果换个编译器版本就出问题。也见过只靠编码规范,结果新人一上来就写了个gets。三层都做到位,才能睡得安稳。