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,链接时没报错,运行时随机崩溃。排查了整整两天,最后用反汇编才看出来。
__stdcall,在 Linux 上默认 cdecl 就行。别偷懒,否则坑的是自己。
3.3 返回地址覆盖:劫持程序控制流
这是缓冲区溢出的核心攻击手法。攻击者通过向缓冲区写入超出其容量的数据,一路覆盖到栈帧中保存的返回地址。当函数执行完毕,执行 ret 指令时,CPU 会从栈上弹出返回地址,然后跳转到那里执行。
如果返回地址被改成了攻击者指定的值——比如指向 Shellcode 的地址——那程序就“被跳转”了。
一个经典的攻击流程:
- 程序定义了一个固定大小的缓冲区,比如
char buf[64]。 - 程序从用户输入读取数据,但没有检查长度,直接
strcpy(buf, user_input)。 - 攻击者构造一个超长输入:前64字节填充缓冲区,接着8字节覆盖保存的 EBP,再接着8字节覆盖返回地址。
- 返回地址被改成 Shellcode 的起始地址。
- 函数返回时,CPU 跳转到 Shellcode 执行。
// 一个典型的脆弱代码示例
void vulnerable() {
char buf[64];
printf("请输入数据:");
gets(buf); // 危险!没有长度限制
printf("你输入了:%s\n", buf);
}
这段代码,我建议任何生产环境都不要出现。gets() 函数在 C11 标准中已经被正式移除,但很多老旧代码里还有。我记得有一次做代码审计,一个金融系统的登录模块里居然用了 gets(),我当时就倒吸一口凉气。
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字节对齐,方便压栈。这些都是前辈们踩坑踩出来的经验。
3.5 知识体系总览
下面这张图梳理了本章的核心逻辑。我建议你把它保存下来,每次遇到缓冲区溢出相关的问题时,对照着看一遍,思路会清晰很多。
这张图把四个核心概念串在了一起。你从“缓冲区溢出”出发,往左走是栈帧结构和返回地址覆盖——这是漏洞的成因和利用方式;往右走是函数调用约定和 Shellcode 注入——这是攻击者需要掌握的工具和载荷。四者缺一不可。
公众号:蓝海资料掘金营,微信deep3321