深入理解计算机系统(CS:APP) - Bomb Lab详解
Bomb Lab
实验代码见GitHub
简介
BombLab是CS:APP中对应第三章内容:程序的机器级表示的lab。主要内容为提供一个二进制对象文件bomb
,当运行时,它会要求用户输入六个字符串,如果其中的任何一个不正确,炸弹就会爆炸,输出一行错误信息并向计分服务器提交(自学所用的材料不会向服务器提交信息,但这不代表我们可以随意让炸弹爆炸),学生必须通过反汇编和逆向工程来找到六个正确的字符串来解除自己的炸弹(理论上每个人的炸弹答案都不同,但自学材料的答案都是一样的,本文针对的是自学材料)。
所用工具
objdump
-用于反汇编二进制对象文件
VS Code
-用于查看反汇编后的结果与文本文件的编写
gdb
-用于运行时单步调试与查看运行时内存与寄存器信息
解题过程
前期
由于之前没有接触过类似的逆向工程问题,拿到问题以后第一时间很难马上开始解决。所以先查看我们能看到的文件信息。
目录中提供了一个bomb.c
文件,文件内容十分简单,有一份非常有趣的LISENCE
:
/***
* Dr. Evil’s Insidious Bomb, Version 1.1
* Copyright 2011, Dr. Evil Incorporated. All rights reserved.
*
* LICENSE:
*
* Dr. Evil Incorporated (the PERPETRATOR) hereby grants you (the
* VICTIM) explicit permission to use this bomb (the BOMB). This is a
* time limited license, which expires on the death of the VICTIM.
* The PERPETRATOR takes no responsibility for damage, frustration,
* insanity, bug-eyes, carpal-tunnel syndrome, loss of sleep, or other
* harm to the VICTIM. Unless the PERPETRATOR wants to take credit,
* that is. The VICTIM may not distribute this bomb source code to
* any enemies of the PERPETRATOR. No VICTIM may debug,
* reverse-engineer, run “strings” on, decompile, decrypt, or use any
* other technique to gain knowledge of and defuse the BOMB. BOMB
* proof clothing may not be worn when handling this program. The
* PERPETRATOR will not apologize for the PERPETRATOR’s poor sense of
* humor. This license is null and void where the BOMB is prohibited
* by law.
***/
接下来的部分就是main
函数,从主函数中我们可以看到整个程序的结构与输入方式:可以从标准输入或文件中读取,一行作为一题的解,解出一个问题以后可以进入下一个问题,注意到返回前的一段注释:
/* Wow, they got it! But isn’t something… missing? Perhaps
* something they overlooked? Mua ha ha ha ha! */
暗示了我们隐藏问题的存在,除此之外再也没有任何关于这个炸弹的信息。
下面我们使用objdump
命令将炸弹文件反汇编出来:
1 | objdump -d bomb > bomb.asm |
然后通过VS Code
来查看反汇编的结果,VS Code
有x86 and x86_64 Assembly这个插件可以高亮汇编,看起来会舒服许多。
反汇编出来的代码有近六千行,但是因为有符号表的存在,说明保留了调试所需的信息,我们可以通过gdb
进行单步调试来查看程序的运行过程。
在使用gdb
的时候,我们可以加上-tui
命令并用layout asm
命令切换到汇编指令模式,就可以在调试的时候查看对应的汇编代码了。界面如下:
可以看到地址0x400da0
就是main
函数的地址。
一直向下查看,我们就可以看到C文件中出现的initialize_bomb
函数,然后就到了phase_1
函数,我们可以推测这个函数就是判断是否通过的核心函数。
这时候就要用到gdb
的指令了,在汇编模式下的指令与普通模式有一些不同。我们可以使用ni
(next instruction
)和si
(step into
)来实现普通模式下的单步向下执行与步入操作。
打断点需要使用b <func_name>
或b *<address>
来进行比如我们可以看到调用phase_1
函数的call
指令的地址是0x400e3a
,所以我们可以使用b phase_1
或b *0x400e3a
来打断点的,这两条命令有一点不同就在于断在地址会停在地址 上也就是call
指令的位置,断在函数名会进入函数中,相当于再进行了一次si
操作。
断点停后有可能出现字符重叠的情况,我们可以使用refresh
命令刷新界面。
下面把断点打在phase_1
函数之后就可以使用r
命令来运行指令了,程序会提示我们输入字符串,这个时候因为我们打了断点不用担心炸弹会爆炸,可以随意输入。执行后程序会停在phase_1
函数的位置,我们可以看到函数内部的情况。
下面就可以根据函数内部的逻辑来解决炸弹了。
代码来自objdump -d
反汇编出来的代码,与gdb
的汇编模式下看到的代码是一样的。
主函数
主函数代码比较长,只贴我们需要分析的关键部分。
1 | 400e32: e8 67 06 00 00 callq 40149e <read_line> |
第一句调用了read_line
函数,我们可以转到函数入口地址40149e
去查看read_line
的代码(事实上一开始我也这么做了),但是会发现代码中包含了许多对系统库函数的调用,仔细分析的难度比较大并且没有必要。从提供的C代码与函数名称,我们可以推测出这个函数的作用是读取一行输入。根据返回值一般存放在rax
中的约定,rax
中应该就是读入的数据的地址,第二句中我们把这个值复制到了rdi
中。
1 | 400e3a: e8 a1 00 00 00 callq 400ee0 <phase_1> |
接下来两句分别开始调用phase_1
与phase_defused
,下面的五个阶段也是上面这样的模式。
阶段一
1 | 0000000000400ee0 <phase_1>: |
阶段一的代码比较短,第二行中把一个地址给了esi
,接下来调用了strings_not_equal
这个函数,我们可以跳到函数入口地址查看这个函数。
1 | 40133c: 48 89 fb mov %rdi,%rbx |
函数中这两行分别把rdi
rsi
的值复制到了rbx
与rbp
,然后调用了string_length
,这个时候就不用去看string_length
函数了,我们可以直接猜测出rbx
与rbp
就是函数的参数。那么可以说明rdi
rsi
就是给string_not_equal
的函数,那么string_not_equal
的返回值是什么呢?
看到string_not_equal
返回后的5、6两句,测试了eax
的值,在eax
等于0时就跳转到400ef7
,如果不为0,那么会继续向下执行,下面一句是调用explode_bomb
函数,不用说这一定是触发炸弹的函数,所以我们需要令string_not_equal
的返回值为0,那么从名字判断,我们需要令两个字符串相等,两个字符串之前说过存放在rdi
与rsi
中,rdi
是我们读入的字符串,而rsi
中存放的是400ee4
复制的0x402400
,这个时候用gdb
去查看该地址中存放的字符串比较方便:
这串字符就是第一阶段的答案。
阶段二
1 | 400efc: 55 push %rbp |
进入phase_2
函数,观察它的代码,可以发现第5行调用了一个名为read_six_numbers
这个函数,顾名思义,这个函数的作用应该是从输入中读取6个数字,那么问题来了,这6个数字是怎么返回的呢?我们注意到第4行中把rsp
的值复制给了rsi
,我们可以猜测这个函数是使用栈来返回读入的结果。
当然只是猜测是不行的,我们需要用实验去验证我们的想法,我们在输入文件中设置1 2 3 4 5 6
这一行输入,然后将断点打在*400f0a
这个函数刚返回的位置(注意输入中应该含有第一阶段的答案,不然炸弹就炸在第一阶段了)。运行停在断点之后查看栈中的内容:
我们打出了rsp
开始32字节的内容,发现栈中依次存放了输入的6个数,之后就是返回的地址。那么我们可以确定读取的数值就是依次存放在栈中的。
接下来看第6、7、8行,它将rsp
中存放的值与1进行比较,如果相等则跳过第8行的引爆代码,说明我们需要输入的第一个数为1 。再看跳转到的位置(19、20行)将rsp+0x4
与rsp+0x18
的值分别存放到了rbx
与rbp
。下一行又进行了一次跳转,来到了第10行,第10行将rbx
的地址减4中存放的内容复制到了eax
中,rbx
的地址减4也就意味着与rsp
相等,它的值也就是第一个读入的值。下一行将eax
的值乘二,接下来将乘二后的值与rbx
也就是第二个值进行比较,如果相同则跳过引爆代码。上面这一系列操作总结起来就是如果第二个值是第一个值的两倍则不引爆。
再往下就是把rbx
的值加上4,因为一个int
占4个字节,也就是把rbx
指向了下一个读入的值。下一步将rbx
与rbp
的值进行比较,回想rbp
的值为的rsp+0x18
也就是 rsp+24
,指向6个int
值之后的位置,所以与它进行比较就是判断是否到达临界条件。如果没有到达临界条件,则跳到上一段中比较的部分继承进行。看到这里,我们已经可以判断出phase_2
的要求是读入的6个数第一个数必为1,而后面的数字都是前面一个数字的两倍。
所以阶段2的答案为1 2 4 8 16 32
.
阶段三
阶段三的代码比较长,我们分开来看:
1 | 0000000000400f43 <phase_3>: |
第3、4两行将rsp+0xc
与rsp+0x8
的值分别给rcx
与rdx
,下一行将一个地址值复制给了esi
,接着将eax
置为0,下一步调用了库函数sscanf
,我们想到sscanf
中的参数中需要一个格式化字符串,那么esi
中的这个地址值就很有可能存放了这个字符串,我们同样使用gdb
在运行时查看这个字符串:
可以看到这就是格式化字符串,读入的是两个整型值。这两个值存放在哪里呢?我们想到之前把rsp+0xc
与rsp+0x8
的值分别给rcx
与rdx
,这是两个地址值,我们可以用之前的方法验证栈中存放的确实是我们读入的这两个值。
下面第8行将eax
与1进行比较,eax
一般用于存放函数返回值,而sscanf
的返回值是成功读入的数值个数,也就是说这几行将成功读入的个数与1进行比较,如果大于1则跳过引爆的代码。
下面第11行将rsp+0x8
中存放的值与0x7
进行比较,如果大于0x7
则跳到400fad
的位置,我们看这个地址的指令:
1 | 400fad: e8 88 04 00 00 callq 40143a <explode_bomb> |
引爆炸弹。
下面的两行比较关键:第13行将rsp+0x8
中存放的值复制入eax
,第14行进行一个跳转,跳转到的地址为0x402470(,%rax,8)
,这就是一个典型的switch
语句的实现:直接跳转到索引*位移的指令位置。
1 | x = 0 |
上面的代码已经加了注释,假设读入的第一个数为x,看到所有分支最后都跳转到了400fbe
这行判断中,将eax
中的值与rsp+0xc
也就是我们读入的第二个数进行判断,如果相等的话跳过引爆代码。
而每个分支都将一个数复制到了eax
中,也就是说我们只要根据不同的第一个参数的值读入对应的第二个参数就可以了,所以我们可以随意选择一个x值,这里我选择x=1
,对应的第二个参数为0x137
换成十进制是311,所以第3阶段的(一个)答案为:
1 311
阶段四
1 | 000000000040100c <phase_4>: |
前面的代码比较熟悉,同样是调用了sscanf
函数,我们查看格式字符串:
也是读入两个参数存放在rcx
与rdx
中。
同样对读入参数的个数进行了判断,要求成功读入参数的个数等于两个,第11、12行要求输入的第一个参数小于0xe
。
接下来把0xe赋给edx
、0x0赋给esi
,rsp+0x8
的值赋给edi
。接下来调用了func4
函数。
在去查看func4
函数的代码之前,我们先查看函数返回后的代码,了解我们需要的结果。第17、18行测试了eax
的值如果不为0,就跳转到引爆代码。
所以我们的目标是返回时eax
的值为0.下面进入func4
函数。
1 | 0000000000400fce <func4>: |
这段代码之中我们调用了func4
,这是一个递归的过程,像之间那样直接分析比较困难,这里我们就将这个代码逆向为C语言再来分析,下面是逆向出的C语言代码:
1 | int fun(int a1, int a2, int x){ |
这里的a1``a2
初始值分别为之前的0xe
与0x0
。我们可以直接写个测试程序来跑出能返回0的输入值:
1 | int main(void){ |
得出允许的值有0 1 3 7
.
回到phase_4
的代码:
1 | 401051: 83 7c 24 0c 00 cmpl $0x0,0xc(%rsp) |
第1、2行将输入的第二个参数与0进行比较,如果不为0就引爆炸弹。所以输入的第二个参数必为0。
综上我们得出(一个)答案为:
0 0
阶段五
后面的阶段难度开始加大,我们分部分进行分析:
1 | 0000000000401062 <phase_5>: |
第4行把输入的地址rdi
给rbx
,第5、7行则是在栈中压入了一个哨兵变量。
1 | 401078: 31 c0 xor %eax,%eax |
第1行清空了eax
,第2行中调用了string_length
,我们想到之前的把输入放入rbx
这个动作,可以推测这个函数是为了统计输入字符的个数,并存放在了eax
中。
下面将eax
的值与0x6
进行比较,等于则进行跳转避免引爆炸弹。我们进入跳转到的位置:
1 | 4010d2: b8 00 00 00 00 mov $0x0,%eax |
把eax
置为0后进行跳转。
继续进入跳转到的位置:
1 | 40108b: 0f b6 0c 03 movzbl (%rbx,%rax,1),%ecx |
第1行中movzbl
命令将从rbx
(输入)开始的rax
位置的一个字节赋给ecx
的低16位。
接下来的两行先把cl
中的值(上一步得到)复制到rsp
处,再将rsp
中的值复制到rdx
中,第4行使用掩码0xf
取edx
的低4位。到这里我们总结一下上面的操作:取读入的字符串中rax
位置处的字符,再取它的低4位放在edx
中。
下面第5行中,将地址0x4024b0+rdx
中的一个字节放入edx
的低16位中。第6行将这16位复制到了rsp+0x10+rax
的位置中。
接下来把rax
加1,我们从前面可以看出来这个rax
起的是一个索引的作用。第 8行与6进行比较,如果不等于6则跳到第1行重复这个过程。
在这段之中,循环一共进行了6次,分别读取了输入的6个字符,记录这个6个字符的低6位作为索引rdx
,从0x4024b0+rdx
的位置复制一个字节到rsp+0x10
开始的6字节中。结束之后,rsp+0x10
开始存放了6个字符。
1 | 4010ae: c6 44 24 16 00 movb $0x0,0x16(%rsp) |
接下来一行在rsp+0x16
的位置也就是6个字符之后置上一个0x0
也就是终止符\0
。
1 | 4010b3: be 5e 24 40 00 mov $0x40245e,%esi |
接下来将0x40245e
这个地址赋给esi
,把rsp+0x10
这个地址赋给rdi
,接下来调用strings_not_equal
这个函数,之前的经验告诉我们esi
与rdi
就是要比较的两个字符串的首地址。如果两个字符串不相同就引爆炸弹。
我们先看0x40245e
位置的字符串:
这就是我们应该构造并存放在rsp+0x10
处的字符串。
接下来再查看我们复制到rsp
中的字符来源也就是0x4024b0
开始的字符:
可以看到我们需要的字符flyers
的索引分别为9 15 14 5 6 7
。这个索引就是我们输入的字符的低4位,那我们只要找到低4位分别是以上数值的字符就可以了。
所以阶段5的(一个)答案为:
ionefg
阶段六
阶段六可以说是最复杂的一个阶段,同样一步步分析:
1 | 00000000004010f4 <phase_6>: |
读入6个数字,存放位置还是栈中。
1 | 40110b: 49 89 e6 mov %rsp,%r14 |
前面是一系列的赋值操作,第5行将eax
减1,eax
中的值是rsp
位置存放的值。第6、7两行将减一以后的值与5进行比较,小于等于5则跳过引爆代码。也就是说rsp
中存放的第一个数必须小于等于6.
之前将r12d
置为0,第9行中将r12d
的值增加1,下一行与6进行比较,如果相等则跳入下一个阶段。
第12行中把r12d
中的值复制给了ebx
,下一步又赋给了rax
,接下来的一行mov
将rsp+rax*4
中的值(也就是第rax+1个读入的int
值)给了eax
。
下一步将eax
中的值与rbp
地址指向的值进行比较,如果不相同则跳过引爆代码。说明这两个值需要不同,再接下来将ebx
中的值加1,再与5进行比较,如果小于等于5则跳到第13行中,更新rax
的值,再去从栈中取下一个新的int
值和rbp
中的进行比较。到这里我们可以看出,从13行到20行相当于一个内循环,从r12d
开始,到5结束,不断地取栈中的值与rbp
的值比较,也就是要求rbp
之后的值需要与rbp
不同。
第21、22行则是外循环,它更新了r13
的值,令r13
指向下一个int
值。跳到第3行用r13
的值更新rbp
的值,也就是把比较的对象向后移一个。同样要求该值小于等于5。后面再进行内循环比较之后的值。
这里我们就可以明白这段代码的作用:限制读入的6个数必须小于等于6并且互不相等。
1 | 401153: 48 8d 74 24 18 lea 0x18(%rsp),%rsi |
第1行中将rsp+0x18
的值赋给rsi
。
第2行将r14
的值赋给rax
,r14
的值是之前保存的rsp
。
第3行将0x7赋给ecx
,第4行又将ecx
复制给edx
。
下一步将edx
减去rax
存放的地址指向的值,接下来又将edx
的值赋回rax
存放的地址指向的值。
第7行将rax
的值加4,也就是指向了下一个int
值,接着与之前设定的rsi
进行的比较,如果不相等则重复这个过程。rsi
实际上指向的是6个int
值之后的位置,作为一个标记使用。
这段代码总结起来就是将栈中的6个值(假设为x)变为7-x。
1 | 40116f: be 00 00 00 00 mov $0x0,%esi |
进入下一段代码,一开始先将esi
归零,然后跳到第14行处执行。
第14行中从rsp+rsi
的位置(也就是栈中我们读入的位置)取出一个数赋给ecx
,接下来对取出的这个值进行判断,如果它小于等于1则跳到第9行处。
我们在这里假设这个数确实小于等于1。到第9行,将一个地址值赋给了edx
,接下来将edx
的值赋给了rsp+2*rsi+0x20
的地址指向的值,这里我们可以知道rsi
起到的是索引的作用,下面一行将rsi
增加4,说明从rsp+0x20
开始存放8个字节的数据。再将rsi
的值与0x18
作比较,说明整个过程要进行6次。接下来又到了第14行将下一个int
值给rcx
。
那么如果rcx
的值不小于等于1,继续往下走,第18行将0x1
赋给eax
,19行将0x6032d0
这个地址赋给edx
,接下来跳转到了第3行。第3-6行的代码是一起的,也是理解这个过程的关键。
首先第3行的命令,把edx+0x8
地址指向的值赋给了edx
,这步操作一开始比较难以理解,我们需要先看看edx
的初始状态是什么样的,使用gdb
在运行时查看内存:
我们可以从这个信息中看出,其实它就是一个链表的结构,首先名字就是node
给了提示,再者每一个node
中偏移8个字节中储存的都是下一个节点的地址,那么前面8个字节自然就是节点储存的数据。
我们再回过头来看第3行的代码,就不难理解这个操作就是我们常用的p = p -> next
,也就是指向下一个节点。
第4行把eax
增1,再将eax
与ecx
进行比较,如果不等就再跳到第3步指向链表下一个节点,那么可以看出这4行代码的作用就是从edx
这个初始位置开始向后移动ecx-1
次,第7行跳过了第9行,把edx
赋给了rsp+0x20
开始的第rsi
个8字节的位置。如果rsi
达到0x18
则跳出这部分代码。
我们整理一下这个过程,其实就是依次从栈中读取存放的6个数放入rcx
,再根据rcx
的值找到链表中对应的节点,把节点的地址放入rsp+0x20
开始的对应位置中。
1 | 4011ab: 48 8b 5c 24 20 mov 0x20(%rsp),%rbx |
这段代码前三行分别将rsp+0x20
地址指向值、rsp+0x28
的值、rsp+0x50
的值赋给了rbx
、rax
、rsi
。第4行将rbx
复制到rcx
中,第5行将rax
(rsp+0x20
)中存放的地址复制入rdx
,第6行将这个数据赋给了rcx
(也就是rbx
、*(rsp+0x20)
)节点的指针域。下一步将rax
增加8,指向栈中的下一个位置。再与rsi
这个临界地址进行比较,如果rax
超出末端则跳出这段代码到第12行的位置。
下面把rdx
中存放的地址值赋给rcx
,跳转到第5行重复过程。
仔细分析,这个过程其实就是按照链表节点在栈中的位置重新将链表连接起来。
最后跳出的第12行则是把新的表尾的指针域赋为NULL
。
1 | 4011d9: 00 |
第2行将ebp
赋上0x5
,第三行中rbx
的值是之前的rsp+0x20
,那么rbx+0x8
这个地址中存放的值就是下一个节点的地址,赋给了rax
。
第4行将rax
代表的节点的数据取出放入eax
,再与rbx
代表的节点的数据的值的低4位进行比较,如果前一个节点的数据的低4字节大于等于后一个节点的,则跳过引爆代码。
第8行又是熟悉的操作:使rbx
指向下一个节点。
第9、10行减小ebp
这个循环变量再进行判断,保证循环进行5次。
也就是说,我们需要使新的链表中前一个节点存放的数据值的低4字节都大于后一个节点的。
弄清楚了过程,下面就可以开始反推答案了:
先找到正确的链表节点排列,根据图:
数据由大到小的排列依次是3 4 5 6 1 2
。
由于有一步x = 7 - x,所以倒推回来的答案应该是:
4 3 2 1 6 5
秘密阶段
在之前C代码的暗示以及我们查看汇编代码的过程中都可以猜测出有一个秘密阶段的存在,secret_phase
的代码就在phase_6
后的func7
之后。第一个问题是我们如何进入secret_phase
。
这里可以用一个简单的方法,直接在反汇编代码中搜索secret_phase
的入口地址,很快就可以发现在每个阶段的phase_x
之后都有一行phase_defused
,就在这个函数里面存在callq secret_phase
的代码。
我们就开始分析这个phase_defused
:
1 | 00000000004015c4 <phase_defused>: |
可以看到第7行将函数num_input_strings
的返回值与6进行比较,如果不等于6则的直接跳过中间代码到达最后的结束部分。
从函数名我们可以推测这个函数的作用的是检测读取的字符串的数量,当读取了6个字符串时,就不会跳过中间的代码。我们继续看中间的代码:
第9到14行又是熟悉的sscanf
调用过程,我们已经知道esi
指向的是格式化字符串的首地址,我们先来查看它的内容:
读取两个整数和一个字符串。
有所不同的是在12行之后又有一行给edi
赋上了一个地址值,我们之前所有阶段中edi
的值都是来自于我们read_line
的地址,想到sscanf
参数中确实存在一个输入,我们可以推测这个edi
中存放的是我们读取位置的首地址。
那么我们就可以在运行时查看这个地址的内容,看是从哪里进行读取的:
首先符号表告诉我们这段数据的名字叫做input_strings
也就是我们输入的字符串,那么这个地址上的0 0
代表的应该就是我们的第4行输入。两个整型数字正好与格式化字符串也是匹配的。现在我们知道,应该在这两个0之后再追加一个字符串作为输入。
第15、16行对成功输入的数据个数进行了一个判断,如果不为3个则跳过调用secret_phase
的代码。
第17-19行是对strings_not_equal
的调用,我们已经知道它的两个参数分别是esi
与edi
,esi
被赋上了一个地址值,edi
被赋上了esp+0x10
,我们可以推测出edi
的地址就是指向我们读入的第三个字符串的,那么需要比较的对象是什么呢?我们在运行时查看内存的内容:
这就是我们需要的第三个参数。
可以看到如果第三个参数与上面这个字符串相同的话就会调用两次puts
输出提示信息,然后进入secret_phase
阶段。
1 | 0000000000401242 <secret_phase>: |
可以看到第3行调用了read_line
函数,接着把read_line
的返回值赋给了rdi
,并调用了strtol
函数,这个标准库函数的作用是把一个字符串转换成对应的长整型数值。返回值还是存放在rax
中,第8行将rax
复制给了rbx
,第9行将rax
减1赋给eax
,第十行与0x3e8
进行比较,如果这个值小于等于0x3e8
就跳过引爆代码。看到这里我们可以知道我们需要再加入一行数据,它应该是一个小于等于1001的数值。
接下来将ebx
赋给了esi
,也就是我们一开始输入的rax
值。第14行将一个地址值赋给了edi
,15行调用了fun7
函数。我们还是先往下了解一下我们需要得到的结果。
函数返回后令返回值eax
与0x2
做了一个比较,如果相等则跳过引爆代码。
所以我们需要返回2。
下面查看fun7
的代码:
1 | 0000000000401204 <fun7>: |
第3、4两行先对我们输入的这个数作一个判断,如果等于0直接跳到第19行,返回-1,这显然不是我们想要的结果。
第5行将rdi
的值读入到了edx
中,第6行则将这个数与我们读入的数进行比较,如果这个数小于等于我们读入的数就跳至第12行,第12行将eax
置0,再进行一次相同的比较,如果相等则跳至第20行返回。
如果不等(也就是edx
小于我们读入的数),则继续向下执行第15行,这行代码有些与之前的链表跳至下一个节点类似,到这里,我们就需要查看一下rdi
这个地址里存放的是怎样一种数据结构:
仔细观察可以发现这是一个二叉树的结构,每个节点第1个8字节存放数据,第2个8字节存放左子树地址,第3个8字节存放右子树位置。并且命令也有规律,nab
,a
代表层数,b
代表从左至右第b个节点。
根据这个结构,我们可以把树画出来以便我们进行分析。随意找了个工具表示一下:
下面我们回到代码,现在我们知道第15行代码的作用是将rdi
移到它的右子树的位置,接着调用fun7
,在返回后令eax = 2 * rax + 1
。
如果第6行的比较中树节点的值大于我们读入的数呢?
代码会进行到第8行,令rdi
移到它的左子树的位置,接下来调用fun7
在返回后令eax = 2 * eax
。下面跳至返回处。
总结上面的过程:edi
指向一个树的节点,令edi
节点的值与我们读入的值进行比较。
- 如果两者相等:返回0
- 如果前者大于后者:
rdi
移至左子树,返回2 * rax
- 如果后者大于前者:
rdi
移至右子树,返回2 * rax + 1
那么我们需要返回2,应该在最后一次调用返回0,倒数第二次调用返回2 * rax + 1
,第一次调用返回2 * rax
。换句话说,这个数应该在第三层,比父节点大且比根结节小。观察上图,唯一的答案是:
0x16
(22)
至此,炸弹全部解除:
实验小结
整个实验包括秘密部分用时九个小时,引爆了3次炸弹(一次因为错误的尝试,两次因为将ni
命令错打成n
)。
一开始拿到题目的时候会比较蒙,需要先去学习工具的使用与一些编译的基础知道(符号表、定址表等等)花费了一些时间。前几个阶段过于关注函数的具体实现而没有根据常识去推测一些明显函数的作用花费了一些时间。
前4个阶段都算比较简单,考查了一些常用结构在汇编中的出现形式。第5、6与秘密阶段分别考察了堆、链表、二叉树这三个数据结构在内存中的结构与汇编级的使用,受益良多。
这个实验需要细致的分析与大胆的猜测与实验验证,还需要小心操作,最重要的是耐心,面对非常晦涩的汇编代码如何一步步地弄清代码的作用很需要毅力。当然也可以通过自己写出等价的C代码来帮助自己理解。