12、缓冲区溢出:Stack Smashing的检测与防护

缓冲区溢出,尤其是栈溢出,是C语言里最经典、也最危险的漏洞之一。我入行那会儿,第一次听说“Stack Smashing”这个词,还以为是哪个黑客搞的恶作剧名字。后来自己写代码踩了坑,才明白这玩意儿有多要命。

说白了,缓冲区溢出就是往一个固定大小的内存块里塞了太多数据,数据溢出来,把旁边的内容给覆盖了。如果覆盖的是栈上的返回地址,那程序的控制权就可能被劫持。嗯,这就是攻击者最喜欢利用的点。

12.1 栈的结构与溢出原理

先看看栈长什么样。每次函数调用,系统都会在栈上压入一个“栈帧”。里面存了局部变量、函数的返回地址,还有可能存一些寄存器的值。

我画了一张图,帮你理解这个过程:

栈帧布局与缓冲区溢出示意图 高地址 函数参数(arg1, arg2, ...) 返回地址(Return Address) 保存的EBP(基址指针) 局部变量区(如 char buf[16]) 溢出方向 低地址 栈底方向 当 buf[16] 写入超过16字节时,数据会向上覆盖EBP和返回地址

看明白了吗?局部变量在低地址,返回地址在高地址。如果你往 buf[16] 里写超过16个字节,数据就会一路往上“涨”,先覆盖EBP,再覆盖返回地址。这就是栈溢出的本质。

核心要点:栈溢出攻击的目标,通常就是覆盖返回地址,让程序跳转到攻击者指定的恶意代码位置。

12.2 一个典型的溢出示例

我写个最简单的例子给你看看:

#include <stdio.h>
#include <string.h>

void vulnerable_func(const char *input) {
    char buf[16];  // 只有16字节
    strcpy(buf, input);  // 危险!没有长度检查
    printf("buf = %s\n", buf);
}

int main() {
    // 正常调用
    vulnerable_func("Hello");
    
    // 恶意调用 - 传入超长字符串
    char evil[] = "AAAAAAAAAAAAAAAA"   // 16字节填满buf
                  "BBBBBBBB"           // 覆盖EBP
                  "CCCCCCCC";          // 覆盖返回地址
    vulnerable_func(evil);
    
    return 0;
}

这段代码在大部分编译器上,运行到第二次调用时就会崩溃。如果攻击者精心构造那8个“C”,让它们指向一段恶意代码的地址,那程序就彻底被控制了。

警告:千万不要在实际项目里用 strcpy()sprintf()gets() 这些不检查长度的函数。我见过太多线上事故,都是这些“老朋友”惹的祸。

12.3 Stack Smashing 检测机制

现代编译器早就想好了对策。GCC 从 4.1 版本开始,默认开启了 -fstack-protector 选项。它的原理很简单:在栈帧的局部变量和返回地址之间,插入一个“金丝雀值”(canary)。

这个金丝雀值是一个随机数,在函数入口处写入,在函数返回前检查。如果发现被改写了,说明发生了溢出,程序立即终止。

我画个流程图,帮你理清这个检测逻辑:

Stack Smashing 检测流程 函数入口 写入金丝雀值 执行函数体 检查金丝雀值是否被修改 终止程序(报错) 正常返回

你看,这个机制其实不复杂。但它的效果非常好。当程序检测到金丝雀值被破坏时,会输出类似这样的信息:

*** stack smashing detected ***: <program name> terminated
Aborted (core dumped)

我个人习惯在开发阶段就开启这个保护。虽然会有一点点性能开销,但比起被黑客攻破,这点代价完全可以接受。

12.4 如何手动启用和配置

GCC 提供了几个相关的编译选项:

编译选项 作用
-fstack-protector 对包含大数组的函数启用保护
-fstack-protector-strong 对更多函数启用保护(推荐)
-fstack-protector-all 对所有函数启用保护(性能影响较大)
-fno-stack-protector 禁用保护(不推荐)

我建议你在项目中至少使用 -fstack-protector-strong。它在安全性和性能之间取得了很好的平衡。

小技巧:在 Makefile 或 CMakeLists.txt 里,把 -fstack-protector-strong 加到全局编译选项里。这样整个项目都能受益,不用每个文件单独设置。

12.5 除了编译器保护,我们还能做什么

编译器保护是最后一道防线。真正的好代码,应该从源头杜绝溢出。我总结了几条实战经验:

  • 使用安全版本的字符串函数:用 strncpy() 代替 strcpy(),用 snprintf() 代替 sprintf()。这些函数会限制写入长度。
  • 检查输入长度:在调用任何可能溢出的函数之前,先算清楚目标缓冲区有多大。
  • 使用动态分配:如果数据长度不确定,用 malloc() 分配足够大的内存,而不是在栈上开固定大小的数组。
  • 开启地址空间布局随机化(ASLR):这个操作系统级别的保护,能让攻击者更难猜出目标地址。

我曾经在一个嵌入式项目里,遇到过因为 sprintf() 拼接日志字符串导致栈溢出的bug。那会儿排查了整整两天,最后发现是日志缓冲区只开了128字节,但某条错误日志拼接后超过了200字节。从那以后,我写代码但凡涉及字符串拼接,第一反应就是检查缓冲区大小。

12.6 避坑指南

最后,分享几个我踩过的坑:

  • 别以为小数组就安全:哪怕只有4字节的缓冲区,只要输入不受控,一样能溢出。
  • 注意结构体中的数组:结构体里的 char array[32] 也是栈上的,同样会被溢出。
  • 递归函数要小心:递归深度太大,栈空间耗尽,也会导致溢出。虽然这不属于“Stack Smashing”的典型场景,但后果一样严重。
  • 不要完全依赖编译器:编译器保护不是万能的。有些优化选项可能会削弱保护效果。最好的策略是“编写安全代码 + 开启编译器保护”双管齐下。

一句话总结:缓冲区溢出是C语言的老问题,但绝不是解决不了的问题。写代码时多留个心眼,编译时开好保护,就能把风险降到最低。

好了,关于 Stack Smashing 的检测与防护,就聊到这里。记住,安全不是靠一个特性、一个函数就能搞定的,而是一种贯穿整个开发过程的习惯。