
Essay2026 / 03 / 30
BombLab
Bomb Lab对应于CSAPP的第三章。在该实验中,你会得到一个名为bomb的可执行程序,以及它源码的一部分bomb.c 其中bomb内设置了六个phase,每个phase对应一个密码,你需要对bomb这个程序进行逆向工程以找到这6个正确密码,解开bomb。 工具选择是自由的,但是较为按实验的设想来说,你应当使用gnu工具链下的工具.
1-将bomb转化成汇编代码
现在你可以直接执行bomb,不过你没有正确的密码,直接尝试会使得炸弹爆炸。 为了逆向bomb,我们首先将其转变为可读的汇编代码
objdump -d bomb > bomb.s
而在bomb.c文件中,我们只看到了bomb程序的main函数。其支持我们将答案放在一个文本文件,然后运行bomb的时候读取它。 它将调用phase1到6这六个函数,而我们没有他们的源码,所以必须从汇编代码进行逆向。
2-Phase_1
下面是Phase_1的汇编源码
0000000000400ee0 <phase_1>:
400ee0: 48 83 ec 08 sub $0x8,%rsp
400ee4: be 00 24 40 00 mov $0x402400,%esi
400ee9: e8 4a 04 00 00 callq 401338 <strings_not_equal>
400eee: 85 c0 test %eax,%eax
400ef0: 74 05 je 400ef7 <phase_1+0x17>
400ef2: e8 43 05 00 00 callq 40143a <explode_bomb>
400ef7: 48 83 c4 08 add $0x8,%rspprr
400efb: c3 retq
关注到其调用了一个函数string_not_equal.并在后续进行判断,在返回值为非0时引爆。
所以我们可以知道这个函数的行为为比较两个字符串,相同返回0,不相同返回非0.
那么在调用函数前,第一个参数$rdi依旧存储着phase_1被调用时传入的char* input
而mov $0x402400,%esi,将$0x402400传入第二个参数。我们猜测这应该是答案字符串所在的内存地址。
接下来,我们使用gdb来调试程序。在实际运行过程中看看该内存地址存储的是什么。
gdb bomb #启动gdb调试bomb
(gdb) break explode_bomb #在爆炸函数设定断点,防止爆炸
(gdb) break phase_1 #在phase_1函数设定断电
(gdb) run #启动
(gdb) kill #终止
(gdb) x/s 0x402400 #以字符串形式输出地址0x402400的内存内容
执行后,我们得到
(gdb) x/s 0x402400
0x402400: "Border relations with Canada have never been better."
很显然,这个字符串(不包含双引号)应该就是phase_1的答案。我们将其写入一个文本文件answer。然后执行
./bomb answer
如果程序输出
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
Phase 1 defused. How about the next one?
那么说明我们的Phase成功解决了。
3-Phase_2
Phase_2的汇编代码如下
0000000000400efc <phase_2>:
400efc: 55 push %rbp
400efd: 53 push %rbx
400efe: 48 83 ec 28 sub $0x28,%rsp
400f02: 48 89 e6 mov %rsp,%rsi
400f05: e8 52 05 00 00 callq 40145c <read_six_numbers>
400f0a: 83 3c 24 01 cmpl $0x1,(%rsp)
400f0e: 74 20 je 400f30 <phase_2+0x34>
400f10: e8 25 05 00 00 callq 40143a <explode_bomb>
400f15: eb 19 jmp 400f30 <phase_2+0x34>
400f17: 8b 43 fc mov -0x4(%rbx),%eax
400f1a: 01 c0 add %eax,%eax
400f1c: 39 03 cmdp %eax,(%rbx)
400f1e: 74 05 je 400f25 <phase_2+0x29>
400f20: e8 15 05 00 00 callq 40143a <explode_bomb>
400f25: 48 83 c3 04 add $0x4,%rbx
400f29: 48 39 eb cmp %rbp,%rbx
400f2c: 75 e9 jne 400f17 <phase_2+0x1b>
400f2e: eb 0c jmp 400f3c <phase_2+0x40>
400f30: 48 8d 5c 24 04 lea 0x4(%rsp),%rbx
400f35: 48 8d 6c 24 18 lea 0x18(%rsp),%rbp
400f3a: eb db jmp 400f17 <phase_2+0x1b>
400f3c: 48 83 c4 28 add $0x28,%rsp
400f40: 5b pop %rbx
400f41: 5d pop %rbp
400f42: c3 retq
这里看到,其第一个调用的函数为read_six_number.我们得到第一个信息。这次的密码应当是6个数字。
然后我们再看到函数开头sub $0x28,%rsp,栈指针向下开辟了40字节的空间,这个应该就是开了个数组,用来给read_six_number往里面放数据的。
第一关-准入条件
那么往下,在read_six_number结束后,第一条指令就是cmpl $0x1,(%rsp)。我们应该就知道,数组的第一个元素num[0]的位置在%rsp指向的内存地址。也就是说,他会比较num[0] == 1这个条件。所以我们可以知道,第一个数字应该是1.
第二关-初始化
然后je 400f30跳转到相应位置后,执行了下面两条指令
很显然,它让%rbx指向了num[1],让rbp指向了不存在的num[6]
400f30: 48 8d 5c 24 04 lea 0x4(%rsp),%rbx
400f35: 48 8d 6c 24 18 lea 0x18(%rsp),%rbp
400f3a: eb db jmp 400f17 <phase_2+0x1b>
第三关-循环
现在程序执行到0x400f17.我们再看相应的汇编代码,选取到分支开始的地方
400f17: 8b 43 fc mov -0x4(%rbx),%eax
400f1a: 01 c0 add %eax,%eax
400f1c: 39 03 cmdp %eax,(%rbx)
400f1e: 74 05 je 400f25 <phase_2+0x29>
可以看到,这个汇编代码的逻辑就是
判断当前数字 == 前一个数字 * 2。如果成立就跳转,不成立就顺序执行。
由于我们的num[0]现在确定为1.那么这个num[1] = 2 * num[0] = 2.
我们可以在接下来的代码中看到,如果条件不成立,就会顺序执行到下面的explode_bomb函数。所以说条件成立才能不发生爆炸。所以我们刚才推断出的num[1]是正确的。
400f20: e8 15 05 00 00 callq 40143a <explode_bomb>
400f25: 48 83 c3 04 add $0x4,%rbx
400f29: 48 39 eb cmp %rbp,%rbx
400f2c: 75 e9 jne 400f17 <phase_2+0x1b>
400f2e: eb 0c jmp 400f3c <phase_2+0x40>
而在条件成立,此时跳转到0X400f25的位置。
往下的代码逻辑就是
取下一个数,判断是否取到num[6].如果取到了num[6],就跳转至400f3c的位置。如果不跳转,就会跳转回400f17的位置。而我们看400f17,刚好就是我们刚刚入口的位置,所以我们应该就知道这是一个循环了。整体看下来,翻译回类似的C代码
//int* rbx = &numbers[1];
//int* rbp = &numbers[6];
do{
int prev = *(rbx - 1);
int expected = prev + prev;
if(*rbx != expected) explode_bomb();
rbc++
}while(rbx != rbp);
所以按照递推关系,这六个数字就分别为1 2 4 8 16 32
第四关-结束
在跳出循环后,执行到400f3c。
400f3c: 48 83 c4 28 add $0x28,%rsp
400f40: 5b pop %rbx
400f41: 5d pop %rbp
400f42: c3 retq
可以看到这就是函数得结尾,也验证了我们刚才认定这是退出循环得标志。
至此,我们就得到了答案1 2 4 8 16 32
输入answer后,运行bomb检验,
That's number 2. Keep going!
我们正确了。
4-Phase_3
5-Phase_4
这里的主题为理解函数递归调用。
5.1-看透Phase_4接受的输入
40100c: 48 83 ec 18 sub $0x18,%rsp
401010: 48 8d 4c 24 0c lea 0xc(%rsp),%rcx
401015: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
40101a: be cf 25 40 00 mov $0x4025cf,%esi
40101f: b8 00 00 00 00 mov $0x0,%eax
401024: e8 c7 fb ff ff callq 400bf0 <__isoc99_sscanf@plt>
401029: 83 f8 02 cmp $0x2,%eax
40102c: 75 07 jne 401035 <phase_4+0x29>
这里的逻辑就是函数在栈上开辟空间准备存出局。阿这个数据有C语言的函数sscanf调用。
其中我们看到传给sscanf的第二个参数指向了内存地址0x4025cf。查阅手册可以得知sscanf的第二个参数就是格式输入字符串。
通过gdb调试,查看该内存地址的字符串内容得到%d %d,可知本处Phase_4的密码是两个整数。
在这里我们假设第一个整数为x,第二个整数为y。
5.2-确定x,y的范围
在接下来的汇编代码中,可以看到,只有在 x <= 14的情况下才不会执行bomb函数。
40102e: 83 7c 24 08 0e cmpl $0xe,0x8(%rsp)
401033: 76 05 jbe 40103a <phase_4+0x2e>
401035: e8 00 04 00 00 callq 40143a <explode_bomb>
40103a: ba 0e 00 00 00 mov $0xe,%edx #func4的第三个参数为14
然后往下
40103f: be 00 00 00 00 mov $0x0,%esi #func4第二个参数为0
401044: 8b 7c 24 08 mov 0x8(%rsp),%edi #func4第一个参数为x
401048: e8 81 ff ff ff callq 400fce <func4>
40104d: 85 c0 test %eax,%eax #func4返回值必须为0
40104f: 75 07 jne 401058 <phase_4+0x4c>
401051: 83 7c 24 0c 00 cmpl $0x0,0xc(%rsp) #y == 0
401056: 74 05 je 40105d <phase_4+0x51>
401058: e8 dd 03 00 00 callq 40143a <explode_bomb>
40105d: 48 83 c4 18 add $0x18,%rsp
401061: c3 retq
这里函数准备开始调用func4函数,并设置了参数。我们不需要知道func4会干什么
然后接下来test %eax,%eax与jne 401058 <phase_4+0x51>说明func4如果返回非0值就会执行explode bomb函数。
所以这里func4的返回值必须为0
而继续往下,我们看到对于y的比较,在y!=0的情况下执行explode bomb函数。所以这里我们得到y = 0这个条件。接下来就是看func4干了说明,确定x的值。
Phase_4还原的C代码大致如下
void Phase_4(char* input){
int x,y;
sscanf(input,"%d %d",x,y);
if(x > 14) explode_bomb();
if(func4(x,0,14) != 0) explode_bomb();
if(y != 0) explode_bomb();
return;
}
5.3-看懂func4
解析func4算是这个phase最有难度的部分 我们这里先给出func4的定义
int func4(int s1,int s2,int s3)
说说第一个难点
400fce: 48 83 ec 08 sub $0x8,%rsp
400fd2: 89 d0 mov %edx,%eax # eax = s3
400fd4: 29 f0 sub %esi,%eax # eax = s3 - s2
400fd6: 89 c1 mov %eax,%ecx # ecx = x
400fd8: c1 e9 1f shr $0x1f,%ecx # x逻辑右移31位
# x为正,ecx = 0
# x为负,ecx = 1
400fdb: 01 c8 add %ecx,%eax # x = x + (x<0 ? 1 : 0)
400fdd: d1 f8 sar %eax # x >> 1
400fdf: 8d 0c 30 lea (%rax,%rsi,1),%ecx # x = (s2 + s3)/2
这段代码对应的C代码逻辑很简单
int mid = (s2 + s3)/2;
但是编译器采用了一段看起来”极其别扭”的汇编代码来实现这个功能。
根本原因是机器中有符号除法的代价”太过昂贵”,而且整数加法可能的溢出情况,所以编译器的策略为,除数为2的幂次就移位,是常数就用乘法替换,只有为变量时才调用除法指令。
而这段汇编代码的,由于除数是除以2,所以直接右移一位就可以。
但是直接移位有符号数与C语言的规定有不符合的情况
如 5 / 2 = 2,而5 >> 1 = 2,但 -5/2 = -2,-5 >> 1 = -3
所以编译器在移位前,插入一长段代码来纠正,保证除法结果遵顼C的向0取整。
然后是第二个难点,就是 递归
6-Phase_5
这个就是字符串比较。你需要输入一个长度为6的字符串。然后通过每个字符与0xf与运算的结果作为索引,并类似哈希函数映射到另一个长度为6的字符串。最后将映射得到的字符串与另一个字符串"flyers"进行比较,如果相同就通过。
7-Phase_6
Phase_6的设计逻辑就是,你输入6个数字。程序首先判断你的数字在不在1到6这个范围内以及是不是各不相同。满足条件的情况下执行num[i] = 7-num[i]的变换。然后在内存中存在一个”链表”。每个节点的结构类似于下面
struct Node{
int ID;
int value;
Node* next;
}
Node[6];
Node[0].value=1;
Node[1].value = 2;
Node[2].value = 3;
Node[3].value = 4;
Node[4].value = 5;
Node[5].value = 6;
程序会将各节点将节点间连接起来,使得遍历下来的value舒徐与我们经过变换后的输入相同。 最后程序将会检查这个链表遍历下来的ID是不是严格递减的,如果不是就引爆,如果是就顺利通过。