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、或者敏感数据。

⚠️ 警告: 永远不要将用户输入直接作为printf的第一个参数。这是格式化字符串漏洞最常见的入口。

6.2 %n:最危险的格式符

如果说%x、%s只是读取数据,那%n就是写入数据的大杀器。

%n的作用是:将当前已经输出的字符数,写入到一个int*指针指向的地址。说白了,它能把一个整数值写到内存里。

攻击者利用%n,可以做到任意地址写入。流程一般是这样的:

  1. 先用%x或%p泄露栈地址,找到目标写入位置
  2. 在格式字符串中构造目标地址(通过栈上的参数传递)
  3. 用%n配合宽度控制(如%100x),精确写入想要的数值

我见过最经典的攻击案例,是用%n改写GOT表项。攻击者把某个函数的GOT地址写到栈上,然后用%n把该地址的值改成shellcode的入口。之后程序一调用那个函数,就直接跳到了攻击者的代码里。

// 攻击者构造的输入示例(伪代码)
// 目标:将某个GOT地址的值改为0xdeadbeef
// 输入: \x10\xa0\x04\x08%16930116x%7$n
// 解释: 先写入目标地址,然后通过宽度控制输出字符数,最后用%n写入
💡 核心要点: %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 知识体系图

下面这张图总结了格式化字符串漏洞的核心逻辑和防御路径:

格式化字符串漏洞知识体系 漏洞根源:格式串用户可控 攻击手法 %x / %p 读取栈内存值 %s 任意地址读取 %n 任意地址写入 泄露栈布局 / 地址 读取敏感数据 改写GOT / 返回地址 核心防御:格式字符串常量 编译警告(-Wformat-security) FORTIFY_SOURCE RELRO + ASLR

6.7 总结

格式化字符串漏洞,说白了就是「信任了不该信任的数据」。攻击者通过%x、%s读取栈内存,通过%n实现任意地址写入。防御手段其实不复杂——坚持使用格式字符串常量,就能从根上解决问题。

我每次做代码审查,看到printf(user_input)这种写法,都会直接标红。这不是小题大做。你想想看,一个%n就能改写GOT表,一个%s就能泄露密钥。这种漏洞一旦被利用,后果往往是灾难性的。

嗯,记住一句话:格式字符串,永远用常量。 就这么简单。


公众号:蓝海资料掘金营,微信deep3321