24、格式化字符串:printf格式化字符串的漏洞与安全写法

格式化字符串,说白了就是 printf 那一套。很多新手觉得它简单,不就是 %d%s 嘛。但我要告诉你,这个看似人畜无害的小东西,曾经搞出过多少安全漏洞,甚至让整个系统被攻破。

我个人习惯,每次看到代码里出现 printf(user_input) 这种写法,心里就咯噔一下。嗯,今天我们就来聊聊这个坑。

24.1 格式化字符串漏洞的本质

先看一个最简单的例子:

// 错误写法
char *name = getUserInput();
printf(name);  // 直接把用户输入当格式化字符串

// 正确写法
printf("%s", name);  // 用户输入只作为数据,不作为格式控制

为什么会这样?因为 printf 的第一个参数是格式化字符串,它决定了后面怎么解析参数。如果你把用户输入直接放进去,用户就可以在输入里塞 %x%n 这些格式符,从而读取甚至改写内存。

警告:永远不要将用户输入直接作为 printf 的第一个参数。这是最基本的安全红线。

24.2 漏洞能造成什么后果?

我记得有一次做代码审计,看到一个老项目里有这样的代码:

void log_message(char *msg) {
    char buffer[256];
    snprintf(buffer, sizeof(buffer), msg);  // 又一个漏洞!
    printf("%s", buffer);
}

你想想看,如果 msg 的内容是 "%x %x %x %x %x %x",那 snprintf 就会把栈上的数据当作参数打印出来。攻击者可以用这个手法泄露栈上的返回地址、canary 值,甚至通过 %n 写入任意内存。

具体来说,格式化字符串漏洞可以做到:

  • 信息泄露:用 %x%p 读取栈上数据
  • 内存读取:用 %s 配合地址偏移,读取任意地址内容
  • 内存写入:用 %n 向指定地址写入已打印字符数
  • 代码执行:通过改写 GOT 表或返回地址,劫持控制流

核心原理:printf 不知道你给了多少个参数。它只按格式化字符串里的 % 标记,从栈上依次取数据。用户控制了格式化字符串,就等于控制了 printf 的「眼睛」和「手」。

24.3 安全写法的黄金法则

我在项目中遇到过好几次类似的隐患,后来总结了一套规则,分享给你:

规则一:永远指定格式符

// 不安全
printf(buf);
fprintf(stderr, buf);
snprintf(out, size, buf);

// 安全
printf("%s", buf);
fprintf(stderr, "%s", buf);
snprintf(out, size, "%s", buf);

规则二:使用带长度限制的函数

// 不安全
sprintf(buffer, "Name: %s", name);  // 可能缓冲区溢出

// 安全
snprintf(buffer, sizeof(buffer), "Name: %s", name);

规则三:警惕可变参数中的类型不匹配

// 错误:类型不匹配
int x = 42;
printf("%lld", x);  // 未定义行为!x 是 int,但期望 long long

// 正确
printf("%lld", (long long)x);
小技巧:我习惯在编译时开启 -Wformat-Wformat-security 警告。GCC 和 Clang 能帮你发现大部分格式化字符串问题。

24.4 实战中的避坑指南

我曾经接手过一个日志模块,里面大量使用了这种模式:

void log_error(const char *fmt, ...) {
    va_list args;
    char buffer[1024];
    
    // 错误:先格式化到 buffer,然后直接 printf(buffer)
    vsnprintf(buffer, sizeof(buffer), fmt, args);
    printf(buffer);  // 漏洞!
    
    // 正确:直接 printf 带格式符
    vprintf(fmt, args);
}

你看,即使 vsnprintf 本身是安全的,但后续的 printf(buffer) 又把格式化字符串的控制权交给了外部数据。这种「二次格式化」的问题,在真实项目中非常常见。

还有一个容易忽略的点:国际化与本地化。有些项目会把翻译后的字符串直接传给 printf:

// 危险!翻译字符串可能包含 % 符号
printf(gettext("File not found: %s"), filename);

如果翻译文件里不小心写了 %s 以外的格式符,或者翻译人员误加了 %n,那就出大事了。我建议所有国际化字符串也要经过格式化安全检查。

24.5 格式化字符串的 SVG 知识图谱

下面这张图总结了格式化字符串的核心知识点,你可以对照着梳理思路:

格式化字符串安全知识体系 printf 格式化字符串 漏洞类型 %x / %p → 栈信息泄露 %s → 任意地址读取 %n → 任意地址写入 安全写法 printf("%s", data) 指定格式 snprintf 限制缓冲区大小 开启 -Wformat 编译警告 核心原则:格式化字符串 = 代码,用户输入 = 数据,两者不可混淆

24.6 总结与检查清单

好了,我们来快速过一遍今天的关键点:

  1. 不要直接 printf 用户输入——这是铁律,没有例外
  2. 指定格式符——用 printf("%s", buf) 而不是 printf(buf)
  3. 警惕二次格式化——先 snprintf 再 printf 也可能出问题
  4. 类型要匹配——%d 配 int,%ld 配 long,别乱用
  5. 开启编译器警告——-Wformat -Wformat-security 是你的好帮手
  6. 代码审查时重点看——所有 printfsprintffprintf 的第一个参数

一句话总结:格式化字符串漏洞,说白了就是「把数据当代码执行了」。你只要记住——printf 的第一个参数是「代码」,后面的参数才是「数据」。代码和数据永远要分开。

嗯,格式化字符串这块就聊到这儿。内容不多,但每次写代码时多留个心眼,就能避免很多麻烦。


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