3、缓冲区溢出防御(上):栈缓冲区溢出原理、gets/strcpy/strcat等危险函数分析
缓冲区溢出,这玩意儿在嵌入式圈子里,简直就是悬在每个工程师头顶的一把剑。我入行那会儿,带我的老工程师第一句话就是:「小子,写代码前先想想你的缓冲区够不够大,别让数据把家给拆了。」
说白了,缓冲区溢出就是程序向缓冲区写入的数据超过了它原本的容量,多出来的数据就会「溢出」到相邻的内存区域。你想想看,这就像你往一个杯子里倒水,满了还不收手,水就会流到桌子上,弄湿你的图纸和芯片。
在嵌入式系统里,后果往往更严重——轻则程序崩溃,重则被攻击者利用,直接拿下设备控制权。我见过太多因为一个 strcpy 导致整个产品返工的案例了。
3.1 栈缓冲区溢出原理
先聊聊栈(Stack)这个东西。每个函数被调用时,系统都会在栈上给它分配一块内存,叫「栈帧」。里面放着局部变量、函数参数,还有最重要的——返回地址。
返回地址是什么?就是函数执行完后,CPU该跳回哪里继续干活。这个地址一旦被篡改,程序就失控了。
来看个典型的例子:
void vulnerable_function() {
char buffer[8]; // 一个8字节的局部数组
gets(buffer); // 从标准输入读取数据
printf("输入的内容是: %s\n", buffer);
}
这段代码有什么问题?buffer 只有8个字节,但 gets() 完全不检查输入长度。你输入9个字符,第9个字符就会溢出到 buffer 后面的内存区域。
栈上的布局大致是这样的(从高地址到低地址):
| 栈地址(高→低) | 内容 | 说明 |
|---|---|---|
| 高地址 | 返回地址 | 函数执行完跳回哪里 |
| ↓ | EBP(基址指针) | 保存的上一帧基址 |
| ↓ | buffer[7..0] | 8字节的局部数组 |
| 低地址 | 其他局部变量 | 如果有的话 |
当你输入超过8个字符时,数据会从 buffer[0] 开始填,填满8个字节后,继续往高地址写——先覆盖掉保存的EBP,再覆盖返回地址。攻击者精心构造输入,就能让返回地址指向恶意代码。
3.2 gets()——最危险的函数之一
gets() 这个函数,我建议你直接把它从你的代码库里「物理删除」。为什么?因为它从标准输入读取一行数据,但完全不检查缓冲区大小。
C标准委员会其实早就意识到这个问题了,在C11标准里直接把它标记为「废弃」并移除了。但很多老旧的嵌入式工具链还在用,这就埋下了隐患。
我曾经接手过一个项目,前任工程师在串口通信里用了 gets() 来接收命令。结果呢?只要有人往串口发一个超过256字节的数据包,整个系统就崩溃。更可怕的是,如果攻击者知道栈布局,完全可以植入一段shellcode,远程控制设备。
正确的做法是什么?用 fgets() 替代:
// 错误用法
char cmd[64];
gets(cmd); // 危险!输入超过64字节就溢出
// 正确用法
char cmd[64];
if (fgets(cmd, sizeof(cmd), stdin) != NULL) {
// 安全处理输入
cmd[strcspn(cmd, "\n")] = '\0'; // 去掉换行符
}
fgets() 会限制读取的字节数,最多读 sizeof(cmd) - 1 个字符,然后自动补上字符串结束符 '\0'。这样就算输入再长,也不会溢出。
fgets() 之后总是手动处理换行符。因为 fgets() 会保留换行符,而很多字符串处理函数(比如 strcmp())会因此出问题。用 strcspn() 替换换行符是个干净利落的做法。
3.3 strcpy()——字符串拷贝的陷阱
strcpy() 是另一个「惯犯」。它的工作很简单:把源字符串拷贝到目标缓冲区,直到遇到 '\0' 为止。但问题来了——它不检查目标缓冲区有多大。
看这个例子:
void process_name(char *user_input) {
char name[32];
strcpy(name, user_input); // 如果user_input超过32字节,就溢出
// 处理name...
}
你可能会想:「我保证输入不会超过32字节不就行了?」嗯,我在项目里也听过这种保证。但现实是,需求会变,代码会改,接手的人不一定知道这个「潜规则」。一旦某个上游函数传入了超长字符串,灾难就发生了。
安全的替代方案是 strncpy() 或 snprintf():
// 使用strncpy(但要注意它不会自动加'\0')
char name[32];
strncpy(name, user_input, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0'; // 手动确保字符串结束
// 或者用snprintf,我个人更推荐这个
char name[32];
snprintf(name, sizeof(name), "%s", user_input); // 自动截断并加'\0'
strncpy() 有个坑——如果源字符串长度大于等于目标缓冲区大小,它不会自动添加 '\0'。所以你必须手动在最后一个位置放一个 '\0'。而 snprintf() 会自动处理截断和结束符,用起来更省心。
3.4 strcat()——字符串拼接的隐患
strcat() 和 strcpy() 是「难兄难弟」。它把源字符串追加到目标字符串的末尾,同样不检查目标缓冲区剩余空间。
举个例子:
void build_path(char *base, char *filename) {
char full_path[128];
strcpy(full_path, base); // 第一次拷贝
strcat(full_path, filename); // 追加,可能溢出!
// 使用full_path...
}
假设 base 是 "/home/user/documents/",已经占了22个字节。如果 filename 是 "a_very_long_filename_that_exceeds_the_remaining_space.txt",那 strcat() 就会把数据写到 full_path 之外。
安全的做法是用 strncat():
char full_path[128];
size_t remaining = sizeof(full_path);
// 先拷贝base
strncpy(full_path, base, remaining - 1);
full_path[remaining - 1] = '\0';
remaining -= strlen(full_path);
// 再追加filename,限制长度
strncat(full_path, filename, remaining - 1);
或者更简洁地用 snprintf() 一步到位:
char full_path[128];
snprintf(full_path, sizeof(full_path), "%s%s", base, filename);
strcat() 拼接多个字段构造数据包。当时测试人员随便发了个长数据包,协议栈就直接崩了。后来查出来,就是因为 strcat() 没有做长度检查。修复方案是把所有 strcat() 替换成了 snprintf(),顺便还重构了整个数据包构造逻辑。
3.5 其他危险函数一览
除了上面三个,还有几个函数也是缓冲区溢出的「常客」:
| 危险函数 | 问题描述 | 安全替代方案 |
|---|---|---|
sprintf() |
格式化输出到缓冲区,不检查长度 | snprintf() |
vsprintf() |
可变参数版本的sprintf | vsnprintf() |
scanf() / sscanf() |
使用 %s 格式时不限制输入长度 |
使用 %Ns 指定最大长度,或改用 fgets() |
realpath() |
某些实现中缓冲区大小参数可能被忽略 | 使用 realpath() 的 NULL 参数版本(POSIX.1-2008) |
bcopy() |
老式内存拷贝,不检查边界 | memcpy_s() 或 memmove() |
嗯,这里要注意:很多嵌入式开发环境可能不支持C11的 _s 安全函数(比如 strcpy_s)。这时候 snprintf() 和 strncat() 就是你的好帮手。它们虽然不是C11标准的安全函数,但几乎所有平台都支持,而且用起来足够安全。
3.6 防御策略总结
说了这么多危险,咱们来点实际的防御措施:
- 禁用危险函数:在代码规范里明确禁止使用
gets()、strcpy()、strcat()、sprintf()等函数。可以在编译时用-Werror=format-security等选项来强制检查。 - 使用安全替代:统一用
snprintf()、strncpy()、strncat()、fgets()等带长度限制的函数。 - 静态代码分析:在CI流程中加入静态分析工具(比如
cppcheck、Coverity),自动检测缓冲区溢出风险。 - 栈保护机制:开启编译器的栈保护选项(如GCC的
-fstack-protector-strong),在栈帧中插入canary值,检测溢出。 - 代码审查:每次代码审查时,重点关注字符串操作和内存拷贝的地方。我个人的经验是,90%的缓冲区溢出漏洞都出在这些地方。
好了,这一章我们聊了栈缓冲区溢出的原理,以及 gets()、strcpy()、strcat() 这几个「危险分子」。下一章我会继续讲堆缓冲区溢出、格式化字符串漏洞,以及更高级的防御技术。到时候咱们再细聊。