在C语言的标准输入输出体系里,printf、fprintf、sprintf等函数都属于可变参数函数。它们通过格式字符串中的转换说明来决定后续参数如何被解释和输出。正常情况下,格式字符串由程序员在代码中硬编码,参数数量和类型都是明确的。然而一旦格式字符串来自用户输入、网络报文或文件内容,程序就可能偏离设计预期,形成格式化字符串漏洞。

一、格式化字符串的解析机制与漏洞成因
printf系列函数在解析格式串时,会从左到右扫描每一个字符。遇到普通字符时直接输出,遇到百分号时则进入转换说明解析流程,根据转换字符决定后续参数的类型和读取方式。例如%d要求从变参列表中读取一个int类型的值,%s要求读取一个char指针并输出其指向的字符串,%x则要求读取一个无符号整数并以十六进制形式显示。函数本身并不会检查调用者实际传入了多少个参数,也不会验证格式串是否与参数类型匹配,它只是机械地按照格式串的指示从栈上取数据。
这种设计在格式串由开发者控制时没有问题,因为开发者能保证格式串与参数一一对应。但C语言允许将变量直接作为格式串传给printf。例如printf(user_input)这种写法,如果user_input中包含百分号,printf就会把它当作转换说明来处理,并试图从栈上取相应的参数值。当输入内容为%x %x %x时,函数会连续取出栈上的多个数据并打印出来。这些数据很可能不是调用者显式传入的参数,而是栈上的返回地址、局部变量、函数指针或栈帧残留内容。
更危险的是%n转换符。它不输出任何字符,而是把当前已经输出过的字符总数写入到对应参数所指向的内存地址中。用%n可以修改一个int变量,配合长度控制还可以精确写入较小的值。如果攻击者能够在格式串中控制栈上某个位置为一个目标地址,再通过%n写入,就实现了任意地址写操作。漏洞本质不是printf的解析逻辑本身出错,而是调用方把不可信数据放到了控制流格式解析的位置上。
二、格式化字符串漏洞的典型利用方式
第一种利用方式是栈数据泄露。攻击者提交一串包含大量%x或%p的输入,观察程序输出。由于printf按照格式串逐个从栈上取参数,输出结果会连续显示出栈中的内容。通过分析这些十六进制值,攻击者可以还原出栈地址、全局变量地址、返回地址甚至字符串内容。下面的代码演示了一个危险的调用形式:
#include <stdio.h>
void vulnerable(char *user_input) {
printf(user_input); // 格式串来自外部输入,属于危险调用
}
int main() {
char input[128];
fgets(input, sizeof(input), stdin);
vulnerable(input);
return 0;
}
如果程序运行时输入%p %p %p %p,printf会从栈上连续读取四个值并输出。这些值可能包括栈帧地址、函数参数、返回值等。即便不能直接控制程序流,仅凭这些泄露出来的地址信息,攻击者就能绕过ASLR,为后续的ret2libc或ROP攻击提供基础。
第二种利用方式是任意地址写入。攻击者先在栈上布置目标地址,再在格式串中加入足够的%x来调整栈指针位置,最后使用%n把已输出字符数写入目标地址。这种手法通常用来覆盖返回地址、修改GOT表中某个函数的入口地址,或者篡改程序中的权限标志位。比如把exit函数的GOT表项改成system函数的地址,程序后续调用exit时就会跳转到system执行命令。
下面这段代码展示了%n的基本行为:
#include <stdio.h>
int main() {
int count = 0;
printf("AAAA%n", &count);
printf("\ncount = %d\n", count);
return 0;
}
程序先输出四个A,随后%n把已输出字符数4写入count变量。这里count是调用者显式传入的参数,所以行为符合预期。但在真实攻击中,攻击者会试图让%n对应到栈上受控的数据,或者直接利用栈上已有的地址。由于%n写入的是int大小,且可通过宽度修饰符控制写入值,攻击者有时需要分多次写入,每次只覆盖目标地址的一个字节或两个字节,降低写入长度过大带来的风险。
第三种利用方式是读取任意地址内容。攻击者把目标地址布置在格式串开头,随后使用%s。printf在处理%s时会把对应参数作为char指针,并输出该地址处的字符串。如果攻击者能够让printf把格式串中的目标地址当作%s的参数,就能读取该地址处的内容。具体做法依赖于栈布局和对齐,通常需要先借助%x遍历栈找到偏移位置,再用%s完成读取。这种手法可以读取程序内存中的敏感信息,例如密码、密钥或配置文件内容。
三、防御与安全编码实践
最直接的防御方式是把格式串固定为常量,所有外部输入都通过参数传入。例如使用printf("%s", user_input),而不是printf(user_input)。这样即使user_input中包含百分号,printf也只会将其视为普通字符串数据,不会对百分号做格式解析。对于需要动态拼接格式串的场景,应严格控制拼接来源,或使用专门的格式化接口,例如Linux下的asprintf配合固定格式串。
现代编译器普遍提供格式串检查功能。GCC和Clang在开启-Wformat或-Wall时会检查printf类函数的格式串和参数类型是否匹配。如果编译器发现格式串不是字符串字面量,或者参数类型与转换符不符,就会给出警告。有些项目将警告视为错误,使用-Werror强制阻断危险代码进入构建产物。不过编译器检查并不能完全覆盖运行时动态修改格式串的情况,因此代码审计仍然必要。
另一个有效策略是使用更安全的库函数。例如在C++中可以用std::format或fmt::format,它们在设计上分离了格式串和参数,且类型安全。对于C语言项目,可以使用snprintf并显式指定格式串,避免直接使用用户输入作为格式串。此外,一些运行环境保护机制也能降低漏洞影响,例如栈不可执行、ASLR、PIE以及RELRO。开启RELRO后,GOT表只读或部分只读,攻击者利用任意地址写入篡改GOT表的难度会明显增加。
#include <stdio.h>
void safe_print(const char *user_input) {
printf("%s", user_input); // 安全:格式串固定,输入仅作为字符串参数
}
int main() {
char input[128];
fgets(input, sizeof(input), stdin);
safe_print(input);
return 0;
}
在代码审计中,重点排查所有printf、fprintf、sprintf、snprintf、syslog等函数的第一个参数。如果第一个参数不是字符串字面量,而是一个变量、指针或拼接结果,就要确认该变量是否可能受外部输入影响。对于嵌入式系统或网络服务,尤其要关注从消息报文、HTTP请求、配置文件读取后直接作为格式串的路径。自动化工具如静态分析器、模糊测试也能有效发现这类漏洞。模糊测试可以生成大量包含百分号的随机输入,观察程序是否出现崩溃或异常输出。
四、总结
格式化字符串漏洞的根源在于printf系列函数对格式串的信任。只要不可信数据能够进入格式解析位置,就可能造成内存泄露或任意地址写入。这类漏洞通常出现在C/C++项目中,尤其是那些为了方便而直接打印用户输入或日志内容的代码路径。修复方式并不复杂,核心原则只有一条:格式串必须由开发者控制,外部数据只能作为参数进入格式化函数。
从工程实践角度看,不仅要修复单个危险调用,还应结合编译器告警、安全编码规范、静态分析和运行时防护来构建完整的防线。对于新项目,优先选择类型安全的格式化库或更高层语言特性。对于存量代码,通过扫描和重构逐步消除printf(user_input)这类模式。理解转换符、栈布局与参数传递机制,有助于开发者在遇到相关告警或代码审查意见时做出正确判断。