1. 基础知识
1.1 常见的格式化输出函数
printf:
是一个用于向标准输出(stdout)按规定的格式输出信息的函数,其原型为 int printf(const char *format, [argument]…)
。其中,format
是格式控制字符串,用于指定输出的格式,而其他参数则是输出项,用于提供与格式控制字符串对应的数据。
sprintf:
是一个将格式化的数据写入指定字符串中的函数,其原型为 int sprintf(char *buffer, const char *format, [argument]…)
。其中,buffer
是要写入数据的目标字符串缓冲区,format
是格式控制字符串,其他参数则是输出项。该函数将第三部分的数据根据第二部分格式控制字符串的要求进行格式化,然后将格式化后的数据存储到指定的字符串缓冲区中。
snprintf:
是在 sprintf
基础上进行改进的函数,它限制了可写入字符的最大长度 n
。其原型为 int snprintf(char *str, size_t size, const char *format, [argument]…)
。当格式化后的字符串长度小于指定的 size
时,函数将整个字符串复制到 str
中,并在末尾添加字符串结束符 \0
;而当格式化后的字符串长度大于或等于 size
时,函数将最多复制 size-1
个字符,并在末尾添加字符串结束符 \0
。通过这种方式,snprintf
可以防止缓冲区溢出,确保字符串不会超出指定的缓冲区大小。
fprintf:
用于将格式化的数据输出到指定的流或文件中,其原型为 int fprintf(FILE *stream, const char *format, [argument]…)
。该函数根据指定的格式控制字符串 format
,将数据写入到输出流 stream
中。当 stream
为 stdout
时,fprintf
的功能与 printf
相同,都是将格式化的数据输出到标准输出。
vprintf
、vsprintf
、vsnprintf
和 vfprintf
的功能分别对应于 printf
、sprintf
、snprintf
和 fprintf
,它们的区别在于将变长参数列表替换为一个 va_list
类型的参数。这使得这些函数可以接受可变参数,从而实现更加灵活的格式化输出。
1.2 格式化字符串
格式化字符串的定义:格式化字符串是由普通字符串和格式化规定字符构成的字符序列。普通字符会原封不动地复制到输出流中,而格式化规定字符以 %
开始,用于确定输出内容的格式。
格式化规定字符的格式:常见的格式化字符的格式为,%[parameter][flags][fieldwidth][.precision][length]type。
parameter
可以是省略的,也可以是n$
形式,其中n
表示参数列表中的第n
个参数,通过这种方式可以直接访问第n
个参数。flags
用于调整输出的格式,包括符号、空白、小数点、八进制和十六进制的前缀等。fieldwidth
用于限制显示数值的最小宽度。当输出字符的个数不足指定宽度时,默认用空格填充,或者使用flags
中指定的其他填充方式;如果输出字符超过限制宽度,则不会被截断,仍然正常显示。precision
用于指定输出的最大长度。length
指定浮点型或整型参数的长度。type
是转换说明符,用于指定所应用的转换类型,它是格式字符串中唯一必须的部分。
#include <stdio.h>
int main() {
int num = 12345;
double pi = 3.141592653589793;
// 示例 1: 输出整数,宽度为 10
// 使用 %10d 来设置输出宽度为 10,不足时会用空格填充
printf("%10d\n", num); // 输出 " 12345"
// 示例 2: 输出浮点数,保留 4 位小数
// 使用 %.4f 来保留 4 位小数
printf("%.4f\n", pi); // 输出 "3.1416"
// 示例 3: 输出浮点数,带符号,宽度为 10,保留 2 位小数
// 使用 %+10.2f 来显示带符号的浮动数,宽度为 10,保留 2 位小数
printf("%+10.2f\n", pi); // 输出 " +3.14"
// 示例 4: 输出字符串,宽度为 10,左对齐
// 使用 %-10s 来设置字符串宽度为 10,并左对齐
printf("%-10s\n", "Hello"); // 输出 "Hello "
return 0;
}
转换说明符 %n
详解:%n
是一种特殊的格式化字符,它并不向 printf()
传递格式化信息,而是令 printf()
将已经输出的字符总数写入对应的整型指针参数所指向的变量中。例如,printf("hello%n", &i);
使得 i
的值为 5,因为 "hello" 输出了 5 个字符。%n
可能会被恶意利用进行缓冲区溢出攻击,比如通过 printf("%n", a)
,程序会计算已输出的字符数量并将其写入到 a
指向的内存位置,从而改写栈中内存。具体来说,%n
写入的内存最大为 4 字节,%hn
最大为 2 字节,%hhn
最大为 1 字节。
#include <stdio.h>
int main() {
int i;
// 使用 %n 获取 printf 输出的字符数
printf("hello%n", &i);
// 输出已打印的字符数
printf("The number of characters printed: %d\n", i); // 输出:The number of characters printed: 5
return 0;
}
普通使用 %n
示例:printf("hello%n", &i);
会输出字符串 "hello"
,并将输出的字符数(即 5)写入 i
变量中。然后,printf("The number of characters printed: %d\n", i);
打印 i
的值,输出结果为 The number of characters printed: 5
。
#include <stdio.h>
int main() {
char buffer[20];
int target;
// 假设 buffer 的初始内容为空,target 变量位于栈上的某个位置
printf("Hello%n World\n", &target);
// 输出 target 的内容,攻击者可能修改它
printf("Target value: %d\n", target); // 如果成功,target 的值将被修改
return 0;
}
利用 %n
进行缓冲区溢出攻击:%n
会将已经输出的字符数写入 target
变量中。在这个例子中,"Hello%n World\n"
会先输出 Hello
(5个字符)然后将字符数 5
写入到 target
变量中。然而,在攻击者控制的环境中,%n
可以用来通过改变 target
的值来实现栈溢出,甚至改变程序的行为。
2. 漏洞原理
2.1 printf 运行原理
printf
的运行原理是通过接受可变参数来实现的。由于参数个数是动态的,调用者可以自由指定参数的数量和类型,而被调用者并不能知道函数调用时有多少参数被压入栈帧。为了应对这一问题,printf
会逐个解析 format
参数中的每个格式化规定字符,从中判断参数的个数和类型。然而,如果 format
中指定的参数数量超过了实际提供的变参列表中的参数数量,printf
由于无法判断这一点,可能会对未提供的内存数据进行读写操作,这可能导致内存错误或潜在的安全漏洞。
#include <stdio.h>
int main() {
int a = 10;
float b = 3.14;
// 这里我们传入了两个参数 (int 和 float),但格式化字符串要求三个参数
printf("Integer: %d, Float: %f, String: %s\n", a, b);
return 0;
}
Integer: 10, Float: 3.140000, String: (some random memory content)
2.2 格式化字符串利用方法
泄漏内存数据:当 printf
只提供了 format
参数,却没有提供其他参数时,printf
会根据 format
的要求输出多个值,但这些值并不是实际输入的参数,而是栈中未初始化的数据。具体来说,打印出的这些数值实际上是位于 format
参数之后的栈数据。这种格式化字符串漏洞允许攻击者通过控制 format
字符串,从栈中泄露敏感信息,甚至可能利用该漏洞进一步破坏程序的安全性。
覆写内存:格式化输出函数不仅能够读取内存数据,还能通过转换说明符 %n
向指定地址写入一个整数值。这种向任意地址写入数据的能力,可以被攻击者利用来覆盖内存中的关键数据。比如,fs_write.c
演示了如何通过格式化字符串漏洞,修改 flag
变量的值,从而实现对程序行为的恶意控制。
复写内存的总体思路是通过构建特定的 printf(format, argument)
参数来实现的。首先,构造含有 %n
的特定 format
格式,将值 0xbeef
写入存放 flag
变量的地址(如 0x804a028
/ 0x804a029
)。具体步骤如下:
- 在
format
数组中放入flag
变量的地址,以构建printf()
的argument
参数(例如0x804a028
/0x804a029
)。 - 构造
printf()
的format
格式,使用$
参数指定flag
变量的地址是第几个参数。例如,利用sprintf()
输出格式%231c%11$hhn%207c%12$hhn
,通过$
来定位flag
变量地址。 - 在
format
中加入%n
和相关的输出字符长度,通过%n
(如hhn
)向对应的地址写入数据,将flag
变量的值从0xbabe
改为0xbeef
,实现内存的复写。
#include <stdio.h>
int flag = 0xbabe; // 初始 flag 变量值为 0xbabe
int main() {
printf("Flag address: %p\n", (void*)&flag); // 打印 flag 的地址
// 构造特定的格式化字符串进行内存覆盖
// 假设我们通过漏洞输入:%231c%11$hhn%207c%12$hhn
// 这将会修改 flag 变量的值为 0xbeef
printf("Exploit: %231c%11$hhn%207c%12$hhn\n", 0, 0);
printf("Modified flag: 0x%x\n", flag); // 打印修改后的 flag 值
return 0;
}
这里我们定义了一个 flag
变量,并初始化为 0xbabe
。在程序运行时,我们想要通过格式化字符串漏洞将其值修改为 0xbeef
。
printf("Flag address: %p\n", (void*)&flag);
这行代码打印出 flag
变量的地址,假设输出的地址是 0x804a028
(这个地址是虚构的,实际地址会根据程序的加载位置不同而有所变化)。
printf("Exploit: %231c%11$hhn%207c%12$hhn\n", 0, 0);
这行代码是构造格式化字符串漏洞的核心部分,详细分析如下:
%231c
:这部分会输出 231 个字符的空格,填充输出缓冲区。%11$hhn
:这部分会将缓冲区中的字符数(231)写入第 11 个参数所指向的地址。假设第 11 个参数是0x804a028
(即flag
变量的地址)。%207c
:这部分会输出 207 个字符的空格,再次填充缓冲区。%12$hhn
:这部分会将 207 写入第 12 个参数所指向的地址。假设第 12 个参数是0x804a029
(flag
变量的下一个字节的地址)。
通过这些格式化字符串,我们可以控制内存的写入,将 flag
变量的两个字节覆盖为 0xbeef
。