Format String¶
约 2352 个字 9 张图片 预计阅读时间 12 分钟
How Format String Works¶
printf()
函数是一个特殊的函数,它和我们常见的函数不同,它的参数个数是不固定的。
从 printf()
的手册我们可以看到,它仅仅只是一个大家族中的一个成员,用于输出到标准输出,其他成员包括 fprintf()
(用于写到文件)、sprintf()
、snprintf()
(用于写到内存)等等。
但是它们都有一个共同点,即它们都有一个参数 format
,随后跟一个省略号。
Function with Varying Length of Arguments
事实上,我们也可以定义一个参数数量不固定的函数,例如:
如上图,我们需要头文件 stdarg.h
,定义一个函数,并使用省略号表示后面有数量不固定的参数,然后使用 va_list
类型来定义一个指针 ap
,随后使用 va_start()
函数来初始化这个指针,它指向参数 Narg
后面的一个参数,最后使用 va_arg()
函数指定类型来获取每个参数的值,同时移动 ap
指针。
在 printf()
函数中,format
参数是一个格式字符串,它可以包含普通文本和格式说明符。格式说明符以 %
开头,后面跟着一个或多个字符,用于指定输出的格式。printf()
函数会解析这个字符串,并根据格式说明符来控制参数指针的指向以及参数的类型,从而从栈帧中获取数据。
格式说明符有很多种,例如:
如果我们在 printf()
函数中使用了格式说明符,但是没有提供足够的参数,程序并不会崩溃,参数的指针仍然会不断地移动并获取数据,这就会导致栈上本不属于这个 printf()
函数的栈帧的数据被读取出来。
- 从某种意义上来说,格式字符串能导致内存读取、内存写入等操作,也可以被视为是一种代码/指令,从之前的所有漏洞来看,我们不能让格式字符串被用户控制,否则就会导致漏洞,以下是三种常见的格式字符串漏洞:
- 第一种是直接将用户输入当作格式字符串使用
- 第二种是将用户输入的字符串当作格式字符串的一部分使用
- 第三种是将环境变量当作格式字符串使用,但是环境变量也是用户可以控制的一部分
Read from Memory Using Vulnerability¶
我们有如下程序:
可以看到,printf(input)
函数直接将用户输入的字符串当作格式字符串使用,这就会导致漏洞。如果我们想要程序崩溃,我们可以输入一系列的 %s
,它会不断地从栈上读取 4 字节的数据,并将其当作一个字符串的地址去寻找这个字符串,但可能会读到一些栈上不存在或者无法访问的地址,这就会导致程序崩溃。
如果我们想要读取栈上的数据,且我们知道栈上数据的偏移量(假设为 28 字节),那么我们可以输入 8 个 %x
, 前 7 个 %x
会读取栈上 7 个 4 字节的数据,最后一个 %x
会读取栈上第 8 个 4 字节的数据,这样就可以读取到栈上的数据了。
如果我们想要读取一个不在栈上的数据,但是我们知道它的地址,那么我们可以将这个地址输入到用户输入当中(即将这个地址存到了栈上),那么同理我们也可以通过偏移量的计算将参数指针移到存储这个地址的位置上,然后使用 %s
来读取这个地址上的数据。
Write to Memory¶
前面讲述了如何读取内存数据,接下来我们讲述如何写入内存数据。
在 printf()
函数中,我们可以使用 %n
来将已经输出的字符数写入到一个整数变量中,这个整数变量的地址可以通过格式字符串来指定。例如 printf("Hello%n", &num)
会将已经输出的字符数写入到 num
变量中,即这句话执行完毕后 num
的值为 5。并且,已经输出的字符数并不会因为 %n
而清零,例如 printf("Hello%nWorld%n", &num1, &num2)
会将 num1
的值设置为 5,num2
的值设置为 10。
这样,我们就可以首先找到目标地址存在栈上的位置(即偏移量),通过格式字符串来控制 %n
的位置,从而将一个整数写入到目标地址上。
The Width Modifier¶
在进行攻击的时候,我们是需要指定一个整数的值来写入到目标地址上的,但是 %n
并不会直接将一个整数写入到目标地址上,而是将已经输出的字符数写入到目标地址上,因此我们需要控制输出的字符数。
一种暴力的方法是通过多次输出 %s
或者 %x
,或者自行添加字符来增加输出的字符数,但是如果当值非常大时,这种方法就会变得非常繁琐。
我们可以使用宽度修饰符(Width Modifier)。宽度修饰符可以指定输出的最小宽度,如果实际输出的字符数少于这个宽度,printf()
会在前面进行填充,例如,如果我们使用 printf("%5d", 123)
,那么输出的结果是 123
,即在前面填充了两个空格;如果使用 printf("%.5d", 123)
,那么输出的结果是 00123
,即在前面填充了两个 0。那么我们可以利用 %.100x
,不破坏地址的值和控制参数指针的偏移的同时,通过前导 0 来增加输出的字符数,这样我们就可以控制写入目标地址的值了。
Example
如果我们想要利用一个格式字符串来同时写入两个整数到两个不同的地址(例如 0xBFFFF304
写入 0x6688
,0xBFFFF360
写入 0x7799
)上,如果我们单纯地将这两个输入地址放在一起,即 0xBFFFF3040xBFFFF360%.8x%.8x...%n
,我们会发现当我们用 %n
写到 0xBFFFF304
上时,参数指针已经要指向下一个地址了(即0xBFFFF360
),这就会导致我们无法将 0x7799
写入到 0xBFFFF360
上。
因此我们需要在两个地址之间加一点东西使得参数指针跳过一个地址给我们再次通过 %.x
来控制输出的字符数,我们可以用 0xBFFFF304@@@@0xBFFFF360
,作为开头,那么这样我们就可以在第一个 %n
后通过 %.Dx
再次控制,其中 D=0x7799-0x6688
The Length Modifier¶
在格式字符串中,我们还可以使用长度修饰符(Length Modifier)来指定参数的类型,例如 h
表示 2 字节整数,hh
表示 1 字节整数:
这样我们就可以通过长度的控制来控制 %n
写入的长度,如果我们需要写入大整数,利用宽度修饰符确实可以做到,但是由于输出大量的前导 0 也需要时间,我们可以通过长度修饰符来更快地实现攻击,例如,如果我们想要写入 0x66778899
,我们可以将其拆成 4 个字节 0x66
、0x77
、0x88
、0x99
,然后使用 %hhn
来分别写入这 4 个字节(也可以拆成两个 2 字节 0x6677
、0x8899
,用 %hn
来写入),这样我们就可以更快地实现攻击。同时,这涉及同时使用多个 %n
来写入多个地址的情况,那么我们就可以利用前面的例子提到的方法,在两个地址之间加一点东西使得参数指针跳过一个地址,给我们再次通过 %.x
来控制。
Tips
事实上,我们可以通过 k$
参数来指定使用第几个参数
如上图,我们利用 %3$.20x
将第三个参数 3
作为输出,同时利用 %6$n
将第六个参数 var
的地址作为输出的目标地址,这样我们就不需要通过移动参数指针来控制了,也就不用在需要写入的两个地址之间加东西了,但是我们需要知道我们需要写入的地址在参数列表中的位置。
Countermeasures¶
Developer¶
对于开发者来说,最直接的方法就是在开发程序时,谨记不要让用户输入直接作为格式字符串使用,仅仅将用户输入当作数据使用
Compiler¶
从编译器的角度,确实有一些编译器可以检测到格式字符串漏洞,例如 GCC 和 clang 的 -Wformat
选项可以检测到格式字符串的使用是否正确,如果程序有格式字符串漏洞,编译器会发出警告。
但是,如果我们将格式字符串以变量的方式传递给 printf()
函数,例如上图,编译器就无法检测到漏洞了。这个问题后续也被解决了,我们可以使用 -Wformat=2
选项来检测这种情况,但是它只会告诉你传入的字符串未被检查过,只是可能存在格式字符串漏洞。