0x00 序


格式化字符串漏洞是一个很古老的漏洞了,现在几乎已经见不到这类漏洞的身影,但是作为漏洞分析的初学者来说,还是很有必要研究一下的,因为这是基础啊!!!所以就有了今天这篇文章。我文章都写好了,就差你来跟我搞二进制了!%>.<%

0x01 基础知识---栈


在进行真正的格式化字符串攻击之前,我们需要了解一些基础知识,方便更好的理解该类漏洞。 个人感觉我们还需要一些堆栈相关的基础知识才能更好的理解并运用格式化字符串漏洞。接下来我们就一起看一下栈相关的知识: 说到栈我们不得不提的就是函数调用与参数传递,因为栈的作用就是动态的存储函数之间的调用关系,从而保证在被调用函数返回时能够回到母函数中继续执行。栈 其实是一种数据结构,栈中的数据是先进后出(First In Last Out),常见的操作有两种:

压栈(PUSH)和弹栈(POP),

用于标识栈属性的也有两个:栈顶(TOP)和栈底(BASE)。

PUSH:为栈增加一个元素。

POP:从栈中取出一个元素。

TOP:标识栈顶的位置,并且是动态变化的,每进行一次push操作,它会自增1,反之,每进行一次pop操作,它会自减1

BASE:标识栈底位置,它的位置是不会变动的。

函数调用时到底发生了什么呢,我们将通过下面的代码做一下简单的认识。 示例代码:

程序的执行过程如下图所示:

漏洞挖掘基础之格式化字符串-安全盒子

通过上图我们可以看到程序执行的流程:

1
main--func_A--func_B--func_A--main

,CPU 在执行程序时是如何知道各个函数之间的调用关系呢,接下来我们将介绍一个新的名词:栈帧。当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,这个栈帧 中的内存空间被它所属的函数独占,当函数返回时,系统栈会弹出该函数所对应的栈帧。32位系统下提供了两个特殊的寄存器(ESP和EBP)识栈帧。

  • ESP:栈指针寄存器,存放一个指针,该指针指向栈顶。
  • EBP:基址指针寄存器,存放一个指针,该指针指向栈底。

CPU利用EBP(不是ESP)寄存器来访问栈内局部变量、参数、函数返回地址,程序运行过程中,ESP寄存器的值随时变化,如果以ESP的值为基 准对栈内的局部变量、参数、返回地址进行访问显然是不可能的,所以在进行函数调用时,先把用作基准的ESP的值保存到EBP,这样以后无论ESP如何变 化,都能够以EBP为基准访问到局部变量、参数以及返回地址。接下来将编译上述代码并进行调试,从而进一步了解函数调用以及参数传递的过程。

首先用gcc进行编译:

1
gcc -fno-stack-protector -o 1 1.c

用objdump进行反汇编查看:

1
objdump -d 1

func_A栈帧如下图所示:

漏洞挖掘基础之格式化字符串-安全盒子

我们将通过以下图例对本次函数调用做一个总结:

漏洞挖掘基础之格式化字符串-安全盒子

通过前面的函数调用细节以及栈中数据的分布情况,我们可以发现局部变量是在栈中挨个排放的,如果这些局部变量中有数组之类的缓冲区,并且程序存在数组越界的问题,那么越界的数组元素就有可能破坏栈中相邻变量的值,进而破坏EBP的值、返回地址等重要数据。

因为本次主要讨论的是格式化字符串漏洞,关于栈溢出的细节就不做讨论了,感兴趣的可以查阅相关资料。

有了以上的基础知识以后,我们就可以进一步分析格式化字符串漏洞了。

0x02 格式化字符串漏洞原理


格式化串漏洞和普通的栈溢出有相似之处,但又有所不同,它们都是利用了程序员的疏忽大意来改变程序运行的正常流程。

接下来我们就来看一下格式化字符串的漏洞原理。

首先,什么是格式化字符串呢,print()、fprint()等*print()系列的函数可以按照一定的格式将数据进行输出,举个最简单的例子:

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

该printf函数的第一个参数就是格式化字符串,它来告诉程序将数据以什么格式输出。上面的例子相信只要学过C语言、上过大学考过计算机二级的都耳熟能详,如果这个都不知道,接下来我真不知道该怎么写了。但是我还是觉得有必要把printf()函数好好写一下。

printf()函数的一般形式为:

1
printf("format", 输出表列)

,我们对format比较关心,看一下它的结构吧:

1
%[标志][输出最小宽度][.精度][长度]类型

,其中跟格式化字符串漏洞有关系的主要有以下几点:

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

2、类型:

  • d 表示输出十进制整数*
  • s 从内存中读取字符串*
  • x 输出十六进制数*
  • n 输出十六进制数

对于其余内容,感兴趣的自行百度吧。

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

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

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

# 特性一: printf()函数的参数个数不固定

我们可以利用这一特性进行越界数据的访问。我们先看一个正常的程序:

我们编译之后运行:

接下来我们做一下测试,我们增加一个printf()的format参数,改为:

1
printf("%s %d %d %d %x\n",buf,a,b,c)

,编译后运行:

虽然gcc在编译的时候提示了一个warning,但还是编译通过了,我们运行后发现多输出了一个C30000,这是个什么数据呢,我们用gdb调试一下看看吧,我们在printf()函数处下个断点,然后运行程序,程序停在了

1
printf()

函数入口处

1
0xb7e652f0 __printf+0 push %ebx

。大家可能发现了我的gdb 有点不大一样,是因为我用了一个叫做gdb-dashboard的可视化工具,个人感觉还是比较方便的,可以实时的查看寄存器、内存、反汇编等,感兴趣的同学可以去github下载安装一下试试:

1
https://github.com/cyrus-and/gdb-dashboard

我们查看一下此时的栈布局:

我们已经看到了0x00c30000,根据第一节我们对栈帧布局的认识,我们可以想象一下调用printf()函数后的栈的布局是什么样的

漏洞挖掘基础之格式化字符串-安全盒子

看了上面的图,相信大家已经很明白了吧,只要我们能够控制format的,我们就可以一直读取内存数据。

上一个例子只是告诉我们可以利用%x一直读取栈内的内存数据,可是这并不能满足我们的需求不是,我们要的是

1
任意地址读取

,当然,这也是可以的,我们通过下面的例子进行分析:

有了上一个小例子的经验,我们可以直接尝试去读取str[]的内容呢

gdb调试,单步运行完

1
call 0x8048340 &lt;fgets@plt&gt;

后输入:

1
AAAA%08x%08x%08x%08x%08x%08x

(学过C语言的肯定知道%08x的意义,不明白的也不要紧,可以先看一下后面的特性三,我这里就不再多说了)

然后我们执行到printf()函数,观察此时的栈区,特别注意一下0x41414141(这是我们str的开始):

继续执行,看我们能获得什么,我们成功的读到了AAAA:

这时候我们需要借助printf()函数的另一个重要的格式化字符参数%s,我们可以用%s来获取指针指向的内存数据。

那么我们就可以这么构造尝试去获取0x41414141地址上的数据:

1
\x41\x41\x41\x41%08x%08x%08x%08x%08x%s

到现在,我们可以利用格式化字符串漏洞读取内存的内容,看起来好像也没什么用啊,就是读个数据而已,我们能不能利用这个漏洞修改内存信息(比如说修改返回地址)从而劫持程序执行流程呢,这需要看printf()函数的第二个特性。

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

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

可以发现我们用%n成功修改了num的值:

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

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

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

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

看到这儿聪明的你肯定明白如何去覆盖一个地址了吧,比如说我们要把0x8048000这个地址写入内存,我们要做的就是把该地址对应的10进制134512640作为格式符控制宽度即可:

可以看到,我们的num被成功修改为8048000

明白了这个原理之后,我们接下来尝试

1
任意地址写

作为本章的结束。知识库之前有一篇格式化字符串漏洞文章,在文章最后有一个实例,但是在我按照作者的方法进行测试的时候,发现并不能成功利用,于是我利用任意地址写的方法完成该实验。

代码参考链接:

首先分析一下汇编代码,下面这一段代码就是将p指向flag,并且将局部变量flag、p压栈,我们只需要利用格式化字符串漏洞覆盖掉*p指向的内存地址的内容为2000就可以了。

下面我们要做到是找到*p指向的内存地址,也就是ebp-0x10的地址。 gdb载入程序,重点关注以上三条指令执行结果:

现在我们知道了,我们需要将0xbffff048这个地址的内容修改为2000。这里有一点需要特别注意:

1
gdb调试环境里面的栈地址跟直接运行程序是不一样的

,也就是说我们在直接运行程序时修改这个地址是没用的,所以我们需要结合格式化字符串漏洞读内存的功能,先泄露一个地址出来,然后我们根据泄露出来的地址计算出ebp-0x10的地址。

我们继续在gdb调试,执行get()函数后随便输入AAAAAAA,执行到printf()的时候观察栈区:

漏洞挖掘基础之格式化字符串-安全盒子

我们如果只输入%x的话就可以读出esp+4地址上的数据,也就是0xbfffefe4,而我们需要修改的地址为0xbffff048,这两个地址的偏移为0x64。

下面我们就可以直接运行程序,并输入%x,然后获取ESP+4地址内的值:

那我们需要修改的地址就是:0xbffff024+0x64=0xbffff088

最后就是要在地址0xbffff088处写入2000: \x88\xf0\xff\xbf%10x%10x%10x%1966x%n