第十四章 格式化字符串漏洞:原理分析、防范措施、C语言安全格式化
格式化字符串漏洞,听起来像个老古董对吧?
说实话,我第一次在代码里遇到它时,还以为是编译器抽风了。直到我亲眼看到攻击者通过一个 %s 就能把栈上的数据读出来,我才意识到——这玩意儿比想象中要危险得多。
今天我们就来彻底扒一扒这个漏洞。我会结合我踩过的坑,把原理、攻击手法和防御措施都讲清楚。
14.1 漏洞原理:到底哪里出了问题?
先看一段典型的“问题代码”:
#include <stdio.h>
void vulnerable() {
char buf[100];
printf("请输入内容:");
fgets(buf, sizeof(buf), stdin);
printf(buf); // 漏洞就在这里!
}
嗯,看出来了吗?printf(buf) 直接把用户输入当成了格式化字符串。正确的写法应该是 printf("%s", buf)。
为什么会这样?
因为 printf 这个函数,它的第一个参数就是格式化字符串。如果你把用户输入直接传进去,那用户输入里的 %d、%s、%x 就会被当成格式控制符来解析。
攻击者可以利用这一点:
- 读取栈内存:通过
%x、%p把栈上的数据打印出来 - 读取任意地址:结合
%s和地址构造,可以读取任意内存位置 - 写入任意地址:通过
%n把已输出的字符数写入指定地址
我在项目中遇到过最离谱的一次,是有人用 %p%p%p%p 就把栈上的返回地址给泄露了。攻击者拿到这个地址,直接就能算出代码基址,然后绕过 ASLR。
14.2 攻击手法:攻击者是怎么玩的?
14.2.1 信息泄露
假设用户输入了:
AAAA%p.%p.%p.%p.%p.%p
输出可能长这样:
AAAA0x7ffd3a2b.0x7f8c4a.0x401234.(nil).0x41414141.0x41414141
看到最后两个 0x41414141 了吗?那就是我们输入的 AAAA(ASCII 码 0x41)。攻击者通过这种方式,可以定位到自己的输入在栈上的位置,然后精准地读取或写入数据。
关键点:%p 打印指针,%x 打印无符号十六进制。攻击者用这些就能把栈上的返回地址、canary、局部变量全给扒出来。
14.2.2 任意地址写入
这才是最狠的。通过 %n,攻击者可以把已经输出的字符数写入一个地址。
举个例子:
printf("\x10\x20\x30\x40%x%x%x%n");
这里 \x10\x20\x30\x40 是一个地址(假设是 0x40302010),后面跟了三个 %x 消耗栈上的参数,最后 %n 会把当前已输出的字符数写入那个地址。
我曾经见过一个案例,攻击者用这种手法把 GOT 表中的函数地址改成了 shellcode 地址。程序一调用那个函数,就直接跳到了攻击者的代码里。
警告:%n 在 C11 标准中已经被标记为不安全,但很多编译器仍然支持。千万不要在正式代码里用 %n,除非你明确知道自己在做什么。
14.3 防范措施:怎么堵住这个洞?
说实话,格式化字符串漏洞的防御并不复杂。核心就一句话:永远不要把用户输入直接作为格式化字符串。
具体来说,我建议做到以下几点:
- 使用带格式参数的函数:
printf("%s", buf)而不是printf(buf) - 启用编译器警告:GCC 的
-Wformat-security能帮你发现这类问题 - 使用安全版本的函数:比如
snprintf而不是sprintf - 静态代码分析:用工具扫描代码,自动检测格式化字符串漏洞
个人习惯:我写代码时,只要看到 printf、sprintf、fprintf 这些函数的第一个参数不是字符串字面量,就会立刻警觉。这已经成了我的肌肉记忆。
14.4 C语言安全格式化:正确的做法
我们来看看安全版本的代码应该怎么写:
#include <stdio.h>
void safe() {
char buf[100];
printf("请输入内容:");
fgets(buf, sizeof(buf), stdin);
printf("%s", buf); // 安全!指定了格式化字符串
}
就这么简单。但实际项目中,情况往往更复杂。比如:
// 错误做法
sprintf(output, input); // 危险!
// 正确做法
snprintf(output, sizeof(output), "%s", input); // 安全
还有一点要注意:snprintf 的返回值是“如果缓冲区足够大,应该写入的字符数”。这个返回值可以用来检测截断,但不要直接用它来索引缓冲区,否则可能引发其他问题。
14.5 知识体系图
下面这张图总结了格式化字符串漏洞的核心知识结构:
14.6 避坑指南
我曾经在维护一个遗留系统时,发现代码里到处都是 printf(user_input) 这种写法。当时我花了一周时间,把所有这类调用都改成了安全版本。改完之后,系统稳定性明显提升,之前偶尔出现的崩溃问题也消失了。
这里总结几个我踩过的坑:
- 不要相信任何外部输入:用户输入、文件内容、网络数据,统统当成不可信数据
- 日志函数也要小心:很多人只关注
printf,却忘了日志函数也可能有格式化字符串漏洞 - 国际化/本地化字符串:如果格式化字符串来自资源文件,也要确保它不会被篡改
- 编译器警告不是万能的:GCC 的
-Wformat-security能发现大部分问题,但有些间接调用它检测不到
我的建议:在团队中推行安全编码规范,把“禁止将用户输入作为格式化字符串”写进代码审查清单。每次 Code Review 都重点检查 printf、sprintf、fprintf、snprintf 的调用方式。
好了,关于格式化字符串漏洞,我们就聊到这里。记住:安全编码不是锦上添花,而是底线。一个 %s 就能让攻击者拿到你的栈数据,一个 %n 就能改写你的程序流程。别给攻击者留机会。