文章封面图

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* inputmov $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,%eaxjne 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是不是严格递减的,如果不是就引爆,如果是就顺利通过。