6. 格式化字符串漏洞:printf族函数的%n、%s、%x等格式符滥用
格式化字符串漏洞,我愿称之为C语言中最「优雅」也最「阴险」的漏洞之一。
为什么这么说?因为它藏得深。你写代码时可能只是少写了一个参数,或者偷懒直接把用户输入扔给了printf。结果呢?攻击者能通过几个%x、%s、%n,把你的栈内存读个精光,甚至往任意地址写数据。
我早年做嵌入式固件安全审计时,就遇到过一例。一个网络设备的管理界面,日志功能用了printf(user_input)。我当时盯着那行代码看了三秒,心里就一个念头:这设备怕是要凉。
6.1 漏洞根源:格式符与参数的不匹配
先说说最基础的东西。printf族函数的原型是:
int printf(const char *format, ...);
第一个参数是格式字符串,后面是可变参数。正常情况下,格式字符串里有多少个格式符,后面就跟着多少个参数。但C语言编译器不会检查你给的参数够不够——它信任你。
问题就出在这里。如果格式字符串是用户可控的,攻击者就能塞入大量格式符,让printf去读取栈上本不属于它的数据。
举个例子:
// 漏洞代码
char buf[100];
gets(buf); // 用户输入: %x.%x.%x.%x.%x
printf(buf); // 打印出栈上的值
这段代码会打印出栈上连续5个整数值。攻击者可以用这个手法,一步步摸清栈布局,找到返回地址、canary、或者敏感数据。
6.2 %n:最危险的格式符
如果说%x、%s只是读取数据,那%n就是写入数据的大杀器。
%n的作用是:将当前已经输出的字符数,写入到一个int*指针指向的地址。说白了,它能把一个整数值写到内存里。
攻击者利用%n,可以做到任意地址写入。流程一般是这样的:
- 先用%x或%p泄露栈地址,找到目标写入位置
- 在格式字符串中构造目标地址(通过栈上的参数传递)
- 用%n配合宽度控制(如%100x),精确写入想要的数值
我见过最经典的攻击案例,是用%n改写GOT表项。攻击者把某个函数的GOT地址写到栈上,然后用%n把该地址的值改成shellcode的入口。之后程序一调用那个函数,就直接跳到了攻击者的代码里。
// 攻击者构造的输入示例(伪代码)
// 目标:将某个GOT地址的值改为0xdeadbeef
// 输入: \x10\xa0\x04\x08%16930116x%7$n
// 解释: 先写入目标地址,然后通过宽度控制输出字符数,最后用%n写入
6.3 %s与%x:栈内存的「读」攻击
%s和%x虽然不能写,但读起来也够要命的。
%s会从栈上取出一个指针,然后打印该指针指向的字符串。如果攻击者控制了这个指针的值,就能读取任意地址的内存。比如读取内存中的密码、密钥、或者敏感配置。
%x则直接打印栈上的整数值。连续多个%x,就像在翻看栈上的「日记本」。攻击者能从中提取出返回地址、栈基址、甚至canary值。
我记得有一次做渗透测试,目标程序用了printf(user_input)。我输入了%p.%p.%p.%p,返回的四个指针值里,第三个正好是栈上的一个局部变量地址。顺着这个线索,我一步步拿到了程序的基址,最终实现了远程代码执行。
printf("%s", user_input)——这其实也是不安全的。虽然第一个参数是常量,但如果user_input里包含%n,它仍然会被解释。正确的做法是用printf("%s", user_input),但确保user_input本身不包含格式符。最稳妥的方案是用fputs(user_input, stdout)。
6.4 防御措施:使用格式字符串常量
说了这么多攻击手法,防御其实很简单——就一条铁律:永远使用格式字符串常量。
什么叫格式字符串常量?就是编译时就能确定的字符串字面量,而不是运行时拼接或用户输入的字符串。
正确的写法:
// ✅ 安全写法
printf("%s", user_input); // 格式串是常量,user_input作为参数传入
fprintf(stderr, "Error: %s\n", error_msg); // 同理
// ❌ 危险写法
printf(user_input); // 格式串是用户输入
fprintf(stderr, error_msg); // 同理
就这么简单的一个改动,就能彻底封死格式化字符串漏洞。
但现实中,我见过太多「聪明反被聪明误」的代码。比如有人为了「灵活」,在运行时拼接格式字符串:
// 看似灵活,实则危险
char fmt[256];
snprintf(fmt, sizeof(fmt), "The value is: %s", user_provided_type);
printf(fmt, value);
如果user_provided_type里包含了%n,那这个代码就炸了。我个人习惯是,只要涉及格式字符串,一律用常量。绝不把任何用户数据放进格式串里。
6.5 其他辅助防御手段
除了核心的「格式字符串常量」原则,现代编译器也提供了一些辅助手段:
| 防御手段 | 说明 | 有效性 |
|---|---|---|
| GCC的-Wformat-security | 编译时警告非常量格式字符串 | 高(但只是警告,不是错误) |
| FORTIFY_SOURCE | glibc的运行时保护,检测%n是否用于非常量格式串 | 中(可被绕过) |
| RELRO(只读重定位) | 将GOT表设为只读,防止%n改写 | 中(增加攻击难度) |
| ASLR(地址空间布局随机化) | 随机化地址,增加攻击者定位难度 | 低(%s/%x可泄露地址) |
这些手段有用,但都不能替代「格式字符串常量」这条根本原则。你想想看,如果代码里根本没有非常量格式串,攻击者连施展的空间都没有。
6.6 知识体系图
下面这张图总结了格式化字符串漏洞的核心逻辑和防御路径:
6.7 总结
格式化字符串漏洞,说白了就是「信任了不该信任的数据」。攻击者通过%x、%s读取栈内存,通过%n实现任意地址写入。防御手段其实不复杂——坚持使用格式字符串常量,就能从根上解决问题。
我每次做代码审查,看到printf(user_input)这种写法,都会直接标红。这不是小题大做。你想想看,一个%n就能改写GOT表,一个%s就能泄露密钥。这种漏洞一旦被利用,后果往往是灾难性的。
嗯,记住一句话:格式字符串,永远用常量。 就这么简单。