3.1.1 格式化字符串漏洞
格式化输出函数和格式字符串
在 C 语言基础章节中,我们详细介绍了格式化输出函数和格式化字符串的内容。在开始探索格式化字符串漏洞之前,强烈建议回顾该章节。这里我们简单回顾几个常用的。
函数
#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, ...);转换指示符
d
4-byte
Integer
u
4-byte
Unsigned Integer
x
4-byte
Hex
s
4-byte ptr
String
c
1-byte
Character
长度
hh
1-byte
char
h
2-byte
short int
l
4-byte
long int
ll
8-byte
long long int
示例
格式化字符串漏洞基本原理
在 x86 结构下,格式字符串的参数是通过栈传递的,看一个例子:
根据 cdecl 的调用约定,在进入 printf() 函数之前,将参数从右到左依次压栈。进入 printf() 之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是 %,字符直接复制到输出中。否则,读取下一个非空字符,获取相应的参数并解析输出。(注意:% d 和 %d 是一样的)
接下来我们修改一下上面的程序,给格式字符串加上 %x %x %x %3$s,使它出现格式化字符串漏洞:
反汇编后的代码同上,没有任何区别。我们主要看一下参数传递:
这一次栈的结构和上一次相同,只是格式字符串有变化。程序打印出了七个值(包括换行),而我们其实只给出了前三个值的内容,后面的三个 %x 打印出了 0xffffd230~0xffffd238 栈内的数据,这些都不是我们输入的。而最后一个参数 %3$s 是对 0xffffd22c 中 的重用。
上一个例子中,格式字符串中要求的参数个数大于我们提供的参数个数。在下面的例子中,我们省去了格式字符串,同样存在漏洞:
如果大家都是好孩子,输入正常的字符,程序就不会有问题。由于没有格式字符串,如果我们在 buf 中输入一些转换指示符,则 printf() 会把它当做格式字符串并解析,漏洞发生。例如上面演示的我们输入了 Hello %x %x %x !(其中 是 fgets() 函数给我们自动加上的),这时,程序就会输出栈内的数据。
我们可以总结出,其实格式字符串漏洞发生的条件就是格式字符串要求的参数和实际提供的参数不匹配。下面我们讨论两个问题:
为什么可以通过编译?
因为
printf()函数的参数被定义为可变的。为了发现不匹配的情况,编译器需要理解
printf()是怎么工作的和格式字符串是什么。然而,编译器并不知道这些。有时格式字符串并不是固定的,它可能在程序执行中动态生成。
printf()函数自己可以发现不匹配吗?printf()函数从栈中取出参数,如果它需要 3 个,那它就取出 3 个。除非栈的边界被标记了,否则printf()是不会知道它取出的参数比提供给它的参数多了。然而并没有这样的标记。
格式化字符串漏洞利用
通过提供格式字符串,我们就能够控制格式化函数的行为。漏洞的利用主要有下面几种。
使程序崩溃
格式化字符串漏洞通常要在程序崩溃时才会被发现,所以利用格式化字符串漏洞最简单的方式就是使进程崩溃。在 Linux 中,存取无效的指针会引起进程收到 SIGSEGV 信号,从而使程序非正常终止并产生核心转储(在 Linux 基础的章节中详细介绍了核心转储)。我们知道核心转储中存储了程序崩溃时的许多重要信息,这些信息正是攻击者所需要的。
利用类似下面的格式字符串即可触发漏洞:
对于每一个
%s,printf()都要从栈中获取一个数字,把该数字视为一个地址,然后打印出地址指向的内存内容,直到出现一个 NULL 字符。因为不可能获取的每一个数字都是地址,数字所对应的内存可能并不存在。
还有可能获得的数字确实是一个地址,但是该地址是被保护的。
查看栈内容
使程序崩溃只是验证漏洞的第一步,攻击者还可以利用格式化输出函数来获得内存的内容,为下一步漏洞利用做准备。我们已经知道了,格式化字符串函数会根据格式字符串从栈上取值。由于在 x86 上栈由高地址向低地址增长,而 printf() 函数的参数是以逆序被压入栈的,所以参数在内存中出现的顺序与在 printf() 调用时出现的顺序是一致的。
下面的演示我们都使用下面的源码:
我们先输入 b main 设置断点,使用 n 往下执行,在 call 0x56555460 <__isoc99_scanf@plt> 处输入 %08x.%08x.%08x.%08x.%08x,然后使用 c 继续执行,即可输出结果。
格式化字符串 0xffffd584 的地址出现在内存中的位置恰好位于参数 arg1、arg2、arg3、arg4 之前。格式字符串 %08x.%08x.%08x.%08x.%08x 表示函数 printf() 从栈中取出 5 个参数并将它们以 8 位十六进制数的形式显示出来。格式化输出函数使用一个内部变量来标志下一个参数的位置。开始时,参数指针指向第一个参数(arg1)。随着每一个参数被相应的格式规范所耗用,参数指针的值也根据参数的长度不断递增。在显示完当前执行函数的剩余自动变量之后,printf() 将显示当前执行函数的栈帧(包括返回地址和参数等)。
当然也可以使用 %p.%p.%p.%p.%p 得到相似的结果。
上面的方法都是依次获得栈中的参数,如果我们想要直接获得被指定的某个参数,则可以使用类似下面的格式字符串:
这里的 n 表示栈中格式字符串后面的第 n 个值。
这里,格式字符串的地址为 0xffffd584。我们通过格式字符串 %3$x.%1$08x.%2$p.%2$p.%4$p.%5$p.%6$p 分别获取了 arg3、arg1、两个 arg2、arg4 和栈上紧跟参数的两个值。可以看到这种方法非常强大,可以获得栈中任意的值。
查看任意地址的内存
攻击者可以使用一个“显示指定地址的内存”的格式规范来查看任意地址的内存。例如,使用 %s 显示参数 指针所指定的地址的内存,将它作为一个 ASCII 字符串处理,直到遇到一个空字符。如果攻击者能够操纵这个参数指针指向一个特定的地址,那么 %s 就会输出该位置的内存内容。
还是上面的程序,我们输入 %4$s,输出的 arg4 就变成了 ABCD 而不是地址 0xffffd57a:
上面的例子只能读取栈中已有的内容,如果我们想获取的是任意的地址的内容,就需要我们自己将地址写入到栈中。我们输入 AAAA.%p 这样的格式的字符串,观察一下栈有什么变化。
格式字符串的地址在 0xffffd584,从下面的输出中可以看到它们在栈中是怎样排布的:
下面是程序运行的结果:
0x41414141 是输出的第 13 个字符,所以我们使用 %13$s 即可读出 0x41414141 处的内容,当然,这里可能是一个不合法的地址。下面我们把 0x41414141 换成我们需要的合法的地址,比如字符串 ABCD 的地址 0xffffd57a:
当然这也没有什么用,我们真正经常用到的地方是,把程序中某函数的 GOT 地址传进去,然后获得该地址所对应的函数的虚拟地址。然后根据函数在 libc 中的相对位置,计算出我们需要的函数地址(如 system())。如下面展示的这样:
先看一下重定向表:
.rel.plt 中有四个函数可供我们选择,按理说选择任意一个都没有问题,但是在实践中我们会发现一些问题。下面的结果分别是 printf、__libc_start_main、putchar 和 __isoc99_scanf:
细心一点你就会发现第一个(printf)的结果有问题。我们输入了 \x0c\xa0\x04\x08(0x0804a00c),可是 13 号位置输出的结果却是 0x2e0804a0,那么,\x0c 哪去了,查了一下 ASCII 表:
于是就被省略了,同样会被省略的还有很多,如 \x07('\a')、\x08('\b')、\x20(SPACE)等的不可见字符都会被省略。这就会让我们后续的操作出现问题。所以这里我们选用最后一个(__isoc99_scanf)。
虽然我们可以通过 x/w 指令得到 __isoc99_scanf 函数的虚拟地址 0xf7e3a790。但是由于 0x804a018 处的内容是仍然一个指针,使用 %13$s 打印并不成功。在下面的内容中将会介绍怎样借助 pwntools 的力量,来获得正确格式的虚拟地址,并能够对它有进一步的利用。
当然并非总能通过使用 4 字节的跳转(如 AAAA)来步进参数指针去引用格式字符串的起始部分,有时,需要在格式字符串之前加一个、两个或三个字符的前缀来实现一系列的 4 字节跳转。
覆盖栈内容
现在我们已经可以读取栈上和任意地址的内存了,接下来我们更进一步,通过修改栈和内存来劫持程序的执行流程。%n 转换指示符将 %n 当前已经成功写入流或缓冲区中的字符个数存储到地址由参数指定的整数中。
i 被赋值为 6,因为在遇到转换指示符之前一共写入了 6 个字符(hello 加上一个空格)。在没有长度修饰符时,默认写入一个 int 类型的值。
通常情况下,我们要需要覆写的值是一个 shellcode 的地址,而这个地址往往是一个很大的数字。这时我们就需要通过使用具体的宽度或精度的转换规范来控制写入的字符个数,即在格式字符串中加上一个十进制整数来表示输出的最小位数,如果实际位数大于定义的宽度,则按实际位数输出,反之则以空格或 0 补齐(0 补齐时在宽度前加点. 或 0)。如:
就是这样,下面我们把地址 0x8048000 写入内存:
还是我们一开始的程序,我们尝试将 arg2 的值更改为任意值(比如 0x00000020,十进制 32),在 gdb 中可以看到得到 arg2 的地址 0xffffd538,那么我们构造格式字符串 \x38\xd5\xff\xff%08x%08x%012d%13$n,其中 \x38\xd5\xff\xff 表示 arg2 的地址,占 4 字节,%08x%08x 表示两个 8 字符宽的十六进制数,占 16 字节,%012d 占 12 字节,三个部分加起来就占了 4+16+12=32 字节,即把 arg2 赋值为 0x00000020。格式字符串最后一部分 %13$n 也是最重要的一部分,和上面的内容一样,表示格式字符串的第 13 个参数,即写入 0xffffd538 的地方(0xffffd564),printf() 就是通过这个地址找到被覆盖的内容的:
对比 printf() 函数执行前后的输出,printf 首先解析 %13$n 找到获得地址 0xffffd564 的值 0xffffd538,然后跳转到地址 0xffffd538,将它的值 0x88888888 覆盖为 0x00000020,就得到 arg2=0x00000020。
覆盖任意地址内存
也许已经有人发现了一个问题,使用上面覆盖内存的方法,值最小只能是 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,详细输出如下:
对比 printf() 函数执行前后的输出,可以看到我们成功地给 arg2 赋值了 0x00000002。
说完了数字小于 4 时的覆盖,接下来说说大数字的覆盖。前面的方法教我们直接输入一个地址的十进制就可以进行赋值,可是,这样占用的内存空间太大,往往会覆盖掉其他重要的地址而产生错误。其实我们可以通过长度修饰符来更改写入的值的大小:
试一下:
于是,我们就可以逐字节地覆盖,从而大大节省了内存空间。这里我们尝试写入 0x12345678 到地址 0xffffd538,首先使用 AAAABBBBCCCCDDDD 作为输入:
由于我们想要逐字节覆盖,就需要 4 个用于跳转的地址,4 个写入地址和 4 个值,对应关系如下(小端序):
把 AAAA、BBBB、CCCC、DDDD 占据的地址分别替换成括号中的值,再适当使用填充字节使 8 字节对齐就可以了。构造输入如下:
其中前四个部分是 4 个写入地址,占 4*4=16 字节,后面四个部分分别用于写入十六进制数,由于使用了 hh,所以只会保留一个字节 0x78(16+104=120 -> 0x78)、0x56(120+222=342 -> 0x0156 -> 0x56)、0x34(342+222=564 -> 0x0234 -> 0x34)、0x12(564+222=786 -> 0x312 -> 0x12)。执行结果如下:
最后还得强调两点:
首先是需要关闭整个系统的 ASLR 保护,这可以保证栈在 gdb 环境中和直接运行中都保持不变,但这两个栈地址不一定相同
其次因为在 gdb 调试环境中的栈地址和直接运行程序是不一样的,所以我们需要结合格式化字符串漏洞读取内存,先泄露一个地址出来,然后根据泄露出来的地址计算实际地址
x86-64 中的格式化字符串漏洞
在 x64 体系中,多数调用惯例都是通过寄存器传递参数。在 Linux 上,前六个参数通过 RDI、RSI、RDX、RCX、R8 和 R9 传递;而在 Windows 中,前四个参数通过 RCX、RDX、R8 和 R9 来传递。
还是上面的程序,但是这次我们把它编译成 64 位:
使用 AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p. 作为输入:
可以看到我们最后的输出中,前五个数字分别来自寄存器 RSI、RDX、RCX、R8 和 R9,后面的数字才取自栈,0x4141414141414141 在 %8$p 的位置。这里还有个地方要注意,我们前面说的 Linux 有 6 个寄存器用于传递参数,可是这里只输出了 5 个,原因是有一个寄存器 RDI 被用于传递格式字符串,可以从 gdb 中看到,arg[0] 就是由 RDI 传递的格式字符串。(现在你可以再回到 x86 的相关内容,可以看到在 x86 中格式字符串通过栈传递的,但是同样的也不会被打印出来)其他的操作和 x86 没有什么大的区别,只是这时我们就不能修改 arg2 的值了,因为它被存入了寄存器中。
CTF 中的格式化字符串漏洞
pwntools pwnlib.fmtstr 模块
文档地址:http://pwntools.readthedocs.io/en/stable/fmtstr.html
该模块提供了一些字符串漏洞利用的工具。该模块中定义了一个类 FmtStr 和一个函数 fmtstr_payload。
FmtStr 提供了自动化的字符串漏洞利用:
execute_fmt (function):与漏洞进程进行交互的函数
offset (int):你控制的第一个格式化程序的偏移量
padlen (int):在 paylod 之前添加的 pad 的大小
numbwritten (int):已经写入的字节数
fmtstr_payload 用于自动生成格式化字符串 paylod:
offset (int):你控制的第一个格式化程序的偏移量
writes (dict):格式为 {addr: value, addr2: value2},用于往 addr 里写入 value 的值(常用:{printf_got})
numbwritten (int):已经由 printf 函数写入的字节数
write_size (str):必须是 byte,short 或 int。告诉你是要逐 byte 写,逐 short 写还是逐 int 写(hhn,hn或n)
我们通过一个例子来熟悉下该模块的使用(任意地址内存读写):fmt.c fmt
为了简单一点,我们关闭 ASLR,并使用下面的命令编译,关闭 PIE,使得程序的 .text .bss 等段的内存地址固定:
很明显,程序存在格式化字符串漏洞,我们的思路是将 printf() 函数的地址改成 system() 函数的地址,这样当我们再次输入 /bin/sh 时,就可以获得 shell 了。
第一步先计算偏移,虽然 pwntools 中可以很方便地构造出 exp,但这里,我们还是先演示手工方法怎么做,最后再用 pwntools 的方法。在 gdb 中,先在 main 处下断点,运行程序,这时 libc 已经被加载进来了。我们输入 "AAAA" 试一下:
我们看到输入 printf() 的变量 arg[0]: 0xffffd1f0 ("AAAA\n") 在栈的第 5 行,除去第一个格式化字符串,即偏移量为 4。
读取重定位表获得 printf() 的 GOT 地址(第一列 Offset):
在 gdb 中获得 printf() 的虚拟地址:
获得 system() 的虚拟地址:
好了,演示完怎样用手工的方式得到构造 exp 需要的信息,下面我们给出使用 pwntools 构造的完整漏洞利用代码:
这样我们就获得了 shell,可以看到输出的信息和我们手工得到的信息完全相同。
扩展阅读
练习
writeup 在章节 6.1.2 中
Last updated
Was this helpful?