手摸手教你Bomb Lab
阅读须知: 网上关于CMU配套教材开源的版本的bomb的教程已经很多了,比如我科学长的这两篇写得相当好
深入理解计算机系统(CS:APP) - Bomb Lab详解, 深入理解计算机系统BombLab实验报告. 我也不想做重复的劳动,去写别人已经写过的东西,这一篇博客是基于学校给我发的bomb版本来写的, 相关的可执行文件等实验结束后会上传到我的github上面, 如果你想要一个其他版本的bomb来练习的话,那么可以用我的这一份.
这个实验一共有6个阶段以及一个隐藏阶段,我目前没有做那个隐藏阶段,如果老师说做隐藏阶段加分的话,我或许会补上^_^! ps:写博客比做实验花时间多系列QwQ
首先搬运一下指导书上面的实验简介:
本实验中,你要使用课程所学知识拆除一个“binary bombs”来增强对程序的机器级表示、汇编语言、调试器和逆向工程等方面原理与技能的掌握。
一个“binary bombs”(二进制炸弹,下文将简称为炸弹)是一个Linux可执行C程序,包含了6个阶段(phase1~phase6)。炸弹运行的每个阶段要求你输入一个特定的字符串,若你的输入符合程序预期的输入,该阶段的炸弹就被“拆除”,否则炸弹“爆炸”并打印输出 “BOOM!!!”字样。实验的目标是拆除尽可能多的炸弹层次。
每个炸弹阶段考察了机器级语言程序的一个不同方面,难度逐级递增:
* 阶段1:字符串比较
* 阶段2:循环
* 阶段3:条件/分支
* 阶段4:递归调用和栈
* 阶段5:指针
* 阶段6:链表/指针/结构
另外还有一个隐藏阶段,但只有当你在第4阶段的解之后附加一特定字符串后才会出现。
为了完成二进制炸弹拆除任务,你需要使用gdb调试器和objdump来反汇编炸弹的可执行文件,并单步跟踪调试每一阶段的机器代码,从中理解每一汇编语言代码的行为或作用,进而设法“推断”出拆除炸弹所需的目标字符串。这可能需要你在每一阶段的开始代码前和引爆炸弹的函数前设置断点,以便于调试。
实验语言:C语言
实验环境:linux
然后对实验中用到的一些文件做一个简单的说明:
- bomb:bomb的可执行程序。
- bomb.c:bomb程序的main函数。
「运行./bomb」:./bomb
是一个可执行程序,需要0或1个命令行参数(详见bomb.c源文件中的main()函数)。如果运行时不指定参数,则该程序打印出欢迎信息后,期待你按行输入每一阶段用来拆除炸弹的字符串,并根据你当前输入的字符串决定你是通过相应阶段还是炸弹爆炸导致任务失败。
你也可将拆除每一阶段炸弹的字符串按行组织在一个文本文件中(比如:result.txt),然后作为运行程序时的唯一一个命令行参数传给程序(./bomb result.txt),程序会自动读取文本文件中的字符串,并依次检查对应每一阶段的字符串来决定炸弹拆除成败。
正文开始
首先将可执行程序bomb
进行反汇编:
1 | 复制代码objdump -d bomb > disassemble.asm |
然后用你喜欢的编辑器打开这个文件, 我用的是VsCode
可以装这个插件来高亮显示代码
主函数比较长,反正我是没看,直接Ctrl + F
搜索phase_1
进入阶段1
阶段一
阶段1比较简单,我分配到的题目如下所示:
1 | 复制代码08048b33 <phase_1>: |
我们来分析一下这个代码,可以看到有两个push操作,然后有一个call操作,所以猜测这两个push操作是在为这个strings_not_equal
函数传递参数, 我们可以用gdb
调试一下看看是不是这样:
1 | 复制代码gdb bomb |
我输入的字符串是hello
, 然后通过调试可以看到,0x1c(%esp)压进栈中的字符串就是hello
1 | 复制代码$1 = 0x804c3e0 <input_strings> "hello" |
所以我们可以猜测我们输入的字符串需要与0x8049fa4
处的字符串进行比对,如果相等,那么就可以成功拆除炸弹了,现在查看这个地址处的字符串
1 | 复制代码x /s 0x8049fa4 |
执行后获得以下的输出, All your base are belong to us.
即为正确答案
1 | 复制代码(gdb) x /s 0x8049fa4 |
总的来说,这个任务还算是非常简单的.
阶段二
阶段2考察的是循环,代码量相对于阶段1要大很多,首先贴一下阶段二完整的代码
1 | 复制代码08048b54 <phase_2>: |
开始的几行就是一些准备工作,不用去研究它是干什么的,爷也不想研究.
下面从第8行开始看起,第8行用了一条lea
语句,这是获得0xc(%esp)
处的地址, 然后就把这个地址压栈了, 可以猜测这个地址是用来存放读取的6个数字的,后面的一个pushl是干啥的我也8太清楚.然后调用read_six_numbers
函数读取6个数字.
第13行, cmpl $0x1,0x4(%esp)
这一条语句是比较读入的第一个参数和1的大小关系,至于为什么是第一个参数,可以通过自己模拟栈的push pop知道. 这里第一个参数必须等于1, 否则就会引爆炸弹. 然后跳转到16行进行执行,.
16, 17行分别将指向第1个数和第6个数的指针存进了ebx 和 esi寄存器中. 从这里可以看出, esi寄存器的值可以看做循环终止的条件.
18, 19行两行是将ebx寄存器中的数字变为原来的两倍后放进了eax寄存器中,然后比较ebx寄存器中数字的后一个数字是否与eax寄存器中的值相等, 也就是比较后一个数字是否是前一个数字的两倍, 如果不是, 那么引爆炸弹, 否则循环继续, 指针往后挪一位, 知道挪到了最后一个.
所以最后的答案就是1 2 4 8 16 32
, 这一题也比较简单
阶段三
阶段三考察的是条件与分支, 也比较简单, 先来看一看阶段三的源代码
1 | 复制代码 8048bb7: 83 ec 1c sub $0x1c,%esp |
从第5行开始看, 这里的两个lea操作很显然就是传地址, 类似与&a, 然后供sacnf函数使用
这里注意第9行push $0x804a16f
, 这里压入了一个地址常量, 根据后面调用的scanf猜测这个应该是格式字符串, 我们通过gdb查看这个字符串的值
1 | 复制代码(gdb) x /s 0x804a16f |
从输出可以看到, scanf接受的是两个int类型的整数.
接着看第13行, 有一个eax寄存器与1的比较操作, 一般eax寄存器存储函数的返回值, 而sacnf函数的返回值就是成功读取的元素个数,于是可知,这里读取的元素个数小于等于1的话就会引爆炸弹.
接着看第16行, 是比较读入的第一个参数和7的大小关系, 注意后面一行的ja是无符号比较, 所以可知第一个参数的1值必须在0~7之间, 否则就会引爆炸弹
接着的18, 19行, 有意思的地方来了, 这里是一个典型的跳转表的实现,比如中断向量表, switch都是用的这种思想.比如说我输入的第一个数字是1, 那么我跳转到的地址需要在gdb中通过如下方式获得:
1 | 复制代码(gdb) print /x *(int*)(0x804a000+4) |
然后再在19~36行中寻找对应的地址跳转过去就行, 比如0x8048bfd是第20行,对应的代码是
1 | 复制代码8048bfd: b8 f3 01 00 00 mov $0x1f3,%eax |
所以eax的值就是0x1f3, 也就是499
通过第38行的代码
1 | 复制代码 8048c3f: 3b 44 24 08 cmp 0x8(%esp),%eax |
可以知道, 我们需要输入的第二个数与eax寄存器中的值相等,不然就会引爆炸弹, 于是一个可能的答案就是1 499, 当然, 还有其他的答案.
阶段四
阶段四需要你进行逆向出对应的C代码, 这样解决起来会简单许多,这一题我很快就逆向出C代码了,但是没想到被scanf函数坑了, 它的第一个参数是你输入的第二个, 第二个参数是你输入的第一个, 搞得我还一直以为我逆向错了,后来在车大牛的提醒下, 单步调试终于发现了这个坑爹的BUG
下面我就来讲一讲我是如何逆向的, 以及我是如何调试的
首先我们先贴上phase_4的代码如下所示:
1 | 复制代码08048ca3 <phase_4>: |
从第6行开始看起, 这里是熟悉的lea, 熟悉的push, 熟悉的scanf, 然后用相同的伎俩可以知道格式化字符串还是%d %d
, 于是我们知道这里也是读入两个数字, 但是, 这里先push进去的是低地址的那一个…
14行比较了一下返回值, 不是2就引爆炸弹, 也再一次证明了要读两个数
16-18行比较了scanf的第一个参数, 也就是我们输入的第二个数减2后与2的大小关系, 这里需要特别注意19行是jbe, 是无符号数比较, 所以我们输入的第二个参数的值必须在2~4之间.
21~23行是在为调用func4准备参数, 查看func4的源代码后我们可以知道,先push进去的是右边的参数
假设我们输入的两个数是b a, 那么这里的函数调用就是func4(9, a), 至于为什么你自己画一下这里的栈就知道了, 电脑上画图太麻烦, 我就不画了.(对了, 在自己分析栈时要注意函数调用时会将返回地址压栈)
我们先不看func4, 先接着看phase_4, 由26到28行我们可以看到我们需要我们输入的第一个参数的值等于调用func4后的返回值, 不然就会引爆炸弹, 于是, 接下来, 我们逆向func4
先上func4的源代码:
1 | 复制代码08048c60 <func4>: |
前面几行是在为函数调用准备, 不做分析.
5, 6两行是从栈中取出参数, 如果前面的栈你自己分析了, 那么这里也不难分析得出ebx放的是第一个参数x1, edi放的是第二个参数x2.
第7, 8两行的作用就是判断x1是否≤0, 经过分析不难得出这几句汇编代码对应的C语言代码, 注意:eax寄存器存储的是函数的返回值
1 | 复制代码if (x1 <= 0) return 0; |
接下来第9行将eax寄存器赋值为x2, 然后又对x1做了一个判断, 如果等于0, 那么跳转到第27行执行, 于是我们可以据此逆向出下面的C语言代码:
1 | 复制代码if (x1 == 1) return x2; |
接下来调用func4(x1 - 1, x2)
, 由18行可知然后将返回值加上x2赋值给了esi
19~21行又在为调用func4函数准备参数, 稍加分析可以知道接下来调用的是func4(x1 -2, x2)
最后由24行可知, 将第二次调用func4的返回值加上esi的值得到最终的返回值, 于是总的逆向程序代码如下所示:
1 | 复制代码int func(int x1, int x2) { |
非常的优雅.
当然了, 如果你对上面的有些难以理解, 那么我可以给出另一份比较笨, 但容易理解的代码, 我们注意到, 在调用的过程中eax寄存器的值是没有保存的, 所以, 我们可以把它看做全局变量, 令它为变量eax, 于是有下面的程序, 完全对应汇编语句的丑陋的C程序:
1 | 复制代码int eax; |
如果你仔细对比的话, 可以看出这个程序的每一条语句的行为都是和汇编一一对应的, 可能更容易理解一点.
最后的结果是264 3
下面讲一下我在一开始被scanf坑到时是如何进行调试的.
首先gdb进入调试界面, 然后在explode_bomb处打一个断点, phase_4处也打一个断点, 然后单步调试, 然后观察寄存器的值, 以及是在哪里炸的, 好吧, 我在说废话, 不这么调试还怎么调试, gdb相关命令直接看文档就行, 不用背.
阶段五
你可能注意到了, 前面的程序我都没有任何的注释, 因为确实也不需要注释, 但是从阶段五开始, 我开始写注释了, 不然自己都不知道自己在看什么
不过阶段五难度还好, 我不到一个小时就做出来了.
先上阶段五的源代码:
1 | 复制代码08048d15 <phase_5>: |
这一题考察的是指针, 不做点注释我都不知道我自己指到哪里去了.
首先看到第6行, 这个函数名一看就知道是计算字符串的长度, 而这个字符串肯定就是我们输的字符串, 不要问我为什么, 男人的直觉, 就是这么准.
第8行中, 比较了eax与6的大小,eax的值就是string_length函数的返回值, 不信的话你可以自己调试看看. 所以从这里我们可以获得一个重要信息, 这个字符串的长度是6
对了, 第5行也有重要信息, 这个push语句很显然是在为调用string_length函数准备参数, 所以ebx寄存器中存储的就是指向字符串首字符的指针, 有内味了!
然后第11行的赋值让eax也指向了字符串首, 由于字符串长度为6, 所以由12行可知, 这里指向了串尾的后一个, 很显然是我们常用的str[i] != 0
的结束标志
14行15行注释说的很清楚了, 就是对字符做了一些处理
第16行就是将ecx加上对应地址处的值, 这里又出现了前面的那个跳转表有木有, 我们可以在gdb中看一看那个地址处的值是啥
1 | 复制代码(gdb) print (int)*(0x804a020) |
17~19行我们可以知道一共要加6次
20行中我们可以看出加了6次后的值要等于0x2c
所以一个可能的结果就是10 + 10 + 10 + 10 + 2 + 2
10代表对应的字符低四位为1, 2代表低四位的值为0, 这个是由跳转表地址以及对应值的关系得到的,所以一个可能的答案是111100
最后, 到了这次实验中最难的一题, 我写了不少注释才看懂这一题, 这一题大概花了我两个多小时
阶段六
这一题的代码巨长无比, 如果不是因为要交作业, 我是不会去做这个的QwQ
1 | 复制代码08048d5d <phase_6>: |
写的有点累了, 这个不太想细讲了, 注释也写了蛮多了, 注意, 注释中说的链表指的是栈上面接在那6个数后面的部分.
从第8行开始看起, 这里又是熟悉的lea操作, 熟悉的push和call操作, 这个函数读了六个数字到栈上的指定地址.
后面用了很多代码就是进行了两个很无聊的操作, 一是限定了6个数的取值范围必须在1~6, 二是限定了6个数互不相同
然后进行了变换的操作
56行注意到有一个地址常量赋值的操作, 这个地址值就是链表头所在的地址, 通过gdb我们可以看到如下的信息
每个节点占12个字节, 第一个字节就是值, 第二个字节是id号, 第三个字节就是下一个节点的地址.
后面进行了将链表的节点按一定顺序放到栈上那6个数后面的操作, 然后还进行了栈上的链表的连接关系的建立操作, 也就是重新规定了每一个节点的下一个节点是谁(是它在栈上的后一个),最后有一个重大的信息, 就是这6个节点的顺序是按照值从大到小的,, 于是我们通过上图可以知道节点的id按照节点的值从大到小的顺序为6 2 4 3 1 5, 最后执行an = 7 - an的还原操作得到最终答案 1 5 3 4 6 2!
完结撒花!!!
本文转载自: 掘金