1. 这道题到底在考什么:从题目命名看命题人意图
拿到jump_by_jump_revenge这个题目名,第一反应就是"又是跳转题"。"revenge" 后缀在 NSSCTF 里基本等于"前作被秒了,这次加料重来",所以在动手之前,最好先找到原版jump_by_jump做一次对比。我能找到的信息是,原题核心考察的是栈上的jmp指令序列利用,而 revenge 版本在此基础上增加了混淆、长度限制和关键字节清理,目的就是让"照着 WP 抄"的选手直接翻车。
先说结论:这道题属于 PWN 中的ROP / JOP(Jump-Oriented Programming)综合题,考点集中在三个方向:
- 栈溢出基础:如何用有限输入控制返回地址;
- 跳转指令的可用性分析:
jmp rax、jmp rsp、jmp [rax+offset]这类 gadget 的寻找与拼接; - 在禁用常规
pop rdi; ret类 gadget或SROP 不可用的情况下,如何借助多次跳转构造任意函数调用。
从热词里能看到同期的[NSSCTF 2nd] php签到是 Web 方向的签到题,说明这套比赛的风格是"每道题都有明确的入门门槛,但 revenge 一定会在某个环节卡住大部分人"。jump_by_jump_revenge的卡人点,几乎可以确定是"只能用跳转类指令完成控制流劫持,普通 ret 被过滤或 gadget 被裁剪"。
如果你之前只做过常规 ROP,第一次看到这题大概率是懵的:ROPgadget搜出来的pop rdi; ret一个都用不了,IDA 里翻来翻去找不到能用的syscall地址。这不是题坏了,而是你需要切换到JOP 思维。
1.1 常规 ROP 为什么在这题里失效
大部分 CTF PWN 题的基础打法就三步:找到溢出点、劫持返回地址、用pop rdi; ret之类的 gadget 调system或execve。但jump_by_jump_revenge显然不是这么设计的,我从题目名和同类题型的演变里总结出三个常见限制:
- ret 指令被禁用或数量受限:有些版本会在二进制里把所有
ret字节(0xc3)抹掉,或者只保留极少几个。ROP 链的核心就是靠ret串联 gadget,没有ret,常规链直接断掉。 pop指令被压缩:即使有ret,也找不到足够的pop来布置参数寄存器。比如没有pop rdi、pop rsi、pop rdx,你拿什么传参?syscall/execve被隐藏:地址随机化加上符号剥离,你很难直接找到syscall; ret这种干净序列。
遇到这类情况,就要把思路从"利用 ret 串联"切换到"利用 jmp 串联"。JOP 的核心不是靠栈上的返回地址,而是靠寄存器中已有的值和可用的跳转 gadget来改变执行流。
1.2 先看原版 jump_by_jump 的经典打法
原版题目通常会给一个明显的栈溢出,然后程序里有一连串jmp rax或jmp rbx之类的 gadget。经典解法是:
输入缓冲区 -> 控制 rax -> 跳到某个函数 -> 再次触发读入 -> 循环利用说白了就是利用jmp指令本身作为"免费的 ret",通过不断跳转到新的 gadget 来拼出完整链。而 revenge 版本加入的干扰通常包括:
- 在关键
jmpgadget 前插入垃圾字节,让你不能直接跳转; - 限制读入长度,导致你没法一次性布置整条链;
- 用
seccomp限制系统调用,只允许open/read/write,不允许execve。
这些干扰每一招都很熟悉,但组合在一起,就让常规解法失效了。所以做这道题前,我建议你先想清楚一件事:它到底需要你构造几个"跳板",才能完成一次完整的 open/read/write 输出文件内容。
2. 环境准备与二进制分析:别急着开打,先看透程序
PWN 题的快慢往往差在第一步。很多人拿到附件直接checksec看一眼就扔进 IDA,结果连保护机制都没搞清楚就开打,浪费大量时间。我的习惯是先把五个问题问清楚:
- 什么架构?32 位还是 64 位?
- 有哪些保护?NX、PIE、Canary、RELRO 分别是什么状态?
- 输入点有几个?分别在什么函数里?
- 可用的输出函数是什么?能不能泄露地址?
- 程序里有没有现成的
syscall、execve、system?
2.1 checksec 结果怎么读
假设我们拿到的是一个 64 位 ELF,checksec输出大概是:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)注意几个关键点:
- No canary:栈溢出可以直接打,不用考虑爆破 canary。
- No PIE:二进制加载地址固定,所有 gadget 地址可以直接写死。
- NX enabled:栈不可执行,别想往栈上扔 shellcode 然后跳过去执行。
- Partial RELRO:GOT 表可写,可以考虑改 GOT,但在 JOP 题里通常用不上,因为你需要的是控制流跳转,而不是改函数指针。
如果 PIE 开了,那题目难度直接上一个台阶,因为你需要先泄露某个地址才能算 gadget 偏移。但从前缀"revenge"的尿性来看,PIE 大概率是关的,否则新手根本没有入门机会。
2.2 IDA 里最该看哪几个地方
打开 IDA 后,不要急着 F5 看反编译,先看两个东西:
第一,程序入口和 main 函数。找到读入函数,确认它读多少字节、往哪读、有没有输出。很多题目会在 main 里直接read(0, buf, 0x100),而 buf 离返回地址只有 0x20,这样一算就能知道溢出多少。
第二,所有jmp相关指令。用 IDA 的 text view 搜索jmp或者直接用 ROPgadget 搜jmp rax、jmp rsp、jmp qword ptr [rax]这类模式。在 JOP 题里,这些才是你真正要用的"积木"。
举个例子,假设你找到这样一段汇编:
0x401020: jmp rax 0x401023: nop 0x401025: ret那0x401020就是一个可用的跳板:只要你能把rax设成目标地址,jmp rax就能把你送到那里。如果题目里有很多这种jmp rax,你甚至不需要栈上的返回地址来串联,只需要在寄存器之间腾挪。
2.3 识别输入点与溢出尺寸
我见过不少人卡在"明明看到有 read,但不知道能读多少"。这里教大家一个快速估算的方法:在 IDA 里找到read(fd, buf, n),其中n就是最大输入长度。如果buf相对于rsp的偏移是 0x20,而n是 0x60,那么你可以覆盖返回地址之外的 0x40 字节——这些空间就是你的链子存放区。
如果是jump_by_jump_revenge这种题,我猜测读入长度会被卡得非常死:比如只能读 0x30 字节,让你刚好覆盖返回地址,但不够放完整 ROP 链。这时候就必须把"第一段跳转"设计成重新调用 read,实现二次读入,才能有足够的空间布置后续内容。
2.4 可用的输出函数与泄露路径
如果题目很想让你泄露地址,一般会留一个puts或者printf调用点。但如果 PIE 关闭,你其实不需要泄露;你需要的是确定一个可控的数据区(比如 BSS 段地址),把要读的文件路径写进去,再调用open、read、write。
在我的经验里,revenge 类题目的默认路径大多是以下之一:
- 直接
execve("/bin/sh"),如果 seccomp 没拦; open("flag", 0)->read(fd, buf, len)->write(1, buf, len),如果 seccomp 只允许特定 syscall;- 如果连
syscall都没有,就需要用现有的 libc 函数system配合binsh字符串。
因为从热词里可以看到这是一场国内比赛,flag 多半在根目录或当前目录,名字大概率是flag。我用open也是按这个假设来做的。
3. 核心难点拆解:没有 ret 的 JOP 链怎么拼
现在到了整道题最核心的部分:怎么在没有常规 ROP gadget 的情况下,用跳转指令完成攻击。
3.1 先理解 JOP 的基本模型
JOP 的核心思想是用jmp指令来代替ret作为"链节"。常规 ROP 链长这样:
[addr of pop rdi; ret] -> [arg1] -> [addr of system] ...每个 gadget 执行完,遇到ret,CPU 从栈顶弹出下一个地址,继续执行。而 JOP 链长这样:
... 设置 rax = gadget1 地址 ... jmp rax gadget1 执行完毕后,jmp rbx(rbx 预先设为 gadget2 地址) gadget2 执行完毕后,jmp [rcx](rcx 指向 gadget3 地址)区别在于:ROP 依赖栈上的返回地址,JOP 依赖寄存器里预先设置的目标地址。寄存器哪来的?要么是程序本身就帮你设置好了,要么是你通过栈溢出控制了某个寄存器值(比如pop rax; jmp something)。
如果题目里连pop rax都没有,还有一个更原始的办法:利用read返回值。read系统调用成功后会返回读入的字节数到rax,这相当于一个天然的pop rax!如果程序在你布置的链中调用了read,返回值就会覆盖rax。你可以利用这一点,让rax成为一个可控的小数值,再配合jmp rax跳到某个偏移地址。
3.2 寻找可用跳板 gadget
拿jump_by_jump_revenge举例,我假设程序里有以下几种典型跳板(具体地址以实际附件为准,但模式通用):
| 指令序列 | 作用 |
|---|---|
jmp rax | 最基础的跳板,只要 rax 可控即可跳转 |
jmp qword ptr [rax] | 内存跳转,让 rax 指向一个存有目标地址的位置 |
add rax, 0x??; jmp rax | 带偏移跳转,常用于对齐地址 |
mov rax, qword ptr [rbp-0x10]; jmp rax | 从栈或内存取地址再跳转 |
xchg rax, rdi; jmp rax | 既能换值又能跳转,非常珍贵的组合 |
你在 ROPgadget 的命令可以这么写:
ROPgadget --binary ./pwn | grep -E "jmp rax|jmp rsp|jmp qword ptr \[rax\]|add rax"或者用ropper:
ropper --file ./pwn --search "jmp"但要提醒一下:ROPgadget对指令序列的识别有时候会有遗漏,比如jmp qword ptr [rax+0x10]这种带偏移的就不一定搜得到。建议再用 objdump 手动筛一遍:
objdump -d ./pwn | grep -E "jmp.*\brax\b"3.3 想清楚:你能控制哪些寄存器
JOP 的难点不是你找不到 gadget,而是你不知道执行到某个 gadget 时寄存器的值是什么。所以拿到二进制后,要顺着程序执行路径走一遍,看清每个关键点的寄存器状态。
一般程序在调用read之前,rdi往往是 0(fd=0),rsi是缓冲区地址,rdx是长度。如果你能构造一个场景,让程序再次进入某个read函数,那么你就能借机控制rax(返回值)、rsi(输入地址)、rdi(0)等寄存器。这就是"用函数副作用来设置寄存器"的技巧,在 JOP 里非常实用。
比如一个理想的控制流:
第一次输入 -> 劫持返回地址到 pop_rsi_ret(如果存在) -> 设置 rsi 为 BSS 地址 -> 调用 read -> 读入第二段数据到 BSS -> 返回后利用 jmp rsi 跳到 BSS 上的数据有的题目里read的地址是现成的 PLT 项,你只要把它的地址跳过去,参数合适就能二次读入。如果参数寄存器不可控,还有个歪招:利用现有的函数调用点。比如程序本来就调用了read(0, buf, 0x30),你就跳回这个地址,它会自动帮你设置好rdi=0, rsi=buf, rdx=0x30。
3.4 构造调用 open/read/write 的完整思路
假设最终目标是读出flag,你需要完成三次系统调用:
open("/flag", 0),把 fd 放到 rax 返回值里;read(fd, buf, 0x50),把文件内容读到内存;write(1, buf, 0x50),输出到 stdout。
在没有普通 syscall 链的情况下,你需要找到程序里已经存在的函数来替代,或者找到一个syscallgadget 并且能正确设置寄存器。
我们以"找到一个syscall指令"为前提继续。那么问题就变成了:怎么设置 rax、rdi、rsi、rdx。如果二进制里有pop rdi; ret这样方便的东西,那只是少了 syscall 的问题;但在 JOP 题里很可能连这些都没有,所以要活用这两个思路:
- 利用已知函数调用的遗留值:比如程序在某个地方调用了
read,调用完rdi可能还是 0,rsi还是旧缓冲区地址,rdx可能是旧长度。你可以先跳到那个函数,把rax变成read的返回值,再跳到syscall。 - 利用 BSS 上的数据伪造函数指针:如果有一个
call qword ptr [bss]之类的调用点,你可以把 BSS 某个地址写成你想调用的函数地址,间接实现任意调用。
说实话,在没有附件的情况下,我不能给出精确到字节的 payload,但能给出一个通解框架:
第一阶段:栈溢出,劫持返回地址到某个二次 read 的调用点; 第二阶段:把第二段输入写到 BSS,里面包含"路径字符串 + 跳转地址表"; 第三阶段:通过 jmp [bss+offset] 实现给 rax/rdi/rsi/rdx 赋值,然后跳到 syscall; 第四阶段:循环回第二阶段,分别完成 open、read、write。3.5 为什么长度限制是 revenge 的核心杀招
很多人在原版能一把梭,到了 revenge 就死,死因多半是输入长度不够。假设第一次只能读 0x30 字节,你要放的东西包括:
- 填充垃圾(比如 0x28 字节)
- 返回地址(8 字节)
这 8 字节最多只够跳一次。所以你必须在这一次跳转里,跳到某个"再次读入"的地址。这个地址最好是程序里本身就有的read(0, 某固定地址, 大长度)调用点。如果找不到固定地址的 read,那就需要你能控制 rsi 寄存器,否则读进去的数据不知道去哪了。
如果连一次跳转都凑不满(比如返回地址偏移是 0x40,但输入只有 0x30),那就要考虑部分覆盖。所谓部分覆盖,就是只覆盖返回地址的低位几个字节。由于没有 PIE,高地址不变,你只需要改低 1~2 字节,就能跳到同一个页内的另一个地址。这是非常实用的技巧,尤其当read的长度被卡死时。
4. 从"读入长度不足"到"任意执行":实测思路与踩坑
说完了理论,我直接讲一遍我平时做这类题的实测路径。这里不针对某个固定附件,但步骤是通用的。
4.1 第一步:用 pwntools 跑通第一层跳转
首先写个简单的脚本,测试能否劫持返回地址到一个已知的read调用:
from pwn import * elf = ELF('./pwn') # 假设程序 main 里有一个 read(0, buf, 0x100) target_read = elf.sym['main'] + offset # 或直接用 ida 里的地址 payload = b'A' * 0x28 payload += p64(target_read) p = process('./pwn') p.send(payload) p.interactive()如果程序在返回后能再次回到read,你会发现程序会等第二次输入。这一步跑通,说明栈溢出点没问题,跳板链路开始工作了。
注意:
elf.sym['main'] + offset这种写法只是示意,实际你最好用 IDA 里看到的read调用地址,因为直接跳到 main 可能会重新初始化,覆盖掉某些栈布局。
4.2 第二步:找到稳定的数据区(BSS/全局缓冲区)
如果程序里没有现成的全局缓冲区,那就选 BSS 段随便一块没被占用的地方。pwntools 里可以直接用elf.bss(),但要注意它返回的地址可能和实际可用区间有偏移,建议在 IDA 里确认一遍,选一块不会被运行数据覆盖的区域。
假设 BSS 地址是0x404800,那么第二段输入你可以构造:
[路径字符串] + [padding] + [地址表]然后想办法让某个 gadget 跳到0x404800去执行。要让跳转生效,常见手段是:jmp rsi或者jmp rax,前提是你能把rsi/rax设成0x404800。
如果程序里现成设置了rsi = 某个全局地址,那简直天助我也,直接把目标设成那个地址就行。
4.3 第三步:处理 seccomp 限制
revenge 题十有八九会加 seccomp。怎么确定有没有加?直接在本地跑一下:
seccomp-tools dump ./pwn如果有输出类似:
0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x09 0xc000003e if (A != ARCH_X86_64) goto 0011 ...说明系统调用被过滤了。我见过最常见的过滤是只允许read、write、open、exit,禁止execve。这样你就没法拿 shell,只能走 flag 文件读取路线。
如果 seccomp 连open都禁了,那你可能需要读一个已经打开的文件描述符。有些题会把 flag 文件提前open好,并且 fd 是 3,这时你只需要read(3, buf, size)再write(1, buf, size),连 open 都省了。这种情况在 revenge 里也不少见,值得留意。
4.4 第四步:把 read 的返回值 fs 到 rax 的妙用
在 JOP 题里,寄存器不好控制时,read的返回值就是你的 "免费赋值器"。因为read调用成功后rax被设置为返回的字节数。举个例子:
调用 read(0, 0x404800, 0x50) 如果这一轮你正好输入了 0x20 字节,那么 read 结束后 rax = 0x20然后如果有一个add rax, 0x??; jmp rax的 gadget,你可以精确地让rax变成某个地址,再跳过去。这种"长度即地址"的技巧,在老手手里非常顺手,我第一次用的时候觉得像变魔术。
但有个坑:read返回的是成功读入的字节数,不包括换行符处理,也不保证一定是你期望的长度。如果程序用read而不是gets,那一切还好说;如果用gets或scanf,返回值语义就变了。所以先确认输入函数是啥。
4.5 踩坑记录:为什么我老是跳到奇怪的地方
我自己在做类似题目时,最常踩的坑有四个:
- 对齐问题:
jmp rax跳过去时,要求rax必须精确指向指令首字节,否则直接非法指令崩溃。如果你在地址上加了 1 或减了 1,就可能落到指令中间,解释成别的 opcode。 - 寄存器被函数调用破坏:glibc 函数内部会使用很多寄存器,你以为调用完
read后rsi还指向缓冲区,实际上某些版本会把寄存器改掉。这时候就得靠jmp [内存]而不是jmp rsi。 - 栈布局不稳定:如果你用栈上数据来存放链表,而后续函数又往栈上写了数据,你的链子就被冲掉了。所以尽量把链和路径字符串放到 BSS 这类固定内存。
syscall地址只差一个字节:有时候程序只有syscall没有ret,你跳过去之后它执行完 syscall,继续往下执行的是随机字节,直接崩。这时要找一个紧跟其后的ret或jmp,形成syscall; ret或syscall; jmp xxx的完整序列。
5. 手把手构建最终 exploit:一步步串起来
下面我给一个可复现的 exploit 模板,假设条件如下:
- 64 位,No PIE,无 canary;
- 栈溢出偏移为 0x28;
- 程序内有
read(0, bss_addr, 0x100)调用点,地址为0x401190; - 找到一个
syscall; ret在0x401020; - 找到一个
jmp rsi在0x401030; - 找到一个
pop rdi; ret(如果运气好还有),地址0x401040; - 找到
/bin/sh或自己把路径字符串写到 BSS; - seccomp 禁止 execve,只能 open/read/write。
我们直接走 ORW 链路。这里最麻烦的是设置寄存器。如果题目里连pop rsi; ret都没有,我会采用一个笨办法:跳进程序自带的read函数,让它去设置rdi=0, rsi=某个地址, rdx=长度,然后靠返回值rax=len去表示系统调用号。
具体来说:
from pwn import * context.arch = 'amd64' context.log_level = 'debug' elf = ELF('./pwn') p = process('./pwn') bss = 0x404800 read_call = 0x401190 # read(0, rsi, rdx),由程序预设 syscall_ret = 0x401020 jmp_rsi = 0x401030 # 第一阶段:用栈溢出跳回 read,往 BSS 写入第二段数据 payload1 = b'A' * 0x28 payload1 += p64(read_call) p.send(payload1) # 第二段数据:包含路径字符串 + 控制数据 # 这里假设跳回 read 时,rsi 自动指向 bss,rdx 足够大 path = b'flag\x00' stage2 = path.ljust(0x100, b'B') # 发送第二段,触发 read 从 stdin 读入 p.send(stage2) # 然后利用某个跳转跳到 bss 上构造好的链不过说老实话,上面的流程太理想化了。真实的jump_by_jump_revenge里,你要么找不到现成的read(0, bss, size),要么pop rdi根本不存在。所以我再给你一套更接近实战的"手工寄存器调配"模板。
5.1 如果能控制 rax 和 rdi
假设你有办法让rax = 2(open 的系统调用号),rdi指向flag字符串地址,rsi = 0,那么直接执行syscall就能完成 open。设置寄存器的方式可以是:
- 调用
read时输入恰好 2 字节,让read返回 2,于是rax = 2; rdi可以靠程序本身的read遗留值:如果上一次调用是read(0, buf, size),那么rdi = 0。此时你需要一个能修改rdi的 gadget,比如mov rdi, rax; jmp rsi这种。
如果没有现成的mov rdi, ...gadget,那就需要找个现有的函数调用点:比如程序调用了puts(某个全局字符串),此时rdi会被设为该全局字符串的地址。你只要能控制那个字符串的内容为flag,就可以利用它。
5.2 实战示例:构造 open 的替代方案
如果实在找不到干净的寄存器设置 gadget,还有一招:利用函数 plt 里现成的调用。比如程序已经.plt导出了open函数,那你只要把返回地址覆盖成open@plt,然后想办法让rdi指向 flag 路径,rsi为 0,就能调用 open。调用完 open 后,rax是 fd(通常为 3),rsi/rdx大概率已经乱掉,需要重新调整。
再看 read 阶段:你可以跳到read@plt,此时需要rdi=3, rsi=bss, rdx=0x50。假如没有 pop gadget,那就在调用 open 之前,用另一个 read 来顺带设置寄存器。说的俗一点:用"函数调用副作用"来逆向布局寄存器,而不是强行要求精确的 pop gadget。
5.3 最后用 write 输出
一旦 flag 读到 BSS,最后只需调用write(1, bss, 0x50)。这一步通常最容易,因为 write@plt 是现成的,只要rdi=1、rsi=bss、rdx=0x50。
如果题目里连 write@plt 都没有,那就用syscall指令直接执行,需要 rax=1。依然可以用 read 返回值老套路:输入 1 字节,read 返回 1,然后跳到 syscall。
5.4 完整的 pwntools 脚本框架
我平时习惯把攻击过程分成三个 send 处理。第一次 send 跳转到 read;第二次 send 构造数据;第三次 send 触发最终跳转。具体长这样:
from pwn import * context.arch = 'amd64' context.log_level = 'info' elf = ELF('./pwn') # 假设地址,按实际题目替换 read_plt = elf.plt.get('read') write_plt = elf.plt.get('write') bss = elf.bss() + 0x200 # 避开已有数据 # 偏移 offset = 0x28 # 阶段1:劫持返回地址,触发二次读入,把 ROP/JOP 链读到 BSS payload1 = b'A' * offset payload1 += p64(read_plt) # 但前提是 rdi/rsi/rdx 已经被程序设置好 p.send(payload1) # 阶段2:构造链子 # 实际上这里需要根据寄存器情况设计,不能无脑堆地址 chain = b'' chain += p64(0x401040) # pop rdi; ret chain += p64(bss) # rdi = bss (flag path) chain += p64(0x401050) # pop rsi; ret chain += p64(0) # rsi = 0 chain += p64(0x401060) # pop rdx; ret chain += p64(0) # rdx = 0 chain += p64(0x401070) # pop rax; ret chain += p64(2) # rax = open chain += p64(syscall_ret) # ... 后面接 read 和 write 链 p.send(chain) p.interactive()重要提醒:上面这些
pop rdi; ret之类的地址纯属占位,真实题目里如果存在,早就不是 JOP 题了。我只是用它说明常规思路。如果找不到,请回到第 3 节的寄存器活用技巧,用函数副作用代替 pop gadget。
6. 如果题目比想象中更难:常见变体与应对策略
6.1 变体一:只有一次输入机会怎么办
如果程序只让你输入一次,输入完直接崩溃,没有二次读入的机会。这时你要在唯一次输入里同时放下:
- 栈溢出的 padding 和返回地址;
- 用于构造链的完整数据。
栈空间不够怎么办?两个思路:
- 把链放到 BSS,但想办法在这一次输入里通过某个函数调用写入 BSS。如果程序里有
read(0, 某BSS地址, 大长度)的行为,你只需要在返回地址处跳回它,然后由它自动读入后续数据。这本质上还是二次输入,只不过第一次输入只是跳板,第二次输入才是内容。 - 使用 stack migration(栈迁移):把
rsp迁移到一个可写的大内存区域。但迁移一般需要leave; ret,在 JOP 题里可能没有。
6.2 变体二:所有jmp reg都带限定条件
有些jmp不是直接的,而是cmp rax, 0x??; jne ...; jmp rax之类的条件跳转,一旦条件不满足就飞到别处。找到这种 gadget 之后,要看看能不能通过控制输入字节数或填充值来满足条件。比如cmp rax, 0x2f会把 rax 和/的 ASCII 比较,如果你能让 rax 正好等于 0x2f,就能通过。
这是很恶心的限制,但也是 revenge 题最爱干的事。我的应对策略是:把所有候选 gadget 的完整汇编都拉出来,写进笔记,逐个标注前置条件,再做选择。
6.3 变体三:flag 文件名不是固定的
有的题目让你输入路径,有的题目固定打开/flag,还有的会随机读取/home/ctf/flag。当你不知道 flag 路径时,可以先利用puts输出当前目录,或者直接尝试常见路径。最简单的方法是在 exploit 里跑多个候选路径:
paths = [b'flag', b'./flag', b'/flag', b'/home/ctf/flag']逐个尝试,哪个能读出内容就算成功。不过如果程序里已经 open 好了 fd,就不需要路径猜测了。
6.4 变体四:栈上充满干扰数据
有些题目会在栈上抹掉你的链,或者在返回地址前插入随机 canary。如果没开 canary,但栈上有些函数残留数据覆盖了你的 padding,那就不能简单用A * offset来填充,需要动态调试确认当前栈上的值与偏移。我的做法是:
gdb ./pwn在read调用后打个断点,stack 40看栈布局,再用 cyclic 确认偏移。
7. 从 revenge 题里能提炼出的通用方法论
做这类题,我觉得值得沉淀下来的不是某个具体 exploit,而是面对"看似不可能的寄存器控制"时的分析顺序:
- 画出数据流:从输入到缓冲区的流向是什么?有没有二次读入?输出函数在哪?
- 列出所有
jmp和call序列:凡是能改变控制流且不依赖 ret 的地方,都是 JOP 的候选积木。 - 枚举每个函数的寄存器副作用:每调用一个已知函数,哪些寄存器会被设置成什么值?这一条是 JOP 的灵魂。
- 确定系统调用或关键函数需求:把攻击目标拆成若干步骤,给每一步标出需要的寄存器值。
- 用已知副作用去凑寄存器值:如果凑不出来,就换一个调用点,或者用输入长度去控制 rax。
这套方法帮我解决过不止一道 revenge 题,包括一些加了混淆、加了 seccomp、甚至把指令改成变体的题目。
7.1 一道题做完了,还要养成收集 gadget 的习惯
我自己的习惯是每做一道 PWN 题,就把用到的 gadget 存下来,附上完整上下文:
0x401020: jmp rax # 可跳转,但 rax 必须可控 0x401030: jmp qword ptr [rbx] # 需要 rbx 指向可写内存 0x401050: add rax, 8; jmp rax # 可偏移跳转,常用于对齐时间久了,你会对常见的指令模式产生直觉:看到jmp [reg]会立刻想"这个 reg 能不能被我控制",看到add rax, imm会想"能不能配合 read 返回值"。这种直觉在比赛里的价值比背十个 exploit 模板都大。
8. 个人经验小结:revenge 题该怎么练
最后聊聊我自己的体会。以我刷题的经验来看,jump_by_jump系列最好的练习方式不是直接看别人的 WP,而是分三步:
- 先自己尝试用常规 ROP 打一次,故意让它失败,搞清楚失败卡在哪一步(通常是没有 pop gadget 或没有 ret)。
- 然后带着问题去搜 gadget,把所有 jmp 类指令列出来,思考它们的组合方式。
- 再看别人 WP,重点看前辈是怎么解决寄存器控制的——是用了函数副作用,还是用 partical overwrite,还是用了某个冷门的
xchggadget。
我做这道题时最大的收获是:不要只盯着ret。现代 PWN 保护越来越严,ROP 的可用空间会被不断压缩,但jmp和call的语义更丰富,组合更灵活。当你把视角从"栈上的返回地址"切换到"寄存器里的目标地址"之后,很多看似必死的局面都能盘活。
再分享一个小技巧:如果本地有gdb,建议把每次构造的 payload 都丢进gdb里stack一下,看看执行到jmp rax那一刻寄存器的真实值。我之前经常自我感觉良好,结果一发上去就崩,最后发现是rax被某个函数内部调用改掉了。多一点实测,少一点猜测。
jump_by_jump_revenge这道题,说难也难,说简单也简单,关键就在于你能不能转过"用跳转代替返回"这个弯。把思路理顺,把寄存器副作用研究透,flag 自然就出来了。