0x00 序言

学pwn,做了两道攻防世界的pwn新手训练,真的是啥也不会,第一道题是连上就有flag我都不知道咋连,就菜到这种地步。一个格式化字符串漏洞看了一天。看了很多博客,最后终于弄明白了,感觉很多博客都对新手不是那么友好,刚学完,总结梳理一下,便于复习。也希望自己写的能帮到其他像我一样刚开始学的小白更快更清楚的明白格式化字符串漏洞。

0x01 格式化字符串

格式化字符串漏洞针对的是printf()函数,所以有必要先深入了解一下printf()函数和什么是格式化字符串。

首先让我们来看一下格式化字符串。

学过C的都知道printf(),在输出某个变量的时候我们一般会这样写:

int a = 100;
printf("%d",a);
// 100

这里的%d,实际上就是所谓的格式化字符串,维基百科是这样定义的:

格式化字符串(英语:format string),是一些程序设计语言在格式化输出API函数中用于指定输出参数的格式与相对位置的字符串参数,例如C、C++等程序设计语言的printf类函数,其中的转换说明(conversion specification)用于把随后对应的0个或多个函数参数转换为相应的格式输出;格式化字符串中转换说明以外的其它字符原样输出。

在我理解,就是你规定要输出的字符串的格式和位置,比如%d就是十进制整数格式,%s就是字符串格式,%x就是十六进制数格式等等。

再比如:

printf("Hello, %s", "Winny");
// Hello, Winny

%s告诉程序:“你应该在‘Hello, ’后面按照字符串格式来输出这个参数!”然后程序就乖乖的按照格式化字符串的要求输出了这个参数。这就是格式化字符串的作用。

比较常见和基本的格式化字符串有这么几个:

字符描述
%d输出10进制整数
%c输出字符
%s输出内存中的字符串,内存中存的是字符串所在的地址
%x输出十六进制数据
%p输出十六进制数据,区别是有前缀“0x”,实际上就是输出个指针,所以32位输出4字节,64位输出8字节
%n将printf已经打印的字符个数赋值给指定的内存地址中

实际上,这只是最简单的用法,格式化占位符的语法是:

%[parameter][flags][field width][.precision][length]type

d、c、s、x、p、n这些都是type。flags、field width、.precision、length这些不是很重要,这里就不说了。有兴趣可以在维基百科上看,这里说一下parameter。

Parameter可以忽略,也可以是:

字符描述
n$输出第n个参数

比如看下面的代码:

printf("the third is %3d, the first is %1d",1,2,3,4,5,6);
// the third is 3, the first is 1

可以看到,程序输出了第三个和第一个参数。至于n$有什么用,后面就会讲到。

回到上面的格式化字符串,最后的%n可能有的人看完描述觉得有点蒙,咱来举个栗子。在看代码之前,先插播一下,由于这个漏洞windows好像是有保护机制的,所以为了演示我所有的代码都在ubuntu上运行,同时编译时候按32位编译(我的ubuntu是64位的)。

编译命令为:

gcc -m32 test.c -o test

其中-m32就是按32位编译,要想在ubuntu上用-m32,先安装以下的库:

sudo apt-get install build-essential module-assistant
sudo apt-get install gcc-multilib g++-multilib

运行编译好的文件就在当前文件夹下输入以下命令:

./test

好,知道了如何在linux系统下编译运行文件,看下面的代码:

#include <stdio.h>

void main()
{
    int s = 0;
    printf("The value of s is %n",&s);
    printf("%d\n",s);
}
// The value of s is 18

你会发现s被赋值为了18,因为printf在%n之前已经输出了18个字符:“The value of s is ”(包括空格)然后%n就把18写入了s所在的地址对应的内容,也就是赋给了s。

现在你应该比刚才清楚一点%n的用法了吧。事实上,如果你敏感,你就会发现通过%n,我们修改了本来不能修改的内存的内容!这是极其危险的一件事!这也是漏洞修改内存值的核心!具体的使用会在漏洞部分详细介绍。

0x02 printf()函数

现在我们知道了格式化字符串,再来了解一下printf()。

printf()是C语言中为数不多的支持可变参数的库函数。就是说你想给printf()传几个参数都可以。根据规定,函数在调用的过程中,传入函数的参数从右到左逐个压入栈中。例如:

print("%d %d %d",1,2,3);
// 1 2 3

反汇编看的更直观一点:

push    3
push    2
push    1
lea     edx, (aDDD - 1FD8h)[eax] ; "%d,%d,%d"
push    edx             ; format
mov     ebx, eax
call    _printf

在这个例子中,我们制定了参数的数量为3和类型为十进制整数,那么printf()就会在栈中向前找三个值并按照十进制整数的形式输出出来。

还是这个例子,只不过我稍微改变一点:

print("%d %d %d",1,2,3,4,5);
// 1 2 3

反汇编看一下:

push    5
push    4
push    3
push    2
push    1
lea     edx, (aDDD - 1FD8h)[eax] ; "%d,%d,%d"
push    edx             ; format
mov     ebx, eax
call    _printf

函数将五个参数都压入了栈中,但是按照format的指示只按照十进制整数的格式打印了前三个参数。

可以看到,对于可变参数的函数,printf()本身并不知道传入参数的数量,也不知道在函数调用前到底有多少参数被压入栈中,所以它要求传入一个format来告诉它有几个参数,你有几个格式化字符串,printf()就认为你传入了几个参数。并忠实的按照format的指示,以指定的格式在指定位置打印指定数量的参数。

这就存在一个问题了。假如我们给printf()实际传递的参数数量小于我们所给的format怎么办?例如:

printf("%d,%d,%d\n",1,2);

这时候我们告诉printf():“我给你传递了三个参数,请把这三个参数安装十进制整数的形式打印出来!”而实际上,我们只给printf()两个参数,那这时候会发生什么呢?先自己思考一下,再往下看。

运行结果如下:

1566005116498

可以看到除了我们输入的参数1、2以外,printf()还打印出了一个值,这个值是什么呢?这个值就是栈上在保存的我们两个参数之后的第三个值,由于我们告诉printf()我们传了三个参数,所以它就会在栈上向前找三个值。也就是说,通过这种方式,我们可以读取本来不应该被读取到的内存的内容。

再来看一个例子:

printf("%x %x %x %x %x %x %x %x %x %x\n");

1566005556194

可以看到printf()将栈中format后10个内存的内容都以十六进制的形式打印了出来。

0x03 格式化字符串漏洞利用

有了上面的前期储备,我们就可以来聊聊格式化字符串漏洞的利用了。实际上,这个漏洞的产生可以说是因为程序员的偷懒。比如下面这个简单的程序:

char str[100];
scanf("%s",str);
printf("%s\n",str);

1566006155661

这样写是没有问题的。但是有的时候程序员会偷懒,写成这样:

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

没有format,但是程序也能正常运行。

1566006274630

但是这时候由于没有format,printf()实际上并不知道传入了几个参数,那么如果我们输入:aaa.%x会发生什么呢?

1566006429774

我们发现,printf()打印出来了本来不应该被打印出来的内存中的值。

这样一来,通过这个漏洞,我们可以实现任意内存的读和写。

任意内存读取

还是上面这个程序,我们可以用很多个格式化字符串来读取很多个内存地址的内容

1566023620247

也可以通过之前说的n$来读取特定偏移量的内存,比如说我想读取第六个内存中的内容,就可以这样构造:

1566023730070

现在我们就读取到了栈中第六个内存地址中的内容。实际上41就是A的十六进制ASCII码,这个地址中存的就是我们输入的“AAAA”(注意,并不是说printf()一直会把输入的内容存到偏移量为6的内存中,只是在这里是如此,具体存到偏移量为几的内存中需要我们自己通过构造很多个%x去找)。

通过%x可以读取内存中的值,通过%s可以读取以内存中的值为地址的字符串。

比如上面如果你用%s,你就会读取0x41414141为首地址上对应的字符串(如果有的话),如果该地址上没有,就会报段错误Segmentation fault。

1566024610292

这实际上是因为访问了不可访问的内存。例如该内存已经超过了系统所给这个程序的内存空间。我们要访问0x41414141,结果这个程序根本没有这个地址,那当然会报错啦。或者是这个内存是受系统保护的,我们不能访问。

我们再举一个%s的例子,在开始实验前,首先你要确保关闭了linux的内存地址随机化机制,否则每次运行程序,数据的地址都不一样,无法进行实验。关闭方法:

echo 0 > /proc/sys/kernel/randomize_va_space

关闭了之后就可以愉快的开始我们的实验了。我们看这个程序:

#include <stdio.h>
#include <string.h>

void main(int argc,char **argv)
{
        char str[100];
        char s[10]="hello pwn!";
        printf("%x\n",&s);
        strcpy(str,argv[1]);
        printf(str);
}

这里没有输出s的内容,如果我们想看到s的内容,那我们就可以通过格式化字符串漏洞达到目的。

攻击思路:首先我们看看“hello pwn!”所在的地址是什么,然后我们通过构造输入把这个地址存到内存中,再通过%s来读取“hello pwn!”。

构造地址可以通过输入字符串,让内存中存字符串对应的ASCII码这种方法来构造,比如如果要让内存中的值为44434241我们就可以输入ABCD(A的十六进制ASCII码为41,在内存中以十六进制存储且为小端序存储,所以41在最右边)。

但是如果地址像ff1b6800这样,那么我们就没法通过这种方式进行构造,因为我们很难找到该ASCII码对应的字符串并输入。这时候我们就要用到printf命令,将shellcode转义(直接输入\x00\x68\x1b\xff这样)。使用的时候把printf命令用反引号括起来。但是注意如果是用scanf输入字符串,则没法使用printf命令,而且scanf和命令行输入的shellcode没法被直接转义。因此,我们给的例子是使用strcpy赋值。

那么我们开始构造输入:

1566029291179

通过构造输入,我们成功改变了内存为ffffd4fe(偏移量是14)。

注意:这个程序没写好,如果输入参数个数不同,s的地址也会变化,比如我输入"`printf "\xfe\xd4\xff\xff"`.%x"时输出的就不是ffffd4fe而是ffffd52e。所以传入的参数数量需要固定,这一点需要注意。实际上,把s设置成静态的(在s前加static)就可以解决问题。下一个例子就改正这个问题。

现在我们已经可以把偏移量为12的内存中的值变成s所对应的内存地址,那么我们把最后一个改成%s,即输出该内存值对应的地址中的字符串。

1566029414006

可以看到我们成功的读取出了s的内容。

任意内存修改

光读取还不够,最重要的是我们可以任意的对内存进行修改,这才是这个漏洞最可怕的地方。修改的方法其实上面已经说到一些了,那就是通过%n。

看一个例子:

#include <stdio.h>
#include <string.h>

void main(int argc,char **argv)
{
        char str[100];
        static int a = 0;
        printf("%x\n",&a);
        strcpy(str,argv[1]);
        printf(str);
        printf("\n%d\n",a);
}

我们的目标是修改a的值。先运行一下看看正常情况下是什么情况

1566032185561

可以看到a为初始值0,那么下面开始构造:

1566032317311

可以看到偏移量为10,把最后的换成%n

1566032384441

可以看到我们成功的改变了a的值,86是因为\x0c占一个字符,所以四个数有四个字符,“.”占一个字符,%x占8个字符,一共10个“.”,9个%x,最终加起来在%n前面一共输出了86个字符,所以是86。

如果要用n$的话,记得要在前面加反斜杠\转义:

1566033484942

最后在附上一个我自己写的小脚本,用来构造地址的,比如说要构造41414141,那么你需要输入的字符串就是AAAA。这个小脚本就是告诉你需要输入的字符串是什么,只需要你填入要构造的地址就行了。(如果ASCII码比较奇怪的可能就不行了,比如ASCII码是0x0B什么的)

'''
Author: Winny
Date: 2019.08.16
'''


def pocstr(addr):
    frag = []
    for j in range(0, len(addr), 2):
        frag.append(int(str('0x') + addr[j:j+2], 16))
    frag = frag[::-1]
    for s in range(len(frag)):
        print(chr(frag[s]), end='')


pocstr('41414141')
#输出结果:AAAA

"Imagination will take you everywhere."