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,再覆盖返回地址。攻击者精心构造输入,就能让返回地址指向恶意代码。

⚠️ 注意: 栈缓冲区溢出是最经典的攻击方式。我曾在某款物联网设备上发现过类似漏洞,攻击者只需要发送一个超长的MQTT消息,就能让设备执行任意代码。修复起来相当麻烦,因为整个通信协议都得重新设计。

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 防御策略总结

说了这么多危险,咱们来点实际的防御措施:

  1. 禁用危险函数:在代码规范里明确禁止使用 gets()strcpy()strcat()sprintf() 等函数。可以在编译时用 -Werror=format-security 等选项来强制检查。
  2. 使用安全替代:统一用 snprintf()strncpy()strncat()fgets() 等带长度限制的函数。
  3. 静态代码分析:在CI流程中加入静态分析工具(比如 cppcheckCoverity),自动检测缓冲区溢出风险。
  4. 栈保护机制:开启编译器的栈保护选项(如GCC的 -fstack-protector-strong),在栈帧中插入canary值,检测溢出。
  5. 代码审查:每次代码审查时,重点关注字符串操作和内存拷贝的地方。我个人的经验是,90%的缓冲区溢出漏洞都出在这些地方。
🔑 核心原则: 永远不要相信外部输入的长度。不管是来自用户、网络、文件还是其他外设,所有输入都要做长度检查。这是嵌入式安全的第一道防线,也是最后一道防线。

好了,这一章我们聊了栈缓冲区溢出的原理,以及 gets()strcpy()strcat() 这几个「危险分子」。下一章我会继续讲堆缓冲区溢出、格式化字符串漏洞,以及更高级的防御技术。到时候咱们再细聊。