栈溢出 (Stack Overflow / Stack Buffer Overflow) 是二进制安全中最基础、最经典,也是最核心的内存破坏漏洞。它是所有 ROP、BROP、Ret2Reg 等高级攻击技术的根基。

简单来说,它的原理是:向栈上的缓冲区写入了超过其容量的数据,导致数据“溢出”覆盖了栈上相邻的关键信息(如函数的返回地址),从而劫持程序控制流。

1. 预备知识:函数调用栈的布局

要理解栈溢出,必须先理解内存中栈(Stack)的结构。

  • 生长方向:栈在内存中是从高地址向低地址生长的。
  • 填充方向:当我们向缓冲区(数组)写入数据时,是从低地址向高地址写的(索引增加)。
  • 栈帧(Stack Frame):每个函数调用时都会在栈上开辟一块区域。一个典型的栈帧包含以下部分(从高地址到低地址排列):
内存方向内容说明
高地址参数 (Parameters)函数调用传递的参数
$\downarrow$返回地址 (Return Address)攻击的核心目标。函数执行完后,CPU 应该跳转回去执行的地方。
Saved EBP/RBP保存调用者的栈基址指针
低地址局部变量 (Local Variables)函数内部定义的变量、数组(缓冲区)

2. 漏洞原理

根本原因

使用了不安全的数据拷贝函数(如 strcpy, gets, strcat, scanf 等),这些函数不检查源数据的长度,直接将数据写入目标缓冲区。

发生过程

  1. 正常情况:用户输入的数据长度小于缓冲区大小。数据乖乖待在局部变量区,函数执行完毕,通过 Saved EBP 恢复栈,取出 Return Address 跳回主程序。

  2. 溢出情况

    • 攻击者输入了超长的数据。
    • 数据填满了局部变量(缓冲区)
    • 数据继续向高地址写入,覆盖了 Saved EBP
    • 数据继续写入,覆盖了 返回地址 (Return Address)
    • (如果数据够长,还可以覆盖参数)。
  3. 劫持控制流

    • 当函数执行 ret (return) 指令时,CPU 会从栈上弹出一个值赋给指令指针寄存器 (EIP/RIP)。
    • 由于这个位置已经被攻击者修改为恶意的地址(比如 Shellcode 的地址或某个 ROP Gadget 的地址),CPU 就会跳转去执行恶意代码。

3. 一个具体的例子

漏洞代码 (vuln.c)

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

void success() {
    printf("Hacked! You got the shell.\n");
    // system("/bin/sh"); // 实际攻击中通常是想执行这个
}

void vulnerable_function(char *str) {
    char buffer[10];  // 只有 10 字节的缓冲区
    
    // 危险!strcpy 不检查 str 的长度
    strcpy(buffer, str); 
}

int main(int argc, char **argv) {
    if (argc > 1) {
        vulnerable_function(argv[1]);
    }
    return 0;
}

内存布局分析

假设编译为 32 位程序,当我们调用 vulnerable_function 时,栈的布局大致如下(地址仅为示意):

[ 高地址 0xffff0014 ]  Return Address (返回到 main 的地址)
[        0xffff0010 ]  Saved EBP (main 函数的 EBP)
[        0xffff0000 ]  Buffer[0] ~ Buffer[9] (10字节空间 + 对齐填充)
[ 低地址           ]

攻击 Paylaod 构造

我们需要计算从 buffer 开始,需要填充多少垃圾数据才能刚好触碰到 Return Address

  1. Buffer 区域:10 字节(通常编译器会进行内存对齐,可能会变成 12 或 16 字节,假设这里加上对齐一共需要填充 12 字节)。
  2. EBP 区域:4 字节。
  3. 目标:覆盖接下来的 4 字节(返回地址)。

构造 Payload: [ 垃圾数据 (12 + 4 = 16 字节) ] + [ success 函数的地址 ]

假设 success 函数的地址是 0x08048456

输入字符串:"AAAAAAAAAAAA" + "BBBB" + "\x56\x84\x04\x08"

执行流程

  1. strcpy 开始拷贝。
  2. buffer 被 ‘A’ 填满。
  3. Saved EBP 被 ‘B’ 覆盖(导致函数返回后上一级函数的栈帧错乱,但这通常不影响我们拿 Shell)。
  4. Return Address0x08048456 覆盖。
  5. vulnerable_function 执行结束,运行 ret 指令。
  6. CPU 弹出栈顶的 0x08048456,赋值给 EIP。
  7. 程序跳转到 success 函数执行,输出 “Hacked!”。

4. 栈溢出的利用方式

一旦能控制返回地址,攻击者可以做什么?

  1. 执行 Shellcode (Ret2Shellcode)

    • 将返回地址指向栈上的缓冲区地址。
    • 在缓冲区里放入机器码(Shellcode)。
    • 前提:栈必须有可执行权限(现在通常被 NX 保护防御)。
  2. 执行程序已有的函数 (Ret2Libc)

    • 将返回地址指向标准库(libc)中的 system 函数。
    • 在栈上布置好参数(指向 “/bin/sh” 字符串)。
    • 用于绕过 NX 保护。
  3. 代码复用攻击 (ROP/JOP)

    • 正如前面介绍的 BROP、ROP,利用程序现有的代码片段(Gadgets)拼凑出攻击逻辑。
    • 用于绕过 NX 和 ASLR。

5. 常见的防御机制

为了防止栈溢出,现代操作系统和编译器引入了多种保护措施:

  1. Canary (栈哨兵)

    • 原理:在 Saved EBPLocal Variables 之间插入一个随机值(Canary)。
    • 检测:函数返回前,检查这个值是否被修改。如果发生溢出,Canary 必然先被覆盖,程序检测到变化后立即终止报错(Stack Smashing Detected)。
    • 绕过方法:泄露 Canary(如利用格式化字符串漏洞),或爆破(BROP)。
  2. NX / DEP (No-Execute / Data Execution Prevention)

    • 原理:将栈内存标记为“不可执行”。
    • 效果:即使跳到了栈上的 Shellcode,CPU 也会抛出异常,拒绝执行。
    • 绕过方法:ROP / Ret2Libc。
  3. ASLR (Address Space Layout Randomization)

    • 原理:每次程序运行时,栈、堆、libc 的基地址都是随机的。
    • 效果:攻击者无法硬编码返回地址(不知道 system 函数或 Shellcode 的确切地址)。
    • 绕过方法:信息泄露(Information Leak)配合偏移量计算。

总结

栈溢出是由于缺乏边界检查导致的内存越界写入。虽然现代防御机制让直接的栈溢出攻击变得困难,但理解其原理(内存布局、函数调用约定、控制流劫持)是学习所有二进制漏洞利用技术的必经之路。