TOC
Open TOC
Bomb Lab
在 CSAPP Lab 下载对应的 tar
包并解压。解压之后有三个文件,README
没啥用,bomb.c
中只给出了 main
函数,并且对我们进行了一番嘲讽 🤣:
所以最重要的就是 bomb
这个可执行文件。
我们的工具有两个,一个是 objdump
,另一个是 gdb
。
首先使用 objdump
得到汇编代码:
为了阅读方便,我们将结果保存在文件中。
汇编代码非常长,我们在其中找到 main
函数的部分:
下面分阶段讲解。
phase_1
阶段一从地址 0x400e32
开始,这里调用了 read_line
函数从输入中(可以是命令行里的输入,也可以是文件里的输入,详见 bomb.c
里的注释)读取一行。
现在的问题是读取的内容存放在了哪里?
我起初认为读取的内容在寄存器中,在地址 0x400e37
设置断点(break * 0x400e37
)发现,无论输入是什么,在调用了 read_line
地址 0x400e37
函数后,相关的寄存器(info registers
)rax
的值总是 0x603780
,于是猜测读取的内容以字符串的形式存放在地址 0x603780
处(这很关键,不然会误入歧途 🤣)。
地址 400e37
这一行将寄存器 rax
的值赋给 rdi
,并调用 phase_1
函数:
地址 0x400ee4
这一行将 0x402400
赋给寄存器 esi
,寄存器 rsi
的高四字节置 0
。接着便调用了 strings_not_equal
函数:
观察从地址 0x40133c
到地址 0x40135a
的部分,通过调用 string_length
函数依次计算寄存器 rdi
和 rsi
所指向地址的字符串的长度(观察 string_length: 0x40131b
的 (%rdi)
可知)。若长度不同,则返回 1
,注意 phase_1: 0x400eee
检测返回值是否为 0
,若不为 0
,则引爆炸弹。可以推断,若两个字符串相同,那么返回值就是 0
,炸弹就不会被引爆。
(╯‵□′)╯炸弹!•••*~●
于是,我们只要得到寄存器 rsi
中的 0x402400
所指向的字符串即可,我们使用下面的指令:
将结果保存在 input
文件中即可:
来测试一下:
( ^_^ ) 不错嘛。
注:在走弯路的过程中 🤣,探索了 read_line
函数究竟是如何将字符串存放在地址 0x603780
处的。在命令行输入的场景下,read_line
函数调用了 skip
函数,skip
函数调用了**fgets
函数**和 blank_line
函数。
给几个观察寄存器的断点:
-
skip: 0x401421
,此时寄存器 rsi
会有从字符串第二个字符开始的 ASCII
码,若字符串为 123456
,则编码为 0x35343332
,这里只有四个字符,推测是按四个字符读取
-
blank_line: 0x4013e1
,此时寄存器 rbp
会有从字符串第一个字符的 ASCII
码,在上面的例子中,就是 0x31
,这里推测是预读第一个字符,若遇到空行则继续读取
-
read_line: 0x40154b
,此时寄存器 rcx
给出了读取的字符串的长度加 2
的结果,在 read_line
函数的后面,将寄存器 rcx
的值减去了 2
,并以此为依据,在 read_line: 0x4015ab
处给字符串的最后加上了 0
,此时寄存器 rax
的值为 0
,尚不清楚其作用 🤣
phase_2
阶段二从 main: 0x400e4e
开始,同样调用 read_line
函数从输入中读取一行,读取的内容以字符串的形式存放在地址 0x6037d0
处。
接下来 main
函数调用 phase_2
函数:
注意 phase_2: 400f02
处,这里将寄存器 rsp
的值赋给寄存器 rsi
,通过断点调试可知此时寄存器 rsi
的值为 0x7fffffffde80
。接着调用 read_six_numbers
函数:
先看前四行,刚进入 read_six_numbers
函数,寄存器 rsp
的值就从 0x7fffffffde80
变为了 0x7fffffffde78
(函数调用所致)。到 read_six_numbers: 0x40146b
时,相关寄存器的值如下:
rsp: 0x7fffffffde60
rdx: 0x7fffffffde80
rcx: 0x7fffffffde84
rax: 0x7fffffffde94
read_six_numbers: 0x40146b
将 0x7fffffffde94
赋给地址 0x7fffffffde68
处,接着寄存器 rax
的值变为 0x7fffffffde90
,并将 0x7fffffffde90
赋给地址 0x7fffffffde60
处。使用 gdb
可以观察:
其实看到这里是一头雾水 🤣
到了 read_six_numbers: 0x40148a
,read_six_numbers
函数调用了 __isoc99_sscanf
函数,然后对其返回值进行测试,若 ≤ 5
,则引爆炸弹。通过查阅sscanf可知,其返回值是成功赋值的接收参数的数量。根据函数名,我们可以推测输入的格式是 6
个以空格分割的数字。尝试输入 1 2 3 4 5 6
,并在 gdb
中一通乱搞🤣,发现从地址 0x7fffffffde80
开始的 6
个字节存储了我们输入的数字。
不太理解为什么是以这种形式存储 🤣
下面就容易了,从 read_six_numbers
函数返回到 phase_2
函数后:
phase_2: 0x400f0a
测试寄存器 rsp
中对应的地址中存储的值是否为 1
,我们知道此时寄存器 rsp
中存储的值为 0x7fffffffde80
,也就是说这里测试输入的第一个数字是否为 1
。若为 1
,我们跳转到 phase_2: 0x400f30
,将寄存器 rbx
设置为 0x7fffffffde84
,寄存器 rbp
设置为 0x7fffffffde98
(终止条件)。接着我们跳转到 phase_2: 0x400f17
,将地址 0x7fffffffde80
中的值赋给寄存器 eax
,并乘 2
,与地址 0x7fffffffde84
中的值比较,若相等,则跳过炸弹引爆,并将 rbx
加 4
,判断是否到了 0x7fffffffde98
。若没有,则继续跳转到 phase_2: 0x400f17
。
也就是说,这里是一个循环。为了不引爆炸弹,我们输入的 6
个数字,后一个数字应该是前一个数字的 2
倍。
于是,我们将结果保存在文件中:
来测试一下:
没有问题。
phase_3
阶段三从 main: 0x400e6a
开始,同样调用 read_line
函数从输入中读取一行,读取的内容以字符串的形式存放在地址 0x603820
处。
接下来 main
函数调用 phase_3
函数:
断点调试可知,在 phase_3: 0x400f47
处,寄存器 rsp
的值为 0x7fffffffde90
,在调用 __isoc99_sscanf
函数前,寄存器 rcx
的值为 0x7fffffffde9c
,寄存器 rdx
的值为 0x7fffffffde98
。联系 phase_2
推测,这便是我们输入的 2
个数字(phase_3: 0x400f60
处检测参数数量是否 > 1
)存放的地址。
phase_3: 0x400f6a
处检测第一个数字是否 ≤ 7
,并将该数字赋给寄存器 eax
。
下面关键的地方是 phase_3: 0x400f75
:
这里根据寄存器 rax
中的值进行无条件跳转,我们使用 gdb
观察地址 0x402470
周围的值:
注意到这里的每一条地址都恰好对应了一条 mov
语句和一条 jmp
语句,其跳转的目标便是 phase_3: 0x400fbe
:
也就是说,我们要让第二个数字和 mov
语句赋给寄存器 eax
的值相同,于是我们有如下 8
种可能的输入(注意将十六进制转换为十进制):
0 207
1 311
2 707
3 256
4 389
5 206
6 682
7 327
我们任选(其实是我第一次通过的那个 🤣)一个保存在文件中:
测试一下:
一半了 ~(≧▽≦)/~ 啦啦啦。
phase_4
阶段三从 main: 0x400e86
开始,同样调用 read_line
函数从输入中读取一行,读取的内容以字符串的形式存放在地址 0x603870
处。
接下来 main
函数调用 phase_4
函数:
前面的部分我们已经很熟悉了,这里要求读取 2
个数字,存放在地址 0x7fffffffde98
处。
寄存器 rsp
的值为 0x7fffffffde90
从 phase_4: 0x40103a
开始,将寄存器 edx
赋值为 e
,寄存器 esi
赋值为 0
,寄存器 edi
赋值为输入的第一个数字。然后调用 func4
函数,并检测其返回值是否为 0
,若不为 0
,则引爆炸弹。同时检测第二个数字是否为 0
。于是下面的重点便是 func4
函数。
首先注意到这是一个递归函数,在 func4
函数内部有两次调用:
- 第一次发生在
func4: 0x400fe9
,不递归调用的条件是寄存器 ecx
的值 ≤
寄存器 edi
的值,在此之前将寄存器 edx
赋值为 rcx-1
- 第二次发生在
func4: 0x400ffe
,不递归调用的条件是寄存器 ecx
的值 ≥
寄存器 edi
的值,在此之前将寄存器 esi
赋值为 rcx+1
我们的目标是让 func4
函数返回 0
,为此我们不能走到 func4: 0x400ffe
,因为该调用语句的后面的 func4: 0x401003
让寄存器 eax
的值发生了变化(注意到 func4: 0x400ff2
将寄存器 eax
的值赋值为 0
,这里只是直觉,并不是严谨的解释 🤣)。
我们还发现这里跳转的条件中使用了寄存器 edi
的值,而寄存器 edi
的值是我们输入的第一个数字。若我们输入的第一个数字是 1
(还是直觉 🤣),从 func4: 0x400ffe
执行到 func4: 0x400fe4
,相关寄存器的值如下:
edx: e
eax: 从 e 变为 7 (e>>1)
ecx: 从 e 变为 0 (e>>31,相当于取符号位) 再变为 7
此时,不满足寄存器 ecx
的值 ≤
寄存器 edi
的值,我们将寄存器 edx
赋值为 rcx-1
,即 6
,递归调用 func4
函数:
从 func4: 0x400ffe
执行到 func4: 0x400fe4
,相关寄存器的值如下:
edx: 6
eax: 从 6 变为 3 (e>>1)
ecx: 从 6 变为 0 (e>>31,相当于取符号位) 再变为 3
此时,不满足寄存器 ecx
的值 ≤
寄存器 edi
的值,我们将寄存器 edx
赋值为 rcx-1
,即 2
,递归调用 func4
函数:
从 func4: 0x400ffe
执行到 func4: 0x400fe4
,相关寄存器的值如下:
edx: 2
eax: 从 2 变为 1 (e>>1)
ecx: 从 2 变为 0 (e>>31,相当于取符号位) 再变为 1
此时,满足寄存器 ecx
的值 ≤
寄存器 edi
的值,我们跳转到 func4: 0x400ff2
,将寄存器 eax
赋值为 0
。我们发现,此时也满足寄存器 ecx
的值 ≥
寄存器 edi
的值。于是我们成功的跳过了第二个递归调用,递归函数开始返回。
返回后的语句在 func4: 0x400fee
,由于这条语句只是让寄存器 eax
的值乘 2
,然而寄存器 eax
的值为 0
,所以最终的返回值还是 0
。
看样子我们碰运气猜出了答案 🤣,实际上可以考虑输入的第一个数字是 2
,我们会在两次递归调用 func4: 0x400fe9(△)
后递归调用 func4: 0x400ffe(☆)
,并继续调用 func4: 0x400fe9(□)
。在 (□)
返回后,寄存器 eax
的值为 0
,在 (☆)
返回后寄存器 eax
的值为 1
,两次 (△)
返回让寄存器 eax
的值变为原来的 4
倍,总之肯定不是 0
了。
对于更严谨的说明,可以写出等价的 C 语言程序(但是我不会 🤣)
于是我们将答案保存在文件中:
测试一下:
注:
- 后来发现第一个数字还可以是
3
或者 7
,规律也许是小于 e
并且有效位均为 1
?
func4
函数感觉有点像二分?
phase_5
阶段五从 main: 0x400ea2
开始,同样调用 read_line
函数从输入中读取一行,读取的内容以字符串的形式存放在地址 0x6038c0
处。
接下来 main
函数调用 phase_5
函数:
在 phase_5: 0x40106a
之后,寄存器 rax
为一个随机的值(联系下面的 phase_5: 0x4010e9
,猜测是为了进行栈破坏检测,这里的随机值便是哨兵值,亦或金丝雀值,详见 CSAPP §3.10.4
),并将其存入地址 0x7fffffffdea8
处(寄存器 rsp
的值为 0x7fffffffde90
)。接着 phase_5
调用 string_length
函数检测寄存器 rdi 中对应地址的字符串的长度(也就是我们输入的字符串长度)是否为 6
:
若为 6
,则跳转到 phase_5: 0x4010d2
,置寄存器 rax
为 0
,再跳转到 phase_5: 0x40108b
。
从 phase_5: 0x40108b
到 phase_5: 0x4010ac
是一个循环,**主要工作是依次取我们输入的字符的低 4
位,作为一个字节数组的索引,取出索引的字节,存入从地址 0x7fffffffdea0
开始的一块内存空间。**这个字节数组从 0x4024b0
开始,我们来看看:
换一种显示方式 🤣:
举一个例子,若我们输入的第一个字符为 1
,对应的 ASCII
码为 0x31
。phase_5: 0x40108b
取得我们第一个字符(注意 phase_5: 0x4401067
将地址赋值给了寄存器 rbx
),并符号扩展为一个双字,存入寄存器 ecx
中。在 phase_5: 0x401099
之前,寄存器 edx
的值从 0x31
变为 1
(取输入字符的低 4
位,防止索引越界),同时地址 0x7fffffffde90
处的值为 0x31
(不知道有什么用 🤣)。
执行完 phase_5: 0x401099
后,有 array[%rdx]→ %edx
,对照字节数组可知,此时寄存器 edx
的值为 0x61
,我们将其存入地址 0x7fffffffdea0
处,此时附近内存空间的情况如下:
这里地址 0x7fffffffdea0
原来的内容是 0x00007fffffffdfb8
将我们输入的 6
个字符作为索引,得到 6
个字节以后。我们在这 6
个字节之后加上一个 0
(可见 phase_5: 0x4010ae
),形成一个字符串,并与地址 0x40245e
的字符串进行比较,我们来看看:
注:gdb
中 x
的一些参数
- 数字:显示几个内存单元
- 输出格式
- 以多少字节为一个内存单元
若省略(或直接回车 🤣),则默认为上一次的配置(或命令)
与字节数组对应,可知索引依次为 9 15 14 5 6 7
,我们可以使用 i o n e f g
得到这组索引(不唯一)。
可对照 man ascii
编码
我们将答案保存在文件中,注意这里没有空格,上面加了空格是为了能看清楚:
测试一下:
ヽ(✿゚▽゚)ノ
phase_6
阶段六从 main: 0x400ebe
开始,同样调用 read_line
函数从输入中读取一行,读取的内容以字符串的形式存放在地址 0x603910
处。接下来 main
函数调用 phase_6
函数:
这里类似阶段二,调用了 read_six_numbers
函数,可以分析得到这 6
个数字存储在以 0x7fffffffde30
开始的地址空间中(此时寄存器 rsp
的值便是 0x7fffffffde30
)。
phase_6
函数的主体由五个循环构成。下面我们来依次分析:
- 循环一:检测输入的
6
个数字是否各不相同,并且都 ≤ 6
寄存器 r13
中存放着数字的地址,初始为 0x7fffffffde30
,即第 1 个数字的地址。寄存器 r12d
是计数器,初始为 1
,代表检测第 1
个数字,若等于 6
,说明所有的数字都通过了检测(第 6
个数字 ≤ 6
,不必再测试是否与其他数字相同,因为之前已经测试过了,详见下面的分析),那么就跳出循环来到 phase_6: 0x401153
。
在循环的内部还有一个循环,是检测当前的数字和之后的数字是否都不同,即从 phase_6: 0x401132
到 phase_6: 0x40114b
的部分,不再赘述。
- 循环二:用
7
减去输入的各个数字,并存储在原来的地方
phase_6: 0x401153
将寄存器 rsi
的值设置为 0x7fffffffde48
,用于循环是否终止的测试,寄存器 r14
存放着数字的起始地址,即 0x7fffffffde30
,并赋值给了寄存器 rax
。其余部分不再赘述。
这个部分就有意思了,我们发现链表存储在以 0x6032d0
开始的一块内存空间中,来看一看:
注意上面的内存空间内容的第三列,除了尾节点,其余节点的这个部分都存储了下一个节点的地址,所以我们判断这是一个链表。
我们回到循环本身,在 phase_6: 0x40116f
,寄存器 esi
被置为 0
,其作用相当于索引。刚开始,我们取出第一个数字(实际上是 7-第一个数字
)到寄存器 ecx
中,记为 x
。若 x!=1
,置寄存器 eax
为 1
,寄存器 edx
为 0x6032d0
,并通过一个内循环得到第 x
个节点的地址(在寄存器 rdx
中),并存放在 0x7fffffffde50
中;否则,直接置寄存器 rdx
为第一个节点的地址,并存放在 0x7fffffffde50
中。
对所有的数字进行相同操作后,我们可以从地址 0x7fffffffde50
观察到 6
个节点的地址:
这里假设输入的 6
个数字为 4 3 2 1 6 5
,用 7
减去后便是 3 4 5 6 1 2
,可以观察到地址的对应
- 循环四:根据
0x7fffffffde50
处 6
个节点的地址修改链表
将存储的第一个节点的地址赋给寄存器 rbx
,寄存器 rbx
再赋给寄存器 rcx
,置寄存器 rax
为存储第二个节点的地址(这里少了一个**“的”**,你细品 🤣),置寄存器 rsi
为 0x7fffffffde80
,用于循环是否终止的测试。
在 phase_6: 0x4011c0
,我们将存储的第二个节点的地址赋给 0x8(%rcx)
,也就是存储的第一个节点的下一个节点。以上面为例,也就是节点 0x6032f0
的下一个节点改为 0x603300
(虽然原来就是如此 🤣)。接着将寄存器 rax
加上 8
(即存储的第三个节点的地址),判断循环是否终止,此时未终止,我们将存储的第二个节点的地址赋给寄存器 rcx
,然后跳转到 phase_6: 0x4011bd
,将存储的第二个节点的下一个节点改为存储的第三个节点的地址……以此类推,最后在 phase_6: 0x4011d2
将链表的尾节点的下一个节点设置为 0
。
综上可知,修改后链表的结构是 node3 → node4 → node5 → node6 → node1 → node2
:
- 循环五:测试修改过的链表各节点的存储的数字是否为降序排列
置寄存器 ebp
为 5
,用于循环是否终止的测试。注意寄存器 rbx
中是存储的第一个节点的地址,也就是链表的头节点,我们将目前链表的第二个节点存储的数字(寄存器 eax
中)与链表的头节点存储的数字进行比较,若 头节点存储的数字≥第二个节点存储的数字
,则继续循环。由此我们推测,我们修改后的链表,元素应该是降序排列的。再次观察原链表:
我们应该让链表的结构变为 node3 → node4 → node5 → node6 → node1 → node2
,也就是我们上面的例子 🤣。
我们将答案保存在文件中:
测试一下:
O(∩_∩)O 哈!
secret_phase
等等,还没结束呢 🤣。在 bomb.c
的最后有这样一段注释:
我们回顾整个反汇编代码,发现在 phase_defused: 0x401630
处好像可以调用 secret_phase
函数:
现在我们研究如何走到 phase_defused: 0x401630
。首先在 phase_defused: 0x4015d8
对地址 0x603760
的值进行了检测,若不为 6
则跳过调用 secret_phase
。通过枚举法 🤣,我们很容易得到,在 main: 0x400ecb
处调用 phase_defused
时,地址 0x603760
的值为 6
(所以地址 0x603760
的值为阶段数),也就是说隐藏阶段在阶段六之后。
然后,在 phase_defused: 0x4015ff
处对 __isoc99_sscanf
函数的返回值进行了检测。通过前后对比,可知 sscanf
将地址 0x603870
处的 3
个以空格分割的字符传入了以地址 0x7fffffffde38
开头的内存空间中,前两个解释为数字,最后的一个解释为字符串:
然而我们发现地址 0x402622
处存放的是我们在阶段四输入的内容(只有 2 个数字):
为此我们需要在阶段四的答案后面加上一个字符串,根据下面调用了 strings_not_equal
函数可知,这个字符串应该和地址 0x402520
存放的字符串相同:
点题了,点题了 🤣
为此我们修改 input
文件,使其变为:
注意 phase_4: 0x401029
对 sscanf
的返回值进行了检测,由于对应的模式中只有两个数字,所以再加上一个字符串并不影响检测:
然后我们就能够进入隐藏阶段了:
DrEvil
继续嘲讽 🤣
来看一看隐藏阶段的反汇编代码:
secret_phase: 0x401243
从输入中读取一行,读取的内容存放在地址 0x603960
中(寄存器 rax
的值)。secret_phase: 0x401255
调用了 strtol
函数,将字符串转换为对应的数字,保存在寄存器 rax
中。rax → rbx
,eax-=1
,然后检测 eax
是否 ≤ 1000
(即 0x3e8
,后面二叉树中元素的最大值为 1001
)。
然后,ebx → esi
,0x6030f0 → edi
,并调用 fun7
函数,测试其返回值是否为 2
。
我们先看看地址 0x6030f0
里是什么:
排版好丑 🤣
这是一个二叉树!大概的结构长这样:
graph TD;
n1-->n21
n1-->n22
n21-->n31
n21-->n32
n22-->n33
n22-->n34
n31-->n41
n31-->n42
n32-->n43
n32-->n44
n33-->n45
n33-->n46
n34-->n47
n34-->n48
注:观察节点对应的值,可知是一棵二叉查找树
再看看 fun7
函数长啥样:
fun7: 0x401208
测试当前节点是否为空。若为空,置寄存器 eax
为 -1
,那必然没救了 🤣,因为修改寄存器 eax
的两条语句:
fun7: 0x40121c
fun7: 0x401232
都没有办法让 -1
在几步之内变为 2
,所以我们不能走到空节点。
观察 fun7: 0x401211
可知,若当前节点存储的值 ≤
我们之前输入的值,则走到右子树(fun7: 0x401229
),反之走到左子树(fun7: 0x401213
)。另外,递归调用右子树前,还会置寄存器 eax
为 0
,并比较当前节点存储的值是否等于我们之前输入的值,若相等,则结束递归调用。
综合分析可知,我们输入的值应该等于二叉树中某一个节点的对应的值。并且,在递归返回的过程中,应该在第一次从右子树返回到父节点(1 → eax
)后,有且仅有一次从左子树返回到父节点 (eax+eax → eax
)。
于是我们输入的值可以是节点 n43
或者节点 n32
对应的值,即 20
或 22
(将十六进制转换为十进制)。
为此我们修改 input
文件,使其变为:
测试一下,注意这里隐藏阶段的成功提示在阶段六之前:
完结撒花 🤗