如果你刚开始学二进制利用第一道能让人真正体验到“劫持程序”快感的题目大概率是这种自带system和/bin/sh的 32 位栈溢出题。 这类题目的训练平台版本很多难度标记为 level 2题面极其简单一个 32 位 ELF一个gets没有 PIE没有 canary甚至连system和/bin/sh都替你准备好了。与其说是一道 CTF 题不如说是一张“栈溢出利用原理示意图”。这篇文章我会从拿到文件开始到最终弹出 shell 结束完整记录这题的做题链路包括每条命令的用途、每个地址的来龙去脉以及我在实际调试中踩过的坑。无论你是刚看完汇编基础、第一次接触 ROP 的新手还是想找一道题系统梳理 ret2system 流程的老手这篇内容都值得花十分钟读一遍。1. 做题前的信息收集先搞清楚手里拿的是什么拿到题目文件后的第一件事不是急急忙忙拖进 IDA而是先做环境侦查。二进制漏洞利用跟侦察一样信息收集做得越细后面构造攻击时的选择就越明确。我先用file确定文件类型再用checksec查看保护机制最后把程序运行一次看个大概行为。file level2 # 输出类似 # level2: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, not stripped关键信息有几个32 位、LSB 小端序、动态链接、not stripped。这些都直接决定了后续利用的写法和困难程度。尤其注意“not stripped”意味着符号表还在静态分析时能直接看到函数名不用对着地址猜来猜去。接着看保护机制。兼容的检查工具是 pwntools 自带的 checksecchecksec --filelevel2常见的检查结果和含义如下表保护项常见状态对本题的意义RELROPartial RELROGOT 可改但本题用不到Stack Canary无可以放心覆盖返回地址不会被检测NX enabled开启栈上不可执行别想着执行 shellcodePIE enabled关闭程序加载地址固定函数地址可以直接写死RPATH/RUNPATH无没有路径劫持问题对这道题来说NX enabled意味着我一开始构思的“往栈上放 shellcode然后跳到栈上执行”这条经典路线直接作废。PIE disabled则是很大的便利意味着所有函数地址、字符串地址在每次运行时都是固定值不需要先泄露地址。最后运行一下程序看它的交互长什么样./level2程序打印了一行提示然后进入等待输入的状态。输入一串字符回车程序直接退出。没有报错信息。仅从行为上判断很像一个简单的“读入后结束”程序大概率存在gets或scanf之类的无边界读取函数。到这里信息收集阶段基本完成32 位、无 canary、无 PIE、NX 开启。一个典型的栈溢出题配置。接下来的问题只有一个漏洞点在哪里程序里有没有可以直接利用的后门。2. 静态逆向找到那行“致命”代码静态分析的第一个动作用 IDA 打开二进制文件。由于文件没有 stripping直接按空格切到函数列表很快就看到main函数。按 F5 反编译之后代码简单得让人有点不真实int main() { char buf[0x88]; gets(buf); return 0; }gets(buf)仅此而已。gets从标准输入读取字符串直到遇到换行符为止完全不检查目标缓冲区长度。C 语言标准库中最臭名昭著的函数之一现代编译环境基本已经禁止使用。对这道题来说它就是那个口子。现在需要关心一个关键数据buf距离栈上保存的返回地址有多少字节。从反编译窗口可以看到buf在栈帧中的起始位置相对于当前函数栈底的偏移是0x88。再看汇编代码更容易理解布局lea eax, [ebp-0x88] push eax call gets注意这里的ebp-0x88buf起始地址位于EBP寄存器向下0x88字节的位置。栈的生长方向是从高地址到低地址而EBP是当前栈帧底部的高地址处。在 32 位程序中一个函数的栈帧布局大致长这样地址方向内容高地址调用者传入的参数高地址返回地址位于 EBP4高地址保存的 EBP位于 EBP低地址↑局部变量区域buf从 EBP-0x88 开始由于gets从buf的起始地址开始写一直写到遇到换行符才停。那么我要覆盖到返回地址需要填充的数据长度就是0x88从 buf 到 EBP 的距离 4EBP 本身的大小 0x8C也就是 140 字节。前 140 个字节可以是任意内容第 141 到 144 个字节就是返回地址程序ret时会从栈上弹出这个值并跳转过去。下一步查找程序中可利用的函数和字符串。题目既然叫 level2说明出题人留了明显的后门。我用 IDA 的 Imports 窗口查看导入函数很快就看到了system。再用字符串搜索快捷键 ShiftF12看到一个非常显眼的字符串/bin/sh。这两样东西凑在一起攻击思路已经非常清晰覆盖返回地址劫持程序执行流程跳到system让它以/bin/sh为参数执行直接弹出一个 shell。到这里有一个值得新手特别留意的点这个题目实际上是不需要逆向的。所有关键信息都是顺着符号表和字符串表直接白给的唯一需要动脑筋的是理解栈上的布局和函数调用约定这两点才是栈溢出利用真正的门槛。3. 核心原理32位栈溢出为什么会“按剧本走”要彻底理解这道题为什么能利用成功有必要把 32 位程序函数调用时的栈布局讲清楚。如果你已经熟悉 x86 调用约定这一节可以快速翻过如果还不太清楚我建议认真读一遍因为后面 64 位的利用方式虽然细节不同但思想完全一样。32 位程序默认使用 cdecl 调用约定。调用一个函数时参数的传递方式是从右往左依次压入栈中。假设正常代码里执行system(/bin/sh)在调用system之前函数的调用方会先把/bin/sh的地址压入栈然后执行call system。call指令本身做了两件事先将当前指令的下一条地址即返回地址压栈然后跳转到system入口。当system开始执行时栈中的状态从低地址到高地址依次是返回地址、参数。system内部通过EBP8来访问这个参数。这给了我们一个利用的模型。当我们通过缓冲区溢出把返回地址改写成system的地址时程序执行完ret后栈指针会自动向下移动 4 字节。此时ESP正好指向我们 payload 中返回地址后面的那 4 字节区域。如果我们在那 4 字节中放置一个“垃圾返回地址”再往后的 4 字节放置/bin/sh地址就能让system在入口处看到的栈布局和正常调用时完全一致。也就是 payload 结构[填充140字节] [system地址] [垃圾返回地址] [/bin/sh地址] ^ ^ ^ 返回地址 伪造的返回地址 system的参数system执行时会把这个/bin/sh地址当作字符串参数执行system(/bin/sh)于是sh启动我们拿到了一个交互式 shell。那个夹在中间的“垃圾返回地址”为什么是必需的因为system也是一个普通函数它执行完以后总需要知道去哪里。哪怕是随便给个无效地址也至少要占 4 字节让参数对齐。如果你不想要这个返回值也可以填一个exit的地址这样 shell 退出后程序会优雅地退出而不是段错误。实际做题时大家经常填0xdeadbeef纯粹是为了观察它会不会崩或者在多个循环利用场景下填入main的地址让程序重新执行一次。来看一下 32 位和 64 位的关键差异。64 位 System V 调用约定中前六个参数是通过寄存器传递的栈上只负责多出来的参数。这就意味着在 64 位程序里单纯把返回地址改成system还不够还需要先找到一段pop rdi; ret这样的 gadget把/bin/sh地址挪到RDI寄存器里然后才能跳转到system。这也是为什么入门阶段先用 32 位题练习更合适——不需要关心 gadget 查找和寄存器传参栈上的字节对齐就是一切。4. 编写 exploit用 pwntools 把它打穿原理清楚以后编写利用脚本就显得顺理成章了。pwntools 是当前国内外做 pwn 题的事实标准它封装了大量最常用的操作。先看完整脚本再逐行讲解。from pwn import * context.arch i386 context.log_level debug elf ELF(./level2) system_plt elf.plt[system] binsh_addr next(elf.search(b/bin/sh)) payload bA * (0x88 4) payload p32(system_plt) payload p32(0xdeadbeef) payload p32(binsh_addr) p process(./level2) p.sendline(payload) p.interactive()逐行看。context.arch i386告诉 pwntools 当前利用目标是 32 位架构影响p32、p64这类打包函数的默认行为也影响某些自动生成的汇编代码。设置了context.log_level debug后pwntools 会把收发数据、调用的系统调用全部打印出来这在调试时价值很大。关键的两行elf.plt[system] # 从 ELF 动态符号表中提取 system 在 PLT 中的地址 elf.search(b/bin/sh) # 在二进制文件中搜索 /bin/sh 字符串返回其虚拟地址为什么用elf.plt[system]而不是从System.map或者硬编码地址因为 PLT 表项在动态链接程序中是固定存在的即使程序本身没有显式调用system只要它导入了这个符号PLT 里就有对应的跳板。本题的 system 在程序里被直接引用了找起来毫无悬念。elf.search返回的是一个生成器所以要用next()取出第一个匹配的地址。/bin/sh在文件里可能出现在.rodata或.data段这两种段在加载后都位于可读内存中作为参数传给 system 没有问题。构造 payload 的几个细节值得留意。bA * (0x88 4)中的加法前面已经算过0x88 是 buf 到 EBP 的距离4 是 EBP 自身的大小。如果你手动数栈偏移一定要搞清楚 EBP 是否被算进去了。p32函数把整数打包成 4 字节小端序任何 32 位地址在 payload 里都要经过p32处理否则直接拼接的字符串长度或是字节序都会出错。最后启动进程并发送 payload。sendline会在 payload 末尾自动追加换行符正好满足gets的结束条件。发送之后调用p.interactive()把进程的标准输入输出交还给用户这样一旦壳弹了起来我们就能直接在里面执行命令。整个脚本执行结果应该是这样的程序打印提示后收到 payloadsystem(/bin/sh)执行出现一个$提示符输入id可以看到当前权限输入exit退出。整个过程行云流水。5. 调试实录payload 没生效时怎么快速定位我第一次做这题时并没有一次成功甚至犯了很低级的错误。这里记录几个最常见的问题和排查方法很多内容在正规教程里不会写但实际做题时几乎人人都会遇到。5.1 偏移量算错了用 cyclic 自动找出精确长度最经典的翻车现场就是自己数偏移数错了。尤其是函数里还有其他局部变量时0x88这种数值从反编译窗口抄下来都有抄错的可能。与其手工数不如用 pwntools 提供的cyclic和cyclic_find自动算。from pwn import * payload cyclic(200) p process(./level2) p.sendline(payload) p.wait() print(p.corefile)程序崩溃后pwntools 会把核心转储文件加载到p.corefile中其中记录了崩溃时被覆盖的返回地址通常是0x6261616b这种看起来像可打印字符的循环序列。然后把崩溃时栈上 ESP 指向的地址附近 4 字节提取出来cyclic -l 0x6261616b它会直接告诉你这 4 字节是循环序列的第几个字节也就是真正的返回地址偏移。比起手工计算这个方式几乎不会出错。需要注意一点如果崩溃时程序是在ret指令上断开的cyclic -l给出的数字往往比实际写入偏移少 4因为ret已经从栈上弹出了返回地址。经验上我会优先看核心转储中栈顶前 4 字节的图案或者直接用cyclic -l再手动确认。5.2 地址里有空字节gets 会截断吗一个常见的疑虑是system的地址可能是0x8048410打包成p32后变成\x10\x84\x04\x08里面并没有\x00。但有些系统函数地址会包含\x00比如0x08048400会变成\x00\x84\x04\x08。很多人担心gets读到\x00会停止。实际上gets自己并不把\x00当作结束符它只认换行符。真正怕\x00的是strcpy、sprintf这类以 C 字符串为语义的函数。题目用的是gets所以 payload 里出现空字节是没问题的。只需要注意sendline发送的换行符不能提前出现否则会在 payload 中间截断。pwntools 的sendline会在最后统一追加一个\n所以常规场景下不用担心。5.3 用 gdb 确认栈布局看到真实参数才能安心如果利用不成功不要盲目改偏移。最直接的排查方式是在system入口处打断点然后观察栈顶的参数。流程如下。先用 gdb 启动程序gdb ./level2 b *0x8048320 # 替换成你从 IDA 看到的 systemplt 地址 run将 payload 通过管道输入程序python3 exp.py | gdb ./level2不过我更推荐在 gdb 里直接调用 pwntools 的gdb.attach()这样调试体验会流畅很多p process(./level2) gdb.attach(p, b *system_plt_addr continue )程序停在system入口后执行x/8wx $esp这条命令会从栈顶开始打印 8 个 4 字节字。正常情况下应该看到这样的布局0xffffd4ac: 0xdeadbeef 0x08048410 ...其中0xdeadbeef是我们伪造的返回地址紧跟着的下一个地址就是/bin/sh字符串地址。再用x/s验证x/s *(char**)$esp4如果打印出/bin/sh说明布局和参数全部正确那问题一定出在别的地方例如地址本身错误或者程序被环境变量影响了栈偏移。这个过程虽然稍微繁琐但对于建立“栈布局”的直觉非常有帮助。5.4 常见问题速查表现象可能原因解决方法程序段错误崩溃地址奇怪偏移数错用cycliccyclic -l重新计算发送 payload 后程序不退出payload 里提前出现了换行符检查发送方式确认发送内容没有\n以外的结束符system执行后没有 shell/bin/sh地址错误或参数位置偏移gdb 在 system 入口看栈顶验证参数地址本地成功远程失败远程环境 libc 版本或程序版本不同如果地址硬编码在程序内多数是题目环境差异注意检查远程端的提示输出和文件保护设置是否一致pwntools 报 EOF程序启动失败或交互时机不对用log_leveldebug查看发送和接收缓冲区内容还有一种比较隐蔽的情况p.interactive()之前如果程序已经提前退出或者system调用后 shell 读不到输入通常是因为进程没有正确等待 shell或者输入输出重定位的问题。这种时候检查一下代码里有没有提前调用p.recv()把 shell 的提示符吃掉了。5.5 一个容易忽略的实战技巧如果题目运行时打印了提示信息例如Input:sendline之前最好用recvuntil(bInput:)先同步一下。如果题目没有打印任何提示直接在启动后立即sendline通常也没问题因为gets在等待输入时是阻塞的进程启动后迟早会进入阻塞等待状态发送的数据会保存在内核缓冲区中等gets一开始读就能立即取到。但有一种比较特殊的情况程序在真正调用gets之前花了几毫秒做一些初始化工作而你的sendline在初始化期间到达。这个间隙极短通常不影响但如果你遇到偶然失灵可以用sleep(0.1)或者p.recvrepeat(0.2)来等一拍。这不算严谨的同步手段但足够对付大部分题目。6. 题目之外从 level2 到更复杂的利用最后说点跟这题有关但又不止这题的事情。我在教新人时会特意把这道题作为第一道需要独立完成的练习。原因很简单它没有把难度藏在逆向里也没有设置晦涩的保护机制唯一的考察点就是“你是否真正理解返回地址和栈布局”。如果能把这道题从头到尾独立做一遍并且能不看答案写出完整的攻击链那说明栈溢出的核心概念已经过关了。做完这道题之后你可能会对两个延伸方向感兴趣。第一个方向是 64 位攻击。把这题的脚本改造成 64 位版本你会发现原来一行就能取地址的方法虽然没有变但 payload 结构需要引入pop rdi; ret这样的 gadget。这背后是寄存器传参和栈传参两种调用约定的差异。等你搞清楚为什么 64 位需要 gadget再把这道题的理论框架迁移过去登录 shell 的那一刻会感觉更加通透。第二个方向是动态地址环境。本题里的system在 PLT 中/bin/sh在程序自身的数据段里所以不需要任何信息泄露。但现实中很多程序不会如此慷慨system不在程序里/bin/sh也不在程序里这时就要用到 ret2libc先调用puts泄露某个 GOT 表项再计算 libc 基址最后重新回到main继续第二轮利用。这套流程本钱质上就是在这道题的 payload 后面再追加一段“读取输出并算距离”的逻辑。我个人做这题时最有收获的一点是很多看似复杂的 ROP 链其实都是在栈上反复排列“返回地址 参数”这样的二元组。抓住这个本质不管题目加多少层 gadget 或者保护核心思路始终是可控的。最后再分享一个小技巧做这类入门题时可以把成功弹 shell 的脚本保存成一个模板以后遇到同构题只需要改一下偏移量和地址提取方式就能快速复用。这种方法虽然看起来简单在比赛中却是节省时间的利器。