格式化字符串(Format String)

原理

格式化字符串函数介绍
格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。也就是格式化字符串函数将计算机内存中表示的数据转化为可读字符串。几乎所有的C/C++程序都会利用到格式化字符串函数来输出信息,调试程序或者处理字符串。一般来说,格式化字符串在利用时主要分为三个部分。

  • 格式化字符串函数
  • 格式化字符串
  • 可选后续参数

printf函数:
alt text

格式化字符串函数
常见的格式化字符串函数有
输入:scanf
输出:

函数 基本介绍
printf 输出到stdout
fprintf 输出到指定FILE流
vprintf 根据参数列表格式化输出到stdout
vfprintf 根据参数列表格式化输出到指定FILE流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等 。。。

格式化字符串
格式化字符串中的占位符用于指明输出的参数值如何格式化。
格式化占位符的基本语法:
%[parameter][flags][field width][.precision][length]type
每种pattern的含义请参考格式化字符串
其中,我们关注的pattern如下:

  • parameter
    • n$,获取格式化字符串中的指定参数
  • flag
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号证书
    • u,无符号证书
    • x/X,16进制unsigned int。x使用小写字母;X使用大写字符。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
    • o,8进制unsigned int。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
    • s,如果没有用|标志,输出null结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了|标志,则对应函数参数指向wchar_t型的数组,输出时把每个宽字符转化为多字节字符,相当于调用了wcrtomb函数。
    • c,如果没有用|标志,把int参数转为unsigned char型输出;如果用了|标志,把win_t参数转为包含两个元素的wchart_t数组,其中第一个元素包含要输出的字符,第二个元素为null宽字符。
    • p,void*型,输出对应变量的值。printf("%p",a)用地址的格式打印变量a的值,printf("%p",&a)打印变量a所在的地址。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    • % %字面值,不接受任何flags,width

参数:就是相应的要输出的变量。

格式化字符串漏洞原理

格式化字符串函数是根据格式化字符串来解析的,那么相应的要被解析的采纳数的个数也自然是由这个格式化字符串所控制。比如说%s表明我们会输出一个字符串参数。
如上的例子,在未call printf之前,栈上的布局由高地址到低地址依次如下:

1
2
3
4
5
some value
3.14
123456
addr of "red"
addr of format string: Color %s...

如果我们这里假设3.14上面的值为某个未知的值。
在进入printf之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况

  • 当前字符不是%,直接输出到相应标准输出
  • 当前字符是%,继续读取下一个字符
    • 如果没有字符,报错
    • 如果下一个字符是%,输出%
    • 否则根据相应的字符,获取相应的采纳数,对其进行解析并输出。

若我们编写的程序为:
printf("Color %s, Number %d, Float %4.2f");
可以看到,我们并没有提供参数,那么程序会如何运行呢,程序会将栈上存储格式化字符串地址上面的三个变量分别解析为:

  1. 解析其地址对应的字符串
  2. 解析其内容对应的整形值
  3. 解析其内容对应的浮点值

如:
alt text
对于2,3来说无妨,但是对于1来说,如果提供了一个不可访问地址,比如0,则程序便会因此而崩溃。

利用

格式化字符串的两个利用手段:

  • 使程序崩溃,因为%s对应参数地址不合法的概率比较大
  • 查看进程内容,根据%d %f输出栈上的内容

程序崩溃:只需要输入若干个%s即可,因为在栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身不能控制程序,但是可以造成程序不可用。

泄露内存

利用格式化字符串漏洞,我们还可以获取想要输出的内容。一般有如下操作:

  • 泄露栈内存
    • 获取某个变量的值
    • 获取某个变量对应地址的内存
  • 泄露任意地址内存
    • 利用GOT表得到libc函数地址,进而获取libc,进而获取其他libc函数地址。
    • 盲打,dump整个程序,获取有用信息。
泄露栈内存

例:给定以下程序

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}

我们编译一下,发现编译器警告,指出了我们的程序中没有给出格式化字符串的参数的问题:
alt text
根据C语言的调用规则,格式化字符串函数会根据格式化字符串直接使用栈上自顶向上的变量作为其参数(64位会根据其传参规则进行获取)。这里使用32位。
获取栈变量数值
我们可以利用格式化字符串来获取栈上变量的数值:
alt text
可以看到,我们确实得到了一些内容。为了更加细致的观察,我们利用GDB调试一下:
首先打断点在printf函数处,然后输入内容
alt text
回车,可以看到,程序首先断在了第一次调用printf函数的位置
alt text
可以看出,此时已经进入printf函数中,栈中第一个变量为返回地址,第二个变量为格式化字符串的地址,第三个变量为a的值,第四个变量为b的只,第五个变量为c的值,第六个变量为我们输入的格式化字符串对应的地址。继续运行程序:
alt text
此时,由于格式化字符串为%x%x%x,所以,程序会将栈上的0xffffd524之后的值分别作为第一、第二、第三个参数按照int型进行解析,分别输出。继续执行,我们可以得到如下结果:
alt text
当然,我们也可以使用%p来获取数据,如下:
alt text
这里需要注意的是,并不是每次得到的结果都一样,因为栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。
值得注意的是,上述方法,都是依次获得栈上的每个参数,我们如果需要直接获取栈中被视为第n+1个参数的值,怎么办?如下:
%n$x,利用该字符串,我们就可以获取到对应的n+1个参数的数值。为什么是n+1个参数呢,这时因为格式化参数里面n指的是该格式化字符串对应的第n个输出参数,那相对与输出函数来说,就是n+1个参数了。
使用gdb调试:
alt text
alt text
可以看到,我们确实获得了printf的第4个参数所对应的值。

获取栈变量对应字符串

使用%s获取栈变量对应的字符串。
alt text
可以看到,在第二次执行printf函数的时候,确实是将0xffffd524处的变量视为字符串变量,输出了其数值对应地址处的字符串。
**当然,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。
此外,我们也可以指定获取栈上第几个参数作为格式化字符串输出,比如我们指定printf的第3个参数,程序不能解析,就崩溃了。
alt text

小技巧总结

  1. 利用%x来获取对应栈的内存,但建议使用%p,可以不用考虑位数的区别
  2. 利用%s来获取变量所对应地址的内容,只不过有零截断
  3. 利用%order$x来获取指定参数的值,利用%order$s来获取指定参数对应地址的内容。
泄露任意地址内存

可以看到,在上面无论是泄露栈上连续的变量,还是说泄露指定的变量值,我们都没能完全控制我们所要泄露的变量的地址。这样的泄露固然有用,可是却不够强力有效。有时候,我们可能会想要泄露某一个libc函数的got表内容,从而得到其地址,进而获取libc版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了。
一般来说,在格式化字符串漏洞中,我们所读取的格式化字符串都是在栈上的(因为是某个函数的局部变量)。那么也就是说,在调用输出函数的时候,其实,第一个参数的值就是该格式化字符串的地址。
所以,如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第k个参数。那么我们就可以通过如下方式来获取某个指定地址addr的内容。
addr%k$s
注:在这里,如果格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址8字节或者16字节
下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定:
[tag]%p%p%p%p%p%p%p...
一般来说,我们会重复某个字符的机器字长来作为tag,而后面会跟上若干个%p来输出栈上的内容,如果内容与前面的tag重复了,那么就有很大把我说明该子地址就是格式化字符串的地址,之所以说是有很大把握,是因为不排除栈上有一些临时变量也是该数值。一般情况下,极其少见,我们也可以更换其他字符进行尝试,再次确认。这里我们利用字符A作为特定字符。
alt text
由0x25414141处所在的位置可以看到我们的格式化字符串的起始地址正好是输出函数的第5个参数,是格式化字符串的第4个参数,我们进行测试,发现崩溃了
alt text
这是因为我们试图将该格式化字符串所对应的值作为地址进行解析,但是显然该值没有办法作为一个合法的地址被解析,所以程序就崩溃了。
若设置一个可访问的地址呢?如scanf@got
首先,获取scanf@got的地址,如下:
alt text
接下来利用pwntools构造payload如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python3

from pwn import *

context.terminal = ['tmux', 'splitw', '-h']
sh = process('./leakmemory')

leakmemory = ELF('./leakmemory')
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
print(hex(__isoc99_scanf_got))
payload = p32(__isoc99_scanf_got) + b'%4$s'
print(payload)
gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil(b'%4$s\n')
print(hex(u32(sh.recv()[4:8])))
sh.interacive()

可以看到,我们的第四个参数确实指向scanf的地址
alt text
再看终端
alt text
我们成功获取到了scanf的地址。
但是,并不是所有的偏移机器字长的整数倍,可以让我们直接相应参数获取,有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字长整数倍的地址处。如:
[padding][addr]
注:我们不能直接在命令输入\x0c\xa0\x04\x08%4$s,这时因为虽然前面的确实是printf@got的地址,但是,scanf函数并不会将其识别为对应的字符串,而是将\,x,0,c分别作为一个字符进行读入。

覆盖内存

使用格式化字符串漏洞来修改任意地址变量的内存
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整形指针参数所指的变量。
通过这个类型参数,再加上一些小技巧,就可以实现修改其对应的数值。这里分为两部分,一部分为覆盖栈上的变量,第二部分为覆盖指定地址的变量。

这里使用如下程序来学习;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if ( c == 16 ) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b = 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}

无论是覆盖哪个地址的变量,我们基本上都是构造类似如下的payload

1
...[overwrite addr]...%[overwrite offset]$n

其中…表示我们的填充内容,overwrite addr表示我们所要覆盖的地址,overwrite offset地址标识我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。所以一般来说,有如下步骤:

  • 确定覆盖地址
  • 确定相对偏移
  • 进行覆盖
覆盖栈内存

确定覆盖地址
首先,想办法知道栈变量C的地址。由于目前几乎所有的程序都开启了aslr保护,所以栈的地址一直在变,所以这里故意输出C变量的地址。
alt text
确定相对偏移
其次,确认存储格式化字符串的地址是printf将要输出的第几个参数,这里通过之前泄露栈变量数值的方法进行操作。调试:
alt text
alt text
在0xffffd504处存储变量c的数值。继而,我们再确定格式化字符串%d%d的地址0xffffd518相对于printf函数的格式化字符串参数0xffffd500的偏移为0x18,即格式化字符串相当于printf函数的第7个参数,相当于格式化字符串的第6个参数

进行覆盖
这样,第六个参数处的值就是存储变量c的地址,我们可以利用%n的特征来修改c的值。payload如下:

1
[addr of c]%012d%6$n

addr of c的长度为4,故而我们得再输入12个字符才可以达到16个字符,以便于来修改c的值为16.
具体脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
from pwn import *

def forc():
# context.terminal = ['tmux', 'splitw', '-h']
sh = process("./overflow")
c_addr = int(sh.recvuntil('\n', drop=True), 16)
print(hex(c_addr))
payload = p32(c_addr) + b'%012d' + b'%6$n'
print(payload)
# gdb.attach(sh)
sh.sendline(payload)
print(sh.recv())
sh.interactive()

forc()

alt text

覆盖任意地址内存

覆盖小数字
首先,来考虑一下如何修改data段的变量为一个较小的数字,比如说,小于机器字长的数字。这里以2为例。可能会觉得没有什么区别,如果我们将要覆盖的地址放在最前面,那么将直接占用机器字长个(4或8)字节。显然,无论之后如何输出,都只会比4大。

1
或许我们可以使用整形溢出来修改对应的地址的值,但是这样将面临着我们得一次输出大量的内容。而这,一般情况下,基本都不会攻击成功。

我们当时只是为了寻找偏移,所以才把tag放在字符串的最前面,如果我们把tag放在中间,其实也是无妨的。类似的,我们把地址放在中间,只要能够找到对应的偏移,其照样也可以到的对应的数值。前面已经说了格式化字符串的为第6个参数。由于我们想要把2写到对应的地址处,故而格式化字符串的前面的字节必须是
aa%k$nxx
此时对应的存储的格式化字符串已经占据了6个字符的位置,如果我们再添加两个字符aa,那么aa%k就是第六个参数,$nxx其实就是第7个参数,后面如果跟上要覆盖的地址,那就是第8个参数,所以如果我们这里设置k为8,其实就可以覆盖了。
利用ida可以得到a的地址为