C语言格式化字符串漏洞:原理、利用与防御实战

📅 2026/6/26 8:08:55
C语言格式化字符串漏洞:原理、利用与防御实战
1. 项目概述从一次诡异的程序崩溃说起几年前我在做一个C语言写的网络服务端程序功能很简单就是接收客户端发来的日志消息然后用printf打印到控制台。上线测试没多久服务就莫名其妙地崩溃了日志里只留下一句“Segmentation fault”。排查了半天最后发现是一个新来的同事在客户端发送的日志里不小心包含了%s、%n这样的字符。就是这么一个看似不起眼的疏忽直接捅出了一个格式化字符串漏洞。这个漏洞英文叫Format String Vulnerability我们行内人常简称为“fmt漏洞”。它不像缓冲区溢出那样名声在外但破坏力一点也不小而且由于成因隐蔽特别容易被开发者忽略。简单来说格式化字符串漏洞就是程序在使用像printf、sprintf、fprintf这类格式化输出函数时如果允许用户控制格式化字符串本身就可能引发的一系列安全问题。比如本该写printf(%s, user_input)的地方如果偷懒写成了printf(user_input)那么user_input里的%x、%p、%n等格式符就会被printf函数当作指令来执行从而可能导致内存信息泄露、程序崩溃甚至任意代码执行。理解这个漏洞不仅是C/C程序员的基本功也是深入理解程序内存布局、函数调用约定的绝佳窗口。无论你是想写出更安全的代码还是对底层安全技术感兴趣搞懂fmt漏洞都至关重要。2. 漏洞原理深度拆解当数据变成了代码要理解格式化字符串漏洞我们不能停留在“不要用用户输入当格式化字符串”这个表面告诫上必须深入到printf这类函数的工作原理和程序的内存栈布局中去。2.1 printf函数到底在干什么很多人用了很久printf却未必清楚它内部的工作机制。当我们调用printf(“%s, %d\n”, “hello”, 123)时编译器会按从右到左的顺序取决于调用约定如cdecl将参数123和”hello”的地址压入栈中最后压入格式化字符串“%s, %d\n”的地址。printf函数被调用后它会从栈上获取格式化字符串的地址然后开始逐个字符解析这个字符串。当遇到普通字符如字母、逗号、空格它直接输出。当遇到%时它知道后面跟的是格式指示符。例如%s它会从栈上“取出”一个参数将其解释为一个指针地址然后去那个地址读取字符串直到遇到空字符\0为止并输出。%d则是取出一个参数将其解释为整数输出。关键点来了printf函数本身并不知道也不检查你传给了它多少个参数。它完全信任格式化字符串的指示。如果你在格式化字符串里写了5个%d它就会忠实地从栈上连续“取”5次把取到的内存数据当作整数打印出来。2.2 漏洞的诞生失控的格式化字符串现在考虑这个危险写法printf(user_input);。假设user_input是用户输入的字符串其内容存储在栈上或堆上的某个缓冲区里。在正常的、安全的用法中user_input应该被视为“要打印的数据”。例如用户输入“Hello World”程序就打印Hello World。但在printf(user_input)中user_input的内容被当作了格式化字符串也就是“要执行的指令”。如果用户输入的不是“Hello World”而是“%x %x %x”事情就变了味。printf开始解析user_input。遇到第一个%x它从栈上取出一个参数这个位置原本可能存放着其他局部变量或返回地址将其作为十六进制数打印。遇到第二个%x它再取出栈上的下一个数据。以此类推。此时用户输入的%x已经不再是数据而是变成了操控printf行为的代码。程序没有崩溃但它正在泄露栈内存的内容这些内容可能包含其他局部变量的值、函数的返回地址、甚至栈上的其他敏感数据。注意这里有一个常见的误解认为漏洞只存在于printf(user_input)。实际上任何将用户可控数据作为格式化字符串参数的函数调用都存在风险例如sprintf(buffer, user_input, ...)、fprintf(file, user_input, ...)甚至一些非标准函数如syslog。2.3 内存布局与参数定位理解漏洞利用必须对调用函数时的栈帧结构有个基本印象。以32位系统cdecl调用约定为例函数调用时栈的增长方向是从高地址到低地址。高地址 ... 调用者栈帧 参数N (从右向左压栈) ... 参数2 参数1 - printf期望的“第一个参数”对应格式化字符串后的第一个% 格式化字符串地址 - printf获取格式化字符串的位置 返回地址 - 函数执行完后要回到这里 保存的ebp - 当前栈帧基址 局部变量区 ... 低地址当printf根据%x去栈上取参数时它取的“第一个参数”的位置是相对于它“认为”的格式化字符串地址之后的位置。在printf(user_input)的漏洞场景下user_input本身可能就是栈上的一个缓冲区局部变量。那么printf从栈上取出的“第一个参数”、“第二个参数”很可能就是user_input缓冲区之后的其他栈内存包括其他局部变量、旧的ebp、乃至函数的返回地址。通过精心构造的格式化字符串攻击者可以像“读幻灯片”一样让printf逐张逐个内存单元地“放映”出栈上的内容。这就是内存信息泄露的本质。3. 漏洞利用手法全解析从读内存到写内存仅仅能读内存危害可能有限虽然泄露敏感信息也很严重。但格式化字符串漏洞真正危险的地方在于它还能写内存。这是通过一个特殊的格式符实现的%n。3.1 %n格式符漏洞利用的“杀手锏”%n的功能非常独特它不输出任何内容而是将到当前位置为止已经成功输出的字符总数写入到一个指定的内存地址中。这个地址由printf从栈上取出作为一个int *类型的参数。例如printf(“Hello%n”, count);执行后变量count的值将被赋值为5因为“Hello”有5个字符。在漏洞利用中攻击者可以这样做利用前面的内存读取能力%x,%p,%s探测栈内存布局找到某个他们可以控制的缓冲区比如user_input本身在栈上的位置。在这个缓冲区中精心嵌入一个目标地址比如某个函数指针GOT表项。通过计算和构造让printf在解析格式化字符串时把这个缓冲区中的地址当作%n的参数。%n就会将已输出的字符数写入到这个地址指向的内存中从而修改那个内存单元的值。3.2 利用链的构建信息泄露与地址计算一次完整的利用通常分两步走第一步信息泄露Information Leak攻击者首先发送一串如%p.%p.%p.%p.%p.%p...的payload。程序会打印出一堆十六进制的地址值。攻击者分析这些输出哪些值看起来像栈地址通常以0xff或0xbf开头哪些值看起来像代码段地址如libc函数地址有没有出现用户输入字符串本身的部分内容这能帮助定位用户输入在栈上的偏移。通过反复尝试和计算攻击者可以确定两件事偏移量Offset用户输入的格式化字符串其开头位于printf内部栈帧的“第几个参数”位置。例如通过输入“AAAA%x%x%x”观察输出中何时出现0x41414141‘A’的ASCII码就能确定偏移。关键地址泄露出的libc函数地址如printf自身的地址可以用来计算libc基址进而推算出system函数或“/bin/sh”字符串的地址。第二步内存写入Arbitrary Write知道了偏移量攻击者就可以在格式化字符串的特定位置放置目标地址。假设偏移量是6那么格式化字符串开头的4字节一个地址就会被printf当作“第6个参数”。攻击者可以这样构造payload[目标地址][填充字符][%偏移量$n][目标地址]想要修改的内存地址例如某个函数的GOT表地址。[填充字符]通过%[number]c来控制输出的字符数从而控制写入的值。例如%100c会输出100个字符。%偏移量$n这是格式化字符串的“直接参数访问”语法。%6$n表示使用栈上的第6个参数作为%n的写入地址。这样就能精准地将已输出的字符数通过填充字符控制写入到[目标地址]指向的内存。通过组合多个%[num]c和%[offset]$n攻击者可以分字节如分高、低16位向目标地址写入任意数值例如将一个函数的GOT表项修改为system函数的地址。3.3 利用场景与危害利用这个漏洞攻击者可以实现内存内容泄露读取栈、堆甚至任意地址的内存获取敏感信息如密码、密钥、绕过ASLR地址空间布局随机化。程序崩溃DoS使用%s读取一个非法地址或使用过多的格式符耗尽资源。任意内存写入修改函数指针、返回地址、GOT表、析构函数指针等最终劫持程序控制流执行任意代码。绕过安全机制在特定条件下可用于辅助绕过栈保护如Canary或实现更复杂的攻击链。实操心得在漏洞利用中%hn写入16位短整型比%n写入32位整型更常用。因为一次性写入一个很大的数如地址值可能需要输出数亿个字符程序可能崩溃或超时。而用%hn分两次写入高16位和低16位则高效得多。例如%[val1]c%[offset]$hn%[val2]c%[offset1]$hn。4. 漏洞挖掘与识别实战知道了原理我们如何在代码中找出这些危险的“地雷”呢不能只靠肉眼扫描printf。4.1 代码审计中的危险模式审计C/C代码时要对以下模式保持高度警惕格式化字符串参数直接来自用户输入char buf[100]; fgets(buf, sizeof(buf), stdin); printf(buf); // 高危间接来自用户输入void log_message(char *user_msg) { printf(user_msg); // 如果user_msg最终来自用户则高危 }使用%s格式化用户输入但未过滤%字符char buf[100]; snprintf(buffer, sizeof(buffer), Error: %s, user_input); // 如果user_input包含%且后续又有其他printf(buffer)可能仍会解析。包装或封装函数void my_printf(const char *fmt, ...) { va_list args; va_start(args, fmt); vprintf(fmt, args); // 如果fmt用户可控危险传递到了这里。 va_end(args); }4.2 动态测试与Fuzzing静态审计可能遗漏复杂的逻辑路径。动态测试是必要的补充手工测试在输入点尝试提交以下payload观察程序反应%p、%x、%lx观察是否输出十六进制数。%s可能导致程序崩溃段错误这是漏洞存在的强烈信号。%n在较旧或未加固的系统上可能导致写入异常。AAAAAAAA%x%x%x...寻找输出中出现0x41414141的偏移。自动化Fuzzing使用像zzuf、AFL等模糊测试工具或编写简单的脚本向程序输入大量包含随机格式符%后跟随机字母的字符串监控程序是否崩溃或产生异常输出。使用专用工具静态分析工具如Coverity、Fortify以及专门针对源码的flawfinder、RATS都能有效检测格式化字符串漏洞。4.3 一个简单的漏洞检测脚本示例下面是一个用Python写的简单检测脚本思路用于测试一个命令行程序import subprocess import sys def test_fmt_vuln(program_path, input_arg): 测试程序是否存在格式化字符串漏洞 test_cases [ “%p” * 10, # 测试信息泄露 “%s”, # 测试读取非法地址 “AAAA%x%x%x%x”, # 测试偏移定位 “%999999c”, # 测试大量输出 ] for payload in test_cases: try: # 假设程序从标准输入读取 proc subprocess.Popen([program_path], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE) stdout, stderr proc.communicate(inputpayload.encode(), timeout2) # 判断依据 if b‘0x’ in stdout and len(stdout) len(payload): # 输出了地址 print(f“[!] 疑似信息泄露: 输入 {payload} 产生输出: {stdout[:100]}) if proc.returncode -11 or b‘Segmentation fault’ in stderr: # 段错误 print(f“[!] 疑似漏洞触发崩溃: 输入 {payload}”) except subprocess.TimeoutExpired: print(f“[!] 输入 {payload} 导致超时可能大量输出) proc.kill() except Exception as e: print(f“[?] 测试 {payload} 时发生异常: {e}”) if __name__ “__main__”: if len(sys.argv) ! 2: print(“用法: python detector.py 待测试程序路径”) sys.exit(1) test_fmt_vuln(sys.argv[1], “-f”)注意事项在生产环境或未经授权的系统上运行此类测试是违法的。务必只在你自己拥有完全权限的测试环境或CTF靶机中进行。5. 防御之道编写安全的格式化代码知道了漏洞怎么产生的防御就有了明确的方向核心原则就是永远不要让用户可控的数据成为格式化字符串。5.1 黄金法则格式化字符串与数据严格分离这是最根本、最有效的防御措施。错误示范char user_input[100]; scanf(“%99s”, user_input); printf(user_input); // 绝对禁止正确做法char user_input[100]; scanf(“%99s”, user_input); printf(“%s”, user_input); // 将user_input作为参数而非格式字符串或者如果格式也需要部分控制但仍需极度谨慎printf(“%.100s”, user_input); // 指定精度同时防止溢出5.2 使用更安全的替代函数对于简单的输出可以考虑使用不解析格式符的函数fputs(user_input, stdout)直接输出字符串不进行任何格式化解析。puts(user_input)输出字符串并自动添加换行符。但要注意这些函数可能无法满足复杂的格式化需求。5.3 编译时与运行时的加固现代编译器和操作系统提供了多种机制来缓解此类漏洞编译器警告GCC/Clang的-Wformat-security和-Wformat2选项可以检测出printf(user_input)这类明显的危险用法。务必在编译时开启这些警告并视其为错误-Werror。位置无关可执行文件PIE与地址空间布局随机化ASLR这虽然是针对整个系统的防护但能极大增加攻击者利用格式化字符串漏洞的难度。ASLR使得栈地址、库地址在每次程序运行时都随机变化攻击者难以预测目标地址。编译时加上-pie -fPIE选项启用PIE。只读重定位RELROPartial RELRO让GOT表在初始化后变为只读防止被%n覆盖。编译选项-Wl,-z,relro。Full RELRO在程序启动时立即解析所有动态符号并将整个GOT表设置为完全只读。这是更强的保护。编译选项-Wl,-z,relro,-z,now。Full RELRO能有效防御通过修改GOT表进行的控制流劫持。栈保护Stack Canary虽然主要防缓冲区溢出但也能让攻击者更难通过覆盖栈上返回地址来利用漏洞。编译选项-fstack-protector-all。非可执行栈NX/DEP防止在栈上执行代码。即使攻击者通过%n在栈上写入了shellcode也无法执行它。现代操作系统默认开启。一个相对安全的编译命令示例gcc -o my_program my_program.c -Wall -Werror -Wformat-security -pie -fPIE -Wl,-z,relro,-z,now -fstack-protector-all -D_FORTIFY_SOURCE2其中-D_FORTIFY_SOURCE2会在编译时和运行时对某些字符串和内存操作函数进行加强检查。5.4 输入验证与过滤如果业务逻辑确实复杂需要用户输入影响格式这种情况应极力避免则必须进行严格的输入验证。白名单过滤只允许一组已知安全的字符。例如如果只需要显示数字和字母可以过滤掉所有%字符。char safe_input[100]; for (int i 0, j 0; user_input[i] ! ‘\0’; i) { if (isalnum((unsigned char)user_input[i]) || user_input[i] ‘ ‘) { safe_input[j] user_input[i]; } } safe_input[j] ‘\0’; printf(“%s”, safe_input);转义%字符将用户输入中的%替换为%%这样printf会将其解释为普通的百分号字符输出。char escaped[200]; // 注意大小最坏情况长度翻倍 escape_percents(user_input, escaped); printf(“%s”, escaped); // 或者如果格式必须混合仍需极度小心实操心得输入过滤非常容易出错且往往治标不治本。一个更健壮的设计是彻底避免将用户输入与格式字符串拼接。例如使用多个printf语句或使用更安全的字符串构建函数如snprintf进行多次拼接但确保格式字符串是硬编码的。6. 从漏洞到利用一个简化的CTF例题分析让我们通过一个极度简化的“CTF风格”示例将前面所有知识串联起来。假设有一个存在漏洞的程序vuln.c#include stdio.h #include string.h void vuln_func() { char buffer[100]; printf(“Enter your name: “); fgets(buffer, sizeof(buffer), stdin); buffer[strcspn(buffer, “\n”)] 0; // 去掉换行符 printf(“Hello, “); printf(buffer); // 漏洞点 printf(“!\n”); } int main() { vuln_func(); return 0; }编译时我们故意关闭一些保护以便观察效果gcc -o vuln vuln.c -m32 -no-pie -fno-stack-protector -z execstack32位无PIE无栈保护栈可执行。攻击者的视角信息泄露确定偏移输入AAAA%x.%x.%x.%x.%x.%x.%x。程序输出Hello, AAAAffaabbcc.80.0.0.41414141.78252e78.2e78252e.252e7825!看到0x41414141出现在第4个%x对应的输出位置。这说明我们输入的AAAA0x41414141出现在了printf眼中“第4个参数”的位置。所以偏移量是4。泄露libc地址计算system地址输入%4$p直接参数访问打印栈上第4个参数即我们输入的前4字节。但我们先输入一个合法地址试试这里我们先泄露一个已知函数的地址。我们可以输入AAAA%5$p、AAAA%6$p…来遍历寻找一个指向libc的指针比如__libc_start_main的返回地址。假设输入%7$p后得到输出0xf7e0a2e3这看起来像一个libc中的地址。在攻击者机器上或通过提供的libc可以计算出这个地址与system函数的偏移差。假设偏移差是delta。那么system_addr leaked_addr delta。覆盖GOT表劫持控制流目标是覆盖printf的GOT表项使其指向system函数。首先需要知道printfgot的地址。由于程序是-no-pie这个地址是固定的可以通过objdump -R vuln得到假设是0x0804c00c。我们需要将0x0804c00c这个地址写入到栈上偏移为4的位置然后使用%n或%hn向这个地址写入system函数的地址值。由于写入的值system的地址可能很大我们使用%hn分两次写入低16位和高16位。这需要精确计算已输出的字符数。构造payload结构[addr_low][addr_high][padding_low]%[offset]$hn[padding_high]%[offset1]$hnaddr_low:printfgot的地址0x0804c00c。addr_high:printfgot 2的地址0x0804c00e。我们需要计算padding_low和padding_high使得已输出的字符数分别等于system_addr的低16位和高16位。假设system_addr 0xf7e0a2e3低16位是0xa2e341443高16位是0xf7e063456。但addr_low和addr_high本身已经输出了8个字节。所以第一个%hn前需要输出41443 - 8 41435个字符。用%41435c实现。第二个%hn前需要再输出63456 - 41443 22013个字符。用%22013c实现。最终payloadPython构造from pwn import * p process(‘./vuln’) printf_got 0x0804c00c system_low 0xa2e3 system_high 0xf7e0 payload p32(printf_got) p32(printf_got2) payload f”%{system_low-8}c%4$hn”.encode() payload f”%{system_high-system_low}c%5$hn”.encode() p.sendline(payload) p.interactive()发送此payload后当下一次程序调用printf时实际执行的将是system函数。如果此时我们能控制printf的参数比如让它打印“/bin/sh”就能获得一个shell。这个例子极度简化忽略了现代系统上ASLR、Full RELRO等保护但清晰地展示了从信息泄露到计算偏移再到任意地址写入的完整链条。7. 高级话题与衍生思考7.1 现代环境下的利用挑战随着安全防护技术的普及传统的格式化字符串漏洞利用在默认开启ASLR、PIE、Full RELRO、NX的现代系统上变得非常困难。信息泄露仍是突破口ASLR随机化了基址但如果没有信息泄露攻击者就是“盲人摸象”。格式化字符串漏洞本身就是一个强大的信息泄露原语。攻击者可以先利用它泄露栈地址、libc地址计算出所需的各种偏移先绕过ASLR然后再进行后续的写内存操作。写什么Full RELRO让GOT表只读%n无法修改。攻击者可能会转向其他可写且有函数指针的地方比如栈上的返回地址需要精确知道其位置并绕过栈保护Canary。析构函数指针dtors或atexit处理程序在某些旧版本或特定配置中可能可写。C虚函数表指针vptr如果对象内存可控。__malloc_hook,__free_hook,__realloc_hook(glibc)这些是glibc中用于调试的全局函数指针在旧版本中可写是经典的利用目标。但在新版本glibc2.34中这些hook已被移除。利用链组合单一的漏洞可能不足以完成利用。攻击者可能需要结合格式化字符串漏洞用于信息泄露和另一个漏洞如堆溢出、UAF用于实现写原语来组成完整的攻击链。7.2 其他语言的类似问题格式化字符串漏洞并非C/C独有。任何提供类似格式化功能的语言如果接口使用不当都可能存在类似问题只是表现形式和危害不同。Python”%s” % user_input是安全的因为%运算符要求元组参数。但user_input % ()呢如果user_input包含%(key)s且上下文提供了字典就可能造成信息泄露。更危险的是旧的%格式化方式与某些库的结合但总体风险较低。应优先使用更安全的.format()或f-string。PHPprintf()、sprintf()等函数同样存在格式化字符串问题。例如sprintf($user_input, $extra)。PHP的过滤机制可能更复杂。JavaString.format()如果格式字符串用户可控同样可能导致异常或信息泄露如通过%n引发异常或通过%s触发空指针异常。Java的%n是平台相关的行分隔符不是写内存但可能造成意外行为。Web模板注入这与格式化字符串漏洞在思想上高度相似。用户输入被当作模板语言如Jinja2, Twig, Smarty解析导致服务端模板注入SSTI可造成远程代码执行危害极大。7.3 安全开发规范建议代码审查清单在团队代码审查中加入对格式化函数的专项检查。重点关注printf,fprintf,sprintf,snprintf,syslog等函数的调用确保格式字符串是字面常量或经过严格验证。使用静态分析工具将静态分析工具集成到CI/CD流水线中自动检测格式化字符串漏洞。安全函数库考虑使用安全版本的字符串处理库或者在公司内部封装安全的日志打印函数强制要求使用。// 一个简单的安全日志函数示例 void safe_log(FILE *stream, const char *fmt, ...) { va_list args; va_start(args, fmt); vfprintf(stream, fmt, args); // 这里fmt必须是可信的常量字符串 va_end(args); } // 用于记录用户数据 void log_user_data(const char *user_data) { safe_log(stdout, “User input: %s\n”, user_data); // 正确用法 // safe_log(stdout, user_data); // 编译警告或错误 }开发者培训让每一位C/C开发者都理解格式化字符串漏洞的原理和危害养成“格式化字符串必须为常量”的肌肉记忆。格式化字符串漏洞是一个经典的“安全-开发”认知差案例。对开发者来说它可能只是一个能简化代码的“小技巧”对安全研究者来说它是一个可以深入程序心脏的“手术刀”。消除这种认知差正是我们深入理解它的意义所在。在我自己的开发生涯中每次手指快要敲下printf(buffer)的时候那次服务崩溃的回忆都会跳出来提醒我数据与代码的边界是系统安全最脆弱的防线之一必须时刻敬畏严守不怠。