安全编码中的格式化字符串漏洞详解
字数 2377 2025-12-13 15:35:13
安全编码中的格式化字符串漏洞详解
1. 知识点描述
格式化字符串漏洞是软件开发中,特别是在使用C、C++等编程语言时,由于不正确地使用像 printf, sprintf, fprintf 这类格式化输出函数而引入的一类严重安全漏洞。攻击者可以控制传递给这些函数的格式化字符串参数,从而可能导致内存信息泄露、内存被任意写入(进而实现任意代码执行)或程序崩溃(拒绝服务)。
简单来说,这类函数的工作原理是:根据格式化字符串中的格式化说明符(如 %d, %s, %x, %n)来解析后续的可变参数,并进行相应的输出或操作。如果攻击者能够控制格式化字符串本身,就能操纵这个解析过程,窥探或篡改程序内存。
2. 循序渐进讲解
第一步:理解格式化函数的基本工作原理
以C语言最经典的 printf 函数为例:
printf("Hello, %s! You are %d years old.\n", name, age);
- 格式化字符串:第一个参数
"Hello, %s! You are %d years old.\n"。它包含普通文本和格式化说明符(%s,%d)。 - 可变参数:后续的
name和age参数,它们按照顺序与格式化说明符一一对应。 - 函数行为:
printf函数解析格式化字符串,遇到%s时,它从栈上(或寄存器,取决于调用约定)读取一个指针(假设是name的地址),然后打印该地址指向的字符串。遇到%d时,读取一个整数值(age)并打印。
关键点:函数依赖于格式化字符串来“知道”栈上有多少个参数以及它们的类型。
第二步:漏洞的成因——程序员错误
漏洞代码示例:
char user_input[100];
fgets(user_input, sizeof(user_input), stdin); // 用户可控的输入
printf(user_input); // 危险!直接将用户输入作为格式化字符串
正确的、无风险的写法应该是:
printf("%s", user_input); // 安全的,用户输入被当作普通字符串参数处理
在漏洞代码中,如果用户输入的不是普通字符串,而是包含格式化说明符的字符串(例如 %x),那么 printf 会忠实地按照说明符去工作。但此时,函数调用并没有为这些“额外的”说明符提供对应的有效参数。
第三步:漏洞利用——信息泄露(读内存)
攻击者输入:%08x.%08x.%08x.%08x
%08x表示以8位十六进制数的形式打印一个整数,不足8位前面补零。- 当
printf遇到第一个%08x时,它会试图从栈上读取本应为第二个参数(对应第一个%s的字符串指针)的位置读取4字节,并将其作为整数打印。 - 由于没有对应的有效参数,它实际上打印的是栈上既有的数据(可能是返回地址、局部变量、函数参数等)。
- 通过精心构造输入,攻击者可以“遍历”栈内存,泄露敏感信息,如:
- 栈上的其他变量值。
- 函数的返回地址(有助于绕过ASLR)。
- 指向其他敏感数据(如密码、密钥)的指针。
- 更危险的是
%s说明符:如果攻击者能控制栈上某个位置的值作为一个指针,并通过%s让printf去读取,就能实现任意地址读,泄露该指针指向地址的内容。
第四步:漏洞利用——任意内存写入(写内存)
核心在于一个特殊的格式化说明符:%n
%n的功能:它不输出内容,而是将截至目前已成功输出的字符总数,写入到对应参数所指向的内存地址(该参数必须是一个int *类型的指针)。- 攻击原理:
- 攻击者首先通过信息泄露,获取栈上某个可写地址(比如一个局部变量的地址)或一个函数指针的地址(如GOT表项)。
- 构造复杂的格式化字符串,将这个目标地址“放置”在栈上合适的位置,使得
printf在处理到某个%n时,恰好将这个栈位置的内容解释为一个指针。 - 在
%n之前,通过输出特定数量的字符(例如,用%123d来输出123个字符宽度的数字,或用输出大量字符的格式)来控制“已输出字符数”这个值。 %n执行时,就会将“已输出字符数”(比如123,也就是0x7b)写入到目标地址指向的内存中。
- 写入的影响:
- 可以修改关键变量(如身份验证标志
is_admin)的值,直接提升权限。 - 可以修改函数指针(如GOT表中的
system函数地址),当下次调用该函数时,就会跳转到攻击者指定的地址(如包含恶意代码的地址或system("/bin/sh")的指令地址),从而实现任意代码执行。
- 可以修改关键变量(如身份验证标志
第五步:漏洞的检测与防御
- 检测:
- 静态代码分析:检查所有格式化函数(
printf,sprintf,fprintf,snprintf,syslog等)的调用,看第一个参数(格式化字符串)是否是用户可控的变量。 - 动态模糊测试:向程序输入包含大量格式化说明符的测试用例,观察是否有异常输出或崩溃。
- 静态代码分析:检查所有格式化函数(
- 防御:
- 最佳实践:永远不要将用户输入直接作为格式化字符串。始终使用静态的、开发者定义的格式化字符串,并将用户输入作为后续参数传递。
// 永远这样做 printf("%s", user_input); snprintf(buffer, size, "%s", user_input); - 编译器保护:现代编译器(如GCC, Clang)提供编译时警告(
-Wformat-security),能识别不安全的模式。 - 运行时保护:一些操作系统或安全机制(如GLIBC的
FORTIFY_SOURCE)在编译时会对格式化函数进行加强,检查格式化字符串是否位于可写的内存段,如果位于(很可能是用户输入的),则会中止程序。 - 使用更安全的函数:对于简单的字符串拼接/输出,优先使用不涉及格式化说明符的函数,如
fputs,puts(注意换行),或C++中的std::cout。 - 代码审计:在安全开发流程中,对使用格式化函数的代码进行重点审查。
- 最佳实践:永远不要将用户输入直接作为格式化字符串。始终使用静态的、开发者定义的格式化字符串,并将用户输入作为后续参数传递。
总结:格式化字符串漏洞源于程序员将不可信的用户数据直接用作控制指令(格式化字符串)。它破坏了程序对内存访问边界的控制,导致从信息泄露到完全控制程序的严重后果。防御的核心原则是严格的职责分离:格式化指令必须由可信的开发者代码定义,不可信的数据只能作为被处理的内容。