1. 项目概述:从一道CTF题看格式化字符串漏洞的实战价值
最近在带新人打CTF,发现很多朋友一遇到格式化字符串漏洞(Format String Vulnerability)的Pwn题就有点发怵。这玩意儿原理听起来不复杂,但真到了实战利用,面对一堆%x、%p和内存地址,往往不知道从何下手。正好,CTFshow平台上有不少经典的格式化字符串漏洞题目,从入门到进阶,覆盖了各种利用场景。我打算结合这些实战题目,把格式化字符串漏洞最常见的五种利用手法,掰开揉碎了讲清楚。这不仅仅是解题,更是理解漏洞利用链路的绝佳机会。无论你是刚接触二进制安全的新手,还是想系统梳理一下利用技巧的老兵,相信这篇从实战中总结出来的经验,都能让你有所收获。格式化字符串漏洞的本质是程序将用户输入直接作为格式化字符串参数(如printf(user_input)),而没有使用正确的格式化占位符(如printf(“%s”, user_input))。攻击者通过精心构造的输入,可以达成读取栈内存、覆写内存数据、甚至执行任意代码的目的。接下来,我们就以CTFshow的题目为战场,逐一拆解这五种核心手法。
2. 漏洞原理与利用基础:栈布局与格式化符探秘
在深入利用手法之前,我们必须把地基打牢。格式化字符串漏洞的利用,高度依赖于对函数调用时栈内存布局的理解。
2.1 栈帧结构与参数传递
当调用printf(format)时,参数format的地址会被压入栈中。如果format是用户可控的字符串,那么该字符串本身通常存储在栈的更高地址(如main函数的局部变量区)或堆上。但关键在于,printf会将其第一个参数之后的栈内容,视为它要打印的变量。在32位系统中,参数通过栈传递;在64位系统中,前六个参数通过寄存器(rdi, rsi, rdx, rcx, r8, r9)传递,多出的参数才用栈。这对于我们的利用姿势有决定性影响。
以一个简单的漏洞函数为例:
void vuln() { char buf[100]; read(0, buf, 99); printf(buf); // 漏洞点! }当printf执行时,它期望buf的内容是一个格式化字符串。如果我们输入%p,printf会将其解释为“打印一个指针”。由于我们没有提供对应的变量参数,printf就会去“预期”的栈位置上取数据——这个位置就是调用printf时,栈顶指针(ESP或RSP)所指向的下一个位置。简单来说,我们输入的格式化字符串本身在栈上的位置,决定了我们能用%n$p(n为整数)这样的格式访问到哪些内存数据。
注意:这里的
n$是POSIX规范的扩展,表示直接指定使用第n个参数。例如%3$p表示打印第三个参数(从格式化字符串本身算起)处的值。并非所有环境都支持,但在CTF中常见。
2.2 核心格式化占位符详解
我们的武器库就是这些格式化占位符,每个都有其独特作用:
%p、%x、%d:用于泄露内存数据。%p以指针格式(带0x前缀)输出,%x以十六进制无前缀输出,%d以十进制输出。通过它们,我们可以窥探栈上的内容,寻找返回地址、libc地址、canary等关键信息。%s:用于泄露任意地址的字符串。它会把对应参数解释为一个指针,并打印该指针指向的字符串,直到遇到空字节。这是获取.got.plt表中函数真实地址(从而计算libc基址)的关键。%n、%hn、%hhn:用于向任意地址写入数据。这是漏洞利用从“读”到“写”的质变点。%n:将截至目前已成功输出的字符数量,写入到对应参数所指向的地址(该参数应是一个指针)。写入的数据类型是int(4字节)。%hn:写入short类型(2字节)。%hhn:写入char类型(1字节)。 通过组合使用%hhn和精确控制输出字符数,我们可以逐字节地写入任意数据,例如将一个函数的GOT表项修改为system函数的地址。
%c:用于精确控制输出字符的数量。%c会打印一个字符,我们可以通过%Nc来输出N个字符(N为整数),从而增加%n系列写入器的计数。这是实现精确写入的关键。%a:有时用于泄露浮点数或特定格式数据,在格式化字符串漏洞中较少作为主要利用手段。
理解这些之后,我们面对一堆%p打印出的十六进制数,就不再是天文数字了。它们可能是栈地址、代码地址、libc地址,甚至是我们的输入字符串本身。下一步就是如何组织这些“武器”,达成攻击目标。
3. 手法一:信息泄露(Memory Leak)—— 窥探内存的奥秘
信息泄露是几乎所有格式化字符串漏洞利用的第一步,目的是获取关键的内存地址,为后续的利用铺平道路。CTFshow的入门题常常从这里开始。
3.1 确定偏移与栈空间测绘
我们的第一个任务是找到我们的输入字符串在栈上的“参数位置”。通常采用“探针”法。假设我们输入AAAA%p,%p,%p,%p,%p,%p,%p,程序输出可能类似:
0xffffd580, 0x64, 0xf7fa4580, 0xffffd5a4, 0x41414141, 0x2c70252c, 0x252c7025看到0x41414141了吗?那就是AAAA的十六进制表示(‘A’的ASCII码是0x41)。它出现在第5个%p对应的位置。这意味着,在这个调用上下文中,我们的格式化字符串是printf的第5个参数。那么,我们可以用%5$p来直接读取这个位置的值。更一般地,如果我们输入AAAA%n$p并递增n,当输出变为0x41414141时,就找到了偏移n。
实操心得:在本地调试时,由于环境差异(ASLR、栈布局),偏移可能与远程服务器不同。一个可靠的方法是先发送一串类似
%p.*20的payload,通过分隔符(如.)清晰地看到所有输出,再寻找规律。有时,题目会直接给出偏移,或通过多次尝试可以确定。
3.2 泄露关键数据:Canary、PIE与Libc
找到偏移后,我们就可以有目的地泄露数据了。
- 泄露栈地址与Canary:Canary(栈保护)通常位于栈上某个固定偏移处。通过泄露栈地址,我们可以计算出canary的位置,然后用
%偏移$p将其读出来。在后续覆盖返回地址时,需要保持canary的值不变,否则程序会因检测到栈破坏而崩溃。 - 泄露程序基址(PIE):如果程序开启了PIE(位置无关可执行文件),那么代码段的地址是随机的,但偏移是固定的。我们可以泄露一个来自
.text段的地址,例如某个函数的返回地址或main的地址,然后减去其在二进制文件中的偏移,就得到了程序的基址。有了基址,我们就能计算出任意got表或plt表的运行时地址。 - 泄露Libc基址:这是获取shell的关键。
printf、puts、read等库函数在程序全局偏移表(.got.plt)中的地址,在程序运行时会被解析为libc中的实际地址。我们可以用%偏移$s来打印这些地址。例如,如果printf的GOT表项地址位于我们可控的某个偏移处,使用%s打印,就能得到libc中的printf地址。然后,减去libc中printf的偏移,就得到了libc的基址。进而可以计算出system、/bin/sh字符串等关键符号的地址。
CTFshow例题解析:在一道入门题中,题目可能只给了简单的格式化字符串漏洞。我们的payload可能像这样:
# 假设通过探针确定格式化字符串本身是第6个参数 # 1. 泄露puts的got表地址内容(即libc中的puts地址) payload = p32(puts_got) + b'%6$s' # 注意:这里puts_got的地址会被放在栈上,%6$s会将其作为指针,打印指向的字符串,直到NULL。由于puts_got地址处存放的是一个地址(即libc puts),所以会打印出4或8字节的地址数据。 send(payload) leak = u64(recv(6).ljust(8, b'\x00')) # 接收并解包 libc_base = leak - libc.symbols['puts']这里有一个关键点:%s需要其对应的参数是一个指针。所以我们需要先把目标地址(如puts_got)写入栈中,然后让%n$s去读这个地址指向的内容。
4. 手法二:任意地址读(Arbitrary Read)—— 用%s撬开内存之门
任意地址读是信息泄露的进阶形式,它意味着我们可以读取程序内存空间中任意指定地址的内容,而不仅仅是栈上相邻的数据。这通常通过组合使用%s和栈上可控的地址来实现。
4.1 原理与Payload构造
核心思路是:将我们想要读取的目标地址,写入到栈上某个我们已知偏移的位置,然后使用%偏移$s去打印它。
假设我们通过探针,发现我们输入的字符串的前4个字节(32位)正好对应printf的第5个参数。那么:
- 我们构造payload:
[目标地址] + “%5$s”。 [目标地址]这4个字节会被放在栈上,占据第5个参数的位置。printf执行时,遇到%5$s,它会将第5个参数的值(即我们写入的目标地址)作为一个指针,去打印该指针指向的字符串,直到遇到空字节\x00。
这就完成了一次任意地址读。如果想读取多个地址,可以在栈上连续放置多个地址,并用%5$s%6$s...依次读取。但要注意栈对齐问题(64位下地址是8字节)。
4.2 实战应用:dump内存与寻找敏感信息
在CTF中,任意地址读可以用于:
- 泄露整个GOT表:获取多个libc函数地址,用于双重验证libc基址,或应对未知libc版本。
- 搜索程序中的敏感字符串:如
/bin/sh、flag、admin等。通过遍历.data段或堆上的地址,可能发现隐藏的线索。 - 泄露程序代码段(.text):在某些题目中,可能需要绕过一些检查,泄露部分代码逻辑。
注意事项:使用
%s进行任意地址读时,必须确保目标地址是可读的,并且以空字节结尾。如果目标地址不可读(如未映射的内存),程序会崩溃(Segmentation Fault)。如果目标地址没有空字节,printf会一直打印下去,直到遇到空字节或崩溃,这可能泄露大量内存,但也可能破坏输出缓冲区。
CTFshow例题场景:一道题目可能隐藏了一个admin_password在全局变量中。我们通过逆向工程找到了这个变量的地址0x804c060。利用格式化字符串漏洞,我们可以构造:
payload = p32(0x804c060) + b'%5$s'如果输出不是乱码,而是一串可读字符,那很可能就是密码。这比盲目的栈扫描要高效得多。
5. 手法三:任意地址写(Arbitrary Write)—— %n家族的魔法
如果说任意地址读是“侦察”,那么任意地址写就是“攻城”。通过%n及其变体,我们可以向内存中写入数据,这是实现控制流劫持的关键。
5.1 %n写入原理与宽度控制
%n的神奇之处在于它写入的值:是到它出现时,printf已经成功输出到屏幕(或缓冲区)的字符总数。例如:
int bytes_written; printf("Hello%n", &bytes_written); // bytes_written 的值将是 5 (Hello的长度)我们可以通过控制输出字符的数量,来控制写入的值。最直接的方法是利用格式说明符中的宽度字段。例如,%100c会输出100个字符(前面填充空格)。所以,%100c%n会向指定地址写入数字100。
5.2 单次写入与逐字节写入(%hhn)
然而,我们通常需要写入的是一个具体的地址值,比如0xdeadbeef或system的函数地址0xf7e13660。这个值可能很大(对应十进制数巨大),一次性输出这么多字符不现实,且可能受缓冲区限制。
这时就需要用到%hhn(写1字节)和分次写入的策略。思路是:将一个4字节(32位)或8字节(64位)的地址,拆分成多个1字节的写入操作,分别写入目标地址的不同字节位。
例如,我们要向地址0x804c014(GOT表中printf项)写入值0xf7e13660(system地址)。
- 假设
0x804c014处原值为0xf7e4c6a0(printf的地址)。 - 我们需要修改的四个字节分别是:
0x60(低字节),0x36,0xe1,0xf7(高字节)。 - 我们可以安排四次
%hhn写入:- 向
0x804c014写入0x60 - 向
0x804c015写入0x36 - 向
0x804c016写入0xe1 - 向
0x804c017写入0xf7
- 向
如何控制每次写入的值呢?我们需要精确计算每次%hhn执行前已输出的字符总数。这通常通过排列组合%c的宽度和静态字符串长度来实现,并注意写入顺序(一般从低字节开始写,因为写入低字节时所需输出的字符数较少)。
构造示例(概念性):
# 假设我们需要向addr1, addr2, addr3, addr4写入value1, value2, value3, value4 (1字节 each) # 并且我们可以控制栈上从偏移5开始放置地址 payload = p32(addr1) + p32(addr2) + p32(addr3) + p32(addr4) written = len(payload) # 目前已经输出的字节数 # 计算每个%hhn前需要输出的总字符数 target1 = value1 target2 = value2 target3 = value3 target4 = value4 # 注意:如果target小于当前已输出数,可以通过溢出(写入target+256)来解决,因为只写1字节。 payload += f'%{(target1 - written) & 0xff}c%5$hhn'.encode() written = target1 payload += f'%{(target2 - written) & 0xff}c%6$hhn'.encode() written = target2 payload += f'%{(target3 - written) & 0xff}c%7$hhn'.encode() written = target3 payload += f'%{(target4 - written) & 0xff}c%8$hhn'.encode()这个过程非常繁琐,通常需要脚本辅助计算。在CTF中,我们常使用pwntools库的fmtstr_payload函数来自动生成此类payload,它封装了所有的偏移计算和宽度控制。
6. 手法四:覆写GOT表劫持控制流
这是格式化字符串漏洞利用的经典目标,也是CTFshow中高级题目的常见考点。全局偏移表(GOT)在程序运行时存储着外部函数(如libc中的函数)的实际地址。如果我们能修改某个GOT表项,比如将printf的GOT项改为system的地址,那么下次程序调用printf时,实际上就会跳转到system去执行。
6.1 利用链设计
一个完整的利用链通常如下:
- 信息泄露:利用漏洞泄露至少一个libc函数的地址(如
printf),计算libc基址,进而得到system和/bin/sh的地址。 - 确定写入目标:选择要覆盖的GOT表项。通常选择
printf、strlen、atoi等接下来很快会被调用的函数,或者选择exit、__stack_chk_fail等即使程序即将结束也会调用的函数。 - 构造写入Payload:使用上述任意地址写手法(通常是
%hhn组合),将system的地址写入目标GOT表项。 - 触发函数调用:确保程序在执行我们的payload后,会调用被我们覆盖的函数。如果覆盖的是
printf本身,那么本次printf调用结束后才会使用新的GOT项,所以可能需要第二次触发漏洞。更常见的做法是覆盖另一个函数,然后让程序正常执行流去调用它。 - 传递参数:如果劫持的是
system,我们需要确保在调用时,其参数(RDI寄存器或栈上)指向字符串/bin/sh的地址。这可能需要额外的栈布局操作或ROP链配合,但在简单的格式化字符串漏洞中,有时可以通过巧妙的输入,让原本传递给printf的格式化字符串参数,恰好成为system的参数。
6.2 CTFshow例题实战拆解
假设有一题,程序循环读取用户输入并用printf输出,存在明显的格式化字符串漏洞,并且之后会调用exit函数。
- 泄露libc:发送
%p或%spayload,泄露出printf或puts的地址,计算出libc基址和system地址。 - 选择目标:我们选择覆盖
exit的GOT表项。因为程序结束后一定会调用exit。 - 自动生成payload:使用pwntools。
from pwn import * context.arch = 'i386' # 32位程序 # 假设 offset 为 6,即我们的输入是第6个参数 # exit_got 是 exit函数GOT表地址 # system_addr 是计算出的system函数地址 payload = fmtstr_payload(6, {exit_got: system_addr})fmtstr_payload会自动帮我们计算如何分字节写入system_addr到exit_got。 - 发送payload:将生成的payload发送给程序。
- 获取shell:当程序执行流结束,调用
exit时,实际跳转到system。但此时system的参数(在32位下是栈上的下一个值)需要是/bin/sh的地址。如果exit调用时栈上下一个值不可控,这种方法可能失败。因此,更稳妥的方案是同时覆盖exit的GOT和栈上的参数,或者寻找其他调用链。
更高级的题目可能会结合栈溢出,或者需要先泄露canary和程序基址(PIE),再利用格式化字符串写GOT。这就需要我们将多种漏洞利用技术结合起来。
7. 手法五:覆写栈上返回地址或函数指针
除了GOT表,栈上的数据也是重要的攻击目标。尤其是函数的返回地址和函数指针。
7.1 覆盖返回地址实现ROP
在栈溢出保护(如NX, Canary)齐全的情况下,直接覆盖返回地址可能困难。但格式化字符串漏洞的写操作是“精确制导”的,它不依赖于连续的缓冲区溢出,因此可以绕过canary检查。我们可以直接计算返回地址在栈上的位置,然后用%n或%hn将其覆盖为我们想要的地址,例如一个one_gadget(libc中一段能直接启动execve(‘/bin/sh’)的短序列)的地址,或者一个ROP链的起始地址。
步骤:
- 泄露栈地址,计算出当前函数返回地址的确切位置(相对于某个泄露点的偏移)。
- 使用任意地址写,将返回地址覆盖为
one_gadget地址。 - 当函数返回时,直接跳转到
one_gadget获取shell。
实操心得:
one_gadget对执行时的环境(寄存器状态、栈状态)有严格要求。在格式化字符串漏洞利用后,环境可能不满足其条件,导致利用失败。需要多尝试几个不同的one_gadget,或者通过部分ROP链来设置寄存器。
7.2 覆盖函数指针或HOOK
程序中可能包含一些函数指针,比如FILE结构体中的vtable、动态注册的信号处理函数、atexit处理程序等。如果这些指针存储在栈或全局变量中,并且其地址可知,那么也可以通过格式化字符串漏洞进行覆盖,从而在特定事件发生时劫持控制流。
例如,覆盖__malloc_hook或__free_hook是堆利用中的常见技术。虽然它们通常不在栈上,但如果题目中存在全局变量存储用户输入,且能通过格式化字符串泄露其地址并写入,也有可能实现。
7.3 综合案例:绕过保护获取Shell
考虑一道综合题:程序开启NX、PIE、Canary,有格式化字符串漏洞,但没有明显的栈溢出。我们的利用思路可能是:
- 利用格式化字符串泄露:
- 泄露栈地址,计算canary位置和返回地址位置。
- 泄露程序基址(通过
.text段地址),计算GOT表地址。 - 泄露libc基址(通过GOT表),计算
system、/bin/sh、one_gadget。
- 利用格式化字符串写入:
- 方案A:覆盖
printf的GOT为system,并确保下一次printf的参数是/bin/sh。这可能需要精心布局栈上的数据,让/bin/sh字符串地址出现在合适的位置。 - 方案B:覆盖返回地址为
one_gadget。需要检查one_gadget的约束条件。 - 方案C:覆盖某个即将被调用的函数指针(如果存在)。
- 方案A:覆盖
- 触发:让程序执行流按照我们修改的路径走。
8. 常见问题与排查技巧实录
在实际操作和CTF比赛中,利用格式化字符串漏洞时总会遇到各种“坑”。这里记录一些典型问题和解决思路。
8.1 Payload发送后程序崩溃或无输出
- 问题:构造的payload发送后,程序直接崩溃(收到SIGSEGV信号)或没有任何输出。
- 排查:
- 检查地址对齐:在64位系统中,栈上参数要求16字节对齐。如果payload中嵌入的地址没有正确对齐,可能导致
printf解析参数时访问错误地址。确保地址放置在8字节边界上(地址前可能需填充若干字节)。 - 检查
%s读取的地址:确保用%s读取的地址是有效的、可读的。尝试先用%p打印该地址的内容,看看是不是一个合理的指针。 - 检查
%n写入的地址:确保用%n写入的地址是可写的。尝试写入一个已知的可写地址(如.bss段)进行测试。 - 检查格式化字符串本身:过多的
%c可能会产生巨大的输出,填满缓冲区导致异常。可以尝试减小写入的值,或使用%hhn分多次写入。 - 本地与远程差异:偏移可能不同。重新探测远程偏移。
- 检查地址对齐:在64位系统中,栈上参数要求16字节对齐。如果payload中嵌入的地址没有正确对齐,可能导致
8.2 泄露的地址看起来不对
- 问题:泄露出的libc地址,计算出的基址看起来无效(例如,不是以
0x7f开头且后跟00的64位地址)。 - 排查:
- 字节序问题:确保接收数据后正确解包。32位用
p32/u32,64位用p64/u64。注意地址可能打印不全,需要接收足够字节并补齐。 - 泄露错了目标:确认你泄露的确实是GOT表项的内容。有时栈上可能有多级指针。用调试器(gdb)在漏洞点下断点,查看栈布局,确认你使用的偏移指向的是正确的内存单元。
- Libc版本不对:CTF题目可能使用特定版本的libc。用泄露出的地址的后12位(最后3个十六进制数字)去libc数据库(如https://libc.blukat.me)搜索,确定准确的libc版本。
- 字节序问题:确保接收数据后正确解包。32位用
8.3 使用pwntools的fmtstr_payload失败
- 问题:
fmtstr_payload(offset, writes)生成的payload执行后没有达到预期效果。 - 排查:
- 偏移(offset)错误:这是最常见的原因。务必通过
%p探针法或题目提示,准确确认偏移。offset参数指的是我们输入的格式化字符串本身在printf参数列表中的位置。 - 写入大小(write_size):
fmtstr_payload默认使用‘byte’(即%hhn)进行单字节写入。如果环境不支持%hhn,需要指定write_size='short'(%hn)或write_size='int'(%n)。 - 自动计算的长度问题:自动生成的payload可能因为长度问题,导致地址在栈上的位置发生偏移。可以尝试在payload前添加或减少一些填充字符(如
‘A’),然后重新计算偏移。 - 手动验证:将生成的payload在本地调试环境中单步执行,观察每一步
%n写入的内存值是否符合预期。
- 偏移(offset)错误:这是最常见的原因。务必通过
8.4 如何应对不支持%n$格式的环境?
- 问题:有些环境(如某些嵌入式设备或严格编译选项)可能不支持POSIX的
%n$直接定位参数。 - 解决方案:采用“堆叠”参数的方式。通过输入大量的
%p或%x来遍历栈,直到找到我们能控制的内存区域(通常是我们输入的长字符串本身)。然后,通过精确控制输入字符串中特定位置的内容作为地址,并用连续的格式化符(如%c%c...%s)来定位到该处进行读写。这需要更精细的栈布局计算。
格式化字符串漏洞的利用就像一场精密的记忆手术,需要对内存布局有清晰的认知,对格式化符的行为有精准的控制。从简单的信息泄露到复杂的任意地址写,每一步都充满了挑战和乐趣。在CTFshow的题目中反复练习这些手法,是掌握这门艺术的最佳途径。记住,调试器(gdb-peda/pwndbg)是你最好的朋友,多下断点,多观察栈和寄存器的变化,每一个成功的exploit背后,都是无数次调试和逻辑推理的结果。