4. 缓冲区溢出漏洞原理:栈溢出、堆溢出与格式化字符串
大家好,我是你们这堂课的讲师。今天咱们聊点硬核的——缓冲区溢出。说实话,这玩意儿是漏洞利用的基石,也是我当年入坑逆向工程时啃得最久的一块骨头。你想想看,一个看似无害的“越界写入”,就能让整个系统沦陷,是不是很刺激?
咱们分三块来讲:栈溢出、堆溢出,还有那个让人又爱又恨的格式化字符串漏洞。嗯,开始吧。
4.1 栈溢出原理
栈溢出,说白了就是往栈上的一块缓冲区里写了太多数据,把隔壁邻居的地盘给占了。谁住在隔壁?返回地址、局部变量、栈帧指针……这些可都是关键角色。
我个人的习惯是,先看一个最朴素的例子:
void vulnerable_function(char *input) {
char buffer[64];
strcpy(buffer, input); // 没有长度检查!
// 函数返回时,返回地址可能已经被覆盖
}
这里 strcpy 不会管你 input 有多长,一股脑全拷进 buffer。如果 input 超过 64 字节,多出来的部分就会覆盖栈上更高的地址——包括函数的返回地址。
为什么会这样?因为栈的生长方向是从高地址向低地址,而缓冲区的写入是从低地址向高地址。你想想看,返回地址正好在缓冲区的高地址侧,这不就撞上了吗?
核心要点:栈溢出的本质是破坏了栈帧的完整性,攻击者通过控制返回地址,劫持程序执行流。
我在项目中遇到过一台思科设备,它的 HTTP 服务就存在类似的漏洞。攻击者发送一个超长的 POST 请求,就能让设备重启——嗯,当时只是 crash,还没到 RCE 的程度。但原理是一样的。
4.1.1 栈溢出的利用步骤
- 定位偏移量:用 pattern 字符串(如
AAAABBBBCCCC...)找到返回地址在缓冲区中的位置。 - 控制返回地址:将返回地址覆盖为攻击者指定的地址,比如 shellcode 的地址或 ROP gadget 的地址。
- 布置 shellcode:在缓冲区中嵌入可执行的机器码,或者利用现有代码片段(ROP)。
- 触发漏洞:函数返回时,CPU 跳转到攻击者控制的地址,执行恶意代码。
避坑指南:我曾经在调试一个 MIPS 架构的固件时,发现栈上还有 8 字节的“对齐空间”。如果你没考虑到这个,偏移量算出来永远是错的。记住,不同架构、不同编译选项,栈布局都可能不一样。
4.2 堆溢出原理
堆溢出比栈溢出稍微复杂一点。堆是动态分配的内存区域,由 malloc、free 这类函数管理。堆溢出发生在堆上的缓冲区越界写入时,它会破坏堆管理器的元数据。
你想想看,堆管理器怎么知道哪些内存是空闲的?它靠的是链表和元数据块。如果你把元数据给改了,堆管理器就会“迷路”,攻击者就能利用这个“迷路”的过程实现任意地址写。
void heap_vuln() {
char *a = malloc(64);
char *b = malloc(64);
strcpy(a, user_input); // 如果 user_input 超过 64 字节,就会覆盖 b 的元数据
free(b); // 触发异常或利用
}
这里 a 和 b 在堆上是相邻的。当 a 溢出时,会覆盖 b 前面的堆块头(chunk header)。free(b) 时,堆管理器会读取被篡改的元数据,执行 unlink 操作——这个 unlink 操作如果被精心构造,就能实现向任意地址写入任意值。
注意:现代堆管理器(如 glibc 的 ptmalloc)有很多保护机制,比如 safe unlink、tcache 等。但嵌入式设备上的堆管理器往往比较老旧,或者被精简过,保护较弱。我在分析某款路由器固件时,发现它用的还是 uClibc 的旧版堆管理器,safe unlink 检查形同虚设。
4.2.1 堆溢出的常见利用手法
| 手法 | 原理 | 适用场景 |
|---|---|---|
| unlink 攻击 | 伪造空闲块的双向链表指针,实现任意地址写 | 旧版堆管理器,无 safe unlink 检查 |
| use-after-free 配合 | 释放后指针未置空,再次使用已损坏的内存 | 对象生命周期管理混乱的 C++ 代码 |
| fastbin 攻击 | 篡改 fastbin 空闲链表,使 malloc 返回任意地址 | glibc 2.26 之前的版本 |
我个人觉得,堆溢出最考验的是对堆管理器内部结构的理解。你不仅要看懂漏洞代码,还得知道 malloc 和 free 在底层干了什么。嗯,这需要花时间调试。
4.3 格式化字符串漏洞
这个漏洞很有意思。它源于 printf 家族函数的一个“特性”——如果格式化字符串由用户控制,攻击者就能用它来读内存、写内存。
printf(user_input); // 错误!应该用 printf("%s", user_input);
为什么能读内存?因为 printf 会根据格式化参数(如 %x、%s、%n)从栈上取数据。如果你传入 %x%x%x%x,它就会把栈上的四个整数打印出来——这些可能是返回地址、局部变量、甚至函数指针。
为什么能写内存?因为 %n 这个格式化参数会把已经输出的字符数写入一个地址。配合 %<number>c 控制输出长度,就能实现任意地址写。
经典利用模式:
%p或%x泄露栈上数据%s读取任意地址的字符串(需要先控制栈上的指针)%n写入任意地址(需要精确控制输出字符数)
我在分析一个思科交换机的 Web 管理界面时,发现它的日志功能就存在格式化字符串漏洞。攻击者只要在 User-Agent 字段里塞入 %s%s%s%s,就能让设备崩溃——更高级的利用可以泄露内存中的密钥。
避坑指南:我曾经在 64 位系统上调试格式化字符串漏洞,发现参数传递方式变了。32 位下参数全在栈上,64 位下前 6 个参数在寄存器里(rdi, rsi, rdx, rcx, r8, r9),第 7 个才开始在栈上。这意味着你的 %x 顺序要重新调整。嗯,这个坑我踩过两次才记住。
4.4 三种漏洞的对比与选择
| 漏洞类型 | 利用难度 | 保护机制影响 | 常见场景 |
|---|---|---|---|
| 栈溢出 | 中等 | ASLR、NX、Stack Canary 影响大 | 字符串处理函数(strcpy, sprintf) |
| 堆溢出 | 较高 | ASLR、堆保护机制影响大 | 网络协议解析、对象管理 |
| 格式化字符串 | 较低(读)/ 中等(写) | ASLR 影响较小,但 RELRO 影响大 | 日志输出、错误处理 |
你想想看,在实际的漏洞利用中,这三种漏洞往往不是孤立存在的。我见过一个案例:先用格式化字符串泄露栈地址和 libc 基址,再用栈溢出控制返回地址,最后用 ROP 链绕过 NX。说白了,组合拳才是王道。
最后提醒一句:嵌入式设备的固件往往没有开启全部保护机制。很多思科设备的老版本固件,连 Stack Canary 都没有。这意味着栈溢出的利用门槛会低很多。但别高兴太早——它们的 CPU 架构可能是 MIPS 或 ARM,指令集和 x86 完全不同,ROP 链的构造方式也要重新学。
好了,这一章的内容就到这里。三种漏洞的原理你都了解了,接下来我们会深入每种漏洞的实际利用技巧。记住,理论是基础,但真正的功夫在调试器里。
公众号:蓝海资料掘金营,微信deep3321