5. 格式化字符串漏洞:printf家族函数的漏洞原理,%n格式符的危害,格式化字符串的防御

好,咱们今天聊一个经典的老漏洞——格式化字符串漏洞。

说实话,这个漏洞在现在的编译器和操作系统上已经不那么好触发了。但你要是做嵌入式开发、IoT固件分析,或者搞CTF比赛,这玩意儿还是经常冒出来。我个人习惯是,每接触一个新平台,第一件事就是测试printf家族函数的行为——这能帮你快速摸清编译器的脾气。

5.1 漏洞原理:printf到底在干什么?

先看一个最简单的例子:

char *name = "Alice";
printf(name);  // 危险写法

你可能会想,这有什么问题?不就是打印个字符串吗?

嗯,问题大了。printf的第一个参数是格式化字符串,它有权力解析%开头的格式符。如果name的内容是用户可控的,比如用户输入了%x %x %x %x,那printf就会从栈上读取数据,当成十六进制数打印出来。

为什么会这样?

因为printf不知道你给了几个参数。它只按格式化字符串里的%号个数,从栈上依次取参数。你给了4个%x,它就取4次。栈上有什么?局部变量、返回地址、甚至函数指针——全给你抖出来。

核心要点:格式化字符串漏洞的本质是参数数量不匹配。printf期望你提供与%号数量一致的参数,但如果你把用户输入直接塞进格式化字符串,攻击者就能控制%号的数量,从而任意读取或写入栈内存。

5.2 %n格式符:真正的杀招

%x只是读,危害有限。真正要命的是%n

%n的作用是:把当前已经输出的字符数,写入到一个int指针指向的地址

举个例子:

int count;
printf("Hello%n", &count);
// 此时count = 5,因为"Hello"是5个字符

看起来人畜无害对吧?但你想过没有——如果攻击者能控制%n前面的输出长度,并且能控制%n对应的地址参数,他就能往任意地址写入任意值

我在项目中遇到过这样一个案例:一个嵌入式设备的Web管理界面,用snprintf拼接日志,结果用户输入的IP地址里带了%n。嗯,那台设备后来被用来做跳板攻击内网。从那以后,我所有涉及格式化字符串的代码,都会加一行注释:// 检查用户输入是否包含%

警告:%n写入的是已输出字符数,不是直接写入你指定的数值。攻击者需要通过填充字符(比如%100d)来精确控制写入的值。这需要精细的构造,但一旦成功,就能改写GOT表、返回地址等关键内存。

5.3 攻击流程:三步走

一个典型的格式化字符串攻击,通常分三步:

  1. 信息泄露:用%x%p读取栈内存,找到目标地址(比如GOT表项、返回地址)。
  2. 定位参数位置:用%n$p(比如%7$p)直接访问第7个参数,找到用户输入在栈上的偏移。
  3. 写入攻击:用%n配合%hn(写入2字节)或%hhn(写入1字节),精确改写目标地址。

你想想看,一个printf就能完成读、写、控制流劫持——是不是很可怕?

5.4 防御措施:怎么防?

防御其实不复杂,但需要养成习惯。我总结了四条铁律:

防御手段 说明 优先级
永远不要用用户输入做格式化字符串 printf("%s", user_input)代替printf(user_input) 最高
启用编译器的格式化字符串检查 GCC的-Wformat -Wformat-security,Clang的-Wformat-nonliteral
使用位置参数时验证偏移 如果必须用%n$p,确保n不超过参数总数
禁用%n(现代glibc默认支持) 通过FORTIFY_SOURCE_FORTIFY_SOURCE=2,运行时检测%n

我的个人习惯:在代码审查时,我会专门搜索printf(sprintf(fprintf(这三个函数,检查第一个参数是否来自外部输入。如果是,直接打回。这个习惯帮我拦下了至少3个线上漏洞。

5.5 安全编码示例

正确的写法:

// 安全:用户输入作为数据,不是格式
printf("%s", user_input);

// 安全:固定格式化字符串
printf("User: %s, Age: %d", name, age);

// 安全:snprintf同样处理
snprintf(buf, sizeof(buf), "%s", user_input);

错误的写法:

// 危险:用户输入直接作为格式
printf(user_input);

// 危险:拼接用户输入到格式字符串
char fmt[256];
snprintf(fmt, sizeof(fmt), "Log: %s", user_input);
printf(fmt);  // 如果user_input包含%n,就完了

我曾经见过一个代码,把用户输入的日志级别拼接到格式化字符串里,然后传给syslog。攻击者通过构造日志级别字符串,成功实现了远程代码执行。嗯,那个CVE编号我还记得——CVE-2018-1000001。

5.6 知识体系图

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

格式化字符串漏洞知识体系 printf家族函数 正常使用:参数匹配 漏洞触发:参数不匹配 安全输出 读:%x %p 泄露栈内存 写:%n 任意地址写入 获取GOT地址、返回地址 改写GOT/返回地址 远程代码执行(RCE) 防御:固定格式字符串

5.7 总结

格式化字符串漏洞,说白了就是信任了不该信任的数据。printf家族函数本身没有错,错的是我们把用户输入当成了代码来执行。

防御的核心就一句话:格式化字符串必须是编译期确定的常量。如果做不到,至少要用%s把用户输入包起来。

嗯,最后提醒一句:别以为现代编译器会自动帮你修这个问题。我见过不少项目,编译时没开-Wformat-security,结果线上出了漏洞。安全,终究是人的事情。


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