格式化字符串漏洞:printf家族函数漏洞原理、利用方法与防御
大家好,我是你们这堂课的老朋友。今天咱们聊一个经典但杀伤力极强的漏洞——格式化字符串漏洞。说实话,我第一次在真实项目中碰到它时,差点把调试日志打印成了攻击入口。嗯,这玩意儿看着不起眼,但用好了能读内存、写内存,甚至直接拿shell。
漏洞原理:printf家族函数的“过度信任”
先看一个最简单的例子:
// 错误写法
printf(user_input);
// 正确写法
printf("%s", user_input);
你看出区别了吗?第一个写法直接把用户输入当成了格式化字符串。这意味着用户可以在输入里塞 %x、%n 这些格式化占位符。printf家族函数(包括sprintf、fprintf等)会忠实地去栈上找对应的参数。
为什么会这样?因为C语言的可变参数机制。printf不知道你传了多少个参数,它只按格式化字符串里的占位符数量,从栈上依次取数据。你给了5个%x,它就取5次栈数据。如果栈上本来没有那么多参数呢?它还是会取——取到的就是栈上残留的任意数据。
核心要点:格式化字符串漏洞的本质是攻击者控制了格式化字符串,从而控制了printf的行为。这相当于给了攻击者一个“栈上任意读”和“栈上任意写”的能力。
利用方法:从内存读取到内存写入
1. 内存读取:泄露栈上数据
假设有这样的代码:
char buf[100];
scanf("%s", buf);
printf(buf); // 漏洞!
攻击者输入 %x.%x.%x.%x,就能看到栈上连续的4个整数值。这些值可能是函数返回地址、局部变量、甚至canary值。
我习惯用 %p 来读指针,因为它直接输出16进制完整地址。比如输入 %p.%p.%p.%p,就能看到栈上指针的值。
更高级的用法是直接定位到特定偏移:
// 输入 %6$p 直接读取第6个参数位置的值
printf("%6$p");
我在项目中遇到过,攻击者通过这种方式泄露了栈上的canary值,然后绕过了栈保护。你想想看,一个看似无害的日志打印,就成了信息泄露的通道。
避坑指南:我曾经在调试一个网络服务时,发现日志里打印了用户输入的字符串。当时觉得“打印一下而已,能有什么问题?”结果安全审计时发现,攻击者通过这个日志接口泄露了堆地址。从那以后,我再也不敢把用户输入直接丢给printf了。
2. 内存写入:利用%n修改任意地址
这才是格式化字符串漏洞的杀手锏。%n 的作用是:把当前已经输出的字符数,写入到一个指针指向的地址。比如:
int count;
printf("Hello%n", &count);
// 此时 count = 5("Hello"的长度)
如果攻击者控制了格式化字符串,他就可以用 %n 往任意地址写入任意值。具体怎么做?
- 先把目标地址放到栈上(比如通过输入字符串的一部分)
- 用
%<数字>c控制输出的字符数 - 用
%<偏移>$n把字符数写入目标地址
举个例子,攻击者想往地址 0x0804a000 写入值 0xdeadbeef:
// 假设目标地址在栈上的偏移是6
// 先构造地址,再控制输出长度
"\x00\xa0\x04\x08" + "%." + str(0xdeadbeef) + "c%6$n"
当然,实际利用要复杂得多。因为一次只能写入2字节(%hn)或4字节(%n),而且地址中可能包含空字节。我一般会分多次写入,每次写2字节,这样更可控。
警告:利用%n写入时,地址中不能包含空字节(\x00),否则printf会在空字节处截断。常用的绕过方法:把目标地址放在格式化字符串的末尾,或者使用栈上已有的指针。
防御与缓解措施
防御这事儿,说白了就是“别把用户输入当格式串”。但实际工程中,总有人会犯这个错。所以我们需要多层防御:
| 防御层次 | 具体措施 | 我的评价 |
|---|---|---|
| 代码层面 | 始终使用 printf("%s", str) 而非 printf(str) |
最根本的防御,没有之一 |
| 编译选项 | 启用 -Wformat-security 警告,GCC会检测非字面量的格式化字符串 |
我习惯在Makefile里加上 -Werror=format-security |
| 运行时保护 | RELRO(只读重定位)、FORTIFY_SOURCE | FORTIFY_SOURCE会检查%n是否在只读段 |
| 系统层面 | ASLR(地址空间布局随机化)、NX(栈不可执行) | 增加利用难度,但不能完全阻止 |
我个人最推荐的做法是:在代码审查时,专门检查所有printf家族函数的调用。只要看到格式化字符串不是常量字符串,就标记为高危。我曾经在一个代码库里发现过30多处这样的问题,改完之后心里踏实多了。
小技巧:如果你用的是C++,可以考虑用 std::cout 替代printf。虽然cout也有自己的问题,但至少不会出现格式化字符串漏洞。嗯,算是一种“用类型安全换内存安全”的取舍吧。
知识体系总览
下面这张图是我梳理的格式化字符串漏洞知识体系,你可以把它当作学习路线图:
这张图把整个知识体系分成了三条线:原理、利用、防御。你从左边开始理解漏洞怎么产生的,中间看攻击者怎么利用,右边学怎么防住它。三条线串起来,就是完整的攻防闭环。
好了,格式化字符串漏洞的核心内容就这些。记住一句话:永远不要相信用户输入,尤其是在printf里。下一节课我们会聊缓冲区溢出,那又是一个经典话题。咱们到时候见。