3. 缓冲区溢出原理:栈帧结构、函数调用约定、返回地址覆盖、Shellcode注入的基本概念

缓冲区溢出,这个名词在安全圈里提起来,老工程师们都会会心一笑。为什么?因为它是经典中的经典,也是无数漏洞的根源。我入行时调试的第一个崩溃,就是缓冲区溢出导致的。那时候我还傻乎乎地以为是硬件问题,查了三天……嗯,从那以后,我再也不敢小看栈上的那点内存了。

说白了,缓冲区溢出就是程序往缓冲区里写数据时,写过头了,把隔壁邻居的内存给覆盖了。这个“邻居”是谁?可能是局部变量,可能是函数返回地址,甚至可能是函数指针。一旦返回地址被覆盖,程序的控制流就被劫持了。这就是攻击者最想要的东西。

3.1 栈帧结构:函数执行时的“临时工位”

每个函数被调用时,操作系统都会在栈上给它分配一块区域,这就是栈帧。栈帧里放着什么?局部变量、保存的寄存器、返回地址,还有上一个栈帧的基址指针。我习惯把栈帧想象成一个“临时工位”——函数干完活,工位就回收了。

栈的生长方向是从高地址向低地址。也就是说,栈顶在低地址,栈底在高地址。这一点很重要,因为缓冲区溢出往往是向高地址方向覆盖。

一个典型的栈帧结构长这样:

高地址
+------------------+
| 调用者的栈帧      |
+------------------+
| 返回地址          |  ← 攻击者最爱的目标
+------------------+
| 保存的 EBP        |  ← 上一个栈帧基址
+------------------+
| 局部变量          |  ← 缓冲区通常在这里
| (例如 char buf[64]) |
+------------------+
| 栈顶 (ESP)        |
低地址

我在项目中遇到过一个问题:一个同事在局部变量里定义了一个大数组,然后又定义了几个控制变量。结果数组越界写,直接把控制变量的值给改了,程序逻辑完全跑偏。调试了一下午才发现,其实就是溢出了一个字节。

⚠️ 注意: 局部变量的布局顺序由编译器决定,不同编译器、不同优化级别下,变量的排列可能不同。不要假设变量在栈上的相对位置。

3.2 函数调用约定:谁负责清理栈?

函数调用约定,说白了就是调用者和被调用者之间的一份“合同”。合同规定了:参数怎么传(用寄存器还是栈)、谁负责清理栈、返回值放哪里。

常见的调用约定有几种:

约定名称 参数传递方式 栈清理者 常见平台
cdecl 从右向左压栈 调用者 x86 上的 C 函数
stdcall 从右向左压栈 被调用者 Win32 API
fastcall 前两个参数用寄存器,其余压栈 被调用者 部分编译器优化
System V AMD64 前6个参数用寄存器 调用者 Linux x86-64

你想想看,如果调用约定不匹配,会发生什么?栈指针错乱,程序必崩。我曾经在接手一个遗留项目时,发现有的函数用 cdecl 声明,有的用 stdcall,链接时没报错,运行时随机崩溃。排查了整整两天,最后用反汇编才看出来。

💡 小技巧: 写跨平台代码时,显式声明调用约定是个好习惯。比如在 Windows 上用 __stdcall,在 Linux 上默认 cdecl 就行。别偷懒,否则坑的是自己。

3.3 返回地址覆盖:劫持程序控制流

这是缓冲区溢出的核心攻击手法。攻击者通过向缓冲区写入超出其容量的数据,一路覆盖到栈帧中保存的返回地址。当函数执行完毕,执行 ret 指令时,CPU 会从栈上弹出返回地址,然后跳转到那里执行。

如果返回地址被改成了攻击者指定的值——比如指向 Shellcode 的地址——那程序就“被跳转”了。

一个经典的攻击流程:

  1. 程序定义了一个固定大小的缓冲区,比如 char buf[64]
  2. 程序从用户输入读取数据,但没有检查长度,直接 strcpy(buf, user_input)
  3. 攻击者构造一个超长输入:前64字节填充缓冲区,接着8字节覆盖保存的 EBP,再接着8字节覆盖返回地址。
  4. 返回地址被改成 Shellcode 的起始地址。
  5. 函数返回时,CPU 跳转到 Shellcode 执行。
// 一个典型的脆弱代码示例
void vulnerable() {
    char buf[64];
    printf("请输入数据:");
    gets(buf);  // 危险!没有长度限制
    printf("你输入了:%s\n", buf);
}

这段代码,我建议任何生产环境都不要出现。gets() 函数在 C11 标准中已经被正式移除,但很多老旧代码里还有。我记得有一次做代码审计,一个金融系统的登录模块里居然用了 gets(),我当时就倒吸一口凉气。

🔴 核心要点: 返回地址覆盖的本质是破坏了栈上的控制流数据。防御思路有两个方向:一是阻止溢出发生(边界检查),二是让溢出无法篡改返回地址(栈保护、ASLR)。

3.4 Shellcode注入:让程序执行攻击者的代码

Shellcode 是一段精心编写的机器码,通常用于启动一个 shell(因此得名),但也可以是任何恶意操作——比如添加用户、下载文件、弹回一个反向连接。

Shellcode 有几个特点:

  • 短小精悍: 通常只有几十到几百字节,因为缓冲区空间有限。
  • 不能有坏字符: 比如 0x00(空字符),因为很多字符串函数遇到 0x00 就停止复制了。
  • 位置无关: Shellcode 不知道自己会被加载到哪个地址,所以必须写成位置无关代码(PIC)。

一个经典的 Linux x86 Shellcode(执行 /bin/sh)大概长这样:

; 汇编伪代码
xor eax, eax        ; 清空 eax
push eax            ; 字符串结束符 '\0'
push 0x68732f2f     ; "//sh"
push 0x6e69622f     ; "/bin"
mov ebx, esp        ; ebx 指向 "/bin//sh"
xor ecx, ecx        ; ecx = 0 (argv = NULL)
xor edx, edx        ; edx = 0 (envp = NULL)
mov al, 0x0b        ; execve 系统调用号
int 0x80            ; 触发系统调用

你可能会问:为什么是 //sh 而不是 /sh?因为 // 在路径解析时等价于 /,而且多一个斜杠可以凑齐4字节对齐,方便压栈。这些都是前辈们踩坑踩出来的经验。

⚠️ 警告: 现代操作系统普遍启用了 NX(No-Execute)保护,栈上的数据不可执行。单纯注入 Shellcode 已经很难成功了。攻击者通常会结合 ROP(Return-Oriented Programming)来绕过 NX。但理解 Shellcode 注入的基本原理,仍然是学习漏洞利用的必修课。

3.5 知识体系总览

下面这张图梳理了本章的核心逻辑。我建议你把它保存下来,每次遇到缓冲区溢出相关的问题时,对照着看一遍,思路会清晰很多。

缓冲区溢出原理知识体系 缓冲区溢出 栈帧结构 函数调用约定 返回地址覆盖 Shellcode注入 局部变量 返回地址 cdecl stdcall 劫持程序控制流 位置无关、无坏字符、短小

这张图把四个核心概念串在了一起。你从“缓冲区溢出”出发,往左走是栈帧结构和返回地址覆盖——这是漏洞的成因和利用方式;往右走是函数调用约定和 Shellcode 注入——这是攻击者需要掌握的工具和载荷。四者缺一不可。

📌 我的建议: 学习这部分内容时,最好动手调试一下。用 gdb 单步跟踪一个简单的溢出程序,亲眼看看返回地址是怎么被覆盖的。纸上得来终觉浅,绝知此事要躬行。

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