3. 栈缓冲区溢出原理:栈帧结构

栈缓冲区溢出,这名字听着挺唬人。说白了,就是程序在栈上写数据的时候,写过头了,把不该碰的地方给碰了。

我刚开始学C语言那会儿,总觉得栈就是个放局部变量的地方,没啥特别的。直到有一次,我写了个网络服务程序,跑着跑着就崩了,而且崩得毫无规律。查了三天,最后发现是一个strcpy把用户输入直接拷到了一个只有16字节的缓冲区里。嗯,从那以后,我再也不敢小看栈了。

3.1 栈帧长什么样?

每次函数被调用,系统都会在栈上给它分配一块“地盘”,这块地盘就叫栈帧。你想想看,一个函数要干活,总得有个地方放它的局部变量、参数,还得记住它执行完了该回哪儿去,对吧?

一个典型的栈帧,从高地址到低地址,大致长这样:

高地址
+------------------+
| 函数参数(从右往左压栈) |
+------------------+
| 返回地址(Return Address)|  ← 关键!函数执行完跳回这里
+------------------+
| 保存的EBP(旧栈底指针)  |  ← 用于恢复上一个栈帧
+------------------+
| 局部变量区域           |  ← 你的数组、临时变量都在这儿
+------------------+
低地址

这里我要强调一下:返回地址是攻击者的终极目标。谁控制了返回地址,谁就控制了程序的执行流。

核心要点:栈的生长方向是从高地址向低地址,但缓冲区的写入是从低地址向高地址。这个方向上的“错位”,就是漏洞的根源。

3.2 局部变量、EBP、返回地址的布局

咱们写个简单的例子来看看:

void vulnerable(char *input) {
    char buffer[16];   // 局部变量,16字节
    int flag = 0;      // 局部变量,4字节
    
    strcpy(buffer, input);  // 危险!没有长度检查
    
    if (flag != 0) {
        printf("你改写了flag!\n");
    }
}

这个函数在栈上的布局,我画个图你就明白了:

栈帧布局示意图(x86 32位) 高地址 低地址 返回地址(4字节) 攻击目标 保存的EBP(4字节) flag(4字节) buffer[16](16字节) 写入方向

看到没?buffer[16]在最底下(低地址),往上依次是flag、保存的EBP、返回地址。你往buffer里写数据,是从低地址往高地址写。一旦超过16字节,就会先覆盖flag,再覆盖EBP,最后——嗯,返回地址就遭殃了。

我的经验:在实际项目中,我见过有人把局部变量声明顺序调一下,试图“保护”返回地址。比如把flag声明在buffer前面。但编译器优化一开,变量顺序可能就变了。别指望这种小聪明,老老实实做边界检查才是正道。

3.3 溢出如何覆盖返回地址

好,现在咱们来模拟一下攻击过程。假设用户输入了这样一串数据:

输入: "AAAAAAAABBBBBBBBCCCCDDDD\xef\xbe\xad\xde"
       |---16字节---||-4-||-4-||--返回地址--|
       buffer        flag  EBP  覆盖为0xDEADBEEF

strcpy执行时,数据会这样填充:

  1. 前16字节:填满buffer[0]buffer[15]
  2. 接下来4字节:覆盖flag变量。如果原来flag是0,现在变成非0,程序逻辑就可能被绕过
  3. 再接下来4字节:覆盖保存的EBP。函数返回时EBP恢复成这个值,后续栈操作会乱套
  4. 最后4字节:覆盖返回地址。函数执行ret指令时,CPU会跳转到这个地址

为什么会这样?因为strcpy根本不关心目标缓冲区有多大,它只管一直拷贝,直到遇到\0才停。你想想看,这多危险。

警告:千万别在生产代码里用strcpysprintfgets这些不检查长度的函数。我见过太多因为strcpy导致的漏洞了。用strncpysnprintffgets这些带长度参数的函数,虽然麻烦点,但安全。

3.4 经典攻击流程演示

一个完整的栈溢出攻击,通常分四步走:

步骤 操作 说明
1. 漏洞定位 找到存在溢出的函数 比如strcpysprintfgets
2. 偏移计算 确定从缓冲区到返回地址的字节数 用调试器或模式字符串(如AAAABBBB...)测试
3. 构造载荷 准备攻击数据 填充数据 + 覆盖EBP + 目标地址 + 可能还有shellcode
4. 触发执行 将载荷发送给程序 程序处理输入时触发溢出,劫持控制流

咱们写个完整的攻击示例:

// 漏洞程序(victim.c)
#include <stdio.h>
#include <string.h>

void secret_function() {
    printf("你成功劫持了控制流!\n");
    // 这里可以执行任意操作
}

void vulnerable(char *input) {
    char buffer[16];
    strcpy(buffer, input);  // 溢出点
    printf("buffer内容: %s\n", buffer);
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("用法: %s <输入>\n", argv[0]);
        return 1;
    }
    vulnerable(argv[1]);
    printf("正常返回main函数\n");
    return 0;
}

攻击者要做的,就是让程序执行secret_function而不是正常返回。假设在32位系统上,secret_function的地址是0x08048456,那么攻击载荷就是:

# 构造攻击输入
# 16字节填充 + 4字节覆盖EBP + 4字节返回地址
python -c "print('A'*16 + 'B'*4 + '\x56\x84\x04\x08')" > payload.txt

# 运行漏洞程序
./victim $(cat payload.txt)

运行结果:

buffer内容: AAAAAAAAAAAAAAAABBBB??
你成功劫持了控制流!

看到没?程序没有打印"正常返回main函数",而是直接跳到了secret_function。这就是典型的栈溢出攻击。

关键点:攻击成功的前提是攻击者知道目标函数的地址。在现代系统中,ASLR(地址空间布局随机化)会让地址每次加载都变化,增加攻击难度。但这不是万能的——攻击者可以通过信息泄露先拿到地址,或者用ROP(面向返回编程)绕过。

3.5 我踩过的坑

我曾经在一个嵌入式项目里,用了一个第三方库的网络解析函数。文档上说“输入长度不超过256字节”,我就没做检查。结果有个恶意设备发了个512字节的包,直接把我设备的栈给打穿了。那台设备在现场跑了三个月都没事,偏偏在客户验收那天崩了。嗯,你懂的,从那以后我养成了两个习惯:

  • 所有外部输入,一律做长度检查。不管文档怎么说,自己动手验证
  • 能用strncpy绝不用strcpy多写一个参数,少熬一个通宵

避坑指南:我曾经以为strncpy就安全了,结果发现它有个坑——如果源字符串比目标缓冲区长,它不会自动加\0结尾。所以用strncpy之后,一定要手动在最后一个位置写\0。我现在都直接用snprintf,它保证\0结尾,省心。

3.6 防御思路

说了这么多攻击,咱们也得聊聊怎么防。我个人建议从这几个层面入手:

  1. 代码层面:永远使用带长度限制的函数。这是最根本的
  2. 编译选项:开启栈保护(-fstack-protector),编译器会在栈上放一个“金丝雀值”,溢出时先破坏它,程序就能检测到并终止
  3. 系统层面:开启ASLR和NX(栈不可执行),让攻击者更难利用
  4. 运行时检测:用AddressSanitizer(-fsanitize=address)在调试阶段发现溢出

记住,安全不是靠某一个措施就能搞定的。深度防御,层层设防,才是正道。


公众号:蓝海资料掘金营,微信deep3321