系列序言

作为非科班毕业的码农,我深感自己的计算机基础亟待补足。因此我下决心好好补一补课,这便是我开始《补课记》这个系列的初衷。我首先决定拿下的就是CMU 15-213这门神课。我将自己求解Lab的思路及结果记录在这里,这是对自己的监督,也方便有像我一样需要补足基础的同学参考。因此我本人是个小白,所以我会从我的角度钜细靡遗地将我求解的思路写下来,觉得啰嗦的同学我就只能抱歉啦。

准备工作

在开始之前我们需要产生如下文件:用objdump命令得到程序的汇编文件:objdump -d bomb > disassembled.s。然后我们就可以开始拆除炸弹了。

Phase 1

这道题难度不高,主要是要求我们掌握gdb的调试方法,只要知道怎么用gdb调试,这道题就可以很快做出来了。首先当然应该仔细阅读汇编代码和C代码,简单浏览对比后我们可以注意到和phase1有关的两个地方,其一是主函数中调用phase_1函数的地方:

1
2
mov    %rax,%rdi
callq  400ee0 <phase_1>

考虑到%rdi是函数第一个参数使用的寄存器,那么不难理解%rax存放的一定是用户输入的字符串的地址。

其二是phase_1函数的过程:

1
2
3
4
5
6
7
8
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 

显然第一行和倒数第二行是函数正常所需的压栈和弹栈,可以暂时忽略。到第三行调用strings_not_equal函数之前,%rdi$rsi都存放了内容,前者是在主函数中存放的用户输入字符串的地址,后者是phase_1函数中第二行设定的。%rdi$rsi这两个寄存器是函数前两个参数默认使用的寄存器,那么我们很容易想到$rsi存放的肯定是正确答案字符串的地址strings_not_equal在比较两个字符串后将结果存放在%rax中,然后phase_1函数通过test命令来决定是否引爆炸弹(当%eax为0时je会被执行,跳过炸弹引爆函数explode_bomb)。于是,我们就需要通过gdb来找到这个$0x402400这个地址存放的到底是什么内容。

进入gdb调试环境,将端点设置在phase_1函数的第二行0x400ee4,然后启动程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ gdb
(gdb) file bomb
Reading symbols from bomb...
(gdb) break *0x400ee4
Breakpoint 1 at 0x400ee4
(gdb) start
Temporary breakpoint 2 at 0x400da0: file bomb.c, line 37.
Starting program: /home/Lab/Lab2_bomb/bomb

Temporary breakpoint 2, main (argc=1, argv=0x7ffffffedfd8) at bomb.c:37
37      { 
(gdb) continue
Continuing.
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
phase1_test

Breakpoint 1, 0x0000000000400ee4 in phase_1 ()

然后使用print命令直接将0x402400地址的内容打印出来:

1
2
(gdb) print (char*)0x402400
$1 = 0x402400 "Border relations with Canada have never been better."

这就是第一题的正确答案了。

做一点拓展,我们可以用info registers命令查看一下各寄存器的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(gdb) info registers
rax            0x603780            6305664
rbx            0x402210            4203024
rcx            0xb                 11
rdx            0x1                 1
rsi            0x603780            6305664
rdi            0x603780            6305664
rbp            0x0                 0x0
rsp            0x7ffffffeded0      0x7ffffffeded0
r8             0x603780            6305664
r9             0x7c                124
r10            0xfffffffffffff28e  -3442
r11            0x7fffff5e7400      140737477768192
r12            0x400c90            4197520
r13            0x7ffffffedfd0      140737488281552
r14            0x0                 0
r15            0x0                 0
rip            0x400ee4            0x400ee4 <phase_1+4>
eflags         0x202               [ IF ]
cs             0x33                51
ss             0x2b                43
ds             0x0                 0
es             0x0                 0
fs             0x0                 0
gs             0x0                 0

可以看到正如我们前面所推测的那样,%rsi存放的就是0x402210。那么%rdi存放的地址对应的内容到底是不是我们输入的字符串呢?我们可以做个验证:

1
2
(gdb) print (char*)0x603780
$2 = 0x603780 <input_strings> "phase1_test"

正是我们输入的字符串,所以前面的推测完全正确。

Phase 2

相比第一题,第二题的难度一下子提升了不少。要解出第二题,关键在于对循环结构的掌握。只要正确的分析出phase_2函数的循环过程就能迅速的得到正确答案。phase_2函数的汇编代码长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

首先快速浏览一遍这段代码,我们可以获得以下几点信息: 1. 函数需要使用%rbp%rbx两个寄存器; 2. 函数需要使用40字节的栈空间; 3. 函数通过调用read_six_numbers从控制台读取6个数字。

由于函数申请的栈空间远大于函数在第一二行压栈两个寄存器所需的空间,我们可以猜测函数需要使用栈来存储用户输入的数据。为了进一步验证猜想,我们看一看read_six_numbers这个函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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   

首先注意到phase_2函数在调用read_six_numbers前先将%rsp传递给了%rsiread_six_numbers函数中,%rsi到%rsi+20分别被传递给了%rdx%rcx%r8%r9%rsp%rsp+8,随后他们被作为参数被传入sscanf。可见这六个寄存器存放的应该是用户输入的数据将要存放的地址,而且这个地址就是栈的地址。

众所周知,函数的前六个参数默认会放在%rdi%rsi%rdx%rcx%r8%r9中。此处却没有用到前两个寄存器的迹象,这是怎么回事呢?我们“故技重施”,用gdb来看看这两个寄存器存放了什么内容。进入gdb后在调用sscanf前加上断点,让程序运行到断点处,我们来看看这两个寄存器的情况(我略去了其他寄存器的值):

1
2
3
4
5
6
7
(gdb) info registers
rsi            0x4025c3            4203971
rdi            0x6037d0            6305744
(gdb) print (char*)0x6037d0
$1 = 0x6037d0 <input_strings+80> "phase2_test"
(gdb) print (char*)0x4025c3
$2 = 0x4025c3 "%d %d %d %d %d %d"

这样就一目了然了:%rdi存放的地址是用户输入数据的地址,%rsi存放的地址是格式化字符串%d %d %d %d %d %d。再联系C语言中sscanf函数的定义,不难理解read_six_numbers这个函数的作用就是从用户输入读取六个整数,然后存放到一个数组中,这个数组对应栈上一个24字节的空间(每个整数4字节,共6个元素),这也印证了我们前面的猜想。

再回到phase_2函数中。根据上面的分析,我们可以知道用户输入的六个数字分别存放在(%rsp)(%rsp+20)的位置上。我们按照程序运行的顺序一句一句分析这段代码:

  1. 0x400f0a:在读取6个数字之后该函数首先将第一个元素(也就是栈顶元素(%rsp))和1进行了比较,如果相等则跳转到0x400f30,否则引爆炸弹。
  2. 0x400f300x400f35:将下一个元素的地址传递给%rbx,将数组的“尾哨兵”位置(也就是第六个元素之后的位置)传递个%rbp,然后跳转到0x400f17
  3. 0x400f170x400f1c:将前一个元素(即(%rbx-4))传递给%eax%eax扩大到原来的两倍,然后再用当前元素(即(%rbx))和%eax作比较,如果相等则跳转到0x400f25,否则引爆炸弹;
  4. 0x400f250x400f2c:将%rbx的地址移动到下一个元素,比较%rbx存放的地址和“尾哨兵”的地址是否相等,如果不相等则跳转到0x400f17即第3步,否则跳转到0x400f3c也就是函数最后弹栈恢复现场的位置。

可见,除了第一次比较是和1比较,之后每次比较时都是将前一次的元素扩大到原来的两倍后比较。因此,正确答案就是1 2 4 8 16 32