实验环境

这个实验需要反编译一个Linux下的ELF文件,因此使用的是VS Code加上Ubuntu WSL的经典组合。

$gdb bomb
(gdb)layout split

这个layout spilt的作用就是分割一下gdb的布局,在终端绘制出一个类似于KEIL调试A51汇编的界面,如图:

接下来就可以开始拆弹了。

Phase 1

观察下bomb.c源文件,不难定位到phase_1()函数的位置在第74行:

72   /* Hmm...  Six phases must be more secure than one phase! */ 
73   input = read_line();             /* Get input                   */ 
74   phase_1(input);                  /* Run the phase               */ 
75   phase_defused();                 /* Drat!  They figured it out! 

对应的汇编源代码如下:

00000000000015a7 <phase_1>:
│   0x15a7 <phase_1>        endbr64  
│   0x15ab <phase_1+4>      sub    $0x8,%rsp 
│   0x15af <phase_1+8>      lea    0x1b9a(%rip),%rsi   #3150 <_IO_stdin_used+0x150>
│   0x15b6 <phase_1+15>     callq  0x1aea <strings_not_equal> 
│   0x15bb <phase_1+20>     test   %eax,%eax 
│   0x15bd <phase_1+22>     jne    0x15c4 <phase_1+29> 
│   0x15bf <phase_1+24>     add    $0x8,%rsp  
│   0x15c3 <phase_1+28>     retq     
│   0x15c4 <phase_1+29>     callq  0x1bfe <explode_bomb> 
│   0x15c9 <phase_1+34>     jmp    0x15bf <phase_1+24>  

注意到第四行跳转到<strings_not_equal> ,根据名字猜测这应该是一个判别字符串是否相等的函数。

直接(gdb)b 74phase_1()函数打个断点, (gdb)r运行程序,随便输入一个字符串进去,到达断点时自动暂停如下:

接下来用(gdb)si逐条指令步进,进入到 phase_1() 函数内♂部,这里和(gdb)s是有区别的,si是一条指令一条指令地调试,而 s 是一行一行代码地调试,所以这里如果使用(gdb)s应该会出错。

步进到调用字符串比对函数的位置,它的上一行指令为:

0x5555555555af <phase_1+8>      lea    0x1b9a(%rip),%rsi    #0x555555557150

lea指令就是将有效地址传送到指定的的寄存器,那这里答案实际上已经呼之欲出了,正确的字符串的地址就在%rsi里,或者说就是后面gdb自动添加的注释0x555555557150。用(gdb) x 可以直接打印出来:

死星,应该是星球大战的meme :)

所以Phase1的答案为 "I turned the moon into something I call a Death Star."。

查了一下,好像是是一部老电影里致敬星球大战的一句台词(说出这句台词的角色恰好是Dr. Evil):

Phase 1算是一个比较简单的 warm-up,接下来的 Phase 应该会更困难。

Phase 2

直接上Phase 2的汇编:

00000000000015cb <phase_2>:
    15cb:	f3 0f 1e fa          	endbr64 
    15cf:	55                   	push   %rbp
    15d0:	53                   	push   %rbx
    15d1:	48 83 ec 28          	sub    $0x28,%rsp
    15d5:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
    15dc:	00 00 
    15de:	48 89 44 24 18       	mov    %rax,0x18(%rsp)
    15e3:	31 c0                	xor    %eax,%eax
    15e5:	48 89 e6             	mov    %rsp,%rsi
    15e8:	e8 3d 06 00 00       	callq  1c2a <read_six_numbers>
    15ed:	83 3c 24 01          	cmpl   $0x1,(%rsp)
    15f1:	75 0a                	jne    15fd <phase_2+0x32>
    15f3:	48 89 e3             	mov    %rsp,%rbx
    ..........

15e8这个位置,函数调用了<read_six_numbers>来“读取六个数字”,因此我们可以直接猜测Phase 2的解应该是六个数字。

gdb调试,使用和Phase 1中相同的方法在函数phase_2()的位置打个断点,因为已经猜到Phase 2的解是六个数字了,所以我们直接输入1 2 3 4 5 6试一试。

值得注意的是,如果我们继续(gdb) si,就会进入到没必要看的read_six_numbers调用。我们可以在callq后面打一个断点,然后用(gdb) next跳过 read_six_numbers 。因为这里的 read_six_numbers 在bomb.c源文件里没有对应的行数,所以需要使用(gdb)b *0x5555555555ed的方式来打一个地址断点。

地址断点

接下来的一段汇编如下:

0x5555555555e5 <phase_2+26>     mov    %rsp,%rsi
0x5555555555e8 <phase_2+29>     callq  0x555555555c2a <read_six_numbers>
0x5555555555ed <phase_2+34>     cmpl   $0x1,(%rsp) 
0x5555555555f1 <phase_2+38>     jne    0x5555555555fd <phase_2+50>  
0x5555555555f3 <phase_2+40>     mov    %rsp,%rbx 
0x5555555555f6 <phase_2+43>     lea    0x14(%rsp),%rbp
0x5555555555fb <phase_2+48>     jmp    0x555555555612 <phase_2+71> 
0x5555555555fd <phase_2+50>     callq  0x555555555bfe <explode_bomb>

这里首先把%rsi里的数据复制到%rsp,随后判断(%rsp)是否等于1,我们可以看看现在%rsp里到底装着什么:

可以看到%rsp就是一个指针!它指向的正是我们刚才输入的数组的首地址,继续按回车也可以看到%rsp指向的地址顺序储存着我们刚才输入的六个数字。

这里我运气比较好,输入的第一个数字恰好就是1,没有引爆炸弹,我们继续逐条指令步进跳转到 <phase_2+71> 。这一段的汇编代码如下:

>0x555555555612 <phase_2+71>     mov    (%rbx),%eax 
 0x555555555614 <phase_2+73>     add    %eax,%eax  
 0x555555555616 <phase_2+75>     cmp    %eax,0x4(%rbx) 
 0x555555555619 <phase_2+78>     je     0x555555555609 <phase_2+62> 
 0x55555555561b <phase_2+80>     jmp    0x555555555604 <phase_2+57>  

这段代码先给%eax翻了个倍,然后再比对%eax里的内容和 0x4(%rbx) 的内容,我们可以先看一下没有翻倍之前%eax和%rbx里的内容:

得到%eax=1,而%rbx和刚才的%rsp一样也是输入的六个数字的首地址,那么 0x4(%rbx) 就不难理解了,它应该是地址0x7fffffffd794的数字,也就是我们输入的第二个数字。因此第二个数字应该是2。接下来程序跳转到 <phase_2+62> :

0x555555555609 <phase_2+62>     add    $0x4,%rbx 
0x55555555560d <phase_2+66>     cmp    %rbp,%rbx
0x555555555610 <phase_2+69>     je     0x55555555561d <phase_2+82>
0x555555555612 <phase_2+71>     mov    (%rbx),%eax
0x555555555614 <phase_2+73>     add    %eax,%eax 
0x555555555616 <phase_2+75>     cmp    %eax,0x4(%rbx) 
0x555555555619 <phase_2+78>     je     0x555555555609 <phase_2+62>
0x55555555561b <phase_2+80>     jmp    0x555555555604 <phase_2+57>

现在容易看出来了,实际上这一段代码就是一个循环, add $0x4,%rbx 使得循环继续访问我们输入的数组中的第三四五六个元素,并且逐个把它们和%eax里的值比对,而%eax的值每次循环都会翻倍,正好就是一个首项为1,公比为2的等比数列:1 2 4 8 16 32 ,这就是炸弹Phase 2的拆除密码。

Phase 3

Phase 3开头部分的汇编代码如下:

0x555555555639 <phase_3>        endbr64                                                                   
0x55555555563d <phase_3+4>      sub    $0x18,%rsp    
0x555555555641 <phase_3+8>      mov    %fs:0x28,%rax   
0x55555555564a <phase_3+17>     mov    %rax,0x8(%rsp)   
0x55555555564f <phase_3+22>     xor    %eax,%eax   
0x555555555651 <phase_3+24>     lea    0x4(%rsp),%rcx  
0x555555555656 <phase_3+29>     mov    %rsp,%rdx    
0x555555555659 <phase_3+32>     lea    0x1ccf(%rip),%rsi        # 0x55555555732f 
0x555555555660 <phase_3+39>     callq  0x5555555552c0 <__isoc99_sscanf@plt>
0x555555555665 <phase_3+44>     cmp    $0x1,%eax
0x555555555668 <phase_3+47>     jle    0x555555555684 <phase_3+75>
0x55555555566a <phase_3+49>     cmpl   $0x7,(%rsp)
0x55555555566e <phase_3+53>     ja     0x5555555556d5 <phase_3+156> 

这里 <phase_3+32> 加载了一个地址 0x55555555732f ,我们用gdb看一下里面是什么:

(gdb) x 0x55555555732f
0x55555555732f: "%d %d"

这表明Phase 3需要我们输入的是两个整型数据,从头开始运行程序到Phase 3并且输入3 6试一下。

我们使用gdb打印出 <phase_3+44> <phase_3+49> 处引用的%eax(%rsp)如下:

(gdb) x $eax
0x2:    Cannot access memory at address 0x2
(gdb) x ($rsp)
0x7fffffffdc60: 0x00000003
0x7fffffffdc64: 0x00000006

不难看出,此处的的 %rsp 和Phase 2中一样也是一个指针,指向我们刚才输入的两个数字构成的数组首地址。因此我们输入的第一个参数不能大于0x7,否则就会直接跳转到<explode_bomb>引爆炸弹。我们这里输入的3满足小于7的条件,因此可以继续单步调试。

下一段汇编代码如下:

0x555555555670 <phase_3+55>     mov    (%rsp),%eax
0x555555555673 <phase_3+58>     lea    0x1b46(%rip),%rdx        # 0x5555555571c0
0x55555555567a <phase_3+65>     movslq (%rdx,%rax,4),%rax
0x55555555567e <phase_3+69>     add    %rdx,%rax  
0x555555555681 <phase_3+72>     notrack jmpq *%rax 

这一段代码根据输入的值计算出一个地址存放在%rax,并且跳转至这个地址,分析可知计算过程如下:

Address = (0x5555555571c0 + 4 * %rax) + 0x5555555571c0

我们使用gdb打印出当%rax取1到7时, (0x5555555571c0 + 4 * %rax) 的值:

(gdb) x 0x5555555571c4
0x5555555571c4: 0xffffe4cb
0x5555555571c8: 0xffffe4eb
0x5555555571cc: 0xffffe4f2
0x5555555571d0: 0xffffe4f9
0x5555555571d4: 0xffffe500
0x5555555571d8: 0xffffe507
0x5555555571dc: 0xffffe50e

这里GDB输出的值实际上应当为16位十六进制数,例如 0xffffe4cb 应当为 0xffffffffffffe4cb,我们可以得到跳转表如下:

%raxAddress跳转到的位置
10x55555555568B<phase_3+82>
20x5555555556AB<phase_3+114>
30x5555555556B2<phase_3+121>
40x5555555556B9<phase_3+128>
50x5555555556C0<phase_3+135>
60x5555555556C7<phase_3+142>
70x5555555556CE<phase_3+149>

显然,这是一个switch语句,我们可以根据后续的汇编代码求出第一个输入从1到7时,第二个输入需要满足的值:

0x55555555568b <phase_3+82>     mov    $0x3e5,%eax
0x555555555690 <phase_3+87>     cmp    %eax,0x4(%rsp) //判断第二个输入是否等于指定值
0x555555555694 <phase_3+91>     jne    0x5555555556e8 <phase_3+175>

0x5555555556ab <phase_3+114>    mov    $0x33a,%eax
0x5555555556b0 <phase_3+119>    jmp    0x555555555690 <phase_3+87> 
0x5555555556b2 <phase_3+121>    mov    $0x19a,%eax
0x5555555556b7 <phase_3+126>    jmp    0x555555555690 <phase_3+87> 
0x5555555556b9 <phase_3+128>    mov    $0x223,%eax
0x5555555556be <phase_3+133>    jmp    0x555555555690 <phase_3+87> 
0x5555555556c0 <phase_3+135>    mov    $0x12d,%eax
0x5555555556c5 <phase_3+140>    jmp    0x555555555690 <phase_3+87>
0x5555555556c7 <phase_3+142>    mov    $0x2b5,%eax                                                                                                                                                
0x5555555556cc <phase_3+147>    jmp    0x555555555690 <phase_3+87>
0x5555555556ce <phase_3+149>    mov    $0x36a,%eax
0x5555555556d3 <phase_3+154>    jmp    0x555555555690 <phase_3+87>
第一个输入 第二个输入
1997
2826
3410
4547
5301
6693
7874
第二个输入对应的值

这就是Phase 3需要的密码。

Phase 4

Phase 4的第一段汇编代码如下:

0x55555555572f <phase_4>        endbr64
0x555555555733 <phase_4+4>      sub    $0x18,%rsp
0x555555555737 <phase_4+8>      mov    %fs:0x28,%rax
0x555555555740 <phase_4+17>     mov    %rax,0x8(%rsp)
0x555555555745 <phase_4+22>     xor    %eax,%eax
0x555555555747 <phase_4+24>     mov    %rsp,%rcx
0x55555555574a <phase_4+27>     lea    0x4(%rsp),%rdx
0x55555555574f <phase_4+32>     lea    0x1bd9(%rip),%rsi        # 0x55555555732f
0x555555555756 <phase_4+39>     callq  0x5555555552c0 <__isoc99_sscanf@plt>

和Phase 3中类似, <phase_4+32> 处也加载了一个地址,我们同样用gdb查看一下这里的数据:

(gdb) x/s 0x55555555732f
0x55555555732f: "%d %d"

可以看出Phase 4的输入和Phase 3相同,也是两个整数,我们把它们记作 a b。同时我们猜想这里%rsp的作用和之前的Phase里相同:指向输入数据构成的数组的第一个元素,因此我们尝试输入1 2并且使用gdb查看(%rsp)的内容:

(gdb) x $rsp
0x7fffffffdc50: 0x00000002
0x7fffffffdc54: 0x00000001

可以看到输入数据在读取时被交换了先后顺序。

下一段汇编代码如下:

0x55555555575b <phase_4+44>     cmp    $0x2,%eax
0x55555555575e <phase_4+47>     jne    0x55555555576b <phase_4+60>
0x555555555760 <phase_4+49>     mov    (%rsp),%eax
0x555555555763 <phase_4+52>     sub    $0x2,%eax
0x555555555766 <phase_4+55>     cmp    $0x2,%eax
0x555555555769 <phase_4+58>     jbe    0x555555555770 <phase_4+65>
0x55555555576b <phase_4+60>     callq  0x555555555bfe <explode_bomb>

这段代码的作用很简单,就是确保(%rsp),也就是输入的第二个数字满足0<b<=4。由于我们尝试的数字是1 2,因此还可以继续运行下去。

接下来的汇编代码如下(这里把func4函数的内容放在了下面):

0x555555555770 <phase_4+65>     mov    (%rsp),%esi
0x555555555773 <phase_4+68>     mov    $0x8,%edi
0x555555555778 <phase_4+73>     callq  0x5555555556f4 <func4>
0x55555555577d <phase_4+78>     cmp    %eax,0x4(%rsp)
0x555555555781 <phase_4+82>     jne    0x555555555798 <phase_4+105>
......
0x555555555798 <phase_4+105>    callq  0x555555555bfe <explode_bomb>
-----------------------------
00000000000016f4 <func4>:
    16f4:	f3 0f 1e fa          	endbr64 
    16f8:	b8 00 00 00 00       	mov    $0x0,%eax
    16fd:	85 ff                	test   %edi,%edi
    16ff:	7e 2d                	jle    172e <func4+0x3a>
    1701:	41 54                	push   %r12
    1703:	55                   	push   %rbp
    1704:	53                   	push   %rbx
    1705:	89 fb                	mov    %edi,%ebx
    1707:	89 f5                	mov    %esi,%ebp
    1709:	89 f0                	mov    %esi,%eax
    170b:	83 ff 01             	cmp    $0x1,%edi
    170e:	74 19                	je     1729 <func4+0x35>
    1710:	8d 7f ff             	lea    -0x1(%rdi),%edi
    1713:	e8 dc ff ff ff       	callq  16f4 <func4>
    1718:	44 8d 24 28          	lea    (%rax,%rbp,1),%r12d
    171c:	8d 7b fe             	lea    -0x2(%rbx),%edi
    171f:	89 ee                	mov    %ebp,%esi
    1721:	e8 ce ff ff ff       	callq  16f4 <func4>
    1726:	44 01 e0             	add    %r12d,%eax
    1729:	5b                   	pop    %rbx
    172a:	5d                   	pop    %rbp
    172b:	41 5c                	pop    %r12
    172d:	c3                   	retq   
    172e:	c3                   	retq   

分析一下代码和func4的构造可以发现,这里实际上是把%esi和%edi作为参数传给了func4函数,然后再比对func4函数的返回值%eax和第一个输入(%rsp+4)的值是否相等,如果不相等则炸弹爆炸。先前已经提到,我们把输入的两个数字记作 a和b,那这里需要满足的条件就是func4(8, b)=a 。

我们先把func4视为一个黑盒函数,不考虑它内部的运作情况,我们可以直接在 <phase_4+78> 处打一个断点来查看函数的返回值如下:

(gdb) x $eax
0x6c

可以看出,func4(8, 2)返回的结果是108。我们可以改变输入的第二个数字为3、4,分别找出此时func4(8, b)的返回值如下:

b func(8, b)
2108
3162
4216

通过这种方式,就得到了Phase 4的拆除密码。

接下来我们深入一点,分析一下func4()函数的具体工作原理,为了实现这一目标,最简单的方法就是把汇编语言转写成容易理解的C语言:

int func4(int a, int b)
{
    if (a <= 0)
        return 0;
    if (a == 1)
        return b;
    return func4(a - 1, b) + func4(a - 2, b) + b;
}

此处转写得到上面所示的C语言代码,使用下面的主函数进行测试:

int main()
{
    printf ("%d %d %d", func4(8,2),func4(8,3),func4(8,4));
    return 0;
}

得到的输出与我们刚才直接在gdb中查看得到的返回值是相符合的:

gcc -o func4 func4.c && ./func4
108 162 216

此函数似乎和斐波那契数列有关。

Phase 5

Phase 5的第一段汇编代码如下:

0x5555555557a4 <phase_5>        endbr64
0x5555555557a8 <phase_5+4>      sub    $0x18,%rsp
0x5555555557ac <phase_5+8>      mov    %fs:0x28,%rax
0x5555555557b5 <phase_5+17>     mov    %rax,0x8(%rsp)
0x5555555557ba <phase_5+22>     xor    %eax,%eax
0x5555555557bc <phase_5+24>     lea    0x4(%rsp),%rcx
0x5555555557c1 <phase_5+29>     mov    %rsp,%rdx
0x5555555557c4 <phase_5+32>     lea    0x1b64(%rip),%rsi        # 0x55555555732f 
0x5555555557cb <phase_5+39>     callq  0x5555555552c0 <__isoc99_sscanf@plt>
0x5555555557d0 <phase_5+44>     cmp    $0x1,%eax 
0x5555555557d3 <phase_5+47>     jle    0x55555555582f <phase_5+139>

和先前的分析一样,我们可以看到 <phase_5+32> 处依然加载了一个地址,用gdb看一下它的类型:

(gdb) x/s 0x55555555732f
0x55555555732f: "%d %d"

可以看出,Phase 5需要的输入也是两个整型数字,我们输入1 2来尝试一下。不难发现%rsp的作用和前面的Phase是相同的,依然是指向储存输入数据的数组:

(gdb) x/4xw ($rsp)
0x7fffffffdc50: 0x00000001      0x00000002

接下来的一段汇编如下:

0x5555555557d5 <phase_5+49>     mov    (%rsp),%eax
0x5555555557d8 <phase_5+52>     and    $0xf,%eax 
0x5555555557db <phase_5+55>     mov    %eax,(%rsp) 
0x5555555557de <phase_5+58>     cmp    $0xf,%eax
0x5555555557e1 <phase_5+61>     je     0x555555555815 <phase_5+113>

这段汇编的作用是截取输入第一个数字的后四位二进制位赋值到%rax,并且检查这后四位是否为1111,如果是则引爆炸弹。

换句话说,我们输入的第一个数字将被截取到0~15之间,并且截取后的结果不能等于15。

继续看下一段汇编:

0x5555555557e3 <phase_5+63>     mov    $0x0,%ecx
0x5555555557e8 <phase_5+68>     mov    $0x0,%edx
0x5555555557ed <phase_5+73>     lea    0x19ec(%rip),%rsi # 0x5555555571e0<array.3469>
0x5555555557f4 <phase_5+80>     add    $0x1,%edx
0x5555555557f7 <phase_5+83>     cltq
0x5555555557f9 <phase_5+85>     mov    (%rsi,%rax,4),%eax
0x5555555557fc <phase_5+88>     add    %eax,%ecx
0x5555555557fe <phase_5+90>     cmp    $0xf,%eax
0x555555555801 <phase_5+93>     jne    0x5555555557f4 <phase_5+80>

在 <phase_5+63> 和 <phase_5+68> ,程序将%ecx和%edx两个寄存器赋值为0;随后, <phase_5+73> 处加载了一个数组array的首地址到%rsi,而 <phase_5+80> 到 <phase_5+93> 部分则是一个循环。

接下来我们使用(gdb)layout regs切换到寄存器视图,方便我们观察循环中寄存器内值的变化情况:

gdb寄存器视图

分析代码运行可以发现:

  • %edx每一次循环都自增1,是一个循环计数器。
  • <phase_5+85> mov (%rsi,%rax,4),%eax 这一句实际上是在用%rax里的数字做索引访问array数组,即:%rax = array[%rax]。注意:我们先前提到过,最开始%rax里的数字就是输入的第一个数字的后四位。
  • %ecx里储存的是每次循环读取到的数组值的累加和,即 %ecx += array[%rax]

那看来这个神秘的数组array就是问题的关键了,我们用gdb把这个数组打印出来看一下:

(gdb) x/16wd 0x5555555571e0
0x5555555571e0 <array.3469>:    10      2       14      7
0x5555555571f0 <array.3469+16>: 8       12      15      11
0x555555557200 <array.3469+32>: 0       4       1       13
0x555555557210 <array.3469+48>: 3       9       6       5

array是一个具有16个元素的数组,因此程序会将输入的第一个数字截取到0~15之间以避免段错误。

这里我们先看接下来的汇编:

0x5555555557fe <phase_5+90>     cmp    $0xf,%eax
0x555555555801 <phase_5+93>     jne    0x5555555557f4 <phase_5+80>
0x55555555580a <phase_5+102>    cmp    $0xf,%edx
0x55555555580d <phase_5+105>    jne    0x555555555815 <phase_5+113>
0x55555555580f <phase_5+107>    cmp    %ecx,0x4(%rsp)
0x555555555813 <phase_5+111>    je     0x55555555581a <phase_5+118>

+90和+93是判断循环结束的条件,当%eax=0xF时就跳出循环。跳出循环之后继续做两个条件判断:循环计数器%edx的值必须为0xF,也就是循环遍历16次数组;遍历到的数组元素的累加和%ecx要等于输入的第二个数字(%rsp+4)。

接下来是我认为Phase 5分析中很妙的一步,我们把这个数组用类似链表的方式画出来:

我们可以发现,这个数组构成了一个环状链表!那么,我们的问题就变成了寻找遍历这个环状链表所有元素的起始点,并且遍历到的最后一个元素的值为0xF(15)。

我们可以从图中直观地看出,如果我们从5开始遍历,那么当我们遍历完所有的十六个表元后,遍历到的最后一个表元就恰好是15:

所以我们输入的第一个数字可以为5,但是别忘了, 输入的第一个数字将被截取出后四位,因此不只是0x5满足题意,我们同样可以取0x15(21)、0x25(37)之类的值。

而输入的第二个数字自然就是遍历一圈后得到整个数组所有元素的和,容易计算出这个和等于115。

此乃Phase 5的拆弹密码,不得不说这个Phase的设计非常巧妙,找到Key之后的感觉就是“ 太棒了,我逐渐理解一切 ”。

太棒了,我逐渐理解一切

Phase 6

首先看一眼objdump得到的汇编,注意到:

1863: e8 c2 03 00 00 callq 1c2a <read_six_numbers>

这实际上是之前出现的“读入六个数字”的函数,因此可以初步确定Phase 6的拆弹密码由六个整数组成,我们输入1 2 3 4 5 6并且单步调试看一下,这一段完整的汇编如下:

0x55555555585d <phase_6+34>     mov    %rsp,%r14 
0x555555555860 <phase_6+37>     mov    %r14,%rsi
0x555555555863 <phase_6+40>     callq  0x555555555c2a <read_six_numbers>
0x555555555868 <phase_6+45>     mov    %r14,%r12
0x55555555586b <phase_6+48>     mov    $0x1,%r15d 
0x555555555871 <phase_6+54>     mov    %rsp,%r13

和前面的Phase类似,%rsp依然是指向输入数字组成的数组第一个元素的指针,同时也可以分析出%r12、 %r13 、 %r14 都和%rsp相等。

接下来耐心地单步调试,拆出来这样下面三段汇编(分别标记为sector1、2、3):

# sector1
0x555555555868 <phase_6+45>     mov    %r14,%r12
0x55555555586b <phase_6+48>     mov    $0x1,%r15d
0x555555555871 <phase_6+54>     mov    %rsp,%r13
0x555555555874 <phase_6+57>     jmpq   0x55555555593a <phase_6+255>
...... 
# sector2
0x555555555883 <phase_6+72>     callq  0x555555555bfe <explode_bomb>
0x555555555888 <phase_6+77>     add    $0x1,%rbx
0x55555555588c <phase_6+81>     cmp    $0x5,%ebx 
0x55555555588f <phase_6+84>     jg     0x555555555932 <phase_6+247>
0x555555555895 <phase_6+90>     mov    0x0(%r13,%rbx,4),%eax
0x55555555589a <phase_6+95>     cmp    %eax,0x0(%rbp)
0x55555555589d <phase_6+98>     jne    0x555555555888 <phase_6+77>
0x55555555589f <phase_6+100>    jmp    0x555555555883 <phase_6+72>
......
# sector3
0x555555555932 <phase_6+247>    add    $0x1,%r15
0x555555555936 <phase_6+251>    add    $0x4,%r14
0x55555555593a <phase_6+255>    mov    %r14,%rbp 
0x55555555593d <phase_6+258>    mov    (%r14),%eax
0x555555555940 <phase_6+261>    sub    $0x1,%eax
0x555555555943 <phase_6+264>    cmp    $0x5,%eax
0x555555555946 <phase_6+267>    ja     0x555555555879 <phase_6+62>
0x55555555594c <phase_6+273>    cmp    $0x5,%r15d
0x555555555950 <phase_6+277>    jg     0x5555555558a1 <phase_6+102>
0x555555555956 <phase_6+283>    mov    %r15,%rbx 
0x555555555959 <phase_6+286>    jmpq   0x555555555895 <phase_6+90>

结合单步调试过程,可以看出这应该是一个二层循环。使用(gdb)layout regs切换到寄存器视图之后重新从头开始运行程序,这一次注意观察寄存器的变化情况,尝试找出这个双层循环实现的功能。

  • 首先分析内层循环,在sector 2中,%rbx显然是作为数组索引,用来访问输入数字组成的数组元素,并且把数组元素同%rbp指向的元素做比较,如果发现相同,就引爆炸弹。
  • 接下来我们就得分析sector 3考察一下这个%rbx和%rbp是怎么来的,在寄存器界面可以看到%rbx初始值为1,而%rbp初始指向数组中的第一个元素。不难发现外层循环在<phase_6+247>和<phase_6+283>改变了%rbx的值为%rbx+1,同时在<phase_6+251>和<phase_6+255>让%rbp指向下一个数组元素。
  • 同时,在外层循环中,程序还通过%r14访问了数组里的每一个元素,并且判断它是否大于0且小于等于6,如果不在这个范围内就引爆炸弹。

现在我们可以容易地把这个双层循环转写成C语言:

//应该没写错罢(心虚)
int k = 1;
for (i = 0; i < 6; i++)
{
    if (arr[i] > 6)
        explode_bomb();
    for (j = k; j < 6; j++)
    {
        if (arr[i] == arr[j])
            explode_bomb();
    }
    k++;
}

现在这个两层循环就很好理解了,它实现的功能就是确保输入的六个数字都满足0<arr[i]<=6,并且两两之间互不相等(这么多行就干个这是吧)。

由于我们这里输入的是1 2 3 4 5 6,正好满足这个要求,所以我们暂时没有引爆炸弹。

继续看循环结束后的下一段汇编:

0x5555555558a1 <phase_6+102>    lea    0x18(%r12),%rcx 
0x5555555558a6 <phase_6+107>    mov    $0x7,%edx
0x5555555558ab <phase_6+112>    mov    %edx,%eax
0x5555555558ad <phase_6+114>    sub    (%r12),%eax
0x5555555558b1 <phase_6+118>    mov    %eax,(%r12)
0x5555555558b5 <phase_6+122>    add    $0x4,%r12
0x5555555558b9 <phase_6+126>    cmp    %r12,%rcx
0x5555555558bc <phase_6+129>    jne    0x5555555558ab <phase_6+112>

可以看到+112到+129之间又是一个循环,这个循环通过%r12做指针来逐个访问输入数组中的元素,并且依次用7减去元素,即*r12=7-*r12,我们打印出此时%rsp指向的内容如下:

(gdb) x $rsp
0x7fffffffdbd0: 0x00000006
0x7fffffffdbd4: 0x00000005
0x7fffffffdbd8: 0x00000004
0x7fffffffdbdc: 0x00000003
0x7fffffffdbe0: 0x00000002
0x7fffffffdbe4: 0x00000001

所以这个循环的作用就是根据输入的数字生成一串新的数字,继续进行分析:

0x5555555558be <phase_6+131>    mov    $0x0,%esi
0x5555555558c3 <phase_6+136>    mov    (%rsp,%rsi,4),%ecx
0x5555555558c6 <phase_6+139>    mov    $0x1,%eax
0x5555555558cb <phase_6+144>    lea    0x392e(%rip),%rdx   # 0x555555559200 <node1>
0x5555555558d2 <phase_6+151>    cmp    $0x1,%ecx
0x5555555558d5 <phase_6+154>    jle    0x5555555558e2 <phase_6+167>
0x5555555558d7 <phase_6+156>    mov    0x8(%rdx),%rdx
0x5555555558db <phase_6+160>    add    $0x1,%eax
0x5555555558de <phase_6+163>    cmp    %ecx,%eax 
0x5555555558e0 <phase_6+165>    jne    0x5555555558d7 <phase_6+156>
0x5555555558e2 <phase_6+167>    mov    %rdx,0x20(%rsp,%rsi,8)
0x5555555558e7 <phase_6+172>    add    $0x1,%rsi 

这里 <phase_6+144> 处加载了一个名为 <node1> 的地址到%rdx,根据先前的经验,这个地址里存的东西很可能就与答案相关,使用gdb把它打印出来:

(gdb) x 0x555555559200
0x555555559200 <node1>: 0x000003e8
0x555555559204 <node1+4>:       0x00000001
0x555555559208 <node1+8>:       0x55559210
0x55555555920c <node1+12>:      0x00005555
0x555555559210 <node2>: 0x000003de
0x555555559214 <node2+4>:       0x00000002
0x555555559218 <node2+8>:       0x55559220
0x55555555921c <node2+12>:      0x00005555
0x555555559220 <node3>: 0x000000bf
0x555555559224 <node3+4>:       0x00000003
0x555555559228 <node3+8>:       0x55559230
0x55555555922c <node3+12>:      0x00005555
0x555555559230 <node4>: 0x000000b7
0x555555559234 <node4+4>:       0x00000004
0x555555559238 <node4+8>:       0x55559240
0x55555555923c <node4+12>:      0x00005555
0x555555559240 <node5>: 0x0000032b
0x555555559244 <node5+4>:       0x00000005
0x555555559248 <node5+8>:       0x55559110
0x55555555924c <node5+12>:      0x00005555
0x555555559250: 0x00000000
0x555555559254: 0x00000000

gdb在这里输出了五个名为node1到node5的东西,我们来分析一下加载的这个数据是什么玩意。x86-64下一个指针8字节,我们把 <node1+12> 和 <node1+8> 的两个数字拼在一起,得到0x555555559210,这恰好就是 0x555555559210 <node2> 的地址,因此我们可以看出%rdx实际上就是指向一个链表头节点的指针。

这里我们发现,gdb并没有打印出node6,这是因为前五个node碰巧被顺序存放在了一起,我们可以从node5得到node6的地址为:0x555555559110,使用gdb查看:

(gdb) x 0x555555559110
0x555555559110 <node6>: 0x00000171
0x555555559114 <node6+4>:       0x00000006
0x555555559118 <node6+8>:       0x00000000
0x55555555911c <node6+12>:      0x00000000
0x555555559120 <n1>:    0x00000024

node6的指针为0,也就是NULL,说明这是链表的最后一个表元了,我们可以分析出这个链表的结构体大致如下:

typedef struct node
{
    int var;           //<node+0>
    int num;           //<node+4>
    struct node* next; //<node+12> and <node+8>
};

搞懂了这个数据结构之后,继续往下分析:

# ------ sector 1
0x5555555558be <phase_6+131>    mov    $0x0,%esi
0x5555555558c3 <phase_6+136>    mov    (%rsp,%rsi,4),%ecx
0x5555555558c6 <phase_6+139>    mov    $0x1,%eax
0x5555555558cb <phase_6+144>    lea    0x392e(%rip),%rdx  # 0x555555559200 <node1>
0x5555555558d2 <phase_6+151>    cmp    $0x1,%ecx
0x5555555558d5 <phase_6+154>    jle    0x5555555558e2 <phase_6+167>
# ------ sector 2
0x5555555558d7 <phase_6+156>    mov    0x8(%rdx),%rdx
0x5555555558db <phase_6+160>    add    $0x1,%eax
0x5555555558de <phase_6+163>    cmp    %ecx,%eax
0x5555555558e0 <phase_6+165>    jne    0x5555555558d7 <phase_6+156>
# ------ sector 3
0x5555555558e2 <phase_6+167>    mov    %rdx,0x20(%rsp,%rsi,8)
0x5555555558e7 <phase_6+172>    add    $0x1,%rsi
0x5555555558eb <phase_6+176>    cmp    $0x6,%rsi
0x5555555558ef <phase_6+180>    jne    0x5555555558c3 <phase_6+136>

<phase_6+156> 到 <phase_6+165> 是一个循环, <phase_6+136> 到 <phase_6+180> 也是一个循环,因此这又是一个双层循环(麻,怎么这么多循环),我们做适当分割,把这段代码同样分为三个sector之后尝试分析这个双层循环的作用:

  • 对外层循环(sector 1、3)来说,%rsi是循环计数器(从0到6),并且每次外层循环都用%rsi作为索引读取%rsp指向的数组中的值arr[%rsi]到%rcx;同时给内层循环的计数器%eax赋值为1,并且判断读取到的%rcx是否为1,如果为1就不进入内层循环。
  • 接下来分析内层循环(sector 2),这个循环的循环计数器为%eax(从1到%rcx),而执行的语句实际上就只有mov 0x8(%rdx),%rdx一句,%rdx内存储的是指向链表表元的指针,而0x8(%rdx)就是这个表元的next指针了,这正是我们常用的p = p->next语句!
  • 接下来关注sector 3部分的<phase_6+167> mov %rdx,0x20(%rsp,%rsi,8),它的作用就是把每次内层循环得到的指针保存起来。

经过这样的分析,我们可以简单地将这个双层循环转写为C语言:

for (int i = 0; i < 6; i++)
{
    int num = arr[i];     // mov    (%rsp,%rsi,4),%ecx
    int j = 1;            // mov    $0x1,%eax
    node *rdx = head;     // lea    0x392e(%rip),%rdx  # 0x555555559200 <node1>
    if (num == 1)
    {
        savePointer(rdx); // mov    %rdx,0x20(%rsp,%rsi,8)
        continue;
    }
    for (; j < num; j++)
    {
        rdx = rdx->next;  // mov    0x8(%rdx),%rdx
    }
    savePointer(rdx);     // mov    %rdx,0x20(%rsp,%rsi,8)
}

在C代码中,我用savePointer(rdx)代替了<phase_6+167>处保存指针的操作。

通过C代码不难理解,外层循环遍历被处理过后的输入数组得到x = arr[i],内层循环找到指向node x的指针,然后再把这个指针放置到指针数组ptr[i]的位置。

我们刚才输入的六个数字为1 2 3 4 5 6,经过处理之后变为6 5 4 3 2 1,那么这个双层循环得到的指针数组ptr[]里面应该依次存放着指向node6、node5……node1的指针,我们可以用gdb来查看我们的推论是否正确:

(gdb) x/6xg ($rsp+0x20)
0x7fffffffdbf0: 0x0000555555559110 <node6>      0x0000555555559240 <node5>
0x7fffffffdc00: 0x0000555555559230 <node4>      0x0000555555559220 <node3>
0x7fffffffdc10: 0x0000555555559210 <node2>      0x0000555555559200 <node1>

重新跑一次程序,这次我们输入2 5 3 1 4 6,处理后变为5 2 4 6 3 1,我们同样地使用gdb查看这个双层循环结束后数组指针:

(gdb)  x/6xg ($rsp+0x20)
0x7fffffffdbf0: 0x0000555555559240 <node5>      0x0000555555559210 <node2>
0x7fffffffdc00: 0x0000555555559230 <node4>      0x0000555555559110 <node6>
0x7fffffffdc10: 0x0000555555559220 <node3>      0x0000555555559200 <node1>

尤里卡!我们的猜想是正确的!继续往下分析:

0x5555555558f1 <phase_6+182>    mov    0x20(%rsp),%rbx
0x5555555558f6 <phase_6+187>    mov    0x28(%rsp),%rax
0x5555555558fb <phase_6+192>    mov    %rax,0x8(%rbx)
0x5555555558ff <phase_6+196>    mov    0x30(%rsp),%rdx
0x555555555904 <phase_6+201>    mov    %rdx,0x8(%rax)
0x555555555908 <phase_6+205>    mov    0x38(%rsp),%rax 
0x55555555590d <phase_6+210>    mov    %rax,0x8(%rdx)  
0x555555555911 <phase_6+214>    mov    0x40(%rsp),%rdx
0x555555555916 <phase_6+219>    mov    %rdx,0x8(%rax)
0x55555555591a <phase_6+223>    mov    0x48(%rsp),%rax 
0x55555555591f <phase_6+228>    mov    %rax,0x8(%rdx)
0x555555555923 <phase_6+232>    movq   $0x0,0x8(%rax)

我们已经分析过 mov 0x8(%rdx),%rdx 就等效于p=p->next, 并且mov 0x28(%rsp),%rax 就是在访问指针数组并且进行赋值操作,因此这里实际上就是按照指针数组ptr[]里的顺序改变6个nodes的next指针,让它们重新排序。

接下来看最后一段汇编:

0x55555555592b <phase_6+240>    mov    $0x5,%ebp
0x555555555930 <phase_6+245>    jmp    0x555555555967 <phase_6+300>
......
0x55555555595e <phase_6+291>    mov    0x8(%rbx),%rbx
0x555555555962 <phase_6+295>    sub    $0x1,%ebp
0x555555555965 <phase_6+298>    je     0x555555555978 <phase_6+317>
0x555555555967 <phase_6+300>    mov    0x8(%rbx),%rax 
0x55555555596b <phase_6+304>    mov    (%rax),%eax
0x55555555596d <phase_6+306>    cmp    %eax,(%rbx)
0x55555555596f <phase_6+308>    jge    0x55555555595e <phase_6+291>
0x555555555971 <phase_6+310>    callq  0x555555555bfe <explode_bomb>
0x555555555976 <phase_6+315>    jmp    0x55555555595e <phase_6+291>
0x555555555978 <phase_6+317>    mov    0x58(%rsp),%rax

<phase_6+291> 到 <phase_6+308> 是一个循环,这个循环用双指针法遍历整个链表,并且检查前指针指向的表元的值是否大于后指针指向的表元的值,如果不满足则引爆炸弹。转写成C代码如下:

node *ptr1 = head;
node *ptr2 = head->next;
for (int i = 0; i < 5; i++)
{
    if (ptr1->var < ptr2->var)
        explode_bomb();
    ptr1 = ptr1->next;
    ptr2 = ptr2->next;
}

因此我们需要保证按照顺序重排之后的链表是递减的,回顾刚才用gdb打印出的各个表元的值:

0x555555559200 <node1>: 0x000003e8
0x555555559210 <node2>: 0x000003de
0x555555559220 <node3>: 0x000000bf
0x555555559230 <node4>: 0x000000b7
0x555555559240 <node5>: 0x0000032b
0x555555559110 <node6>: 0x00000171

很容易看出,如果要满足递减,那新的排序方式应该是1 2 5 6 3 4。又因为输入的六个数字都要经过7-arr[i]这一操作,我们可以得出输入应当为6 5 2 1 4 3。这就是Phase 6的拆除密码了。

至此,二进制炸弹的全部6个Phase都拆除成功啦!

Secret Phase

在我们拆除炸弹之后打败Dr.Evil后,在bomb.c却又发现了一行注释:

    /* Wow, they got it!  But isn't something... missing?  Perhaps
     * something they overlooked?  Mua ha ha ha ha! */

看来这个炸弹还有一些隐藏起来的东西需要我们发掘,翻翻objdump出的.s文件中可以发现还有一个secret_phase存在:

00000000000019dd <secret_phase>:
    19dd:	f3 0f 1e fa          	endbr64 
    19e1:	53                   	push   %rbx
    ......

看来Dr.Evil还给自己的炸弹加上了隐藏的雷管,为了把它彻底拆除,我们得先找到如何触发这个Secret Phase的方法,直接Ctrl+F在汇编文件中搜索 secret_phase:

我们发现,<secret_phase>这个函数的入口原来就隐藏在<phase_defused>函数里,因此我们得先分析<phase_defused>来找出进入隐藏关卡的方法。

打开gdb,给主函数内六个phase_defused()函数处都打上断点进行分析。

首先我们先看这个函数的第一段:

0x555555555db7 <phase_defused>          endbr64
0x555555555dbb <phase_defused+4>        sub    $0x78,%rsp
0x555555555dbf <phase_defused+8>        mov    %fs:0x28,%rax
0x555555555dc8 <phase_defused+17>       mov    %rax,0x68(%rsp)
0x555555555dcd <phase_defused+22>       xor    %eax,%eax
0x555555555dcf <phase_defused+24>       cmpl   $0x6,0x38ba(%rip)        # 0x555555559690 <num_input_strings>
0x555555555dd6 <phase_defused+31>       je     0x555555555ded <phase_defused+54>
0x555555555dd8 <phase_defused+33>       mov    0x68(%rsp),%rax 
0x555555555ddd <phase_defused+38>       xor    %fs:0x28,%rax 
0x555555555de6 <phase_defused+47>       jne    0x555555555e5b <phase_defused+164>
0x555555555de8 <phase_defused+49>       add    $0x78,%rsp 
0x555555555dec <phase_defused+53>       retq
0x555555555ded <phase_defused+54>       lea    0xc(%rsp),%rcx
......

在 <phase_defused+24> 处,程序将 (%rip+0x38ba) 处的数据与6进行比对,如果相等那我们才能跳转到<phase_defused+54> 处,否则就会直接顺序运行然后ret退出函数,为了搞清楚 (%rip+0x38ba) 到底是什么,在每次执行phase_defused()函数时都使用gdb打印出此地址的内容发现内容依次为0x1、0x2、0x3、0x4、0x5和0x6,也就是说这里其实储存的是我们已经解决的问题数,只有我们解决了全部六个问题之后才能够进入隐藏关卡。

一路(gdb) n到Phase 6解除后的 phase_defused() 函数,此时有:

(gdb) x 0x555555559690
0x555555559690 <num_input_strings>:     0x00000006

这时我们就能够跳转到 <phase_defused+54> ,尝试进入隐藏关,接下来的代码如下:

0x555555555ded <phase_defused+54>       lea    0xc(%rsp),%rcx 
0x555555555df2 <phase_defused+59>       lea    0x8(%rsp),%rdx 
0x555555555df7 <phase_defused+64>       lea    0x10(%rsp),%r8
0x555555555dfc <phase_defused+69>       lea    0x1576(%rip),%rsi        # 0x555555557379
0x555555555e03 <phase_defused+76>       lea    0x3986(%rip),%rdi        # 0x555555559790 <input_strings+240>
0x555555555e0a <phase_defused+83>       callq  0x5555555552c0 <__isoc99_sscanf@plt>
0x555555555e0f <phase_defused+88>       cmp    $0x3,%eax

在+69和+76处,程序载入了两个地址,根据先前6个Phase的经验,遇到这种地址看看里面是什么总是没错的:

(gdb) x 0x555555557379
0x555555557379: "%d %d %s"
(gdb) x 0x555555559790
0x555555559790 <input_strings+240>:     "108 2"

我们发现,这里的输入为%d %d %s,而两个数字为108 2,正好是我们先前做出来第四题的答案,因此我们可以推测,进入隐藏关卡的方式可能是在Phase 4 Key的后面再加上一段字符串。

我们把solution.txt里面第四行“108 2”后面再加上字符串“abc”,重新运行发现:

(gdb) x/s 0x555555559790
0x555555559790 <input_strings+240>:     "108 2 abc"

炸弹并没有爆炸并且读入了我们输入的后一个字符串“abc”,继续单步调试:

0x555555555e0f <phase_defused+88>       cmp    $0x3,%eax  
0x555555555e12 <phase_defused+91>       je     0x555555555e22 <phase_defused+107>  
0x555555555e14 <phase_defused+93>       lea    0x149d(%rip),%rdi        # 0x5555555572b8
0x555555555e1b <phase_defused+100>      callq  0x555555555200 <puts@plt>
0x555555555e20 <phase_defused+105>      jmp    0x555555555dd8 <phase_defused+33> 
0x555555555e22 <phase_defused+107>      lea    0x10(%rsp),%rdi
0x555555555e27 <phase_defused+112>      lea    0x1554(%rip),%rsi        # 0x555555557382
0x555555555e2e <phase_defused+119>      callq  0x555555555aea <strings_not_equal>

在 <phase_defused+119> 处,我们又看到了 <strings_not_equal> 这个函数,我们在Phase 1中已经知道这个函数是用来判断字符串是否相等的,因此我们可以把前一条指令加载的地址处的内容打印出来:

(gdb) x 0x5555555572b8
0x5555555572b8: "Congratulations! You've defused the bomb!"
(gdb) x 0x555555557382
0x555555557382: "DrEvil"

看来进入隐藏关卡的Key就是”DrEvil“了,我们把solution.txt里面第四行加上的”abc“更改为”DrEvil“试一下,果然成功了:

抵达二进制炸弹最高关,Secret Phase!

接下来我们用(gdb) b* 0x5555555559dd给<secret_phase>打上地址断点,开始分析这个函数。

0x5555555559dd <secret_phase>           endbr64 
0x5555555559e1 <secret_phase+4>         push   %rbx 
0x5555555559e2 <secret_phase+5>         callq  0x555555555c6f <read_line>
0x5555555559e7 <secret_phase+10>        mov    %rax,%rdi 
0x5555555559ea <secret_phase+13>        mov    $0xa,%edx 
0x5555555559ef <secret_phase+18>        mov    $0x0,%esi
0x5555555559f4 <secret_phase+23>        callq  0x5555555552a0 <strtol@plt>
0x5555555559f9 <secret_phase+28>        mov    %rax,%rbx 
0x5555555559fc <secret_phase+31>        lea    -0x1(%rax),%eax
0x5555555559ff <secret_phase+34>        cmp    $0x3e8,%eax
0x555555555a04 <secret_phase+39>        ja     0x555555555a2c <secret_phase+79>

首先看这一段汇编,在+5处首先调用了 <read_line> 函数来读取一行输入, 然后在+23处调用 <strtol> 函数,我们知道这个函数是用来将字符串转译成long int形式:

因此可以猜测,这段汇编首先从stdin读取一行输入并且把这行输入转化为整型,然后将输入减一,与0x3e8(1000)比较,如果大于1000则直接引爆炸弹——因此我们的输入应该小于1001。我们给solution.txt添加一行输入“123”重新运行程序看看。

(gdb) x $rax
0x7b:   Cannot access memory at address 0x7b
(gdb) si
0x00005555555559ff in secret_phase ()
(gdb) x $rax
0x7a:   Cannot access memory at address 0x7a

果然,我们的输入被读入后存放到了%rax里。

0x555555555a06 <secret_phase+41>        mov    %ebx,%esi
0x555555555a08 <secret_phase+43>        lea    0x3711(%rip),%rdi        # 0x555555559120 <n1> 
0x555555555a0f <secret_phase+50>        callq  0x55555555599c <fun7>
0x555555555a14 <secret_phase+55>        cmp    $0x1,%eax
0x555555555a17 <secret_phase+58>        jne    0x555555555a33 <secret_phase+86>

这段汇编首先加载了一个地址<n1>,随后调用了函数 <fun7> ,然后比对函数的返回值是否等于1,如果不相等的话就引爆炸弹,我们首先试着搞清楚<n1>是什么东西:

(gdb) x 0x555555559120
0x555555559120 <n1>:    0x00000024
0x555555559124 <n1+4>:  0x00000000
0x555555559128 <n1+8>:  0x55559140
0x55555555912c <n1+12>: 0x00005555
0x555555559130 <n1+16>: 0x55559160
0x555555559134 <n1+20>: 0x00005555
0x555555559138: 0x00000000

有了之前在Phase 6中分析链表的经验,我们可以一眼看出 <n1+12><n1+8> 和 <n1+20><n1+16> 是两个指针,我们分别访问这两个指针试试:

(gdb) x/3g 0x555555559140
0x555555559140 <n21>:   0x0000000000000008      0x00005555555591c0
0x555555559150 <n21+16>:        0x0000555555559180
(gdb) x/3g 0x555555559160
0x555555559160 <n22>:   0x0000000000000032      0x00005555555591a0
0x555555559170 <n22+16>:        0x00005555555591e0

我们又访问到了两个结构体,现在我们可以写出这个结构体的C语言形式如下:

typedef struct n
{
    long long var;          //<n+0>
    struct n *left;         //<n+8>
    struct n *right;        //<n+16>
};

这显然是我们很熟悉的二叉树:)。我们把函数<fun7>分成两个部分,用gdb的regs视图进行分析:

# Sector 1
0x55555555599c <fun7>                   endbr64
0x5555555559a0 <fun7+4>                 test   %rdi,%rdi
0x5555555559a3 <fun7+7>                 je     0x5555555559d7 <fun7+59>
0x5555555559a5 <fun7+9>                 sub    $0x8,%rsp
0x5555555559a9 <fun7+13>                mov    (%rdi),%edx
0x5555555559ab <fun7+15>                cmp    %esi,%edx
0x5555555559ad <fun7+17>                jg     0x5555555559bb <fun7+31>
0x5555555559af <fun7+19>                mov    $0x0,%eax
0x5555555559b4 <fun7+24>                jne    0x5555555559c8 <fun7+44>
0x5555555559b6 <fun7+26>                add    $0x8,%rsp
0x5555555559ba <fun7+30>                retq
# Sector 2
0x5555555559bb <fun7+31>                mov    0x8(%rdi),%rdi
0x5555555559bf <fun7+35>                callq  0x55555555599c <fun7>
0x5555555559c4 <fun7+40>                add    %eax,%eax
0x5555555559c6 <fun7+42>                jmp    0x5555555559b6 <fun7+26>
0x5555555559c8 <fun7+44>                mov    0x10(%rdi),%rdi
0x5555555559cc <fun7+48>                callq  0x55555555599c <fun7> 
0x5555555559d1 <fun7+53>                lea    0x1(%rax,%rax,1),%eax 
0x5555555559d5 <fun7+57>                jmp    0x5555555559b6 <fun7+26>
0x5555555559d7 <fun7+59>                mov    $0xffffffff,%eax 
0x5555555559dc <fun7+64>                retq
  • %rsi中储存的是输入的数字num,%rdi是指向二叉树根节点的指针,对%rsp的操作主要是递归中的调用堆栈相关。
  • 在第一段代码中,从%rdi读出二叉树节点中的数值var到%rdx,随后比较我们输入的数字%rsi和%rdx。如果var>num,则跳转到<fun7+31>,如果var<=num则跳转到<fun7+44>。
  • 在第二段代码中,<fun7+31>首先进行mov 0x8(%rdi),%rdi,等同于ptr=ptr->left,即访问二叉树的左节点,随后将更新后的%rdi作为参数再次调用fun7,并且在+40处给调用的返回值乘2;而<fun7+44>首先进行mov 0x10(%rdi),%rdi,等同于ptr=ptr->right,即访问二叉树的右节点,随后递归调用fun7并且在+53处给调用的返回值乘2加1。

经过这样的分析,我们可以转写出C代码如下:

int fun7(node *ptr, int num)
{
    if (ptr->var == num)
        return 0;
    if (ptr->var > num)
        return fun7(ptr->left, num);
    else
        return fun7(ptr->right, num);
}

分析出递归函数后,我们只需要查明二叉树的具体构造就行了:

这里我直接用笨办法手动遍历了二叉树,还好这个二叉树只有四层,遍历出来也不用很多时间,结果如下:

为了解除炸弹,我们需要让fun7的返回值为1,因此第一层递归必然需要访问右子树(如果访问左子树,fun7的返回值将是偶数),同时第二层递归需要返回0,因此,第二层递归需要访问的是左子树,即图中值为45的节点。递归过程已经在图中标了出来。

这里也可以看出来,刚才我们遍历画出整个二叉树是完全没必要的,因为根据分析我们就可以知道递归访问的路径。

这样我们就得到了Secret Phase的解除密码为45。现在,我们的solution.txt内容如下:

I turned the moon into something I call a Death Star.
1 2 4 8 16 32
1 997
108 2 DrEvil
5 115
6 5 2 1 4 3
45

运行试一下:

Defused!!

至此,二进制炸弹就被我们完全拆除了。

感想

这个拆弹PJ确实是一个非常有意思的PJ,从一开始难度较低的Phase再到后面困难Phase的渐入佳境,这个PJ提供了很多“啊哈时刻”,抽丝剥茧最后找到解决问题关键的过程非常有趣。

做完这个PJ感觉看汇编的水平大大提高……

开发gdb的那帮人是100%的天才。

Wisp于2022年5月9日