实验简介
Bomb LAB 目的是熟悉汇编。
一共有7关,六个常规关卡和一个隐藏关卡,每次我们需要输入正确的拆弹密码才能进入下一关,而具体的拆弹密码藏在汇编代码中。实验中的bomb实际上是一个程序的二进制文件,该程序由一系列phase组成,每个phase需要我们输入一个字符串,然后该程序会进行校验,如果输入的字符串不满足拆弹要求,那么就会打印BOOM!!!
完成整个实验的思路是通过objdump对bomb进行反编译(objdump -d bomb > bomb.txt),获取所有的汇编代码。提取每个阶段对应的代码并借助gdb进行分析,逐一拆弹。
Github地址:Bomb Lab
准备
汇编复习
| 类型 | 语法 | 例子 | 备注 |
|---|---|---|---|
| 常量 | 符号$ 开头 |
$-42, $0x15213 |
一定要注意十进制还是十六进制 |
| 寄存器 | 符号 % 开头 |
%esi, %rax |
可能存的是值或者地址 |
| 内存地址 | 括号括起来 | (%rbx), 0x1c(%rax), 0x4(%rcx, %rdi, 0x1) |
括号实际上是去寻址的意思 |
一些汇编语句与实际命令的转换:
| 指令 | 效果 |
|---|---|
mov %rbx, %rdx |
rdx = rbx |
add (%rdx), %r8 |
r8 += value at rdx |
mul $3, %r8 |
r8 *= 3 |
sub $1, %r8 |
r8-- |
lea (%rdx, %rbx, 2), %rdx |
rdx = rdx + rbx*2 |
比较与跳转是拆弹的关键,基本所有的字符判断就是通过比较来实现的,比方说 cmp b,a 会计算 a-b 的值,test b, a 会计算 a&b,注意运算符的顺序。例如
1 | cmpl %r9, %r10 |
等同于 if %r10 > %r9, jump to 8675309
各种不同的跳转:
| 指令 | 效果 | 指令 | 效果 |
|---|---|---|---|
| jmp | Always jump | ja | Jump if above(unsigned >) |
| je/jz | Jump if eq / zero | jae | Jump if above / equal |
| jne/jnz | Jump if !eq / !zero | jb | Jump if below(unsigned <) |
| jg | Jump if greater | jbe | Jump if below / equal |
| jge | Jump if greater / eq | js | Jump if sign bits is 1(neg) |
| jl | Jump if less | jns | Jump if sign bit is 0 (pos) |
| jle | Jump if less / eq | x | x |
举几个例子
1 | cmp $0x15213, %r12 |
若 %r12 >= 0x15213,则跳转到 0xdeadeef
1 | cmp %rax, %rdi |
如果 %rdi 的无符号值大于等于 %rax,则跳转到 0x15213b
1 | test %r8, %r8 |
如果 %r8 & %r8 不为零,那么跳转到 %rsi 存着的地址中。
x86-64寄存器规则: 默认函数的第一个参数是%rdi 第二个参数%rsi 第三个参数%rdx
反汇编
1 | 检查符号表 |
GDB
1 | gdb bomb |
用 ctl+c 可以退出,每次进入都要设置断点(保险起见),炸弹会用 sscanf 来读取字符串,了解清楚到底需要输入什么。
Phase 1
1 | Dump of assembler code for function phase_1: |
先 gdb bomb,然后设置断点 break explode_bomb 和 break phase_1
这段代码还是挺好理解的,保存Stack pointer,将$0x402400传给%esi,调用位于0x401338的strings_not_equal函数,比较%eax是否为0,不为零则调用explode_bomb函数,为零则返回设置断点 phase_1和explode_bomb,输入命令r运行会在断点处停下,此时随便输入一个字符串用于测试“abcd”,然后disas查看反汇编代码:=>箭号为当前运行的位置

查看寄存器内容 info register,eax就是rax的低位用print $eax 打印出来,是一个地址用x/s $eax,查看出地址里的内容,发现是输入字符串

用stepi 逐步执行,执行完 mov 之后,把地址中的内容传到%esi中,用print查看!得到字符串,这就是第一关的答案。退出后新建一个文本 touch sol.txt,方便之后输入


Phase 2
这次我们有了第一关的答案,进入gdb后设置好断点,和命令参数。

试运行,在phase_1停住,然后continue,答案正确,触发 phase_2的断点,这次输入abc

反汇编Phase2部分的代码
1 | (gdb) disas |
根据Phase1,很敏感的会发现movl $0x4025c3, %esi这行。通过之前一样的方法,得到0x4025c3内存里的字符串
1 | (gdb) x/s $esi |
再根据bomb[0x40148a] <+46>: callq 0x400bf0 ; symbol stub for: __isoc99_sscanf这句,猜一下,立马就能联想到scanf("%d %d %d %d %d %d",a,b,c,d,e,f);,也就是说,输入的格式已经确定了。
解读出循环中,从1开始,是一个等比数列,公比为2。1 2 4 8 16 32

Phase 3
1 | Dump of assembler code for function phase_3: |
查看地址内的内容,为输入格式,需要输入两个数,后面的 cmp $0x1,%eax 表明输入参数大于1个,
1 | (gdb) x/s 0x4025cf |
看到多个分片语句,反应类似siwtch语句,所以第一个数字是用来进行跳转的

p/x 命令查看跳转表,可以看到

用 p/x可以看跳转表的地址,但是没有x/s直观。用 x/s 命令可以查看跳转表,如case0,对应的就是<phase_3+57>,内容是$0xcf,%eax ,所以(0,207)就是一组输入,同理还可以得到其他的解
(0,207) (1,311) (2,707) (3,256) (4,389) (5,206) (6,682) (7,327)
1 | (gdb) x/s *(0x402470) |
输入一组解,成功

Phase 4
1 | Dump of assembler code for function phase_4: |
跟上题一样,先看看可疑的0x4025cf中的内容
看到输入格式和上题一样都是两个整数。在执行 callq 0x400bf0 <__isoc99_sscanf@plt> 指令后,返回值(参数数量)存储于%eax,然后判断%eax是否等于2,若不等于则爆炸。否则执行cmpl $0xe,0x8(%rsp) ,该指令将输入的第一个数和常数0xe进行比较,如果第一个数>0xe,拆弹失败。否则跳转到0x40103a执行
1 | 0x000000000040103a <+46>: mov $0xe,%edx //14 |
这三条指令用来设置func4的参数,根据x86-64寄存器使用规范,第1,2,3,个参数分别存储在寄存器%edi,%esi,%edx中
在查看func4对应的代码之前,先观察执行callq 400fce <func4>指令之后phase_4的操作:test %eax,%eax指令检查%eax的值是否等于0,如果不等于0,则会引爆炸弹,否则执行指令cmpl $0x0,0xc(%rsp),该指令将输入的第二个数与0做比较,如果相等,那么phase_4正常退出,拆弹成功。因此,phase_4的第二个输入值即为0。经过以上的分析,可以意识到phase_4的核心目标在于要让func4执行后,%eax的值等于0,这取决于输入的第一个数。接着需要分析func4执行的操作,其对应代码如下所示。
反汇编func4
1 | 0000000000400fce <func4>: |
在分析func4之前,不要忘了传递到func4的三个参数分别存储于寄存器%edi、%esi和%edx,其值分别为x(输入的第一个数)、0和14。在0x400fe9处执行了指令callq 400fce <func4>,因此func4很可能是个递归函数,我们将func4翻译成等价的C代码,如下所示。
1 | void func4(int x, int y, int z) { |
func4的目的是要让函数退出后%eax的值为0,而在0x400ff2处mov $0x0,%eax显示的将%eax的值设置为0,该指令对应于C代码中的t = 0。并且,func4执行递归的退出条件为k == x,其中x对应于输入的第一个数,而k则可以通过一系列计算得到,由于y = 0且z = 14,易知k = 7,因此输入的第一个数即为7。将字符串7 0作为phase_4的输入,拆弹成功,如下图所示。
Phase 5
1 | Dump of assembler code for function phase_5: |
根据x86-64寄存器使用规范,%rdi寄存器存储的是第一个参数的值,由于输入的是字符串,因此%rdi存储的应该是输入字符串的起始地址。0x401067处的指令mov %rdi,%rbx将字符串起始地址保存在%rbx中,即%rbx为基址寄存器。指令xor %eax,%eax的作用是将%eax清零,接着调用string_length函数获取输入字符串的长度,并将长度值(返回值)存储于%eax。指令cmp $0x6,%eax将string_length的返回值与常数6作比较,若不相等则会引爆炸弹,由此可以得知,phase_5的输入字符串长度应该等于6。
1 | (gdb) x/s 0x40245e |
待比较的字符串为flyers,且长度也为6。所以,接下来的关键任务是需要对循环操作进行分析,理解该循环操作对输入字符串做了哪些操作。提取循环操作的代码,如下所示。
1 | 40108b: 0f b6 0c 03 movzbl (%rbx,%rax,1),%ecx |
由于%rbx存储的是输入字符串的起始地址,%rax初始化为0,其作用等价于下标,因此movzbl (%rbx,%rax,1),%ecx指令的作用是将字符串的第%rax个字符存储于%ecx,movzbl意味做了零扩展。接着,mov %cl,(%rsp)指令取%ecx的低8位,即一个字符的大小,通过内存间接存储至%rdx中。and $0xf,%edx指令将%edx的值与常数0xf进行位与,由指令movzbl 0x4024b0(%rdx),%edx可知,位与后的值将会作为偏移量,以0x4024b0为基址,将偏移后的值存储至%edx。最后,指令mov %dl,0x10(%rsp,%rax,1)以%edx低8位的值作为新的字符,对原有字符进行替换。综上,phase_5遍历输入字符串的每个字符,将字符的低4位作为偏移量,以0x4024b0为起始地址,将新地址对应的字符替换原有字符,最终得到flyers字符串。打印0x4024b0处的内容,如下图所示。

例如,如果要得到字符f,那么偏移量应为9,二进制表示为1001,通过查找ASCII表,可知字符i的ASCII编码为01101001,满足要求。(或者字符y(01111001)所以解不唯一)剩余5个字符采用同样的策略可以依次求得,最终,phase_5的输入字符串的一个解为ionefg。
Phase 6
phase_6的代码很长
1 | Dump of assembler code for function phase_6: |
分析清楚phase_6非常需要耐心,我将phase_6划分为5个Section,每个Section完成特定的功能,详细的注释直接附到了相关代码。前两个Section不难理解:Section 1确保输入数组的值的范围在1 ~ 6且不存在重复值;Section 2用7减去输入数组的每个元素,相当于求补。Section 3中出现了一个常数地址,使用gdb将该地址存储的内容打印出来,如下图所示。
可以意识到这其实是一个链表数据结构,链表的节点由3部分组成:value 1、value 2和一个地址值(next域,指向下一个节点)。Section 3根据我们输入的数组,按照数组元素的值将对应结构体数组中的元素的首地址存储到内存的某个位置(mov %rdx,0x20(%rsp,%rsi,2))。例如,假设输入数组为[3, 4, 5, 6, 1, 2],那么Section 3首先会将结构体数组的第3个元素的地址存储到0x20(%rsp,%rsi,2)处,接着将结构体数组的第4个元素……依次类推。Section 4根据Section 3构建的地址数组,修改结构体数组的next域的值,实现单链表的排序操作。Section 5进行验证,要求单链表递减排序,若满足要求,那么拆弹成功。
综上,根据已有的结构体数组以及phase_6的操作,若要实现单链表的递减排序,应将第3个节点放在第1位,将第4个节点放在第2位……最终得到序列:[3, 4, 5, 6, 1, 2]。不要忘记Section 2中的求补操作,所以phase_6的输入序列应该为[4, 3, 2, 1, 6, 5]。

reference