第十四章 格式化字符串漏洞:原理分析、防范措施、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 防范措施:怎么堵住这个洞?

说实话,格式化字符串漏洞的防御并不复杂。核心就一句话:永远不要把用户输入直接作为格式化字符串

具体来说,我建议做到以下几点:

  1. 使用带格式参数的函数printf("%s", buf) 而不是 printf(buf)
  2. 启用编译器警告:GCC 的 -Wformat-security 能帮你发现这类问题
  3. 使用安全版本的函数:比如 snprintf 而不是 sprintf
  4. 静态代码分析:用工具扫描代码,自动检测格式化字符串漏洞

个人习惯:我写代码时,只要看到 printfsprintffprintf 这些函数的第一个参数不是字符串字面量,就会立刻警觉。这已经成了我的肌肉记忆。

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 知识体系图

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

格式化字符串漏洞知识体系 格式化字符串漏洞 漏洞原理 攻击手法 防御措施 printf(buf) 危险 % 被解析为格式符 信息泄露 %p %x 任意写入 %n 指定格式参数 启用编译器警告 栈上数据泄露 定位输入位置 GOT表覆写 snprintf 安全替代 静态分析工具扫描 代码审查 + 安全编码规范

14.6 避坑指南

我曾经在维护一个遗留系统时,发现代码里到处都是 printf(user_input) 这种写法。当时我花了一周时间,把所有这类调用都改成了安全版本。改完之后,系统稳定性明显提升,之前偶尔出现的崩溃问题也消失了。

这里总结几个我踩过的坑:

  • 不要相信任何外部输入:用户输入、文件内容、网络数据,统统当成不可信数据
  • 日志函数也要小心:很多人只关注 printf,却忘了日志函数也可能有格式化字符串漏洞
  • 国际化/本地化字符串:如果格式化字符串来自资源文件,也要确保它不会被篡改
  • 编译器警告不是万能的:GCC 的 -Wformat-security 能发现大部分问题,但有些间接调用它检测不到

我的建议:在团队中推行安全编码规范,把“禁止将用户输入作为格式化字符串”写进代码审查清单。每次 Code Review 都重点检查 printfsprintffprintfsnprintf 的调用方式。

好了,关于格式化字符串漏洞,我们就聊到这里。记住:安全编码不是锦上添花,而是底线。一个 %s 就能让攻击者拿到你的栈数据,一个 %n 就能改写你的程序流程。别给攻击者留机会。


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