Featured image of post 💣 CSAPP BombLab 实验指南

💣 CSAPP BombLab 实验指南

实验二,用GDB调试汇编程序

⚠️ 注意,本文只提供个人的解法与思路,不能代替自己亲手用调试器做实验。如果你希望更好地锻炼自己的能力,仅仅在你尝试过,并且被卡住时候,观看对应章节的内容。或者,在你独立做完实验,可以与本指南中的内容核对。也欢迎各位对本文中的拼写错误、细节遗漏等方面进行补充!

本文受到了 更适合北大宝宝体质的 Bomb Lab 踩坑记 很大的启发,因此你可能发现一些雷同的内容,这都只是我的拙劣模仿。

实验介绍

本实验是 CSAPP 第三章的实验内容,设计了汇编的许多方面,包括整数运算、字符串比较、循环条件分支、递归调用、栈、指针、链表、结构等等。本文读者应当学习过以上内容,进而更好地理解该实验。

Bomblab 的实验背景相当经典,可以在 bomb.c 中看到。简单来说,你需要根据二进制程序来拆弹💣。

实验准备

实验暴露出来的源码 bomb.c 只涵盖了一小部分,没有涉及每个函数具体是如何实现的。不过,我们可以从中看出大概流程——获取输入,然后执行下面的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
initialize_bomb();

printf("Welcome to my fiendish little bomb. You have 6 phases with\n");
printf("which to blow yourself up. Have a nice day!\n");

/* Hmm...  Six phases must be more secure than one phase! */
input = read_line();             /* Get input                   */
phase_1(input);                  /* Run the phase               */
phase_defused();                 /* Drat!  They figured it out!
                    * Let me know how they did it. */
printf("Phase 1 defused. How about the next one?\n");

// 更多内容

每次都读取一行作为输入,给 phase_n ,然后运行 phase_defused 函数。

为了调试二进制程序,需要使用 gdb。我已经在之前的文章中,介绍了 GDB Dashboard 以及 pwndbg。为了更好的体验,建议先配置 pwndbg ,但这不是必需的。本文前四个阶段使用 GDB Dashboard,后两个阶段使用 pwndbg。

参考北大Bomblab踩坑记1,该实验用到的常用调试指令如下:

指令全称描述
rrun开始执行程序,直到下一个断点或程序结束
qquit退出 GDB 调试器
ninexti执行下一条汇编指令,但不进入函数内部
sistepi执行当前汇编指令,如果是函数调用则进入函数
bbreak在指定位置设置断点
ccont从当前位置继续执行程序,直到下一个断点或程序结束
pprint打印变量的值
x打印内存中的值

注意 n 或者 q 指令是直接进行一步源代码指令,因此该实验中不会用到。p 是打印变量的值,通常是寄存器,不过在 pwndbg 的存在下没什么必要。x 是打印某个地址对应的值,也可以看做进行一次解引用操作,相当常用。

一些常用指令:

1
2
3
4
5
6
7
8
9
x/2x $rsp  # 以十六进制格式查看栈指针 %rsp 指向的内存位置 M[%rsp] 开始的两个单位。
x/2d $rsp # 以十进制格式查看栈指针 %rsp 指向的内存位置 M[%rsp] 开始的两个单位。
x/2c $rsp # 以字符格式查看栈指针 %rsp 指向的内存位置 M[%rsp] 开始的两个单位。
x/s $rsp # 把栈指针指向的内存位置 M[%rsp] 当作 C 风格字符串来查看。

x/b $rsp # 检查栈指针指向的内存位置 M[%rsp] 开始的 1 字节。
x/h $rsp # 检查栈指针指向的内存位置 M[%rsp] 开始的 2 字节(半字)。
x/w $rsp # 检查栈指针指向的内存位置 M[%rsp] 开始的 4 字节(字)。
x/g $rsp # 检查栈指针指向的内存位置 M[%rsp] 开始的 8 字节(双字)。

注意 / 后面的后缀(如 2x、2d、s、g、20c)指定了查看内存的方式和数量。具体来说:

  • 第一个数字(如 2、20)指定要查看的单位数量。
  • 第二个字母(如 x、d、s、c)指定单位类型和显示格式,其中:
    • c / d / x 分别代表以字符 / 十进制 / 十六进制格式显示内存内容。
    • s 代表以字符串格式显示内存内容。
  • 第三个字母(如 b / h / w / g)分别代表以 1 / 2 / 4 / 8 字节为单位(unit)显示内存内容。

为了方便每次运行 gdb bomb 都能进入我们想要的部分,可以编辑当前文件夹下的 .gdbinit 文件(注意还需要更改系统的~/.config/gdb/gdbinit,参考GDB Dashboard 教程 ):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
set args psol.txt

b phase_1
#b phase_2
#b phase_3
#b phase_4
#b phase_5
#b phase_6

#b phase_defused

setupwin /dev/pts/5

这当中 set args 可以指定参数。当前目录下的 psol.txt 文件的每一行,就是对每一个阶段的解答。这样每次完成一个阶段,都可以写入 psol.txt 文件,不用重复输入密码。

断点可以根据当前阶段设置。# 开头的是注释。例如当前完成了前两个阶段,就可以只为第三阶段开启。

最后一行是我为 pwndbg 自定义的函数,为了打印调试信息到指定内容,参考 pwndbg 教程与自定义配置

同时,因为 GDB 或者 pwndbg 能看到的反汇编代码的范围有限,为了方便查找,可以一次性生成反汇编代码并存储:

1
objdump -d bomb > bomb.asm

这样就可以在 VSCode 中查找。

如果你发现 CSAPP Labs 只有远程桌面,其实你可以简单地把 gitlab 链接复制,在本地或其他指定的地方克隆下来,运行即可。例如我使用的是一个远程 Ubuntu 电脑,本地使用 VSCode SSH,以及直接利用 Tabby SSH 连接。

本文中不区分 eax 与 rax 等 32 位与 64 位寄存器,认为是等价的。本文中十六进制尽量使用 0x 前缀开头,但可能有遗漏。

phase_1

运行程序 gdb bomb,然后输入 run

1
2
3
4
(gdb) run
Starting program: /home/ywr/dsc/learn/csapp/bomblab/bomb psol.txt
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!

此时可以随意输入一些尝试,例如我输入 123456 并回车,就进入了 phase_1 的断点:

1
2
Breakpoint 1, 0x0000000000400e6d in phase_1 ()
(gdb) 

搜索 phase_1,可以看到如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
0000000000400e6d <phase_1>:
  400e6d:	48 83 ec 08          	sub    $0x8,%rsp
  400e71:	be d0 23 40 00       	mov    $0x4023d0,%esi
  400e76:	e8 cf 04 00 00       	callq  40134a <strings_not_equal>
  400e7b:	85 c0                	test   %eax,%eax
  400e7d:	75 05                	jne    400e84 <phase_1+0x17>
  400e7f:	48 83 c4 08          	add    $0x8,%rsp
  400e83:	c3                   	retq   
  400e84:	e8 be 05 00 00       	callq  401447 <explode_bomb>
  400e89:	eb f4                	jmp    400e7f <phase_1+0x12>

可以看到,第三步调用了 strings_not_equal 函数。让我们输入两次 ni,到达 0x400e76 处。

1
2
3
4
(gdb) ni
0x0000000000400e71 in phase_1 ()
(gdb) ni
0x0000000000400e76 in phase_1 ()

汇编中第一个与第二个参数分别是 %rdi 与%rsi,让我们尝试打印:

1
2
3
4
(gdb) p/x $rdi
$1 = 0x603780
(gdb) p/x $rsi
$2 = 0x4023d0

我们将这两个看成字符串指针,尝试打印:

1
2
3
4
(gdb) x/s $rdi
0x603780 <input_strings>:       "123456"
(gdb) x/s $rsi
0x4023d0:       "Slave, thou hast slain me. Villain, take my purse."

可以看出,其中 123456 就是我们输入的字符,也是 input_strings。剩下的就应该是第一阶段的密码了。

阅读原先汇编的逻辑,也可以知道在 strings_not_equal 调用后,会利用 test %eax,%eax 来判断返回值是否为 0(test 指令会对两个操作数进行按位与运算),之后根据 jne 进行跳转。如果不为 0,则跳转到 0x400e84,然后调用 explode_bomb 函数,引发爆炸;如果为 0,说明字符串匹配,则恢复栈指针位置,然后返回。

所以第一阶段密码为 Slave, thou hast slain me. Villain, take my purse.

phase_2

read_six_numbers 分析

调用 read_six_numbers 后爆炸,对该函数打断点。

rcx 写为 0x00007fffffffdb44,rax 写为 0x00007fffffffdb54,推入栈,然后写为 0x00007fffffffdb50,再推入栈。r9 为0x00007fffffffdb4c,r8 为0x00007fffffffdb48 。

调用前,rdi 为 0x00007fffffffd4d0,rsi 为 0x00000000004025c3。

打印 sscanf 输入参数对应位置:

1
2
3
4
>>> x/s $rdi
0x6037d0 <input_strings+80>:    "123456"
>>> x/s $rsi
0x4025c3:       "%d %d %d %d %d %d"

发现其中第一个参数就是我们输入的字符串,第二个就是指定的格式。参考 sscanf 的使用方法2

1
int sscanf ( const char * s, const char * format, ...);

函数返回值代表捕捉到的参数个数。第一个参数是输入字符串(如 123456),第二个是指定格式(如 %d %d %d %d %d %d),之后的参数都是指针,指向希望存储数据的位置。

根据定义,第三个参数开始分别是 rdx、rcx、r8、r9,超过六个参数都会压入调用者的栈中。注意,栈顶是从内存大区域往小区域的,并且压入的参数是逆序的,也就是先压入第8个参数,再压入第7个参数。所以地址最大的 %rsi+0x14 是最先压入的,也就是最后的参数。

调用后,返回值 eax 会与 5 比较,如果小于等于 5,则会直接爆炸。这说明我们应该符合格式,输入 6 个整数进行尝试,如 1 2 3 4 5 6。在 0x401486 处停下,打印这些变量,就可以得到此时这些变量。打印的是现在 rsi 指向的地址,也就是0x7fffffffdb40。

1
2
3
>>> x/6dw 0x7fffffffdb40
0x7fffffffdb40: 0       0       4199486 0
0x7fffffffdb50: -9112   32767

目前都为空。经过 sscanf 函数后再次打印:

1
2
3
>>> x/6dw 0x7fffffffdb40
0x7fffffffdb40: 1       2       3       4
0x7fffffffdb50: 5       6

我们发现这就是 sscanf 获得的输入,将我们的输入存到了 6 个 int 类型中,放在栈上,是连续的六个变量。

参数值/指向的地址指向的值
rdx%rsi1
rcx%rsi+0x42
r8%rsi+0x83
r9%rsi+0xc4
第七个参数%rsi+0x105
第八个参数%rsi+0x146

phase_2 本身分析

之后来分析 phase_2 本身。该部分有详细的汇编注释,但之后的汇编中我会减少相关注释。注意下面的 emoji 代表了跳转的位置:

 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
27
0000000000400e8b <phase_2>:
# ...省略...

400ea3:	e8 c1 05 00 00       	callq  401469 <read_six_numbers> # 获取六个数字
400ea8:	83 3c 24 00          	cmpl   $0x0,(%rsp) # rsp 第一个数字 与 0 比较
400eac:	78 07                	js     400eb5 <phase_2+0x2a> # 有符号比较,当第一个数字为负数的时候跳转到爆炸
400eae:	bb 01 00 00 00       	mov    $0x1,%ebx # 将 ebx 变为1
400eb3:	eb 11                	jmp    400ec6 <phase_2+0x3b> # 无条件跳转到 ec6 一行😭
400eb5:	e8 8d 05 00 00       	callq  401447 <explode_bomb> # 爆炸
400eba:	eb f2                	jmp    400eae <phase_2+0x23>
400ebc:	48 83 c3 01          	add    $0x1,%rbx # 😁:rbx += 1
400ec0:	48 83 fb 06          	cmp    $0x6,%rbx # 将 rbx 与 6 比较
400ec4:	74 12                	je     400ed8 <phase_2+0x4d> # 如果相等,说明循环结束,跳转到🚩
400ec6:	89 d8                	mov    %ebx,%eax  # 😭:eax 变成 ebx
400ec8:	03 44 9c fc          	add    -0x4(%rsp,%rbx,4),%eax # eax += rsp对应数组中rbx偏移量后减去4,也就是rbx为1 2 3分别对应数组索引 0 1 2,也即 rsp[rbx-1]
400ecc:	39 04 9c             	cmp    %eax,(%rsp,%rbx,4) # 将 rsp[rbx] 与 eax 比较
400ecf:	74 eb                	je     400ebc <phase_2+0x31> # 如果相等,跳转到😁
400ed1:	e8 71 05 00 00       	callq  401447 <explode_bomb>
400ed6:	eb e4                	jmp    400ebc <phase_2+0x31>
400ed8:	48 8b 44 24 18       	mov    0x18(%rsp),%rax # 🚩:此时循环结束,将 rax 变成 rsp 地址加上 18
400edd:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax # 异或比较
400ee4:	00 00 
400ee6:	75 06                	jne    400eee <phase_2+0x63> # 如果不相等,跳转到最后一行
400ee8:	48 83 c4 20          	add    $0x20,%rsp # rsp 增加 20
400eec:	5b                   	pop    %rbx # 推出元素
400eed:	c3                   	retq   @ 返回
400eee:	e8 0d fc ff ff       	callq  400b00 <__stack_chk_fail@plt> # 报错

可以跟着编译器,看出其跳转的逻辑,写成 C 的话类似这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int[] rsp;
int eax;

if (rsp[0] < 0 ) {
    explode();
}

int ebx = 1;

do {
    eax = ebx;
    eax += rsp[ebx-1];

    if (!(eax = rsp[ebx])) {
        explode();
    }

    ebx++;
} while (ebx != 6)

所以,第一个循环 eax 为 1,并且 eax 加上数组第 0 个元素,判断是否等于数组第 1 个元素;之后继续循环。

可以有多种解,就是一个二阶等差数列(二次函数),相邻两个元素的差是 1 2 3 4 5。同时保证第一个数不小于 0 即可:

1
2
3
4
0 1 3 6 10 15
1 2 4 7 11 16
2 3 5 8 12 17
...

phase_3

在 sscanf 调用前,esi 便是第二个输入参数,代表了匹配的格式,打印得出:

1
2
>>> x/s $rsi
0x4025cf:       "%d %d"

这说明第三阶段希望匹配两个整数。前面将函数第三个参数 rdx 设置为 rsp 地址,第四个参数 rcx 设置为 0x4(%rsp) ,说明 rsp 指向了我们输入的两个数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
0000000000400ef3 <phase_3>:
400f0f:	be cf 25 40 00       	mov    $0x4025cf,%esi
400f14:	e8 87 fc ff ff       	callq  400ba0 <__isoc99_sscanf@plt>
400f19:	83 f8 01             	cmp    $0x1,%eax  # 比较 sscanf 返回值与 1
400f1c:	7e 10                	jle    400f2e <phase_3+0x3b> # 如果小于等于1,则爆炸
400f1e:	83 3c 24 07          	cmpl   $0x7,(%rsp) # 比较 rsp 指向的值与 7
400f22:	77 42                	ja     400f66 <phase_3+0x73> # 如果大于7,爆炸
400f24:	8b 04 24             	mov    (%rsp),%eax # 否则将 rsp 指向的值放入 eax
400f27:	ff 24 c5 40 24 40 00 	jmpq   *0x402440(,%rax,8) # 将 rax 作为索引,跳转到指定位置
...

尝试输入 1 2。调试发现 0x400f1e 步 rsp 指向的值为 1,也就是我们输入的第一个数。接下来与 7 进行比较,然后有一大堆跳转。不难想到,这是 switch 的跳转表。可知表的位置在 0x402440 ,尝试打印(打印 8 个单位,以十六进制,每个单位有 8 个字节)。

1
2
3
4
5
>>> x/8xg 0x402440
0x402440:       0x0000000000400f72      0x0000000000400f35
0x402450:       0x0000000000400f3c      0x0000000000400f43
0x402460:       0x0000000000400f4a      0x0000000000400f51
0x402470:       0x0000000000400f58      0x0000000000400f5f

这就是输入数从 0 到 7 的时候,对应的跳转位置。让我们根据下面的代码进行分析,加上注释,将第一个输入称为 x:

 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
27
28
29
30
31
32
33
34
400f1e:	83 3c 24 07          	cmpl   $0x7,(%rsp)
400f22:	77 42                	ja     400f66 <phase_3+0x73>
400f24:	8b 04 24             	mov    (%rsp),%eax
400f27:	ff 24 c5 40 24 40 00 	jmpq   *0x402440(,%rax,8)
400f2e:	e8 14 05 00 00       	callq  401447 <explode_bomb>
400f33:	eb e9                	jmp    400f1e <phase_3+0x2b> # 调到rsp的值与7的比较
400f35:	b8 35 02 00 00       	mov    $0x235,%eax   		# x = 1
400f3a:	eb 3b                	jmp    400f77 <phase_3+0x84>
400f3c:	b8 a7 01 00 00       	mov    $0x1a7,%eax   		# x = 2
400f41:	eb 34                	jmp    400f77 <phase_3+0x84>
400f43:	b8 2b 02 00 00       	mov    $0x22b,%eax   		# x = 3
400f48:	eb 2d                	jmp    400f77 <phase_3+0x84>
400f4a:	b8 6c 00 00 00       	mov    $0x6c,%eax   		# x = 4
400f4f:	eb 26                	jmp    400f77 <phase_3+0x84>
400f51:	b8 f1 02 00 00       	mov    $0x2f1,%eax   		# x = 5
400f56:	eb 1f                	jmp    400f77 <phase_3+0x84>
400f58:	b8 3e 00 00 00       	mov    $0x3e,%eax    		# x = 6
400f5d:	eb 18                	jmp    400f77 <phase_3+0x84>
400f5f:	b8 48 02 00 00       	mov    $0x248,%eax   		# x = 7
400f64:	eb 11                	jmp    400f77 <phase_3+0x84> 
400f66:	e8 dc 04 00 00       	callq  401447 <explode_bomb>
400f6b:	b8 00 00 00 00       	mov    $0x0,%eax
400f70:	eb 05                	jmp    400f77 <phase_3+0x84>
400f72:	b8 21 01 00 00       	mov    $0x121,%eax  		 # x = 0
400f77:	39 44 24 04          	cmp    %eax,0x4(%rsp) 		 # 继续运行,比较eax 与第二个输入数字的值
400f7b:	74 05                	je     400f82 <phase_3+0x8f>  # 如果相等,跳转到下方
400f7d:	e8 c5 04 00 00       	callq  401447 <explode_bomb>  # 不然爆炸
400f82:	48 8b 44 24 08       	mov    0x8(%rsp),%rax  # 开始各种栈相关的操作
400f87:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax
400f8e:	00 00 
400f90:	75 05                	jne    400f97 <phase_3+0xa4>
400f92:	48 83 c4 18          	add    $0x18,%rsp
400f96:	c3                   	retq   
400f97:	e8 64 fb ff ff       	callq  400b00 <__stack_chk_fail@plt>

通过注释,我们不难发现,当 x 从 0 遍历到 7 的时候,都会通过一行 mov 命令设置 eax 的值,然后跳转到 0x400f77,将 eax 与第二个输入进行比较。如果不相等,就爆炸。

因此可以得出多种解法,只要让第二个参数,与对应的 mov 中的硬编码的值一样即可。注意之前 sscanf 的 %d 只能匹配十进制输入,所以需要将十六进制进行转换。

原先十六进制:

1
2
3
4
5
6
7
8
0 0x121
1 0x235
2 0x1a7
3 0x22b
4 0x6c
5 0x2f1
6 0x3e
7 0x248

转换为十进制:

1
2
3
4
5
6
7
8
0 289
1 565
2 423
3 555
4 108
5 753
6 62
7 584

任意一行都是有效的解。

phase_4

phase_4 本身分析

照样有 sscanf 函数,打印出 rsi,发现还是两个整数:

1
2
>>> x/s $rsi
0x4025cf:       "%d %d"

第三个参数 rdx 设置为 rsp 地址,第四个参数 rcx 设置为 0x4 (%rsp) ,与第三阶段一样,还是将两个输入存放在 rsp 指向的位置。

调用 sscanf 后先检验返回值是否为 2,如果不是,直接爆炸。接着比较 rsp 指向的值,也就是第一个输入与 0xe(14) 的关系,如果小于等于 14,正常跳转;否则爆炸。

1
2
3
4
5
6
7
400ff7:	be cf 25 40 00       	mov    $0x4025cf,%esi
400ffc:	e8 9f fb ff ff       	callq  400ba0 <__isoc99_sscanf@plt>
401001:	83 f8 02             	cmp    $0x2,%eax # 检验返回值是否为2
401004:	75 06                	jne    40100c <phase_4+0x31>
401006:	83 3c 24 0e          	cmpl   $0xe,(%rsp)
40100a:	76 05                	jbe    401011 <phase_4+0x36> # 如果rsp指向的值小于等于14,跳转
40100c:	e8 36 04 00 00       	callq  401447 <explode_bomb> # 爆炸

分析接下来的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
401011:	ba 0e 00 00 00       	mov    $0xe,%edx
401016:	be 00 00 00 00       	mov    $0x0,%esi
40101b:	8b 3c 24             	mov    (%rsp),%edi
40101e:	e8 79 ff ff ff       	callq  400f9c <func4>
401023:	83 f8 03             	cmp    $0x3,%eax
401026:	75 07                	jne    40102f <phase_4+0x54>
401028:	83 7c 24 04 03       	cmpl   $0x3,0x4(%rsp) # 将第二个数字与 3 比较
40102d:	74 05                	je     401034 <phase_4+0x59>
40102f:	e8 13 04 00 00       	callq  401447 <explode_bomb>
401034:	48 8b 44 24 08       	mov    0x8(%rsp),%rax # 正常操作
401039:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax
401040:	00 00 
401042:	75 05                	jne    401049 <phase_4+0x6e>
401044:	48 83 c4 18          	add    $0x18,%rsp
401048:	c3                   	retq   
401049:	e8 b2 fa ff ff       	callq  400b00 <__stack_chk_fail@plt>

设置 edx 为 14,esi 为 0,edi 为第一个数字的值,然后调用 func4 函数。

根据输入参数顺序,可以 func4 第一个参数 rdi 为第一个数字,第二个参数为 rsi 为 0,第三个参数 rdx 为 14 。

调用后将返回值与 3 比较,如果不一样,直接爆炸;否则将第二个输入与 3 比较,如果不相等,直接爆炸。所以这里可以确定第二个输入为 3 。

所以,剩下来就看 func4 是如何处理 eax 的,让 fun4 返回值为 3。

func4 分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0000000000400f9c <func4>:
400f9c:	48 83 ec 08          	sub    $0x8,%rsp
400fa0:	89 d0                	mov    %edx,%eax
400fa2:	29 f0                	sub    %esi,%eax
400fa4:	89 c1                	mov    %eax,%ecx
400fa6:	c1 e9 1f             	shr    $0x1f,%ecx
400fa9:	01 c1                	add    %eax,%ecx
400fab:	d1 f9                	sar    %ecx
400fad:	01 f1                	add    %esi,%ecx
400faf:	39 f9                	cmp    %edi,%ecx
400fb1:	7f 0e                	jg     400fc1 <func4+0x25>
400fb3:	b8 00 00 00 00       	mov    $0x0,%eax
400fb8:	39 f9                	cmp    %edi,%ecx
400fba:	7c 11                	jl     400fcd <func4+0x31>
400fbc:	48 83 c4 08          	add    $0x8,%rsp
400fc0:	c3                   	retq   
400fc1:	8d 51 ff             	lea    -0x1(%rcx),%edx
400fc4:	e8 d3 ff ff ff       	callq  400f9c <func4>
400fc9:	01 c0                	add    %eax,%eax
400fcb:	eb ef                	jmp    400fbc <func4+0x20>
400fcd:	8d 71 01             	lea    0x1(%rcx),%esi
400fd0:	e8 c7 ff ff ff       	callq  400f9c <func4>
400fd5:	8d 44 00 01          	lea    0x1(%rax,%rax,1),%eax
400fd9:	eb e1                	jmp    400fbc <func4+0x20>

首先将栈指针 rsp 减小,edx(14)放入 eax,减去 esi(0),再放到 ecx,向右移位 0x1f,也就是 31 位,ecx 变成 0 。ecx 加上 eax 变成 14。单独的 sar 就是向右算数移位 1 位,ecx 变成 7。加上 esi 不变。将 ecx(7)与 edi(第一个输入)比较,如果大于,跳转。

  • 第一个输入小于 7,跳转到400fc1,将 edx 设置为 rcx 减去 1(6),递归调用自身
  • 第一个输入大于等于 7,没有跳转,eax 变成 0,比较 ecx(7)与 edi,此时一定小于等于
    • 小于时跳转400fcd,将 esi 设置为 rcx+1,即 8,递归调用
    • 输入等于 7,不跳转,将栈指针增大,返回

为了方便看,将所有变量前面的 e 或者 r 隐藏:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func4(int dx = 14, int si = 0, int di = 第一个输入) {
    int ax = dx - si;
    int cx = ax >> 31 + ax;
    cx = cx >> 1;
    cx += si;   // cx = (dx-si的符号位 + dx-si) / 2 + si
    if (cx > di) {
        dx = cx - 1;
        func4(dx, si, di); // 这里的 esi 与 edi 与输入的值一样,edx不同
        ax *= 2;
        return;
    } else {
        ax = 0;
        if (cx < di) {
            si = cx + 1;
            func4 (dx, si, di);
            ax = 2 * ax + 1;
            return;
        } else {
            return;
        }
    }
}

我们希望运行这个函数后,ax 的值为 3 。

直接分析很难,尝试倒推。ax 有几个地方会被修改:

  • 进入函数,ax 设置为 dx - si
  • 当修改后的 cx <= di 时,ax 设置为 0
  • 之后如果 cx < di,调用函数后,ax 设置为 2 * ax + 1

也可以发现,函数中 di 仅仅会在判断的时候用到,永远不会被修改。并且判断分为三种可能,也就是 cx 与 di 的大小关系,其中 cx 为 (dx-si -(dx-si) 的符号位 ) / 2 + si,看起来是取 dx 与 si 的平均值。

例如,一开始输入 dx 为 14,si 为 0,则 cx 为 7。ax 在函数开始初始化为两端之差。

  • 若均值大于 di,则 dx 变成 cx-1,也就是大端变成均值-1;然后递归调用,其中会改变 ax,最后返回 ax * 2
  • 若均值小于 di,则 si 变成 cx+1,也就是小端变成均值+1;然后递归调用,其中会改变 ax,最后返回 ax * 2 + 1
  • 如果均值等于 di,则直接返回,ax 为 0

这种形式很像二分法。我们希望最后的 ax 返回值为 3,是奇数,其倒推的过程应该是 0 -> (0 * 2 + 1 = 1) -> (1 * 2 + 1 = 3) 因此均值应该小于 di,也就是 di 大于 7 。

调用函数过程中,ax 应该为 1 。在这个函数中,1 为奇数,因此其均值也应该小于 di,第二层递归。在第二层递归中,应该均值等于 di,直接返回 ax 为 0 。这样就可以构造出结果。

[0, 14] 的均值为 7,di 大于 7 。[8, 14] 的均值为 11,di 应该大于 11。[12, 14] 的均值为 13。所以应该为 13。

总之,第四阶段输入为:

1
13 3

phase_5

开始时的 sscanf 前面还是 0x4025cf,调试发现还是将输入的两个数字,存到 rsp 所指的栈上,若输入个数小于 2 则爆炸。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
40106a:	be cf 25 40 00       	mov    $0x4025cf,%esi
40106f:	e8 2c fb ff ff       	callq  400ba0 <__isoc99_sscanf@plt> 
401074:	83 f8 01             	cmp    $0x1,%eax
401077:	7e 57                	jle    4010d0 <phase_5+0x82> #输入个数小于2,爆炸
401079:	8b 04 24             	mov    (%rsp),%eax # eax变成第一个输入
40107c:	83 e0 0f             	and    $0xf,%eax
40107f:	89 04 24             	mov    %eax,(%rsp)
401082:	83 f8 0f             	cmp    $0xf,%eax
401085:	74 2f                	je     4010b6 <phase_5+0x68> #爆炸
401087:	b9 00 00 00 00       	mov    $0x0,%ecx
40108c:	ba 00 00 00 00       	mov    $0x0,%edx
401091:	83 c2 01             	add    $0x1,%edx
401094:	48 98                	cltq   

接着 eax 变成第一个输入,与 0xf 按位与运算,写入 rsp 第一个输入。如果 eax 等于 0xf,爆炸。然后 ecx 为 0,edx 为 1,并将 eax 拓展为 64 位。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
401091:	83 c2 01             	add    $0x1,%edx # 😎
401094:	48 98                	cltq   
401096:	8b 04 85 80 24 40 00 	mov    0x402480(,%rax,4),%eax
40109d:	01 c1                	add    %eax,%ecx
40109f:	83 f8 0f             	cmp    $0xf,%eax
4010a2:	75 ed                	jne    401091 <phase_5+0x43> # 跳转到 😎,循环计数
4010a4:	c7 04 24 0f 00 00 00 	movl   $0xf,(%rsp)
4010ab:	83 fa 03             	cmp    $0x3,%edx
4010ae:	75 06                	jne    4010b6 <phase_5+0x68>
4010b0:	39 4c 24 04          	cmp    %ecx,0x4(%rsp)
4010b4:	74 05                	je     4010bb <phase_5+0x6d>
4010b6:	e8 8c 03 00 00       	callq  401447 <explode_bomb>
4010bb:	48 8b 44 24 08       	mov    0x8(%rsp),%rax
4010c0:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax
4010c7:	00 00 
4010c9:	75 0c                	jne    4010d7 <phase_5+0x89>
4010cb:	48 83 c4 18          	add    $0x18,%rsp
4010cf:	c3                   	retq   
4010d0:	e8 72 03 00 00       	callq  401447 <explode_bomb>
4010d5:	eb a2                	jmp    401079 <phase_5+0x2b>
4010d7:	e8 24 fa ff ff       	callq  400b00 <__stack_chk_fail@plt>

接下来会取 0x402480 开始的第 rax 的元素,尝试打印:

1
2
3
4
5
6
pwndbg> x/20dw 0x402480
0x402480 <array.3415>:  10      2       14      7
0x402490 <array.3415+16>:       8       12      15     11
0x4024a0 <array.3415+32>:       0       4       1      13
0x4024b0 <array.3415+48>:       3       9       6      5
0x4024c0:       2032168787      1948284271      1802398056      1970239776

发现这是一个大小为 16 的数组,这也说明了之前为什么要限制 eax 为 0 到 15 之间的数:

1
2
3
4
10 2 14 7
8 12 15 11
0 4 1 13
3 9 6 5

其索引与数组元素的对应关系如下:

01234567
1021478121511
89101112131415
041133965

取其中第 rax 的元素放入 eax,将 ecx 加上 eax。判断 eax 与 0xf(15) 的关系,如果不相等,跳转回 0x401091。期间对 edx 不断加 1,ecx 不断加上 eax。

画出其跳转关系:

0 -> 10 -> 1 -> 2 -> 14 -> 6 -> 15 -> 5 -> 12 -> 3 -> 7 -> 11 -> 13 -> 9 -> 4 -> 8 -> 0,然后循环。可以知道当变成 15 的时候终止,所以可以改变终点:

5 -> 12 -> 3 -> 7 -> 11 -> 13 -> 9 -> 4 -> 8 -> 0 -> 10 -> 1 -> 2 -> 14 -> 6 -> 15

循环结束后,将 15 写入 rsp 指向的元素,也就是第一个输入改为 15 。然后将 edx 与 3 进行比较,如果不为 3,跳转并爆炸。

这说明循环需要进行 3 次。例如,我的尝试中输入为 13 14,在 0x4010a4 打上断点,发现此时 edx 为 0xa(10),刚好是上面的链条中 13 到最后 15 的距离。

因此,第一个输入应该是 2,或者 2 + 16n,其中 n 为自然数。

之后,将 ecx 与第二个输入比较,如果不相等,爆炸。已知 ecx 是累计的数字的和,可知其为 14 + 6 + 15 = 35

总之,输入可以是如下的数:

1
2
3
4
2 35
18 35
34 35
...

phase_6

首先将多个 caller-saved 的寄存器推入栈:

1
2
3
4
5
6
0000000004010dc <phase_6>:
  4010dc:	41 56                	push   %r14
  4010de:	41 55                	push   %r13
  4010e0:	41 54                	push   %r12
  4010e2:	55                   	push   %rbp
  4010e3:	53                   	push   %rbx

接着是金丝雀数的一些操作(参考1),此处略。

然后将 eax 清零,rsp 放入第二个参数 rsi,调用 read_six_numbers。

1
2
3
4
5
6
7
4010f6:	31 c0                	xor    %eax,%eax
4010f8:	48 89 e6             	mov    %rsp,%rsi
4010fb:	e8 69 03 00 00       	callq  401469 <read_six_numbers>
401100:	49 89 e4             	mov    %rsp,%r12
401103:	49 89 e5             	mov    %rsp,%r13
401106:	41 be 00 00 00 00    	mov    $0x0,%r14d
40110c:	eb 25                	jmp    401133 <phase_6+0x57>

回顾之前的 read_six_numbers 函数,可知第一个参数 dsi 是我们的输入字符串的地址,例如此处我的输入为 2 3 4 5 6 7,第二个参数 rsi 是当前 rsp 的地址。

调用 read_six_numbers 后,打印当前 rsp 对应的量:

1
2
3
pwndbg> x/6dw $rsp
0x7fffffffd9f0: 2       3       4       5
0x7fffffffda00: 6       7

可知我们输入的六个数都存在了栈上。然后函数更改一些寄存器,跳到 0x401133。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
401133:	4c 89 ed             	mov    %r13,%rbp
401136:	41 8b 45 00          	mov    0x0(%r13),%eax
40113a:	83 e8 01             	sub    $0x1,%eax
40113d:	83 f8 05             	cmp    $0x5,%eax
401140:	77 cc                	ja     40110e <phase_6+0x32> # 爆炸
401142:	41 83 c6 01          	add    $0x1,%r14d
401146:	41 83 fe 06          	cmp    $0x6,%r14d
40114a:	74 05                	je     401151 <phase_6+0x75>
40114c:	44 89 f3             	mov    %r14d,%ebx
40114f:	eb cc                	jmp    40111d <phase_6+0x41>

将第一个输入放入 eax,与 5 比较,如果大于,爆炸。r14 之前初始化为 0 。现在加上 1,与 6 比较,如果相等,跳到 0x401151 结束循环;否则将 r14 移到 ebx 中,跳到 40111d。

1
2
3
4
5
6
7
8
401115:	83 c3 01             	add    $0x1,%ebx
401118:	83 fb 05             	cmp    $0x5,%ebx
40111b:	7f 12                	jg     40112f <phase_6+0x53>
40111d:	48 63 c3             	movslq %ebx,%rax  # 刚刚跳转的位置
401120:	8b 04 84             	mov    (%rsp,%rax,4),%eax
401123:	39 45 00             	cmp    %eax,0x0(%rbp)
401126:	75 ed                	jne    401115 <phase_6+0x39>
401128:	e8 1a 03 00 00       	callq  401447 <explode_bomb>

将 ebx 移入 rax,rsp 中第 rax 个元素放入 eax。第一次来到这里时 ebx 为 1,所以这就是取第二个输入,例如我的是 3。目前 rbp 指向元素就是第一个元素,所以比较第二个元素与第一个是否相等。如果相等,就直接爆炸。否则跳转到 401115,增加 ebx,与 5 比较,如果大于,跳转到 40112f,这会给 rsp 指向的地址加 4,也就是指向下一个元素。

利用调试器,多看几次流程。会发现其本质就是检查输入的 6 个数中第 i 个元素与第 j 个元素是否相等,如果相等就会触发爆炸。然后每次循环后增加 i 索引。同时,也会讲当前第 i 个元素减 1 后与 5 比较,如果大于,也会爆炸。说明当前元素应该小于等于 6 。

因此,我们尝试输入 1 2 3 4 5 6,为 0x401151 打断点,跳转。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
401151:	49 8d 4c 24 18       	lea    0x18(%r12),%rcx
401156:	ba 07 00 00 00       	mov    $0x7,%edx
40115b:	89 d0                	mov    %edx,%eax
40115d:	41 2b 04 24          	sub    (%r12),%eax
401161:	41 89 04 24          	mov    %eax,(%r12)
401165:	49 83 c4 04          	add    $0x4,%r12
401169:	4c 39 e1             	cmp    %r12,%rcx
40116c:	75 ed                	jne    40115b <phase_6+0x7f>
40116e:	be 00 00 00 00       	mov    $0x0,%esi
401173:	eb 1a                	jmp    40118f <phase_6+0xb3>

首先将输入字符串的指针的地址(在栈上)写入 rcx。edx 与 eax 设为 7,将 7 - 第一个输入 写入第一个输入。然后增加 r12 指针指向下一个元素,直到指向尽头(rcx),否则跳转到 0x40115b 重复操作。总之,就是数组每个元素都变成 7-自身,因此现在我的数组变成了 6 5 4 3 2 1。将 esi 变成 0,跳转到 0x40118f 。

1
2
3
4
5
6
40118f:	8b 0c b4             	mov    (%rsp,%rsi,4),%ecx
401192:	b8 01 00 00 00       	mov    $0x1,%eax
401197:	ba d0 32 60 00       	mov    $0x6032d0,%edx
40119c:	83 f9 01             	cmp    $0x1,%ecx
40119f:	7f d4                	jg     401175 <phase_6+0x99>
4011a1:	eb dd                	jmp    401180 <phase_6+0xa4>

rsp[rsi] 放入 ecx,eax 写入 1,edx 是一个地址,然后 rsp[rsi] 与 1 比较。如果大于,跳转到 0x401175,否则跳转到 0x401180。尝试打印 edx:

1
2
3
4
pwndbg> x/12xw 0x6032d0
0x6032d0 <node1>:       0x0000027a      0x00000001      0x006032e0       0x00000000
0x6032e0 <node2>:       0x00000353      0x00000002      0x006032f0       0x00000000
0x6032f0 <node3>:       0x00000399      0x00000003      0x00603300       0x00000000

可以看到这是一个类似链表的结构—— node1 中 0x006032e0 指向下一个的地址 0x006032e0,以此类推。我们看看一共有多少:

1
2
3
4
5
6
7
8
pwndbg> x/28xw 0x6032d0
0x6032d0 <node1>:       0x0000027a      0x00000001      0x006032e0       0x00000000
0x6032e0 <node2>:       0x00000353      0x00000002      0x006032f0       0x00000000
0x6032f0 <node3>:       0x00000399      0x00000003      0x00603300       0x00000000
0x603300 <node4>:       0x00000136      0x00000004      0x00603310       0x00000000
0x603310 <node5>:       0x00000249      0x00000005      0x00603320       0x00000000
0x603320 <node6>:       0x0000008a      0x00000006      0x00000000       0x00000000
0x603330 <bomb_id>:     0x000007e6      0x00000000      0x00000000       0x00000000

可以看到刚好有 6 个,并且其中数字部分就是 1 2 3 4 5 6 。尝试更换我们的输入,我们发现这些链表的值与我们的输入是无关的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Good work!  On to the next...
6 5 4 3 2 1

Breakpoint 1, 0x00000000004010dc in phase_6 ()

pwndbg> c
Continuing.

Breakpoint 2, 0x0000000000401151 in phase_6 ()

pwndbg> c
Continuing.

Breakpoint 3, 0x0000000000401197 in phase_6 ()

pwndbg> x/28xw 0x6032d0
0x6032d0 <node1>:       0x0000027a      0x00000001      0x006032e0       0x00000000
0x6032e0 <node2>:       0x00000353      0x00000002      0x006032f0       0x00000000
0x6032f0 <node3>:       0x00000399      0x00000003      0x00603300       0x00000000
0x603300 <node4>:       0x00000136      0x00000004      0x00603310       0x00000000
0x603310 <node5>:       0x00000249      0x00000005      0x00603320       0x00000000
0x603320 <node6>:       0x0000008a      0x00000006      0x00000000       0x00000000
0x603330 <bomb_id>:     0x000007e6      0x00000000      0x00000000       0x00000000

换回 1 2 3 4 5 6 的输入。我这里 rsp[rsi] 是 5,所以大于,跳转到 0x401175。

1
2
3
4
5
6
7
8
401175:	48 8b 52 08          	mov    0x8(%rdx),%rdx
401179:	83 c0 01             	add    $0x1,%eax
40117c:	39 c8                	cmp    %ecx,%eax
40117e:	75 f5                	jne    401175 <phase_6+0x99>
401180:	48 89 54 f4 20       	mov    %rdx,0x20(%rsp,%rsi,8)
401185:	48 83 c6 01          	add    $0x1,%rsi
401189:	48 83 fe 06          	cmp    $0x6,%rsi
40118d:	74 14                	je     4011a3 <phase_6+0xc7>

这一段不断增加 rdx,也就是寻找下一个链表元素,并且计数到 eax 中。记住 ecx 是 rsp[rsi] ,所以就相当于是取这个链表的第 rsp[rsi] 个元素,rdx 就指向该元素。

然后将链表的第 rsp[rsi] 个元素的地址放入栈中。结束循环,现在的栈如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
00:0000│ rsp         0x7fffffffd9f0 ◂— 0x500000006
01:0008│-00c         0x7fffffffd9f8 ◂— 0x300000004
02:0010│ r13-4 rbp-4 0x7fffffffda00 ◂— 0x100000002
03:0018│ r12         0x7fffffffda08 —▸ 0x603910 (input_strings+400) ◂— '1 2 3 4 5 6'
04:0020│+00c         0x7fffffffda10 —▸ 0x603320 (node6) ◂— 0x60000008a
05:0028│+014         0x7fffffffda18 —▸ 0x603310 (node5) ◂— 0x500000249
06:0030│+01c         0x7fffffffda20 —▸ 0x603300 (node4) ◂— 0x400000136
07:0038│+024         0x7fffffffda28 —▸ 0x6032f0 (node3) ◂— 0x300000399
08:0040│+02c         0x7fffffffda30 —▸ 0x6032e0 (node2) ◂— 0x200000353
09:0048│+034         0x7fffffffda38 —▸ 0x6032d0 (node1) ◂— 0x10000027a
0a:0050│+03c         0x7fffffffda40 ◂— 0

可以看到栈中 00 到 02 存储的是输入数字,已经经过被7减处理了,原本是 1 2 3 4 5 6,现在是 6 5 4 3 2 1。03 存储的是输入字符串的指针。04 到 09 存储的是链表节点的指针,顺序是根据处理后的输入决定的。

例如,输入 2 4 5 3 1 6,处理后变成 5 3 2 4 5 1,内存如下:

1
2
3
4
5
6
04:0020│+00c         0x7fffffffda10 —▸ 0x603310 (node5) ◂— 0x500000249
05:0028│+014         0x7fffffffda18 —▸ 0x6032f0 (node3) ◂— 0x300000399
06:0030│+01c         0x7fffffffda20 —▸ 0x6032e0 (node2) ◂— 0x200000353
07:0038│+024         0x7fffffffda28 —▸ 0x603300 (node4) ◂— 0x400000136
08:0040│+02c         0x7fffffffda30 —▸ 0x603320 (node6) ◂— 0x60000008a
09:0048│+034         0x7fffffffda38 —▸ 0x6032d0 (node1) ◂— 0x10000027a

可以看到这里链表指针的顺序就与我们的输入有关。

接下来又是许多代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
4011a3:	48 8b 5c 24 20       	mov    0x20(%rsp),%rbx
4011a8:	48 8b 44 24 28       	mov    0x28(%rsp),%rax
4011ad:	48 89 43 08          	mov    %rax,0x8(%rbx)
4011b1:	48 8b 54 24 30       	mov    0x30(%rsp),%rdx
4011b6:	48 89 50 08          	mov    %rdx,0x8(%rax)
4011ba:	48 8b 44 24 38       	mov    0x38(%rsp),%rax
4011bf:	48 89 42 08          	mov    %rax,0x8(%rdx)
4011c3:	48 8b 54 24 40       	mov    0x40(%rsp),%rdx
4011c8:	48 89 50 08          	mov    %rdx,0x8(%rax)
4011cc:	48 8b 44 24 48       	mov    0x48(%rsp),%rax
4011d1:	48 89 42 08          	mov    %rax,0x8(%rdx)
4011d5:	48 c7 40 08 00 00 00 	movq   $0x0,0x8(%rax)
4011dc:	00 
4011dc:	00 
4011dd:	bd 05 00 00 00       	mov    $0x5,%ebp
4011e2:	eb 09                	jmp    4011ed <phase_6+0x111>

还是先将输入变回 1 2 3 4 5 6,然后会发现这串代码会改变链表的顺序。原始顺序:

1 -> 2 -> 3 -> 4 -> 5 -> 6

在存放顺序是 6 5 4 3 2 1 的情况下,这些操作的结果如下(我省略了其他代码的结果):

1
2
3
4
5
6
0x4011ad <phase_6+209>    movq   %rax, 8(%rbx)        [node6+8] => 0x603310 (node5) ◂— 0x500000249
0x4011b6 <phase_6+218>    movq   %rdx, 8(%rax)        [node5+8] => 0x603300 (node4) ◂— 0x400000136
0x4011bf <phase_6+227>    movq   %rax, 8(%rdx)        [node4+8] => 0x6032f0 (node3) ◂— 0x300000399
0x4011c8 <phase_6+236>    movq   %rdx, 8(%rax)        [node3+8] => 0x6032e0 (node2) ◂— 0x200000353
0x4011d1 <phase_6+245>    movq   %rax, 8(%rdx)        [node2+8] => 0x6032d0 (node1) ◂— 0x10000027a
0x4011d5 <phase_6+249>    movq   $0, 8(%rax)          [node1+8] => 0

再次打印,发现顺序发生变化:

1
2
3
4
5
6
7
8
pwndbg> x/28xw 0x6032d0
0x6032d0 <node1>:       0x0000027a      0x00000001      0x00000000       0x00000000
0x6032e0 <node2>:       0x00000353      0x00000002      0x006032d0       0x00000000
0x6032f0 <node3>:       0x00000399      0x00000003      0x006032e0       0x00000000
0x603300 <node4>:       0x00000136      0x00000004      0x006032f0       0x00000000
0x603310 <node5>:       0x00000249      0x00000005      0x00603300       0x00000000
0x603320 <node6>:       0x0000008a      0x00000006      0x00603310       0x00000000
0x603330 <bomb_id>:     0x000007e6      0x00000000      0x00000000       0x00000000

6 -> 5 -> 4 -> 3 -> 2 -> 1 -> NaN

所以,该函数的功能就是根据栈中存放的顺序,重新调整链表节点的顺序。例如输入 1 2 3 4 5 6,存放到栈中为 6 5 4 3 2 1 ,于是链表顺序变成 6 5 4 3 2 1

尝试输入 5 3 1 2 6 4 ,存放到栈中为 2 4 6 5 1 3,链表顺序为 2 4 6 5 1 3

接着跳转到 0x4011ed,将 rbx 指向的下一个节点地址放入 rax,将该下一个节点的值放入 eax,让 rbx 指向的节点的值与该值比较。如果大于等于,跳转;否则爆炸。

1
2
3
4
5
4011ed:	48 8b 43 08          	mov    0x8(%rbx),%rax
4011f1:	8b 00                	mov    (%rax),%eax
4011f3:	39 03                	cmp    %eax,(%rbx)
4011f5:	7d ed                	jge    4011e4 <phase_6+0x108>
4011f7:	e8 4b 02 00 00       	callq  401447 <explode_bomb>

想要跳出循环,需要在上面满足该条件,也就是 ebp 等于 0。而 ebp 一开始为 5,说明会进行遍历操作。

1
2
3
4011e4:	48 8b 5b 08          	mov    0x8(%rbx),%rbx
4011e8:	83 ed 01             	sub    $0x1,%ebp
4011eb:	74 11                	je     4011fe <phase_6+0x122>

若此处跳转,就可以结束了。所以,我们的目标明确了起来——找到一组 1-6 的顺序,可以调整链表的连接顺序,进而满足最后的每个节点大于另一个节点的条件

观察原始链表中的大小,可以得到正确的排序:

链表 id
30x399
20x353
10x27a
50x249
40x136
60x08a

也就是处理后为 3 2 1 5 4 6,处理前为 4 5 6 2 3 1。第六阶段密码为 4 5 6 2 3 1

结束了?

翻一下汇编,我们会发现一个神秘的函数 fun7,它会在 secret_phase 中被调用。搜索 secret_phase,发现它隐藏在 phase_defused 中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
00000000004015d6 <phase_defused>:
4015d6:	48 83 ec 78          	sub    $0x78,%rsp
4015da:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
4015e1:	00 00 
4015e3:	48 89 44 24 68       	mov    %rax,0x68(%rsp)
4015e8:	31 c0                	xor    %eax,%eax
4015ea:	83 3d 7b 21 20 00 06 	cmpl   $0x6,0x20217b(%rip)        # 60376c <num_input_strings>
4015f1:	74 15                	je     401608 <phase_defused+0x32>
4015f3:	48 8b 44 24 68       	mov    0x68(%rsp),%rax
4015f8:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax
4015ff:	00 00 
401601:	75 67                	jne    40166a <phase_defused+0x94>
401603:	48 83 c4 78          	add    $0x78,%rsp
401607:	c3                   	retq   

发现其中要想进入隐藏阶段,必须满足 cmpl $0x6,0x20217b(%rip),这就是 num_input_strings 的地址。在 read_line 中,每次读取一行的输入,都会将该变量加一。所以这一行代表着结束第六阶段后才能解锁。

1
2
3
4
5
6
7
8
401608:	4c 8d 44 24 10       	lea    0x10(%rsp),%r8
40160d:	48 8d 4c 24 0c       	lea    0xc(%rsp),%rcx
401612:	48 8d 54 24 08       	lea    0x8(%rsp),%rdx
401617:	be 19 26 40 00       	mov    $0x402619,%esi
40161c:	bf 70 38 60 00       	mov    $0x603870,%edi
401621:	e8 7a f5 ff ff       	callq  400ba0 <__isoc99_sscanf@plt>
401626:	83 f8 03             	cmp    $0x3,%eax
401629:	74 0c                	je     401637 <phase_defused+0x61>

调试可以发现,该 sscanf 的输入是 input_strings+240,也就是第四阶段输入的字符串,例如我的是 13 3。匹配的格式是 %d %d %s,说明需要额外输入一个字符串。

尝试改变第四阶段输入为 13 3 hello,进入 0x401637 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
401637:	be 22 26 40 00       	mov    $0x402622,%esi
40163c:	48 8d 7c 24 10       	lea    0x10(%rsp),%rdi
401641:	e8 04 fd ff ff       	callq  40134a <strings_not_equal>
401646:	85 c0                	test   %eax,%eax
401648:	75 e1                	jne    40162b <phase_defused+0x55>
40164a:	bf f8 24 40 00       	mov    $0x4024f8,%edi
40164f:	e8 8c f4 ff ff       	callq  400ae0 <puts@plt>
401654:	bf 20 25 40 00       	mov    $0x402520,%edi
401659:	e8 82 f4 ff ff       	callq  400ae0 <puts@plt>
40165e:	b8 00 00 00 00       	mov    $0x0,%eax
401663:	e8 f7 fb ff ff       	callq  40125f <secret_phase>

发现 esi 为字符串 urxvt,然后调用字符串匹配。因此我们修改输入为 13 3 urxvt,成功进入隐藏阶段。

一开始就要输入,然后调用 strtol 将字符串转换为 long。接着返回值移入 rbx,eax 变成输入的数减一,与 0x3e8 (1000) 比较,如果大于,爆炸。因此我们尝试输入 987。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
00000000040125f <secret_phase>:
40125f:	53                   	push   %rbx
401260:	e8 43 02 00 00       	callq  4014a8 <read_line>
401265:	ba 0a 00 00 00       	mov    $0xa,%edx
40126a:	be 00 00 00 00       	mov    $0x0,%esi
40126f:	48 89 c7             	mov    %rax,%rdi
401272:	e8 09 f9 ff ff       	callq  400b80 <strtol@plt>
401277:	48 89 c3             	mov    %rax,%rbx
40127a:	8d 40 ff             	lea    -0x1(%rax),%eax
40127d:	3d e8 03 00 00       	cmp    $0x3e8,%eax
401282:	77 27                	ja     4012ab <secret_phase+0x4c>
401284:	89 de                	mov    %ebx,%esi
401286:	bf f0 30 60 00       	mov    $0x6030f0,%edi
40128b:	e8 90 ff ff ff       	callq  401220 <fun7>

接下来调用函数 fun7,第一个参数为 0x6030f0,第二个参数为我们输入的数。可以发现我们希望这个函数的返回值是 4,这就是最终目标。

1
2
3
4
5
6
0000000000401220 <fun7>:
401220:	48 85 ff             	test   %rdi,%rdi
401223:	74 34                	je     401259 <fun7+0x39>
...
401259:	b8 ff ff ff ff       	mov    $0xffffffff,%eax
40125e:	c3                   	retq   

首先判断 rdi 不为 0,否则直接返回 -1 。

然后将 rdi 指向的值放入 edx,这里的值是 0x24,判断与我们输入数的关系。

  • 如果输入大于 edx,不跳转,eax 写入 0,跳转到 40124a。将 rdi 后面隔 16 个字节的中数写入 rdi,递归调用
  • 如果输入小于等于 edx,跳转,将 rdi 后面隔 8 个字节中的数写入 rdi,递归调用

打印这些值:

 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
27
28
29
30
31
32
33
pwndbg> x/64xg $rdi
0x6030f0 <n1>:  0x0000000000000024      0x0000000000603110 <n21>
0x603100 <n1+16>:       0x0000000000603130 <n22>      0x0000000000000000
0x603110 <n21>: 0x0000000000000008      0x0000000000603190 <n31>
0x603120 <n21+16>:      0x0000000000603150 <n32>      0x0000000000000000
0x603130 <n22>: 0x0000000000000032      0x0000000000603170 <n33>
0x603140 <n22+16>:      0x00000000006031b0 <n34>      0x0000000000000000
0x603150 <n32>: 0x0000000000000016      0x0000000000603270 <n43>
0x603160 <n32+16>:      0x0000000000603230 <n44>      0x0000000000000000 
0x603170 <n33>: 0x000000000000002d      0x00000000006031d0 <n45>
0x603180 <n33+16>:      0x0000000000603290 <n46>      0x0000000000000000
0x603190 <n31>: 0x0000000000000006      0x00000000006031f0 <n41>
0x6031a0 <n31+16>:      0x0000000000603250 <n42>      0x0000000000000000
0x6031b0 <n34>: 0x000000000000006b      0x0000000000603210 <n47>
0x6031c0 <n34+16>:      0x00000000006032b0 <n48>     0x0000000000000000
0x6031d0 <n45>: 0x0000000000000028      0x0000000000000000
0x6031e0 <n45+16>:      0x0000000000000000      0x0000000000000000
0x6031f0 <n41>: 0x0000000000000001      0x0000000000000000
0x603200 <n41+16>:      0x0000000000000000      0x0000000000000000
0x603210 <n47>: 0x0000000000000063      0x0000000000000000
0x603220 <n47+16>:      0x0000000000000000      0x0000000000000000
0x603230 <n44>: 0x0000000000000023      0x0000000000000000
0x603240 <n44+16>:      0x0000000000000000      0x0000000000000000
0x603250 <n42>: 0x0000000000000007      0x0000000000000000
0x603260 <n42+16>:      0x0000000000000000      0x0000000000000000
0x603270 <n43>: 0x0000000000000014      0x0000000000000000
0x603280 <n43+16>:      0x0000000000000000      0x0000000000000000
0x603290 <n46>: 0x000000000000002f      0x0000000000000000
0x6032a0 <n46+16>:      0x0000000000000000      0x0000000000000000
0x6032b0 <n48>: 0x00000000000003e9      0x0000000000000000
0x6032c0 <n48+16>:      0x0000000000000000      0x0000000000000000
0x6032d0 <node1>:       0x000000010000027a      0x0000000000603310
0x6032e0 <node2>:       0x0000000200000353      0x00000000006032d0

这些节点的相互引用关系如图:

树的指针参考

不断调用,可以发现当 rdi 指向 n48 时,n48 中的值为 3e9 (1001),必定大于我们的输入。此时结束,跳转到 0x40123d,此时会取 rdi 指向位置的后 8 个字节,放入 rdi(与之前的 16 字节不同!)。不过现在 n48 对应的这个值为 0 。再次递归调用。这时候因为 rdi 参数为 0,所以将 -1 写入 eax,返回。

  • 8 字节情况返回 401246:eax = eax + eax,例如我这里变成了-2
  • 16 字节情况返回 401253:eax = rax + rax + 1,例如我这里有几个状态:
    • -2 + -2 + 1 = -3
    • -3 + -3 + 1 = -5
    • -5 + -5 + 1 = -9
  • 最终在 987 输入的情况下,返回为 -9

于是,当时让我迷惑的事情来了:secret_phase 中希望我们最后得到的返回值是整数 4,这怎么做到呢?

尝试输入 1 调试,发现当输入的数恰好等于叶子结点时,就会停止递归调用。此时会运行 0x40122f ,将 eax 变成 0 。所以,从反向推导,要得到 4 应该是这样的:

0 -> 2 * 0 + 1 = 1 -> 1 * 2 = 2 -> 2 * 2 = 4

反向过程先是一个大于,然后是两个小于等于。正向过程就是先两个小于等于,然后一个大于。

参考图片:

树的指针参考2

答案为 7 。

总结

这个实验确实量大管饱,我差不多前前后后花了好几天时间才写完。

对于此前没有接触过汇编的我来说,确实有些难度。还好借助了 GDB Dashboard、pwndbg 等强大的调试器,以及部分地方参考了 arthals 的北大 bomblab 博客1。自然有些细节上还是有差异的,并且该博客没有写 secret phase。

经过这样的折腾,确实感觉自己对汇编的掌握更加深入。也让我想起了很久之前手挫 cs61b 的 gitlet 的快感()

该指南很多地方都是我做 lab 的时候,边想边写的,所以语句可能不通顺,也不是最适合理解的顺序。该指南只记录了我解题中的一些思考过程。如果能在某个卡点帮到你,有所启发,那么我的目的就达成了。

参考资料


  1. 更适合北大宝宝体质的 Bomb Lab 踩坑记. https://arthals.ink/blog/bomb-lab ↩︎ ↩︎ ↩︎

  2. sscanf. cplusplus. https://cplusplus.com/reference/cstdio/sscanf ↩︎

Licensed under CC BY-NC-SA 4.0