这周把CTFshow前置基础的pwn32到pwn34一口气刷完了。说实话,这三题在题目列表里看着也就是入门关,但真正动手之后我发现,“前置基础”这四个字把好多该打的地基都藏在了里面。很多人在pwn入门时容易一上来就冲ret2libc,遇到canary、PIE这些保护就懵;其实回到这组题里慢慢拆一遍,很多困惑是能自然解开的。这篇不打算整理成每一步从零开始的教程,而是按我实际做题的顺序,把pwn32、pwn33、pwn34这三道题从拿到文件到打通flag的过程、中间踩过的坑,以及为什么是这么构造payload的原因,一次性讲清楚。适合正在刷CTFshow入门序列、学过一点汇编但还没能把栈利用串起来的同学参考。
1. pwn32:用一道ret2text把栈布局彻底过一遍
1.1 拿到文件的三个常规动作
先别急着上payload。任何pwn题,拿到文件后走三件事:file看文件格式、checksec看保护、IDA反汇编找漏洞点。
file pwn32 pwn32: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not strippedchecksec --file=pwn32 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个保护组合对入门来说非常舒服:没有canary,意味着覆盖栈上返回地址不需要担心“金丝雀”校验;没有PIE,意味着IDA里看到的函数地址可以直接写进payload;NX开启,意味着我们不能简单地把shellcode塞到栈上执行,所以需要找程序里现成的后门函数。
用IDA打开后的结构也很典型。main调用了vuln,vuln里定义了一个16字节的缓冲区,然后用gets读入。gets是这个漏洞链的核心,因为它不做长度检查,可以一直往缓冲区里写,直到遇到换行符。题目里还放了一个明显故意设计的函数,里面调用了system("/bin/sh"),或者是直接读flag的系统调用。这种“故意留后门”的题,正是后面所有ret2libc、ROP题的简化版——本质都是劫持控制流,只不过这里劫持到一个现成地址。
1.2 从buf到返回地址的距离
IDA里看到buf大小是0x10,很多人就直接认为偏移是0x10,然后覆盖返回地址。这是入门阶段最容易错的一步。缓冲区低地址在栈上,返回地址在高地址,两者之间还隔着被保存的rbp。所以在x64下,如果buf大小是n,从buf到返回地址的偏移通常是n+8,如果编译器插入了其他变量或者做了对齐,还要另算。
pwn32这个题里,n=16,所以理论上偏移是24字节。但我不会只靠理论,而是用cyclic验证:
cyclic 100跑gdb,run后输入这串pattern,程序崩掉时看rip:
RIP: 0x6161616c ('laaa')然后:
cyclic -l 0x6161616c 24得到同样的24。这里要强调的是:cyclic的偏移和IDA静态推算必须互相印证。很多题目如果加了alloca动态分配栈空间,或者编译器做了奇怪的对齐,静态看到的偏移会和实际不一致,这时候以动态实测为准。
顺便说一句栈的方向问题。栈是从高地址向低地址生长的,所以局部变量buf在低地址,被保存的rbp在上面,返回地址在更上面。覆盖的时候,payload从buf开始,一路往高地址方向写,先填满buf,再盖掉rbp,最后才能碰到返回地址。很多人一开始搞反方向,老是觉得自己偏移差了个符号,其实就是没把这张图在脑子里立起来。
1.3 后门函数地址与payload组装
IDA反汇编窗口里找到后门函数地址,比如0x4011d6。别直接双击复制就完事,去符号表确认一次,或者在gdb里info functions看一眼:
gdb -batch -ex "info functions" ./pwn32确认无误后,用一个pwn脚本组装payload:
from pwn import * elf = ELF('./pwn32') context.arch = 'amd64' context.log_level = 'debug' p = process('./pwn32') win = 0x4011d6 payload = b'A' * 24 + p64(win) p.sendline(payload) p.interactive()跑起来以后,程序把控制流劫持到system("/bin/sh"),获得shell。这里有个细节值得讲:如果后门函数是system("/bin/sh"),你需要在payload后多按几次回车,因为题目进程可能已经读到EOF退出,但通过管道连接的shell还是能用的。
1.4 为什么有的题要额外加一条ret
我做pwn32时本地没遇到问题,但后来拿同样思路去打另外一道题,同样的偏移、同样的system,却一直段错误。排查半天发现是栈对齐问题。x86-64调用约定要求调用函数时栈指针按16字节对齐,某些glibc版本的system内部会用到movaps这类要求对齐的指令,如果不满足就直接崩溃。解决办法是在调用system之前,先ret一次,把栈指针额外挪8字节。
ret = 0x40101a # 一个ret gadget payload = b'A' * 24 + p64(ret) + p64(win)这个ret gadget可以用ROPgadget找,或者直接在IDA里找汇编指令为ret的地址。这也解释了为什么很多公开pwn题的exp里会出现一个看似无意义的ret——它不是没用,而是在帮system“校准”栈。
2. pwn33:格式化字符串先“偷”canary,再打ret2libc
2.1 这题为什么不能照搬上一题的思路
pwn33一checksec,保护情况立刻变了:
checksec --file=pwn33 Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)多了canary。canary也叫栈保护值,进入函数时程序会从fs:0x28(线程局部存储)取一个随机数放在rbp-8的位置,返回前检查它有没有被改过。如果不匹配,程序就调用__stack_chk_fail终止。这意味着上一题那种“直接24字节覆盖到返回地址”的payload在pwn33里会把canary一起覆盖掉,程序直接崩溃,根本走不到劫持控制流那一步。
所以要过这题,分两步走:第一步,想办法把canary原样泄露出来;第二步,覆盖返回地址时把刚泄露的canary值原封不动写回去,骗过检查。
2.2 找格式化字符串的“第几个参数”
打开IDA后能看到漏洞点非常经典:
char buf[32]; read(0, buf, 0x60); printf(buf);printf直接把用户输入当格式化字符串用,这就是格式化字符串漏洞。由于buf在栈上,而printf取参数时会按可变参数规则从寄存器、栈上依次取,所以我们可以通过“第几个参数”这种索引方式,把栈上任意位置的8字节内容用%p打印出来。
实际操作时,我一般先输入一组编号参数,快速定位输入内容本身在参数列表里的位置:
%1$p-%2$p-%3$p-%4$p-%5$p-%6$p-%7$p-%8$p-%9$p-%10$p输出里会露出一段以“0x7024”开头的值,那个就是用户输入在栈上的位置。把位置定下来之后,再去附近找canary。canary的特征很明显:一个8字节值,最低位(最右侧字节)是0x00。因为它保存时会以字符串截断符结束,同时这也是防止通过字符串读取直接把canary泄露出来的手段之一。所以在一堆输出里看到类似0x12d85cd700的值,基本可以确定就是它。
定位的时候还有一个耐心活:如果输入的buf本身离canary比较远,可能要从输入位置往上往下多扫几组%p,直到确认哪一个编号对应canary。扫的时候建议用脚本自动跑,手动一轮轮试很容易看花眼。
2.3 把canary塞回栈的正确姿势
找到canary在我们栈上的参数编号后,记下这个值,然后构造栈溢出的payload:
payload = b'A' * 24 + p64(canary) + b'B' * 8 + p64(ret) + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(vuln_addr)这里24还是从buf到canary的偏移,因为canary位于rbp-8,而缓冲区到rbp的距离不变。覆盖顺序是:先把原buf填满,然后原样写入泄露的canary,再写8字节的假rbp,之后才是返回地址。经常有人把canary写到rbp的位置,或者把rbp漏了,这两种情况都会让栈布局错位。
有一个非常容易翻车的点:泄露出来的canary是一个Python int,转成p64的时候要注意大小端字节序,p64自动处理了,但你别手抖把值写错。还有一种翻车是sendline时末尾的\n会被gets读进去,导致payload长度和预期不一致。如果漏洞点是gets,建议用sendline后检查日志,必要时改用send直接发送raw payload再手动补一个回车。
2.4 泄露libc地址并回到vuln二次利用
有了canary,只解决了一半。接下来要在不开PIE的程序里泄露libc地址,再次调用vuln,第二次溢出时跳system。
第一步payload让程序调用puts,参数是puts@got里的内容。puts@got存的是puts函数真实运行地址,把它打印出来,再减去本机或目标libc里puts的偏移,就得到libc基址:
libc_base = puts_addr - libc.symbols['puts'] system_addr = libc_base + libc.symbols['system'] bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))然后第二次发送payload时,把返回地址改成pop_rdi; ret,后面跟bin_sh_addr,再跳system_addr。pop_rdi; ret这个gadget在x64下非常常用,因为函数前六个参数是要放到寄存器里的,而system只需要一个参数,放在rdi即可。
x64下调用函数时,前6个整型参数依次放在rdi、rsi、rdx、rcx、r8、r9,剩下的放栈上。所以ROP链里每调用一个有参数的函数,几乎都要先找一个合适的gadget来设置寄存器。pwn33这个题里,puts只需要rdi,所以pop_rdi就够用;如果你的题目要调用read三个参数,那就得找pop rsi; pop r15; ret之类的gadget。
2.5 栈对齐这个问题在这里又出现了
第一次泄露时没注意,程序崩溃了。原因还是栈对齐。调用puts前,栈指针没有满足16字节对齐的要求,puts内部调用某个glibc函数时触发了movaps指令,直接SIGSEGV。解决方法和上一题一样:在调用puts之前先放一个ret gadget。
这也解释了一个很常见的现象:同样一份exp,有的人在本机能打通,你的机器上就打不通,运行环境、libc版本、甚至编译器版本不同都可能让对齐行为不一样。所以我现在的习惯是:凡是payload里要调用外部函数,都先放一个ret占位,能多稳定不少。
3. pwn34:三件套全开之后,利用链要这样搭
3.1 拿到pwn34先看:这不像是入门题
pwn34的checksec结果:
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabledcanary、NX、PIE全开,还开了Full RELRO。这个配置打起来难度一下就上来了:PIE开,意味着函数地址每次加载都随机化,不能像上一题那样写死;Full RELRO,意味着GOT表只读,不能改GOT;canary还是老问题,必须先泄露。
不过弄清楚之后会发现,题目本质还是那三件套:一个格式化字符串漏洞用来泄露,一个栈溢出点用来劫持控制流。多做几步地址计算而已。真正比赛里大多数栈题都是这种配置,所以pwn34其实是把入门和进阶之间的那道缝补上了。
3.2 同时把canary和PIE基址“偷”出来
思路还是格式化字符串,但这次要找的不止canary,还有程序本身的elf地址。怎么找?第一次调试时,先在vuln函数下断点,看栈上有哪些值属于程序本身。你会在栈里的返回地址位置看到一个0x55开头或者0x56开头的值,因为PIE下程序地址一般以0x55或0x56开头,libc地址以0x7f开头。记录下这个值在格式化参数里的编号,记作idx_pie。
然后跑本地时,用gdb看当前程序的加载基址:
gdb -batch -ex "start" -ex "info proc mappings" ./pwn34从输出里找到pwn34二进制的映射起始地址,就是PIE基址。用泄露值减基址,得到偏移。这个偏移固定不变,因此远程打的时候,payload里就可以动态计算所有程序内地址了。canary的定位方法和pwn33一模一样,看到低位是0x00的值就是它。
有个细节要注意:栈上可能同时存在多个程序地址,不一定是返回地址。你取出来的那个值,一定要确认它对应的符号是什么。最简单的方法是在gdb里用vmmap查基址,再算偏移,然后回到IDA里看这个偏移落在哪个函数附近。如果随便抓一个程序地址就拿来当基址偏移用,后面计算符号地址时可能偏了一大截,还找不到问题在哪。
3.3 完整ROP链的组装
由于Full RELRO,我们不能改GOT,所以不要尝试got覆写。我们仍然走ret2libc路线。第一遍payload让程序调用puts泄露puts@got,然后回到vuln;第二遍把canary填回去,跳pop_rdi; ret + bin_sh + system。
关键区别是所有程序内符号地址都要加上PIE基址:
payload = b'A' * offset payload += p64(canary) payload += p64(0) # fake rbp payload += p64(ret + pie_base) payload += p64(pop_rdi + pie_base) payload += p64(puts_got + pie_base) payload += p64(puts_plt + pie_base) payload += p64(vuln_addr + pie_base)泄露libc后,第二次利用时注意:system和“/bin/sh”都来自libc,不需要再加PIE基址;但pop_rdi如果是从程序里找的gadget,就还是要加PIE基址。很多人会在这一步把libc地址和PIE地址混在一起算,导致第二段payload直接崩。我的经验是每次计算完地址后在脚本里print出来检查一遍,特别是printf格式化字符串时,有时地址中间的0a字节会被当成换行截断,导致payload不完整。
第二段payload发送后,程序又回到vuln,这时候输入空间、栈布局都和第一次一样,canary也还是同一个进程里的同一个值,所以理论上第二次利用的稳定性和第一次完全一致。如果第二次也崩,优先查libc基址算没算对,其次查栈对齐,再次查payload里是不是混入了换行。
3.4 one_gadget很诱人,但别一上来就赌
处理这种全开题时,很多人会直接上one_gadget,期望一个gadget直接execve(“/bin/sh”),省去传参的麻烦。one_gadget输出的每个地址后面都有一串约束条件,比如[rsp+0x50] == NULL、[rsi] == NULL之类,这些条件在当前栈布局下经常不满足。我在pwn34远程环境试过两条one_gadget,都因为约束不满足失败,最后老老实实回到system(“/bin/sh”)。
所以我的建议是:先用传统ROP打通,再考虑one_gadget优化。传统ROP虽然看起来啰嗦,但每一步都可控、可调试;one_gadget一旦失败,排查起来反而麻烦。等你对栈布局的感觉足够稳,再在实战中根据上下文选one_gadget,那时候才是效率提升,而不是碰运气。
4. 前置基础这三题沉淀下来的通用经验
4.1 五个让我身边人反复卡住的位置
这段时间在群里看到不少人和我一样刷这组题,总结下来卡点基本集中在五个位置。
第一个是偏移算错。尤其pwn33和pwn34加了canary以后,有些人只覆盖了返回地址,忘了canary要原样写回,程序一开就stack smashing。第二个是格式化字符串定位不准,找不到canary位置,一直试%10$p、%20$p,其实应该先定位输入参数编号,再往周边扫。第三个是send和sendline弄混。read和gets对末尾换行的处理不一样,payload长度容易差一个字节。第四个是libc版本对不上,远程用LibcSearcher搜出来好几个候选,一个个试,其实应该先看题目给的docker文件或者远程提示猜版本。第五个是栈对齐问题,表现为本地时好时坏,加一个ret gadget解决。
这些问题在刷题序列里看似都是“粗心”,但它们其实是同一件事的后果:对栈上的布局没有建立起具体的画面感。一旦你脑子里能把“buf、canary、rbp、ret”这段栈帧画出来,很多报错是能直接预判的。
4.2 一套可以复用到底的exp骨架
刷完pwn32到pwn34,我把exp模板固定成下面这个结构,后续大多数入门题都能套:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' elf = ELF('./pwn') if args.REMOTE: p = remote('127.0.0.1', 10000) else: p = process('./pwn') # 第一步:计算偏移 # 第二步:泄露 # 第三步:计算基址 # 第四步:构造最终payload p.interactive()别小看这个骨架,它强制你按顺序思考:先搞清楚要从程序里拿到什么信息,再想payload怎么拼。很多人在入门阶段喜欢直接抄exp改地址,结果换个题就动不了,原因就是跳过了第二步和第三步,没有理解地址是怎么来的。
4.3 做题之外的三个练习方向
做完这三题,建议再往这三个方向补一补。第一个是汇编基础,至少能看懂push、pop、call、ret、leave这几条指令对栈的影响;不需要会写复杂的汇编,但栈溢出利用链路里每一步在寄存器层面发生了什么,必须能说清楚。第二个是延迟绑定和GOT/PLT机制。为什么泄露puts地址用puts@plt调用,为什么要读puts@got而不是puts本身;这两个问题理解了,ret2libc就不是魔法。第三个是学会在gdb里“人肉模拟”一次payload的执行,比如在返回地址处下断点,看看rsp、rip、rdi这些寄存器的值是否符合预期。很多坑不用上网搜,自己下断点看一遍就明白了。
最后说个我自己的体会吧。pwn32到pwn34这三题做完,最值钱的不是会默写几个payload模板,而是养成了一个习惯:每次动手前,先checksec,再对着IDA的反汇编看栈帧,想清楚“我这次要覆盖到哪里、要从哪里泄露什么”。后面再碰到更难的PWN题,你会发现绝大多数所谓进阶技巧,其实都是在这三题的基本功上改改花样。如果刷题时也卡在哪道题上了,建议先回这三题把栈布局和格式化字符串找参数的位置吃透,再回头看,思路会清楚很多。