实验简介
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