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 攻击流程:三步走
一个典型的格式化字符串攻击,通常分三步:
- 信息泄露:用
%x或%p读取栈内存,找到目标地址(比如GOT表项、返回地址)。 - 定位参数位置:用
%n$p(比如%7$p)直接访问第7个参数,找到用户输入在栈上的偏移。 - 写入攻击:用
%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 知识体系图
下面这张图总结了格式化字符串漏洞的核心逻辑:
5.7 总结
格式化字符串漏洞,说白了就是信任了不该信任的数据。printf家族函数本身没有错,错的是我们把用户输入当成了代码来执行。
防御的核心就一句话:格式化字符串必须是编译期确定的常量。如果做不到,至少要用%s把用户输入包起来。
嗯,最后提醒一句:别以为现代编译器会自动帮你修这个问题。我见过不少项目,编译时没开-Wformat-security,结果线上出了漏洞。安全,终究是人的事情。
公众号:蓝海资料掘金营,微信deep3321