Bomb Lab
Bomb Lab
简介
这是HNU计算机系统课程的第三次实验,实验要求是给一个用C语言编写的可执行文件bomb,你可以看到它主函数的C语言代码,除此之外,一概不知,实验分为六个阶段,每个阶段需要输入一串字符,以此来破译炸弹的密码,如果六次输入的密码都是正确的,则炸弹拆除,否则炸弹爆炸(退出并打印爆炸信息)。为了防止学生互相借鉴,HNU采用了最传统的防作弊方式——让每个学生做不同的题目。这次实验好行并没有什么评分标准,所以可以不断测试自己的答案。
C语言源代码
1 | /*************************************************************************** |
实验环境
本实验使用 WSL2 以及 Ubuntu 20.4 完成,编译器为 VS code。
准备工作
由于实验所给的文件中只有一个bomb.c 和一个可执行文件,而bomb.c 并不能直接编译,因此我们只能通过给出的可执行文件来进行分析。将可执行文件通过反汇编得到完整程序的汇编代码,操作如下:
建议创建一个文件,并将反汇编出的代码输入到该文件中,方便后续查看。
1 | linux> objdump -d > objdump.txt |
实验解析
phase_1
密钥:The moon unit will be divided into two divisions.
反汇编代码如下:
1 | 08048b60 <phase_1>: |
分析如下:
结合源代码可以得出 0x20(%esp)
对应的是 bomb.c 中 read_line
读入的字符串的首地址。
阅读phase_1的第 2 到第 6行 ,观察到数据 $0x804a1c4
和 0x20(%esp)
均被作为参数传入栈帧中,结合第 7 行的调用 strings_not_equal
函数,可以推测, $0x804a1c4
处存储的也是一个字符串的首地址,并且要与之前读入的字符串进行比较。
查看 strings_not_equal
函数的汇编代码,得知该函数是用于比较两个字符串是否相等的,若相等则返回0,不相等则返回1,返回值均保存在 %eax
中。
继续查看下面的汇编代码,其中第 8 到第 10 行可以得出:当 %eax
为 0 时,跳转到 <phase_1+0x20>
处(即跳过炸弹爆炸);当 %eax
为 1 时,不会发生跳转,此时炸弹爆炸。
因此我们可以得出结论,当我们输入的字符串与 $0x804a1c4
处存储的字符串相等时,就能通过phase_1。
通过 gdb 调试查看 $0x804a1c4
处存储的字符串:
通过 gdb 中的 print 命令,打印出来phase_1的通关密钥。
phase_2
密钥:1 2 4 8 16 32
反汇编代码如下:
1 | 08048b84 <phase_2>: |
可以看到phase_2中调用了一个交 read_six_numbers
的函数,反汇编如下:
1 | 0804922b <read_six_numbers>: |
从函数的名字我们也不难猜出该函数的作用,读取 6 个数字。注意到 8049259
这一行:
1 | 8049259: c7 44 24 04 d7 a3 04 movl $0x804a3d7,0x4(%esp) |
这里在 0x4(%esp)
里存了一个地址,输出一下看看是什么。
想来这个就是输入函数 sscanf 的格式了,果不其然是输入6个数字。
已知 read_six_numbers
函数的作用,我们再次回到 phase_2的汇编代码中。第 10 行的 cmpl $0x1,0x18(%esp)
表明第一个数字必须为 1,否则炸弹直接爆炸。
由第 13 行到第 22 行,我们可以看出,每次将下一个数字的地址给到寄存器 %ebx
,将当前数字给到寄存器 %eax
,再将 %eax
中的值做乘 2 处理,最后将 %ebx
地址处的值与 %eax
中的值作比较,相等则继续循环,不等则炸弹爆炸。循环结束的条件是 6 个数字全部比较结束。
因此输入的 6 个数字应该为:1 2 4 8 16 32
phase_3
密钥:0 -418 或 1 -906 或 2 -536 或 3 -639 或 4 0 或 5 -639
反汇编代码如下:
1 | 08048bcc <phase_3>: |
分析:
第 3 ~ 11 行是为scanf函数配置参数以及执行scanf函数的过程,用 print 查看0x804a3e3
中的内容,发现是 "%d %d"
,说明 scanf 读取的是两个整型数字,即密钥是由两个整数组成的。从第 12 行可以看出,当输入的参数个数小于 2 时,炸弹就会爆炸。
第15 ~ 16行,0x18(%esp)
中存储的是输入的第一个数,ja
指令意味着无符号指令的大小比较,可以推断出第一个数的取值范围是[0,7]。
第 18 行代码是一个变址寻址的地址跳转,跳转的地址为*(0x804a200+(%eax)*4)
,使用 gdb 输出 0x804a200
地址处的 8 个值(因为输入的第一个参数为0~7,合理推测此处最多只有 8 个值)
1 | (gdb) x/8wx 0x804a220 |
可以看出,0x804a220
之后存储的 8 个值都是要跳转的地址。跳转后就会执行各种加或减的操作。一系列操作后,第44 ~ 45行代码又将第一个参数的取值范围缩小至[0,5]。第46 ~ 47行代码则是将操作后得到的数与我们输入的第二个参数相比较,相等则函数结束,不相等则炸弹爆炸。
因此要得出所有的答案,只需要在第18行跳转完成后计算出加减操作得到的最后的值,就是我们要输入的第二个数。
phase_4
密钥:11 18
反汇编代码如下:
1 | 08048cf1 <phase_4>: |
有第 7 行的 0x804a3e3
地址处所存的内容以及第 12 行将 scanf 函数的返回值与 2 作比较,可以得出输入的是两个参数,且参数类型为整形。
由第 14 ~ 18 行分析可得,输入的第一个参数的取值范围应该是 [0, 14]。第 20 ~ 26 行,主函数调用 func4 函数,并将(x, 0, 14)作为函数的三个参数传入(x 表示我们输入的第一个参数)。以下是 func4
函数的反汇编代码:
1 | 08048c88 <func4>:A |
这个函数有点绕,大概是做一个二分,具体的内容写在注释里,自己可以用笔模拟下这个函数在干嘛,可以推断出C语言代码大致如下:
1 | int func(int a, int b, int c){ |
从 func4
函数中返回后,第 27 行代码将函数的返回值与 0x12
作比较,不相等时炸弹直接爆炸,相等则继续下面的判断。第 29 行代码显示将输入的第二个值与 0x12
作比较,当不相等时发生爆炸,相等则安全退出函数。由推断出的 func4
的C语言代码可尝试在输入[0, 14]时,函数的不同返回值。只有当输入值为 11 时,返回值才是18,即与 0x12
相等。由此便可推出密钥为 11 18
。
phase_5
密钥:bcejno 或 cdeijn 或 cejlmn(只列出了极少的情况,全部可能应该有几万种可能)
1 | 08048d60 <phase_5>: |
分析前 8 行代码可知,输入值为一个字符串,且长度一定要是 6,否则炸弹直接爆炸。接下来就是一个循环结构,遍历输入的字符串,并将当前遍历的字节与 oxf
相与得到一个值,得到的值将作为首地址在 0x804a240
处的整数数组的下标,并将访问的值进行累加,若和为 0x41
,则该字符串就是密钥,否则炸弹爆炸。下面是该函数的C语言代码:
1 | char s[6] = 输入的字符串 |
查看 0x804a240
地址处的长度为 16 的整数数组的全部值:
每个整数值对应的字母大小写如下:
1 | 0x804a240 <array.2999>: 2-p\P 10-a\q\A\Q 6-b\r\B\Q 1-c\s\C\S |
全部不重复的情况如下(没有任何参考价值,尽量不要尝试算出所有的情况):
1 | [1, 6, 13, 14, 15, 16], |
phase_6
密钥:4 2 3 6 5 1 或 4 2 6 3 5 1
反汇编代码如下:
1 | 08048da9 <phase_6>: |
由第 9 行代码可以得出我们一共输入 6 个整型数字。分析后面的代码可得,输入的 6 个值的取值范围为[1, 6],且不能两两相等。第 28 ~ 44 行是一个循环结构,用于比较当前数字与后一个数字的大小,前一个数一定不能小于后一个数,否则炸弹直接爆炸。其中要进行比较的数组为以 0x804c13c
为首地址的数组。
但是在输出该地址的内容后,发现这并不是一个数组,而是一个结构体,每个结构体中有3个元素,一个记录数值、一个记录node编号,一个存放下一个node的地址(可以把node理解为链表),共占12个字节。
存储node的方式是:按照读入的6个数(假定为a[i]),将编号为a[i]的node存储到第i个位置。然后从栈帧中的存储的第一个node开始,每个node与其后一个node中的数值进行比较,若后大于前,则会引爆炸弹。以下是打印出的 6 个node的信息。
1 | 0x804c13c <node1>: 99 1 134529352 |
由此可以的出密钥应该是 4 2 3 6 5 1 或 4 2 6 3 5 1(因为6 与 3中的值是相等的,所以可以调换顺序)。
答案
至此,我们以及得出了六个关卡的正确密钥,如下:
1 | The moon unit will be divided into two divisions. |
输入以上六个密钥,便可拆除炸弹。
但是,不知道你有没有注意到在该程序的源代码中的结尾处有一行注释,如下:
1 | /* Wow, they got it! But isn't something... missing? Perhaps |
这就十分可疑,再结合源代码中每个关卡在输出正确答案提示之前都调用了同一个函数——phase_defused();
,下面的这个部分就是探寻隐藏关卡的部分。
隐藏关卡
密钥: 107
phase_defused
函数的反汇编代码如下:
1 | 0804927b <phase_defused>: |
分析
在找到 phase_defused
函数的反汇编之后,不难找到几个有助于我们分析关键部分,如下:
- 第 6 行将地址
0x804c3cc
处的值与 6 作比较; - 第 8 ~ 18 行调用
__isoc99_sscanf@plt
函数,其中第 14 和第 18 行为分析的关键点; - 第 20 行将
0x804a3f2
地址处的值传给寄存器; - 第 24 行调用比较字符串函数;
- 第 27 ~ 30 行输出两次地址中的值;
- 第 31 行调用名为
secret_phase
的函数; - 第 32 ~ 33 行输出地址
0x804a318
中的值。
根据以上关键点,我们可以得出以下结论:
第 6 行, 地址
0x804c3cc
中的值应该是当前通过的数目,只有通关数为 6 时才迈出了进入隐藏关卡的第一步;第 8 ~ 18 行,调用输入函数,且由第 18 行得知输入的参数的个数应该是 3 个;由第 14 行可知,三个参数的类型为前两个是整形,第三个是字符串类型。
因为一共要输入两个整数和一个字符串,且每次在解决一个关卡后都会调用该函数。由此可得,前两个整数应该是某一个关卡的密钥。而在我们得出得答案中,只有第 3 关和第 4 关的密钥是两个整型数字,经过测试后,隐藏关卡的实际入口在第4关
输出第 20 行地址处的值为:
DrEvil
;
- 比较函数用于将第 20 行地址处的字符串与输入的第三个参数作比较,根据后面两行的代码可以分析出当输入值与设置值不相等时,会直接打印祝贺信息并退出该函数,只有当两个字符串相等时,我们操迈出了进入隐藏关卡的第二步。
- 第 27 ~ 30 行,一共输出了两个地址存储的值,打印出来后发现并没有什么实质价值,只是成功输入字符串的隐藏值。
- 第 31 行,被调用的函数就是隐藏关卡,反汇编代码如下:
1 | 08048ecf <secret_phase>: |
进入secret_phase,函数先读取我们输入的密钥,然后调用了c语言的strtol函数,这个函数将字符串转为长整型数,其中参数0xa意味着将其转为十进制。接下来函数会比较经转换后的数自减1后与0x3e8(十进制为1000)的无符号数大小,这意味着我们输入的数必须在[1, 1001]中。接着,函数会调用一个递归函数func7,参数为0x804c088和我们输入的数,func7的伪代码如下
1 | int func7(int* k, int n) |
又由以下两个指令知,func7(, 我们输入的数)
的返回值必须为3,否则将引爆炸弹。
1 | 8048ef2: 83 f8 03 cmp $0x3,%eax |
观察内存中地址为0x804c088的内容,是一棵二叉树:第一个地址存储数据,第二、三个地址存储下子结点的地址。于是我们只要分析func7的行为,并根据0x804c088中的内容,即可确定要输入的数。
首先,函数要求func7最终返回3。要构造一个3,根据func7的特性,可以这样构造$2\cdot(2\cdot(0+1))+1$ ,这意味着,最后一次要返回 0,倒数第二次(或第二次)要返回 $2 \cdot func7(k+2, n) + 1$,倒数第三次(或第一次)要返回 $2 \cdot func7(k+2, n) + 1$ 。或者说,要返回的值是二叉树查找时比较的次数。要满足这样的关系,假如我们把第1~3次比较的数设为k1、k2、k3,那么,我们输入的数n要满足的条件是n > k1且n > k2且n == k3。要找到k3,查找0x804c088处的内存即可,经查找,为0x6b,转换为十进制为107,即为密钥。
打印 0x804c088
地址处的值可得:
所以,隐藏关卡的密钥为 107
。
下图为实验结果:
总结
写到此处,我们的炸弹也就拆完了。在拆除炸弹之后,有关于x86-64的汇编指令以及gdb的一些调试方法我们都已经有所了解。总的来说,这个实验还是十分有趣的,通过剧情让我们了解汇编指令,值得自己独立研究一下,会有不小的收获。