资讯动态

从pwn32到pwn34:栈溢出与格式化字符串利用的入门实战解析

发布时间:2026/9/15 16:03:50 来源:尧图企业网站定制
这周把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 --filepwn32 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个保护组合对入门来说非常舒服没有canary意味着覆盖栈上返回地址不需要担心“金丝雀”校验没有PIE意味着IDA里看到的函数地址可以直接写进payloadNX开启意味着我们不能简单地把shellcode塞到栈上执行所以需要找程序里现成的后门函数。用IDA打开后的结构也很典型。main调用了vulnvuln里定义了一个16字节的缓冲区然后用gets读入。gets是这个漏洞链的核心因为它不做长度检查可以一直往缓冲区里写直到遇到换行符。题目里还放了一个明显故意设计的函数里面调用了system(/bin/sh)或者是直接读flag的系统调用。这种“故意留后门”的题正是后面所有ret2libc、ROP题的简化版——本质都是劫持控制流只不过这里劫持到一个现成地址。1.2 从buf到返回地址的距离IDA里看到buf大小是0x10很多人就直接认为偏移是0x10然后覆盖返回地址。这是入门阶段最容易错的一步。缓冲区低地址在栈上返回地址在高地址两者之间还隔着被保存的rbp。所以在x64下如果buf大小是n从buf到返回地址的偏移通常是n8如果编译器插入了其他变量或者做了对齐还要另算。pwn32这个题里n16所以理论上偏移是24字节。但我不会只靠理论而是用cyclic验证cyclic 100跑gdbrun后输入这串pattern程序崩掉时看ripRIP: 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脚本组装payloadfrom pwn import * elf ELF(./pwn32) context.arch amd64 context.log_level debug p process(./pwn32) win 0x4011d6 payload bA * 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 bA * 24 p64(ret) p64(win)这个ret gadget可以用ROPgadget找或者直接在IDA里找汇编指令为ret的地址。这也解释了为什么很多公开pwn题的exp里会出现一个看似无意义的ret——它不是没用而是在帮system“校准”栈。2. pwn33格式化字符串先“偷”canary再打ret2libc2.1 这题为什么不能照搬上一题的思路pwn33一checksec保护情况立刻变了checksec --filepwn33 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在我们栈上的参数编号后记下这个值然后构造栈溢出的payloadpayload bA * 24 p64(canary) bB * 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参数是putsgot里的内容。putsgot存的是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表只读不能改GOTcanary还是老问题必须先泄露。不过弄清楚之后会发现题目本质还是那三件套一个格式化字符串漏洞用来泄露一个栈溢出点用来劫持控制流。多做几步地址计算而已。真正比赛里大多数栈题都是这种配置所以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泄露putsgot然后回到vuln第二遍把canary填回去跳pop_rdi; ret bin_sh system。关键区别是所有程序内符号地址都要加上PIE基址payload bA * 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输出的每个地址后面都有一串约束条件比如[rsp0x50] 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地址用putsplt调用为什么要读putsgot而不是puts本身这两个问题理解了ret2libc就不是魔法。第三个是学会在gdb里“人肉模拟”一次payload的执行比如在返回地址处下断点看看rsp、rip、rdi这些寄存器的值是否符合预期。很多坑不用上网搜自己下断点看一遍就明白了。最后说个我自己的体会吧。pwn32到pwn34这三题做完最值钱的不是会默写几个payload模板而是养成了一个习惯每次动手前先checksec再对着IDA的反汇编看栈帧想清楚“我这次要覆盖到哪里、要从哪里泄露什么”。后面再碰到更难的PWN题你会发现绝大多数所谓进阶技巧其实都是在这三题的基本功上改改花样。如果刷题时也卡在哪道题上了建议先回这三题把栈布局和格式化字符串找参数的位置吃透再回头看思路会清楚很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价