CSAPP Lab-2 BOMBLAB
第二个 Lab 就比较有趣了。
这一个 Lab 的任务是,我们得到了一个 bomb
炸弹程序,这个炸弹程序有 \(6\) 个 phase,每个 phase 都会读取我们的输入,判断我们的输入是否符合要求,如果正确这个 phase 的炸弹就会被拆除,否则炸弹就会爆炸。我们需要借助各种工具,对程序进行反汇编等等,获得能够通过程序判断的输入。
准备
首先我们通过阅读下发的 bomb.c
,得知我们的输入是通过一个 read_line
函数输入,字符串地址被拷贝给 input
变量,这个变量作为参数传递给 phase_x
函数进行判断(如果不正确会在这个函数中调用变量让炸弹爆炸),成功后运行 phase_defused
进行拆除,然后输出成功通知。
因此,我们应该找到 phase_x
函数,通过这个函数的内容来寻找正确的答案。
我们通过运行下面的指令,获得反汇编的程序,以及程序的字符串表。因为内存的最低地址是从 0x400000
开始的,因此在查阅字符串表时需要将查阅地址减去 0x400000
。
objdump -d bomb > bomb.s
strings -t x bomb > strings.txt
下图是整数寄存器的示意图,可能会对我们有所帮助。如函数的六个参数依次为 %rdi, %rsi, %rdx, %rcx, %r8, %r9
,而被调用者保存的寄存器为 %rbx, %rbp, %r12~%r15
,我们可以认为在函数调用后这些寄存器的值不变。
Phase 1
我们查看 bomb.s
中 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,%rsp
400efb: c3 retq
注意,我们是在 main
函数中通过 phase_1(input)
调用的,因此 input
字符串的地址已经在寄存器 %rdi
中了。
在 400ee4
指令中,我们将 $0x402400
移动到了 %esi
寄存器。在下一个指令中,我们调用了 strings_not_equal
函数,从字面意思不难判断这是用于判断字符串相等的函数。而寄存器 %rsi
是函数第二个参数的寄存器,因此我们可以明白这里是将 input
参数和 $0x402400
处的字符串作比较。结合下面的指令可以发现,如果返回值为 \(0\)(猜测是比较成功),那么就会直接返回。
因此,我们只需要找到 $0x402400
处的字符串即可。通过查找 strings.txt
的字符串表,并且将地址减去 0x400000
得到答案:
Border relations with Canada have never been better.
于是,第一个炸弹拆除!
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 cmp %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
一开始程序就将栈指针减去了 0x28
,然后将地址作为第二个参数调用了函数 read_six_numbers
,显然这里是开了一个小数组。
read_six_numbers
这个名字一看就是要我们输入六个整数,不过来看看程序,具体是怎么操作的吧!
000000000040145c <read_six_numbers>:
40145c: 48 83 ec 18 sub $0x18,%rsp
401460: 48 89 f2 mov %rsi,%rdx
401463: 48 8d 4e 04 lea 0x4(%rsi),%rcx
401467: 48 8d 46 14 lea 0x14(%rsi),%rax
40146b: 48 89 44 24 08 mov %rax,0x8(%rsp)
401470: 48 8d 46 10 lea 0x10(%rsi),%rax
401474: 48 89 04 24 mov %rax,(%rsp)
401478: 4c 8d 4e 0c lea 0xc(%rsi),%r9
40147c: 4c 8d 46 08 lea 0x8(%rsi),%r8
401480: be c3 25 40 00 mov $0x4025c3,%esi
401485: b8 00 00 00 00 mov $0x0,%eax
40148a: e8 61 f7 ff ff callq 400bf0 <__isoc99_sscanf@plt>
40148f: 83 f8 05 cmp $0x5,%eax
401492: 7f 05 jg 401499 <read_six_numbers+0x3d>
401494: e8 a1 ff ff ff callq 40143a <explode_bomb>
401499: 48 83 c4 18 add $0x18,%rsp
40149d: c3 retq
很容易看到调用了 sscanf
函数,这个函数的作用是从字符串中读取变量。注意到 phase_2
和 read_six_numbers
都没有修改寄存器 %rdi
,也就是说我们的 input
一直是第一个参数,这里也会是 sscanf
的第一个参数,也就是要从 input
里读取变量。那么我们只要确定剩余几个参数的值和顺序,就能知道是怎么读取数据的了!
401480
指令将地址 $0x4025c3
赋值给了 %esi
作为 sscanf
第二个参数,也就是 sscanf
的格式化字符串,查阅字符串表得到格式化字符串的内容是:%d %d %d %d %d %d
,不出所料是读取了六个 int
类型的整数。
根据寄存器使用规范,跟着的四个参数应该是使用的 %rdx, %rcx, %r8, %r9
四个寄存器,可以看到在对应的值依次是 %rsi, 0x4 + %rsi, 0x8 + %rsi, 0xc(%rsi)
。应该还有两个参数无法用寄存器传递,那么应该是存放在栈中,找到 (%rsp), 0x8(%rsp)
对应的值,分别是 0x10 + %rsi, 0x14(%rsi)
。也就是说,我们的 input
中六个数字依次被放进了 phase_2
函数的小数组的前 \(6\) 位中。为了方便起见,我们后面用 \(s[i]\) 表示这六个数中第 \(i\) 个数。
让我们回到 phase_2
函数。在调用 read_six_numbers
结束后,将 (%rsp)
(\(s[0]\)) 和 \(1\) 做了比较,如果不相等就爆炸,所以显然第一个数应该是 \(1\)。然后第一个数正确的话,程序跳转到 400f30
。
接下来,程序将 \(s[1], s[6]\) 的地址分别存进了 %rbx, %rbp
中,然后跳转到了 400f17
,这条指令中,-0x4(%rbx)
也就是 \(s[0]\) 的值被拷贝到了 %eax
并累加了其自身一次,得到的 \(2s[0]\) 被用于和 \(s[1]\) 比较,如果不相等就爆炸,因此第二个数正确的值应该是 \(2\)。
随后程序跳转到 400f25
,将 %rbx
的值加上了 \(4\)(现在指向了 \(s[2]\)),然后与地址 \(s[6]\) 比较,如果不相等就跳转回 400f17
。显然,这里出现了一个循环,%rbx
就是一个循环变量,从 \(s[1]\) 循环到 \(s[5]\)。因此很容易类推得后面的数字。
综上,这里的正确答案就是:
1 2 4 8 16 32
第二个炸弹,拆除!
Phase 3
汇编代码太长了……我们慢一点看。
0000000000400f43 <phase_3>:
400f43: 48 83 ec 18 sub $0x18,%rsp
400f47: 48 8d 4c 24 0c lea 0xc(%rsp),%rcx
400f4c: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
400f51: be cf 25 40 00 mov $0x4025cf,%esi
400f56: b8 00 00 00 00 mov $0x0,%eax
400f5b: e8 90 fc ff ff callq 400bf0 <__isoc99_sscanf@plt>
400f60: 83 f8 01 cmp $0x1,%eax
400f63: 7f 05 jg 400f6a <phase_3+0x27>
400f65: e8 d0 04 00 00 callq 40143a <explode_bomb>
哈,又是熟悉的 sscanf
!可以看到,依然是用我们的 input
字符串作为输入,而第二个参数的格式化字符串是 %d %d
,也就是要输入两个整数,存储地址是第三、四个参数:0x8(%rsp), 0xc(%rsp)
。
然后对比了 sscanf
的返回值,如果返回值小于等于 \(1\),那么说明输入有误,直接爆炸!
我们继续看!
0000000000400f43 <phase_3>:
400f6a: 83 7c 24 08 07 cmpl $0x7,0x8(%rsp)
400f6f: 77 3c ja 400fad <phase_3+0x6a>
400f71: 8b 44 24 08 mov 0x8(%rsp),%eax
400f75: ff 24 c5 70 24 40 00 jmpq *0x402470(,%rax,8)
嗯,如果第一个数比 \(7\) 大,那么就跳转到 400fad
。我们看一下这个地址对应的指令,发现是 callq 40143a <explode_bomb>
也就是直接爆炸。因此我们第一个参数必须要是 \(0\) 到 \(7\) 之间的。然后程序会将第一个参数的值拷贝给 %eax
,然后跳转到 (0x402470 + 8 * %rax)
处存储的地址。
这里真的卡了我一下,因为这种跳转到被存储进内存的地址的操作让我措手不及。没办法,只能上 gdb
了。
我们使用 gdb
查看当第一个数分别为 \(0\cdots 7\) 时,对应内存中的地址是多少:
结合汇编代码:
0000000000400f43 <phase_3>:
400f7c: b8 cf 00 00 00 mov $0xcf,%eax
400f81: eb 3b jmp 400fbe <phase_3+0x7b>
400f83: b8 c3 02 00 00 mov $0x2c3,%eax
400f88: eb 34 jmp 400fbe <phase_3+0x7b>
400f8a: b8 00 01 00 00 mov $0x100,%eax
400f8f: eb 2d jmp 400fbe <phase_3+0x7b>
400f91: b8 85 01 00 00 mov $0x185,%eax
400f96: eb 26 jmp 400fbe <phase_3+0x7b>
400f98: b8 ce 00 00 00 mov $0xce,%eax
400f9d: eb 1f jmp 400fbe <phase_3+0x7b>
400f9f: b8 aa 02 00 00 mov $0x2aa,%eax
400fa4: eb 18 jmp 400fbe <phase_3+0x7b>
400fa6: b8 47 01 00 00 mov $0x147,%eax
400fab: eb 11 jmp 400fbe <phase_3+0x7b>
400fad: e8 88 04 00 00 callq 40143a <explode_bomb>
400fb2: b8 00 00 00 00 mov $0x0,%eax
400fb7: eb 05 jmp 400fbe <phase_3+0x7b>
400fb9: b8 37 01 00 00 mov $0x137,%eax
400fbe: 3b 44 24 0c cmp 0xc(%rsp),%eax
400fc2: 74 05 je 400fc9 <phase_3+0x86>
400fc4: e8 71 04 00 00 callq 40143a <explode_bomb>
可以发现,不论第一个数是几,对应的指令都会将 %eax
赋值成某个数,然后跳转到 400fbe
。在这个位置,程序会将 %eax
与输入的第二个数相比较,如果不相等就会直接爆炸。如果相等就会跳转到 400fc9
准备返回。
因此这个题目的答案其实很多,输入的第一个数不同,对应合法的第二个数也不同,最简单的答案就是:
0 207
Phase 4
下一弹!
000000000040100c <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>
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>
Phase 4 最开始的代码和 Phase 3 几乎一样,通过 sscanf
函数从我们的 input
中读入两个正整数,存进 0x8(%rsp)
和 0xc(%rsp)
,如果数量不对就爆炸。
然后,程序判断第一个输入是否小于 0xe
(也就是 \(14\)),如果成立就跳转 40103a
,没有跳转就会爆炸。
000000000040100c <phase_4>:
40103a: ba 0e 00 00 00 mov $0xe,%edx
40103f: be 00 00 00 00 mov $0x0,%esi
401044: 8b 7c 24 08 mov 0x8(%rsp),%edi
401048: e8 81 ff ff ff callq 400fce <func4>
下面,程序以第一个数的值、\(0\) 和 \(14\) 依次作为三个参数调用了函数 func4
。
0000000000400fce <func4>:
400fce: 48 83 ec 08 sub $0x8,%rsp
400fd2: 89 d0 mov %edx,%eax
400fd4: 29 f0 sub %esi,%eax
400fd6: 89 c1 mov %eax,%ecx
400fd8: c1 e9 1f shr $0x1f,%ecx
400fdb: 01 c8 add %ecx,%eax
400fdd: d1 f8 sar %eax
400fdf: 8d 0c 30 lea (%rax,%rsi,1),%ecx
400fe2: 39 f9 cmp %edi,%ecx
400fe4: 7e 0c jle 400ff2 <func4+0x24>
400fe6: 8d 51 ff lea -0x1(%rcx),%edx
400fe9: e8 e0 ff ff ff callq 400fce <func4>
400fee: 01 c0 add %eax,%eax
400ff0: eb 15 jmp 401007 <func4+0x39>
400ff2: b8 00 00 00 00 mov $0x0,%eax
400ff7: 39 f9 cmp %edi,%ecx
400ff9: 7d 0c jge 401007 <func4+0x39>
400ffb: 8d 71 01 lea 0x1(%rcx),%esi
400ffe: e8 cb ff ff ff callq 400fce <func4>
401003: 8d 44 00 01 lea 0x1(%rax,%rax,1),%eax
401007: 48 83 c4 08 add $0x8,%rsp
40100b: c3 retq
func4
这个函数……我能看懂它的含义,但是我并不是很能理解这个东西算出来的有什么实际意义……
很显然,看到函数中两处 callq 400fce <func4>
调用它自身,很容易明白这是一个递归函数。
400fd2
到 400fdf
这一段的含义比较明显。假设 %rsi
作为一段区间的左端点,%rdx
作为右端点,那么这一段代码执行后 %ecx
的值就是这段区间的中点。具体解释一下,假设 \(L, R\) 作为左右端点,这段代码大概就是先构造出 \(R - L\),然后求出 \((R - L) / 2\) (这里是整除,向 \(0\) 舍入)的值,得到 \(M = L + (R - L) / 2\) 作为中点。(值得注意的是,代码中使用了右移 \(31\) 位得到符号位,然后在执行除以 \(2\) 之前给被除数加上了符号位的值,从而确保了为正时下去整,为负时上取整)
然后,代码出现了分支。我们记 \(X\) 为 %edi
。如果 \(X < M\),那么代码以 \((X, L, M - 1)\) 作为参数递归调用 func4
,将返回值 \(\times 2\) 返回回去。如果 \(X \geq M\),那么代码会进一步判断,如果 \(X \leq M\)(综合起来就是 \(X = M\))就直接返回 \(0\),否则递归调用 \(func4(X, M + 1, R)\),并将返回值 \(\times 2 + 1\)。
并不明白在计算什么,我们回到 phase_4
中。
000000000040100c <phase_4>:
40104d: 85 c0 test %eax,%eax
40104f: 75 07 jne 401058 <phase_4+0x4c>
401051: 83 7c 24 0c 00 cmpl $0x0,0xc(%rsp)
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
先判断返回值如果不为 \(0\),那么直接跳转到 401058
爆炸,否则继续判断如果第二个输入为 \(0\) 那么直接返回,否则爆炸。很显然第二个参数一定要为 \(0\),同时第一个参数在调用 func4
的返回值也要是 \(0\)。
很显然如果第一个输入为 \(7\),那么一定会返回 \(0\)(甚至不用递归)。但是这应该不是唯一答案。比如,第一个输入为 \(3\) 或者为 \(1\) 都是可能的,甚至可以为 \(0\)。
我是用的是最直接的解答:
7 0
Phase 5
到了 Phase 5 难度就增大了,涉及了一些控制逻辑了。
我们先来看第一段:
0000000000401062 <phase_5>:
401062: 53 push %rbx
401063: 48 83 ec 20 sub $0x20,%rsp
401067: 48 89 fb mov %rdi,%rbx
40106a: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
401071: 00 00
401073: 48 89 44 24 18 mov %rax,0x18(%rsp)
401078: 31 c0 xor %eax,%eax
40107a: e8 9c 02 00 00 callq 40131b <string_length>
40107f: 83 f8 06 cmp $0x6,%eax
401082: 74 4e je 4010d2 <phase_5+0x70>
401084: e8 b1 03 00 00 callq 40143a <explode_bomb>
401062
是在执行 %rbx
寄存器的被调用者保存的义务,而后面的 401063
到 401073
的语句看不太懂,还涉及到了 %fs
寄存器,隐隐约约记得书上提到过这个寄存器和段寻址有关,是用来存储金丝雀值以供栈破坏检测使用的,我们应该不用太关心。这里涉及到的唯一寄存器 %rax
也很快会被 string_length
调用的返回值覆盖。
string_length
的参数依然是 %rdi
,也就是我们的 input
,因此这里得到了我们输入字符串的长度。下一句中将这个长度和 \(6\) 作比较,如果相等会跳转到 4010d2
,否则继续执行就会发生爆炸。
继续看 4010d2
这边的一段:
0000000000401062 <phase_5>:
4010d2: b8 00 00 00 00 mov $0x0,%eax
4010d7: eb b2 jmp 40108b <phase_5+0x29>
嗯,没有什么特殊的意思,将 %eax
寄存器清零,然后跳转到了 40108b
。
0000000000401062 <phase_5>:
40108b: 0f b6 0c 03 movzbl (%rbx,%rax,1),%ecx
40108f: 88 0c 24 mov %cl,(%rsp)
401092: 48 8b 14 24 mov (%rsp),%rdx
401096: 83 e2 0f and $0xf,%edx
401099: 0f b6 92 b0 24 40 00 movzbl 0x4024b0(%rdx),%edx
4010a0: 88 54 04 10 mov %dl,0x10(%rsp,%rax,1)
4010a4: 48 83 c0 01 add $0x1,%rax
4010a8: 48 83 f8 06 cmp $0x6,%rax
4010ac: 75 dd jne 40108b <phase_5+0x29>
从最后一句来看,这一段似乎是一个循环啊。
注意到下面的 %rax
的值一直在被修改,所以我们这里先假设 %rax
是一个循环变量,下面用 \(i\) 来代表这个寄存器的值。%rbx
寄存器在之前的 401067
中被修改为了 input
的地址,因此第一句话可以看作是将 char
类型的 input[i]
零扩展到了 int
类型的变量中,用 %ecx
存储。下一句话中,%cl
是 %ecx
的一个字节版本,于是这两句话可以看做是将 input[i]
存储进了栈中。
下面两句话,从栈中取出了 input[i]
的值,然后和 0xf = 1111
做 &
,也就是取出了后 \(4\) 位。注意到数字字符 '0' - '9'
的 ASCII 码是从 0x30 - 0x39
,而大小写字母的前 \(15\) 个字符也分别是 0x41 - 0x4f, 0x61 - 0x6f
,我们可以先假设这六个字符都是数字或者字母,那么这两句话的效果将这些字符映射成了一些编号。
下一句从这些编号加上 0x4024b0
得到的地址中取一个字节零扩展存进 %edx
,我们可以认为 0x4024b0
这是一个数组或者字符串吧,去查查字符串表,可以发现这个地址中的字符串是 maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?
。
下一句话将我们取出来的字符存进了地址 0x10(%rsp,%rax,1)
。emm,假设 16 + %rsp
对应的地址记作 \(t\),那么这句话就是将这个字符存进了 t[i]
中。
下面两句就很简单了,将循环变量 \(i\) 加一,然后判断如果不等于 \(6\) 了就继续循环。
于是这段循环的意义就是完成了一段映射,将输入的字符串转化成了另一个字符串。
接着看:
0000000000401062 <phase_5>:
4010ae: c6 44 24 16 00 movb $0x0,0x16(%rsp)
4010b3: be 5e 24 40 00 mov $0x40245e,%esi
4010b8: 48 8d 7c 24 10 lea 0x10(%rsp),%rdi
4010bd: e8 76 02 00 00 callq 401338 <strings_not_equal>
4010c2: 85 c0 test %eax,%eax
4010c4: 74 13 je 4010d9 <phase_5+0x77>
4010c6: e8 6f 03 00 00 callq 40143a <explode_bomb>
下面将将 \(0\) 赋值给了 0x16(%rsp)
,算下来这个位置应该就是我们之前的 \(t\) 数组的第六位,也就是新字符串的末尾,这句话应该是在补充 '\0'
。
下面几句话很容易理解:将 $0x40245e
处的字符串作为第二个参数,0x10(%rsp)
处的 \(t\) 字符串作为第一个参数,调用 strings_not_equal
判断两个字符串是否相等,如果不相等就跳转到 4010d9
,否则继续执行就会发生爆炸。
4010d9
处的操作和开头第一段的操作很类似,应该依然是对金丝雀值和栈破坏检测的操作,我们不关心,没有什么影响。
所以我们的目标很明确了,让我们输入的字符串长度为 \(6\),且经过映射后的字符串与 0x40245e
处字符串相同。那么 0x40245e
处的字符串查一下表就可以发现是 flyers
。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
m a d u i e r s n f o t v b y l
根据上表映射回原来的字符串,那么每个字符的编号应该依次是:9, 15, 14, 5, 6, 7
。这显然不可能是数字,因此可以被映射为字母串 ionefg
或者大写的版本 IONEFG
。当然,甚至不一定要是字母,任意的满足末四位与之对应的字符串都是可以的。
于是我提交的答案就是:
ionefg
Phase 6
终于到最后一题啦(o)/
首先来看第一段指令。
00000000004010f4 <phase_6>:
4010f4: 41 56 push %r14
4010f6: 41 55 push %r13
4010f8: 41 54 push %r12
4010fa: 55 push %rbp
4010fb: 53 push %rbx
4010fc: 48 83 ec 50 sub $0x50,%rsp
401100: 49 89 e5 mov %rsp,%r13
401103: 48 89 e6 mov %rsp,%rsi
401106: e8 51 03 00 00 callq 40145c <read_six_numbers>
40110b: 49 89 e6 mov %rsp,%r14
40110e: 41 bc 00 00 00 00 mov $0x0,%r12d
开头就是五行 push
,一看就知道是把被调用者保存的寄存器压进栈里。然后 4010fc
分配了一堆栈空间,应该有不止一个数组。接着有吧栈地址存进了 %r13
,不知道是做什么的。
401103
下面两句话比较清晰,将栈地址作为第二的参数(input
仍然是第一个参数)调用 read_six_numbers
,这个函数我们已经分析过了,是从我们的输入里读取六个整数,存进了紧贴着栈指针的六个 \(4\) 字节空间中,我们将这些数记作 \(a[i]\)。
下面两句话将栈指针赋值给了 %r14
,然后将 %r12d
清空了。
00000000004010f4 <phase_6>:
401114: 4c 89 ed mov %r13,%rbp
401117: 41 8b 45 00 mov 0x0(%r13),%eax
40111b: 83 e8 01 sub $0x1,%eax
40111e: 83 f8 05 cmp $0x5,%eax
401121: 76 05 jbe 401128 <phase_6+0x34>
401123: e8 12 03 00 00 callq 40143a <explode_bomb>
401128: 41 83 c4 01 add $0x1,%r12d
40112c: 41 83 fc 06 cmp $0x6,%r12d
401130: 74 21 je 401153 <phase_6+0x5f>
401132: 44 89 e3 mov %r12d,%ebx
401135: 48 63 c3 movslq %ebx,%rax
401138: 8b 04 84 mov (%rsp,%rax,4),%eax
40113b: 39 45 00 cmp %eax,0x0(%rbp)
40113e: 75 05 jne 401145 <phase_6+0x51>
401140: e8 f5 02 00 00 callq 40143a <explode_bomb>
401145: 83 c3 01 add $0x1,%ebx
401148: 83 fb 05 cmp $0x5,%ebx
40114b: 7e e8 jle 401135 <phase_6+0x41>
40114d: 49 83 c5 04 add $0x4,%r13
401151: eb c1 jmp 401114 <phase_6+0x20>
从最后一句的跳转 401114
,估摸着这一段应该是一个循环,因为后面的指令也没有在跳转回 401151
之前的了。看起来这个循环内部还嵌套了一个循环,我们先着重看一下外循环。
那么我们先猜测一下哪些可能是循环变量。从 40114d
来看,每次循环都会把 %r13
加上 \(4\),猜测这个变量可能是 \(a\) 数组的一个指针,我们记这个指针为 \(p\)。从 401130
这一句跳转到 401153
跳出了循环,猜测这里是循环的唯一退出条件。因此前两句话中每次将 %r12d
加一并和 \(6\) 比较应该就是循环的测试条件,因此 %r12d
应该就是循环的数组下标 id,我们记这个下标为 \(i\)。
然后我们来看看每一句话的含义。第一条指令将 \(p\) 指针拷贝到了 %rbp
中。下面五条指令实现了从当前 \(p\) 指向的位置读取数值到 %eax
,并将 %eax
减去 \(1\),然后和 \(5\) 比较,如果小于等于 \(5\) 就跳转到 401128
,如果没有跳转就会发生爆炸。从这里可以看出,我们输入的六个整数每个数都必须为正,且小于等于 \(6\)。
再往下的三句话我们刚刚已经分析过了,就是跳出循环的语句。
下面是一个内循环。在循环之前,将当前的循环下标 \(i\) 拷贝到了 %ebx
中。从内循环的末尾三句话可以看出,这个循环中 %ebx
就是循环变量,我们记为 \(j\)。这个循环变量将会完成从 \(0\) 到 \(5\) 的递增。内循环的前两条指令将 \(a\) 数组第 \(j\) 个数拷贝进了 %eax
,接着判断 \(a[i]\) 和 \(a[j]\) 是否相等,如果相等就会爆炸。这意味着我们输入的 \(6\) 个整数必须互不相等。接着就是内循环的循环条件了,我们已经分析过了。
综上,这一个嵌套循环告诉了我们两件事情,这里重复一遍:我们输入的六个整数每个数都必须为正,且小于等于 \(6\),且互不相等。
00000000004010f4 <phase_6>:
401153: 48 8d 74 24 18 lea 0x18(%rsp),%rsi
401158: 4c 89 f0 mov %r14,%rax
40115b: b9 07 00 00 00 mov $0x7,%ecx
401160: 89 ca mov %ecx,%edx
401162: 2b 10 sub (%rax),%edx
401164: 89 10 mov %edx,(%rax)
401166: 48 83 c0 04 add $0x4,%rax
40116a: 48 39 f0 cmp %rsi,%rax
40116d: 75 f1 jne 401160 <phase_6+0x6c>
从最后一句话可以看出,401160
到 40116d
这一段又是一个循环。
第一句指令将 0x18(%rsp)
的地址拷贝进 %rsi
,注意到我们读入的六个整数的地址应该是 0x0(%rsp) - 0x17(%rsp)
这 \(24\) 个字节,因此 %rsi
就是数组结束的下一位,可以用来判断循环枚举结束。结合下一句话以及循环的最后三句指令可以看出,%rax
就是这个循环中用来枚举数据的指针(记不记得 %r14
被存储了一份栈指针的副本),每次循环结束会 \(+4\) 然后和 %rsi
比较,相等了就会退出循环。我们记 %rax
中的指针为 \(p\)。
第三句话将 \(7\) 写入了 %ecx
,这个值在循环过程中一直没有改变。然后我们来看看循环的内容。每次循环将 \(7\) 减去 \(p\) 指针处的值,然后写进 \(p\) 指向的位置。也就是说,这个循环的意义是将数组的每个数用 \(7\) 减掉。
00000000004010f4 <phase_6>:
40116f: be 00 00 00 00 mov $0x0,%esi
401174: eb 21 jmp 401197 <phase_6+0xa3>
401176: 48 8b 52 08 mov 0x8(%rdx),%rdx
40117a: 83 c0 01 add $0x1,%eax
40117d: 39 c8 cmp %ecx,%eax
40117f: 75 f5 jne 401176 <phase_6+0x82>
401181: eb 05 jmp 401188 <phase_6+0x94>
401183: ba d0 32 60 00 mov $0x6032d0,%edx
401188: 48 89 54 74 20 mov %rdx,0x20(%rsp,%rsi,2)
40118d: 48 83 c6 04 add $0x4,%rsi
401191: 48 83 fe 18 cmp $0x18,%rsi
401195: 74 14 je 4011ab <phase_6+0xb7>
401197: 8b 0c 34 mov (%rsp,%rsi,1),%ecx
40119a: 83 f9 01 cmp $0x1,%ecx
40119d: 7e e4 jle 401183 <phase_6+0x8f>
40119f: b8 01 00 00 00 mov $0x1,%eax
4011a4: ba d0 32 60 00 mov $0x6032d0,%edx
4011a9: eb cb jmp 401176 <phase_6+0x82>
下面这一段应该还是一个大循环。我之所以认为这个循环应该划到这里结束,是因为上面这一段最多也就是跳转到 4011ab
(最后一句的下一句指令),而 4011ab
之后的语句就没有跳转回 4011ab
之前的了。
并不能很快看出来这个代码在做什么,也不能很快猜测出循环的结构,没办法,只能一行一行看了。
首先将 %esi
清零,考虑到代码中经常有 (%rsp,%rsi,1)``0x20(%rsp,%rsi,2)
这样的引用,我们可以认为 %esi
是一个循环变量,所以这里记作 \(i\)。
接下来跳转到 401197
,然后首先取出了 \(a[i]\) 存进 %ecx
,与 \(1\) 比较,如果 \(a[i] \leq 1\) 就跳转到 401183
。这里很绕人,我看了很久都没有判断出来循环的结构,我们还是先假设没有跳转吧,那么继续看下面的语句。下面的指令将 \(1\) 写入了 %eax
,然后将地址 0x6032d0
写入了 %edx
,跳转到 401176
。跳转后的 \(4\) 行应该是一个小循环(401176 - 40117f
),很明显 %eax
是循环变量,%ecx
(也就是 \(a[i]\))是枚举终点。这个循环是从 \(1\) 枚举到 \(a[i] - 1\) 结束,每次将 0x8(%rdx)
存进 %rdx
。这样的赋值方法很容易让我们联想到链表的结构,因此我们可以假设这里的 %rdx
其实是一个链表的指针。而这个操作实际上就是在取链表中的第 \(a[i]\) 个项目。
然后程序会直接跳转到 401188
。这时候我们会过来看之前 \(a[i] \leq 1\) 时应该跳转到的 401183
。这一句话是直接将链表头指针赋值给了 %edx
。那么这里的用意就很明显了——如果 \(a[i] = 1\) 那么就不用循环了,直接拿走头指针就行了。然后两个分支都会运行到 401188
。这一个语句会把 %edx
储存的链表项地址存进 0x20(%rsp,%rsi,2)
。这是一个新的数组,我们之前没有使用过这里的地址,结合后续两个语句很容易观察到这个数组一个元素的大小是 \(8\),因此应该是指针数组,符合其存储地址的用意。我们把这个数组就做 \(b[i]\)。
下面的三条语句就是循环变量改变和循环结束的判断了,很容易理解。
以下程序是我根据汇编指令改写的 C 语言程序,应该能很直观的体现运行的流程。这个部分的难点在于指令跳转混乱,而且指令的编排顺序和常规的代码书写顺序完全不同,因此很难直观地判断循环结构,必须得一条条指令逐步代入。
// int a[6]; T *b[6];
for (int i = 0; i < 6; ++i) {
T *p;
if (a[i] > 1) {
p = head;
for (int j = 1; j < a[i]; ++j) p = p->next;
} else p = head;
b[i] = p;
}
那么这段代码的作用就是将一个链表的第 \(a[i]\) 个项目的地址存储进 \(b[i]\) 中。
然后我们来看下一个部分:
00000000004010f4 <phase_6>:
4011ab: 48 8b 5c 24 20 mov 0x20(%rsp),%rbx
4011b0: 48 8d 44 24 28 lea 0x28(%rsp),%rax
4011b5: 48 8d 74 24 50 lea 0x50(%rsp),%rsi
4011ba: 48 89 d9 mov %rbx,%rcx
4011bd: 48 8b 10 mov (%rax),%rdx
4011c0: 48 89 51 08 mov %rdx,0x8(%rcx)
4011c4: 48 83 c0 08 add $0x8,%rax
4011c8: 48 39 f0 cmp %rsi,%rax
4011cb: 74 05 je 4011d2 <phase_6+0xde>
4011cd: 48 89 d1 mov %rdx,%rcx
4011d0: eb eb jmp 4011bd <phase_6+0xc9>
4011d2: 48 c7 42 08 00 00 00 movq $0x0,0x8(%rdx)
4011d9: 00
相比于这一个部分这里可简单太多了!很容易看出来这是几条顺序指令和一个循环组成的。
前三条指令存储了几个地址。第一条指令将 \(b[0]\) 的值存储进了 %rbx
,第二条指令和第三条指令分别存储了 &b[1]
和 &b[6]
的地址。第四条指令将 %rbx
中的 \(b[0]\) 拷贝了一份给 %rcx
,我们记存储进 %rcx
的指针为 \(p\)。结合循环体的内容可以猜测,%rax
是这次的循环变量,它从 &b[1]
枚举到 &b[5]
,我们记它为 \(q\)。
每次循环中,\(q\) 指向的内容会被拷贝给 %rdx
,然后存储进 8 + %rcx
也就是 p->next
。下面三行就是常规的判断循环结束了,如果 \(q\) 指向了 \(b[6]\) 就跳出循环。如果没有跳出,那么就会把 \(q\) 指向的内容赋值给 \(p\) 指针——如此,每次循环中,\(q\) 指向的内容永远是 \(p\) 的下一位。
循环结束后,将最后的 %rdx
也就是 b[5]
的 next
设置为了 NULL
。
这一段代码实现了将 \(b[i + 1]\) 变成 b[i]->next
,也就是将这些链表的项目按照输入顺序重新连接。
00000000004010f4 <phase_6>:
4011da: bd 05 00 00 00 mov $0x5,%ebp
4011df: 48 8b 43 08 mov 0x8(%rbx),%rax
4011e3: 8b 00 mov (%rax),%eax
4011e5: 39 03 cmp %eax,(%rbx)
4011e7: 7d 05 jge 4011ee <phase_6+0xfa>
4011e9: e8 4c 02 00 00 callq 40143a <explode_bomb>
4011ee: 48 8b 5b 08 mov 0x8(%rbx),%rbx
4011f2: 83 ed 01 sub $0x1,%ebp
4011f5: 75 e8 jne 4011df <phase_6+0xeb>
4011f7: 48 83 c4 50 add $0x50,%rsp
4011fb: 5b pop %rbx
4011fc: 5d pop %rbp
4011fd: 41 5c pop %r12
4011ff: 41 5d pop %r13
401201: 41 5e pop %r14
401203: c3 retq
下面的代码就简单了。最后的一堆 add
和 pop
可以不用管,这是处理栈指针和被调用者保存寄存器的指令。
主体部分还是一个循环,可以看出来 %ebp
一定是一个循环变量,从 \(5\) 枚举到 \(1\)。初始时,%rbx
仍然存储着 b[0]
的值,我们记这个寄存器的内容为 \(p\)。每次循环,将 p->next
的值存储进 %rax
,从而将 p->next->val
存储进 %eax
(因为 0x8(%rbx)
存储的是 next
成员,很容易猜测到 (%rbx)
存储的是 val
成员)。如果 p->val < p->next->val
炸弹就会爆炸。否则会把 p->next
赋值给 \(p\),进入下一次循环。
啊,终于判断清楚程序的意图了!
所以,我们回顾一下我们的输入需要满足的条件:我们要输入 \(6\) 个正整数 \(a_i\),每个正整数必须要在 1-6
之间,且不能重复。然后每个正整数会作为减数被 \(7\) 减去得到 \(a'_i = 7 - a_i\)。然后,程序从一个链表中取出第 \(a'_i\) 项,要求取出的项随着 \(i\) 增加要严格递减。
那么我们用 gdb 查看一下这个链表的内容!
可以看出,我们的链表项目的值依次是 332, 168, 924, 691, 477, 443
。从大到小排序后项目编号依次是 3, 4, 5, 6, 1, 2
。对 \(7\) 求一个补可以得到:
4 3 2 1 6 5
最后
通关啦!