CSAPP Bomb Lab
本 Lab 可以说是 CSAPP 的几个 Lab 中最为人津津乐道的一个,对应知识点为书中的第 3 章(程序的机器级表示),要求使用 GDB 调试器,对汇编语言进行调试,从而得出正确的“拆弹密码”。共分为 6 个关卡和一个隐藏关卡,每个关卡都分别考察了一种语法结构或数据结构的汇编表示,部分关卡逻辑比较复杂,要求对 x86 汇编有一定的熟悉度。
bomb.c
int main(int argc, char *argv[])
{
char *input;
/* Note to self: remember to port this bomb to Windows and put a
* fantastic GUI on it. */
/* When run with no arguments, the bomb reads its input lines
* from standard input. */
if (argc == 1) {
infile = stdin;
}
/* When run with one argument <file>, the bomb reads from <file>
* until EOF, and then switches to standard input. Thus, as you
* defuse each phase, you can add its defusing string to <file> and
* avoid having to retype it. */
else if (argc == 2) {
if (!(infile = fopen(argv[1], "r"))) {
printf("%s: Error: Couldn't open %s\n", argv[0], argv[1]);
exit(8);
}
}
/* You can't call the bomb with more than 1 command line argument. */
else {
printf("Usage: %s [<input_file>]\n", argv[0]);
exit(8);
}
/* Do all sorts of secret stuff that makes the bomb harder to defuse. */
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");
/* The second phase is harder. No one will ever figure out
* how to defuse this... */
input = read_line();
phase_2(input);
phase_defused();
printf("That's number 2. Keep going!\n");
/* I guess this is too easy so far. Some more complex code will
* confuse people. */
input = read_line();
phase_3(input);
phase_defused();
printf("Halfway there!\n");
/* Oh yeah? Well, how good is your math? Try on this saucy problem! */
input = read_line();
phase_4(input);
phase_defused();
printf("So you got that one. Try this one.\n");
/* Round and 'round in memory we go, where we stop, the bomb blows! */
input = read_line();
phase_5(input);
phase_defused();
printf("Good work! On to the next...\n");
/* This phase will never be used, since no one will get past the
* earlier ones. But just in case, make this one extra hard. */
input = read_line();
phase_6(input);
phase_defused();
/* Wow, they got it! But isn't something... missing? Perhaps
* something they overlooked? Mua ha ha ha ha! */
return 0;
}
首先观察 bomb.c
的 main 函数结构,最开始判断 argc 是否为 1,如果为 1,表示运行 bomb 程序时没有指定命令行参数,即从标准输入中读取 “拆弹密码”;否则,从指定的文件中读取。为了后续调试的方便,可以将所有的密码写入一个文件 ans.txt
中,后续在启动 bomb 程序时对其指定:./bomb ans.txt
.
随后便是初始化“炸弹”,每次读取一行密码,利用该密码进行“拆弹”,如果正确,则进入下一关卡,否则,“炸弹”就会爆炸,“拆弹”失败。一次性输对 6 个密码后,“炸弹”就会被“拆除”。
注意最后的注释:
Wow, they got it! But isn’t something… missing? Perhaps something they overlooked? Mua ha ha ha ha!
一定程度上暗示了隐藏关卡的存在。
phase_1
每次拆弹时,可以使用 disas
命令进行反汇编,查看函数对应的汇编代码,以下是 disas phase_1
的结果:
0x0000000000400ee0 <+0>: sub $0x8,%rsp
0x0000000000400ee4 <+4>: mov $0x402400,%esi
0x0000000000400ee9 <+9>: call 0x401338 <strings_not_equal>
0x0000000000400eee <+14>: test %eax,%eax
0x0000000000400ef0 <+16>: je 0x400ef7 <phase_1+23>
0x0000000000400ef2 <+18>: call 0x40143a <explode_bomb>
0x0000000000400ef7 <+23>: add $0x8,%rsp
0x0000000000400efb <+27>: ret
热身关卡,代码的逻辑很简单,读取一行密码,判断该密码与事先指定的字符串是否相同,如果不相同,则“引爆炸弹”。
这里需要熟悉 x86 寄存器的使用惯例(也可以 GDB 自行调试),寄存器 %rdi
寄存器 %rsi
分别作为函数调用时的参数 1 和参数 2。在这里,%rdi
存储着读取到的密码字符串(准确来说,是字符串首字母的地址),而 %rsi
则被赋值为 0x402400
,然后,将这两个地址作为参数 1 和参数 2,调用 string_not_equal
,从函数名称上看,该函数用来判定两个字符串是否相同。那么思路就很清晰了,密码就是地址 0x402400
处的字符串值,使用 x/s 0x402400
查看即可。
phase_2
0x0000000000400efc <+0>: push %rbp
0x0000000000400efd <+1>: push %rbx
0x0000000000400efe <+2>: sub $0x28,%rsp
0x0000000000400f02 <+6>: mov %rsp,%rsi
0x0000000000400f05 <+9>: call 0x40145c <read_six_numbers>
0x0000000000400f0a <+14>: cmpl $0x1,(%rsp)
0x0000000000400f0e <+18>: je 0x400f30 <phase_2+52>
0x0000000000400f10 <+20>: call 0x40143a <explode_bomb>
0x0000000000400f15 <+25>: jmp 0x400f30 <phase_2+52>
0x0000000000400f17 <+27>: mov -0x4(%rbx),%eax
0x0000000000400f1a <+30>: add %eax,%eax
0x0000000000400f1c <+32>: cmp %eax,(%rbx)
0x0000000000400f1e <+34>: je 0x400f25 <phase_2+41>
0x0000000000400f20 <+36>: call 0x40143a <explode_bomb>
0x0000000000400f25 <+41>: add $0x4,%rbx
0x0000000000400f29 <+45>: cmp %rbp,%rbx
0x0000000000400f2c <+48>: jne 0x400f17 <phase_2+27>
0x0000000000400f2e <+50>: jmp 0x400f3c <phase_2+64>
0x0000000000400f30 <+52>: lea 0x4(%rsp),%rbx
0x0000000000400f35 <+57>: lea 0x18(%rsp),%rbp
0x0000000000400f3a <+62>: jmp 0x400f17 <phase_2+27>
0x0000000000400f3c <+64>: add $0x28,%rsp
0x0000000000400f40 <+68>: pop %rbx
0x0000000000400f41 <+69>: pop %rbp
0x0000000000400f42 <+70>: ret
这一关主要是考察 循环语句 ,可以仔细阅读书中第 3.6.7 节,加强对汇编的循环结构的熟悉程度,如果感觉思路很乱,可以采用与书中类似的方法:先将汇编翻译为等价的带 goto 的高级语言,再参考几种典型的循环形式,将 goto 改写为循环结构,以下便是最终翻译得到的类 C 语言伪代码:
read_six_numbers();
if (Mem[%rsp] != 1) {
explode_bomb();
}
for (%rbx = %rsp + 4, %rbp = %rsp + 24; %rbx != %rbp; %rbx += 4) {
%eax = Mem[%rbx - 4]; // 上一个元素
%eax *= 2;
if (Mem[%rbx] != %eax) {
explode_bomb();
}
}
首先注意到 read_six_numbers()
函数,字面意思是读取 6 个数字,推测密码由 6 个数字组成。
然后判断 Mem[%rsp]
的值是否为 1,不是则“爆炸”。这里可以善用 GDB,先随便蒙 6 个数字,然后使用 p/x
打印 Mem[%rsp]
的值,发现其值正好等于输入的第一个数字,结合后面的 6 次循环可知,输入的第 i (i 从 0 开始)个数字存储在地址 %rsp + 4 * i
处,且每个数字都必须为它前一个数字的两倍。
那么代码逻辑便理清楚了:输入的第一个数字为 1,其后每一个数字都为前一个数字的两倍,密码为:1 2 4 8 16 32
.
phase_3
0x0000000000400f43 <+0>: sub $0x18,%rsp
0x0000000000400f47 <+4>: lea 0xc(%rsp),%rcx
0x0000000000400f4c <+9>: lea 0x8(%rsp),%rdx
0x0000000000400f51 <+14>: mov $0x4025cf,%esi
0x0000000000400f56 <+19>: mov $0x0,%eax
0x0000000000400f5b <+24>: call 0x400bf0 <__isoc99_sscanf@plt>
0x0000000000400f60 <+29>: cmp $0x1,%eax
0x0000000000400f63 <+32>: jg 0x400f6a <phase_3+39>
0x0000000000400f65 <+34>: call 0x40143a <explode_bomb>
0x0000000000400f6a <+39>: cmpl $0x7,0x8(%rsp)
0x0000000000400f6f <+44>: ja 0x400fad <phase_3+106>
0x0000000000400f71 <+46>: mov 0x8(%rsp),%eax
0x0000000000400f75 <+50>: jmp *0x402470(,%rax,8)
0x0000000000400f7c <+57>: mov $0xcf,%eax
0x0000000000400f81 <+62>: jmp 0x400fbe <phase_3+123>
0x0000000000400f83 <+64>: mov $0x2c3,%eax
0x0000000000400f88 <+69>: jmp 0x400fbe <phase_3+123>
0x0000000000400f8a <+71>: mov $0x100,%eax
0x0000000000400f8f <+76>: jmp 0x400fbe <phase_3+123>
0x0000000000400f91 <+78>: mov $0x185,%eax
0x0000000000400f96 <+83>: jmp 0x400fbe <phase_3+123>
0x0000000000400f98 <+85>: mov $0xce,%eax
0x0000000000400f9d <+90>: jmp 0x400fbe <phase_3+123>
0x0000000000400f9f <+92>: mov $0x2aa,%eax
0x0000000000400fa4 <+97>: jmp 0x400fbe <phase_3+123>
0x0000000000400fa6 <+99>: mov $0x147,%eax
0x0000000000400fab <+104>: jmp 0x400fbe <phase_3+123>
0x0000000000400fad <+106>: call 0x40143a <explode_bomb>
0x0000000000400fb2 <+111>: mov $0x0,%eax
0x0000000000400fb7 <+116>: jmp 0x400fbe <phase_3+123>
0x0000000000400fb9 <+118>: mov $0x137,%eax
0x0000000000400fbe <+123>: cmp 0xc(%rsp),%eax
0x0000000000400fc2 <+127>: je 0x400fc9 <phase_3+134>
0x0000000000400fc4 <+129>: call 0x40143a <explode_bomb>
0x0000000000400fc9 <+134>: add $0x18,%rsp
0x0000000000400fcd <+138>: ret
这一关的代码量比较大,但是中间一段看起来很有规律,尤其注意这一句:jmp *0x402470(, %rax, 8)
,直接根据 %rax
寄存器的值计算偏移量进行跳转,这便是 switch 语句 所采用的跳转方式,地址 0x402470
即跳转表的首地址。
另外,还需要关注的一条指令是 call 0x400bf0 <__isoc99_sscanf@plt>
,貌似是一个函数调用指令,以下是我借助大语言模型得到的解释:
__isoc99_sscanf@plt
是一个指向sscanf
函数的 PLT(Procedure Linkage Table)入口点的符号引用。sscanf
函数是 C 语言标准库中的一个函数,用于从输入流中按照指定格式读取数据。@plt
表示这是一个通过动态链接的程序跳转表(Procedure Linkage Table)来调用的函数。参数传递
在 x86-64 架构中,函数参数通常是通过寄存器传递的。对于
sscanf
函数,它的参数如下:
%rdi
:第一个参数,通常是文件描述符或指针类型。对于sscanf
,这是指向输入字符串的指针。%rsi
:第二个参数,指向格式化字符串的指针。%rdx
:第三个参数,如果有的话,指向第一个要填充的变量的地址。- 更多的参数会继续使用后续的寄存器
%rcx
,%r8
, 和%r9
。如果参数超过六个,那么它们将会通过栈传递。返回值
在 x86-64 架构中,返回值会被放在
%rax
寄存器中。sscanf
返回成功匹配和赋值的项数,如果没有任何匹配,则返回零。如果输入结束前格式化字符串就被耗尽了,也返回零。如果遇到任何读取错误(如读取一个整数但输入不是有效的整数),则返回负数。
简而言之,sscanf
类似于 scanf
,只是输入从标准输入变成了指定的字符串。在这里,sscanf
指定了 4 个参数,作用为:从 %rdi
寄存器指向的字符串中进行读取,%rsi
指向格式化字符串,%rdx
和 %rcx
分别指向被格式化读取到的变量 1 和变量 2. 若读取成功,则返回成功读取的项数,即为 2,存入 %rax
寄存器中。
查看 0x4025cf
处的字符串,即格式化字符串,为 %d %d
,说明读取的两个值都为十进制整数,即本关密码的形式。
最后查看一下整张跳转表的值,根据最终跳转到的位置确定输入的值。
然后将其改写为 switch 语句,下面直接给出完整代码的翻译结果:
%rcx = %rsp + 12;
%rdx = %rsp + 8;
%esi = 0x4025cf;
%eax = 0;
sscanf(%rdi, %rsi, %rdx, %rcx);
if (%eax <= 1) { // 读取成功的值个数小于2
explode_bomb();
}
if (Mem[%rsp + 8] > 7u) { // 读取到的(输入的)第一个值大于7或小于0
explode_bomb();
}
%eax = Mem[%rsp + 8];
switch (%rax) {
case 0:
%eax = 0xcf; break;
case 1:
%eax = 0x137; break;
case 2:
%eax = 0x2c3; break;
case 3:
%eax = 0x100; break;
case 4:
%eax = 0x185; break;
case 5:
%eax = 0xce; break;
case 6:
%eax = 0x2aa; break;
case 7:
%eax = 0x147; break;
}
// 输入的第二个值等于%eax寄存器的值
if (%eax != Mem[%rsp + 12]) {
explode_bomb();
}
要使得 %eax
的值等于输入的第二个值,只需要保证输入的第一个值经过 switch 语句选择之后,赋值正好等于输入的第二个值。
因此本关的答案并不是固定的,0 207
、 3 256
等等都是正确答案。注意不能写成 0 0xcf
、3 0x100
,因为输入格式为十进制整数,需要将十六进制进行转换。
phase_4
0x000000000040100c <+0>: sub $0x18,%rsp
0x0000000000401010 <+4>: lea 0xc(%rsp),%rcx
0x0000000000401015 <+9>: lea 0x8(%rsp),%rdx
0x000000000040101a <+14>: mov $0x4025cf,%esi
0x000000000040101f <+19>: mov $0x0,%eax
0x0000000000401024 <+24>: call 0x400bf0 <__isoc99_sscanf@plt>
0x0000000000401029 <+29>: cmp $0x2,%eax
0x000000000040102c <+32>: jne 0x401035 <phase_4+41>
0x000000000040102e <+34>: cmpl $0xe,0x8(%rsp)
0x0000000000401033 <+39>: jbe 0x40103a <phase_4+46>
0x0000000000401035 <+41>: call 0x40143a <explode_bomb>
0x000000000040103a <+46>: mov $0xe,%edx
0x000000000040103f <+51>: mov $0x0,%esi
0x0000000000401044 <+56>: mov 0x8(%rsp),%edi
0x0000000000401048 <+60>: call 0x400fce <func4>
0x000000000040104d <+65>: test %eax,%eax
0x000000000040104f <+67>: jne 0x401058 <phase_4+76>
0x0000000000401051 <+69>: cmpl $0x0,0xc(%rsp)
0x0000000000401056 <+74>: je 0x40105d <phase_4+81>
0x0000000000401058 <+76>: call 0x40143a <explode_bomb>
0x000000000040105d <+81>: add $0x18,%rsp
0x0000000000401061 <+85>: ret
这一关主要分成两个函数:phase_4 和 func_4,首先查看 phase_4,代码前一段和 phase_3 非常类似:读取两个整数,且保证输入的第一个值位于区间 [0, 15)
内。
%rcx = %rsp + 12;
%rdx = %rsp + 8;
%esi = 0x4025cf;
%eax = 0;
sscanf(%rdi, %rsi, %rdx, %rcx);
if (%eax != 2) {
explode_bomb();
}
if (Mem[%rsp + 8] >= 15u) {
explode_bomb();
}
%edx = 0xe;
%esi = 0;
%edi = Mem[%rsp + 8];
func4(%rdi, %rsi, %rdx); // func4(Mem[%rsp+8], 0, 14)
if (%eax != 0) {
explode_bomb();
}
if (Mem[%rsp + 12] != 0) {
explode_bomb();
}
后一段便是传递 3 个参数给函数 func_4 进行调用,需要保证返回值和输入的第二个数为 0,因此密码的第二个数为 0。可以看到,phase_4 的代码结构还是很简单易懂的,关键是对 func_4 函数的分析。
0x0000000000400fce <+0>: sub $0x8,%rsp
0x0000000000400fd2 <+4>: mov %edx,%eax
0x0000000000400fd4 <+6>: sub %esi,%eax
0x0000000000400fd6 <+8>: mov %eax,%ecx
0x0000000000400fd8 <+10>: shr $0x1f,%ecx
0x0000000000400fdb <+13>: add %ecx,%eax
0x0000000000400fdd <+15>: sar %eax
0x0000000000400fdf <+17>: lea (%rax,%rsi,1),%ecx
0x0000000000400fe2 <+20>: cmp %edi,%ecx
0x0000000000400fe4 <+22>: jle 0x400ff2 <func4+36>
0x0000000000400fe6 <+24>: lea -0x1(%rcx),%edx
0x0000000000400fe9 <+27>: call 0x400fce <func4>
0x0000000000400fee <+32>: add %eax,%eax
0x0000000000400ff0 <+34>: jmp 0x401007 <func4+57>
0x0000000000400ff2 <+36>: mov $0x0,%eax
0x0000000000400ff7 <+41>: cmp %edi,%ecx
0x0000000000400ff9 <+43>: jge 0x401007 <func4+57>
0x0000000000400ffb <+45>: lea 0x1(%rcx),%esi
0x0000000000400ffe <+48>: call 0x400fce <func4>
0x0000000000401003 <+53>: lea 0x1(%rax,%rax,1),%eax
0x0000000000401007 <+57>: add $0x8,%rsp
0x000000000040100b <+61>: ret
仔细观察 func_4 的代码,发现含有对 func_4 的调用,因此 func_4 是一个 递归 函数。在对递归函数进行翻译时,本质上与普通的函数并没有区别,结果如下:
int func4(int a, int b, int c) {
int res = c - b;
int temp = (unsigned)res >> 31;
res += temp;
res >>= 1;
temp = b + res;
if (temp > a) {
c = temp - 1;
res = func4(a, b, c);
res *= 2;
}
else {
res = 0;
if (temp < a) {
b = temp + 1;
res = func4(a, b, c);
res = 2 * res + 1;
}
}
return res;
}
可以看到,程序的逻辑还是比较复杂的,但是注意到参数 b 和 c 的值都是确定的,真正的变量只有参数 a。因此这里有一个偷懒的办法:将程序翻译为一个语法严格正确的高级语言程序(而不是之前的伪代码),然后枚举所有可能的 a(只有 15 中情况),运行测试即可,结果为 0 的即为满足要求的值,也就是密码的第一个数。
可见,本关的正解同样不止一个,1 0
、3 0
、7 0
都是正确答案。
phase_5
0x0000000000401062 <+0>: push %rbx
0x0000000000401063 <+1>: sub $0x20,%rsp
0x0000000000401067 <+5>: mov %rdi,%rbx
0x000000000040106a <+8>: mov %fs:0x28,%rax
0x0000000000401073 <+17>: mov %rax,0x18(%rsp)
0x0000000000401078 <+22>: xor %eax,%eax
0x000000000040107a <+24>: call 0x40131b <string_length>
0x000000000040107f <+29>: cmp $0x6,%eax
0x0000000000401082 <+32>: je 0x4010d2 <phase_5+112>
0x0000000000401084 <+34>: call 0x40143a <explode_bomb>
0x0000000000401089 <+39>: jmp 0x4010d2 <phase_5+112>
0x000000000040108b <+41>: movzbl (%rbx,%rax,1),%ecx
0x000000000040108f <+45>: mov %cl,(%rsp)
0x0000000000401092 <+48>: mov (%rsp),%rdx
0x0000000000401096 <+52>: and $0xf,%edx
0x0000000000401099 <+55>: movzbl 0x4024b0(%rdx),%edx
0x00000000004010a0 <+62>: mov %dl,0x10(%rsp,%rax,1)
0x00000000004010a4 <+66>: add $0x1,%rax
0x00000000004010a8 <+70>: cmp $0x6,%rax
0x00000000004010ac <+74>: jne 0x40108b <phase_5+41>
0x00000000004010ae <+76>: movb $0x0,0x16(%rsp)
0x00000000004010b3 <+81>: mov $0x40245e,%esi
0x00000000004010b8 <+86>: lea 0x10(%rsp),%rdi
0x00000000004010bd <+91>: call 0x401338 <strings_not_equal>
0x00000000004010c2 <+96>: test %eax,%eax
0x00000000004010c4 <+98>: je 0x4010d9 <phase_5+119>
0x00000000004010c6 <+100>: call 0x40143a <explode_bomb>
0x00000000004010cb <+105>: nopl 0x0(%rax,%rax,1)
0x00000000004010d0 <+110>: jmp 0x4010d9 <phase_5+119>
0x00000000004010d2 <+112>: mov $0x0,%eax
0x00000000004010d7 <+117>: jmp 0x40108b <phase_5+41>
0x00000000004010d9 <+119>: mov 0x18(%rsp),%rax
0x00000000004010de <+124>: xor %fs:0x28,%rax
0x00000000004010e7 <+133>: je 0x4010ee <phase_5+140>
0x00000000004010e9 <+135>: call 0x400b30 <__stack_chk_fail@plt>
0x00000000004010ee <+140>: add $0x20,%rsp
0x00000000004010f2 <+144>: pop %rbx
0x00000000004010f3 <+145>: ret
这一关的汇编代码逻辑不算复杂,我们主要关注翻译后的代码:
%rbx = %rdi;
Mem[%rsp + 0x18] = Mem[%fs + 0x28]; // 4Byte
%eax ^= %eax; // %eax = 0
if (string_length() != 6) {
explode_bomb();
}
for (%eax = 0; %eax != 6; ++%eax) {
%edx = Mem[%rbx + %rax] & 0xf;
Mem[%rsp + %rax + 0x10] = Mem[0x4024b0 + %rdx]; // 1Byte
}
Mem[%rsp + 0x16] = 0;
%esi = 0x40245e;
%rdi = %rsp + 0x10;
if (string_not_equal(%rdi, %esi) != 0) {
explode_bomb();
}
%rax = Mem[%rsp + 0x18] ^ Mem[%fs + 0x28];
if (%rax != 0) {
__stack_chk_fail();
}
从 if (string_length() != 6) explode_bomb();
可以看出密码是一个长度为 6 的字符串,随后的 for
循环遍历字符串的各个字符,提取低一字节的值 %edx
,将其作为相对于地址 0x4024b0
的偏移量,读取目标地址 0x4020b0 + %rdx
处的低 4 位数据,存入地址 %rsp + %rax + 0x10
处,构造出一个起始地址为 %rsp + 0x10
的长度为 6 的字符串。然后将起始地址为 %rsp + 0x10
的字符串与起始地址为 0x40245e
的字符串作比较,如果不相同,则“引爆炸弹”。最后进行缓冲区溢出检测,如果溢出,则调用 __stack_chk_fail()
.
经过以上的描述,不难看出输入的 6 位字符串其实是一个相对于数组 0x4024b0
的索引,只不过索引值不直接给出,而是等于字符的低 4 位值。本关的目标便是使得输入的 6 位索引经过映射之后得到的字符串正好等于地址 0x40245e
的字符串,即 "flyers".
以字符 f 为例,f 在 array 表中的(最小)索引为 9,而所有低 4 位等于 9(1001)的字符都满足条件,例如 i .
字符c1 | 索引 | 字符c2 |
---|---|---|
f | 9 | i |
l | 15 | o |
y | 14 | n |
e | 5 | e |
r | 6 | f |
s | 7 | g |
依次类推,一个满足条件的密码为:ionefg .
phase_6
最复杂的一关,代码量非常大,而且逻辑比较复杂,整体观察比较困难,可以先将代码按照循环块拆分为几个部分,依次进行分析。
在使用 GDB 调试的时候,可以为每个块的起始部分分别打上断点,同时为了调试的方便,可将这些命令写入 .gdbinit
中。
b phase_6
b *0x401153
b *0x40116f
b *0x4011ab
b *0x4011d2
r ./ans.txt
block_1
0x00000000004010f4 <+0>: push %r14
0x00000000004010f6 <+2>: push %r13
0x00000000004010f8 <+4>: push %r12
0x00000000004010fa <+6>: push %rbp
0x00000000004010fb <+7>: push %rbx
0x00000000004010fc <+8>: sub $0x50,%rsp
0x0000000000401100 <+12>: mov %rsp,%r13
0x0000000000401103 <+15>: mov %rsp,%rsi
0x0000000000401106 <+18>: call 0x40145c <read_six_numbers>
0x000000000040110b <+23>: mov %rsp,%r14
0x000000000040110e <+26>: mov $0x0,%r12d
0x0000000000401114 <+32>: mov %r13,%rbp
0x0000000000401117 <+35>: mov 0x0(%r13),%eax
0x000000000040111b <+39>: sub $0x1,%eax
0x000000000040111e <+42>: cmp $0x5,%eax
0x0000000000401121 <+45>: jbe 0x401128 <phase_6+52>
0x0000000000401123 <+47>: call 0x40143a <explode_bomb>
0x0000000000401128 <+52>: add $0x1,%r12d
0x000000000040112c <+56>: cmp $0x6,%r12d
0x0000000000401130 <+60>: je 0x401153 <phase_6+95>
0x0000000000401132 <+62>: mov %r12d,%ebx
0x0000000000401135 <+65>: movslq %ebx,%rax
0x0000000000401138 <+68>: mov (%rsp,%rax,4),%eax
0x000000000040113b <+71>: cmp %eax,0x0(%rbp)
0x000000000040113e <+74>: jne 0x401145 <phase_6+81>
0x0000000000401140 <+76>: call 0x40143a <explode_bomb>
0x0000000000401145 <+81>: add $0x1,%ebx
0x0000000000401148 <+84>: cmp $0x5,%ebx
0x000000000040114b <+87>: jle 0x401135 <phase_6+65>
0x000000000040114d <+89>: add $0x4,%r13
0x0000000000401151 <+93>: jmp 0x401114 <phase_6+32>
第一部分整体而言不算太复杂,直接查看翻译后的代码:
// 读取6个4Byte数字放入从%r14寄存器指向地址开始的内存空间中
%r13 = %rsp;
%rsi = %rsp;
read_six_numbers();
%r14 = %rsp;
for (%r12d = 0; %r12 != 6; ) {
%rbp = %r13;
%eax = Mem[%r13];
%eax -= 1;
if (%eax > 5u) {
explode_bomb();
}
%r12d += 1;
if (%r12d == 6) break;
for (%ebx = %r12d; %ebx <= 5; ++%ebx) {
%rax = %ebx; // 符号扩展
%eax = Mem[4 * %rax + %rsp];
if (Mem[%rbp] == %eax) {
explode_bomb();
}
}
%r13 += 4;
}
与 phase_2 类似,首先读取 6 个数字,确定密码由 6 个数字组成。
随后主要关注循环中导致触发 explode_bomb
的条件,这些条件指明了密码的限定范围。第一个是 %eax > 5u
,注意前一条指令是 %eax
自减一,因此可以确定 6 个数字的范围都是 [1, 6]
.
这里自减一很有意思,刚开始看可能以为是多此一举,直接判断 %eax 是否大于 6u 不就完了吗?但是考虑到 0 这个特例,它在自减一后得到 -1,而 -1 满足无符号比较大于 5u,因此被排除在外。如果直接判断 %eax 是否大于 6u,那么数字的限定范围就变成了 [0, 6].
后面的内层循环不难看出是用来判重的,因此六个数字的范围得以确定:每个数字都位于区间 [1, 6]
内且无重复数字。
block_2
0x0000000000401153 <+95>: lea 0x18(%rsp),%rsi
0x0000000000401158 <+100>: mov %r14,%rax
0x000000000040115b <+103>: mov $0x7,%ecx
0x0000000000401160 <+108>: mov %ecx,%edx
0x0000000000401162 <+110>: sub (%rax),%edx
0x0000000000401164 <+112>: mov %edx,(%rax)
0x0000000000401166 <+114>: add $0x4,%rax
0x000000000040116a <+118>: cmp %rsi,%rax
0x000000000040116d <+121>: jne 0x401160 <phase_6+108>
// %r14 = %rsp
// 遍历6个数字,每个数字num的值变为7-num
%ecx = 7;
for (%rax = %r14, %rsi = %rsp + 0x18; %rax != %rsi; %rax += 4) {
%edx = %ecx - Mem[%rax];
Mem[%rax] = %edx;
}
第二部分非常简单,遍历输入的 6 个数字,将每个数字 num 更改为 7 - num.
block_3
0x000000000040116f <+123>: mov $0x0,%esi
0x0000000000401174 <+128>: jmp 0x401197 <phase_6+163>
0x0000000000401176 <+130>: mov 0x8(%rdx),%rdx
0x000000000040117a <+134>: add $0x1,%eax
0x000000000040117d <+137>: cmp %ecx,%eax
0x000000000040117f <+139>: jne 0x401176 <phase_6+130>
0x0000000000401181 <+141>: jmp 0x401188 <phase_6+148>
0x0000000000401183 <+143>: mov $0x6032d0,%edx
0x0000000000401188 <+148>: mov %rdx,0x20(%rsp,%rsi,2)
0x000000000040118d <+153>: add $0x4,%rsi
0x0000000000401191 <+157>: cmp $0x18,%rsi
0x0000000000401195 <+161>: je 0x4011ab <phase_6+183>
0x0000000000401197 <+163>: mov (%rsp,%rsi,1),%ecx
0x000000000040119a <+166>: cmp $0x1,%ecx
0x000000000040119d <+169>: jle 0x401183 <phase_6+143>
0x000000000040119f <+171>: mov $0x1,%eax
0x00000000004011a4 <+176>: mov $0x6032d0,%edx
0x00000000004011a9 <+181>: jmp 0x401176 <phase_6+130>
第三部分虽然代码量不大,但是跳转语句很多,逻辑非常复杂。这里我采用了分部的方式,首先改写为带 goto 语句的高级语言伪代码:
%esi = 0;
goto phase_6_163;
phase_6_130:
%rdx = Mem[%rdx + 0x8];
%eax += 1;
if (%eax != %ecx) goto phase_6_130;
goto phase_6_148;
phase_6_143:
%edx = 0x6032d0;
phase_6_148:
Mem[%rsp + 2 * %rsi + 0x20] = %rdx;
%rsi += 4;
if (%rsi == 0x18) goto phase_6_183;
phase_6_163:
%ecx = Mem[%rsp + %rsi];
if (%ecx <= 1) goto phase_6_143;
%eax = 1;
%edx = 0x6032d0;
goto phase_6_130;
然后对照一些常见的形式 goto 改写为循环语句,这里的翻译过程比较繁琐,需要静下来仔细思考。
%esi = 0;
while (%rsi != 0x18) {
%ecx = Mem[%rsp + %rsi];
if (%ecx > 1) {
%eax = 1;
%rdx = 0x6032d0;
while (%eax != %ecx) {
%rdx = Mem[%rdx + 0x8];
%eax += 1;
}
}
else {
%edx = 0x6032d0;
}
Mem[%rsp + 2 * %rsi + 0x20] = %rdx;
%rsi += 4;
}
观察翻译后的代码,似乎和 phase_5 类似,遍历每个数字,并将每个数字当作索引 i,在起始地址为 0x6032d0
的表中查找第 i 个元素,以 %rsp + 0x20
作为起始地址创建一个线性结构。
打印起始地址为 0x6032d0
的 12 个 8 字节数据,可以看到第二列中表示的值就是某一行的地址,且这些地址正好可以串联成一个线性结构,加上符号名 "node" 的提示,是不是很熟悉?没错,就是 链表 。上图每一行的第一列为值域,第二列为 next 域。
回过来观察代码,第三部分的作用就是将输入的六个数字作为索引,创建一个数组,每个数组元素都为索引对应的 next 域。
block_4
0x00000000004011ab <+183>: mov 0x20(%rsp),%rbx
0x00000000004011b0 <+188>: lea 0x28(%rsp),%rax
0x00000000004011b5 <+193>: lea 0x50(%rsp),%rsi
0x00000000004011ba <+198>: mov %rbx,%rcx
0x00000000004011bd <+201>: mov (%rax),%rdx
0x00000000004011c0 <+204>: mov %rdx,0x8(%rcx)
0x00000000004011c4 <+208>: add $0x8,%rax
0x00000000004011c8 <+212>: cmp %rsi,%rax
0x00000000004011cb <+215>: je 0x4011d2 <phase_6+222>
0x00000000004011cd <+217>: mov %rdx,%rcx
0x00000000004011d0 <+220>: jmp 0x4011bd <phase_6+201>
// 创建链表
%rbx = Mem[%rsp + 0x20];
for (%rax = %rsp + 0x28, %rsi = %rsp + 0x50; ; %rcx = %rdx) {
%rcx = %rbx;
%rdx = Mem[%rax];
Mem[%rcx + 8] = %rdx;
%rax += 8;
if (%rax == %rsi) break;
}
理解清楚了第三部分,第四部分的作用就很明显了:根据第三部分创建的由 next 域构成的数组,创建一个链表结构。
block_5
0x00000000004011d2 <+222>: movq $0x0,0x8(%rdx)
0x00000000004011da <+230>: mov $0x5,%ebp
0x00000000004011df <+235>: mov 0x8(%rbx),%rax
0x00000000004011e3 <+239>: mov (%rax),%eax
0x00000000004011e5 <+241>: cmp %eax,(%rbx)
0x00000000004011e7 <+243>: jge 0x4011ee <phase_6+250>
0x00000000004011e9 <+245>: call 0x40143a <explode_bomb>
0x00000000004011ee <+250>: mov 0x8(%rbx),%rbx
0x00000000004011f2 <+254>: sub $0x1,%ebp
0x00000000004011f5 <+257>: jne 0x4011df <phase_6+235>
0x00000000004011f7 <+259>: add $0x50,%rsp
0x00000000004011fb <+263>: pop %rbx
0x00000000004011fc <+264>: pop %rbp
0x00000000004011fd <+265>: pop %r12
0x00000000004011ff <+267>: pop %r13
0x0000000000401201 <+269>: pop %r14
0x0000000000401203 <+271>: ret
// 遍历链表,判断是否从大到小排序,若不是,则引爆
Mem[%rdx + 8] = 0;
for (%ebp = 5; %ebp != 0; --%ebp) {
%rax = Mem[%rbx + 8];
%eax = Mem[%rax];
if (Mem[%rbx] < %eax) {
explode_bomb();
}
%rbx = Mem[%rbx + 8];
}
终于到最后一部分了,这一部分的作用很明显:判断链表是否有序,更准确地说,是否以非递增顺序排列。
那么本关的目标终于浮出水面了:
输入六个数字,对于每个数字 num,将 7 - num 作为索引,根据链表 node 重构出一个新的链表,并保证重构的链表按非递增顺序排列。
注意链表值域的比较只关注低 4 字节,因此链表各结点值域从大到小排序为:3 4 5 6 1 2
,那么对应的输入数字为:4 3 2 1 6 5
,即本关的正确答案。
secret_phase
解决隐藏关卡首先要解决的问题是:如何进入?观察 main 函数的汇编代码,在结束 phase_6 之后、main 函数返回之前,只有 phase_defused 函数被调用,看来入口可能隐藏在一直以来被忽略的部分。
对 phase_defused 进行反汇编,结果如下:
0x00000000004015c4 <+0>: sub $0x78,%rsp
0x00000000004015c8 <+4>: mov %fs:0x28,%rax
0x00000000004015d1 <+13>: mov %rax,0x68(%rsp)
0x00000000004015d6 <+18>: xor %eax,%eax
0x00000000004015d8 <+20>: cmpl $0x6,0x202181(%rip) # 0x603760 <num_input_strings>
0x00000000004015df <+27>: jne 0x40163f <phase_defused+123>
0x00000000004015e1 <+29>: lea 0x10(%rsp),%r8
0x00000000004015e6 <+34>: lea 0xc(%rsp),%rcx
0x00000000004015eb <+39>: lea 0x8(%rsp),%rdx
0x00000000004015f0 <+44>: mov $0x402619,%esi
0x00000000004015f5 <+49>: mov $0x603870,%edi
0x00000000004015fa <+54>: call 0x400bf0 <__isoc99_sscanf@plt>
0x00000000004015ff <+59>: cmp $0x3,%eax
0x0000000000401602 <+62>: jne 0x401635 <phase_defused+113>
0x0000000000401604 <+64>: mov $0x402622,%esi
0x0000000000401609 <+69>: lea 0x10(%rsp),%rdi
0x000000000040160e <+74>: call 0x401338 <strings_not_equal>
0x0000000000401613 <+79>: test %eax,%eax
0x0000000000401615 <+81>: jne 0x401635 <phase_defused+113>
0x0000000000401617 <+83>: mov $0x4024f8,%edi
0x000000000040161c <+88>: call 0x400b10 <puts@plt>
0x0000000000401621 <+93>: mov $0x402520,%edi
0x0000000000401626 <+98>: call 0x400b10 <puts@plt>
0x000000000040162b <+103>: mov $0x0,%eax
0x0000000000401630 <+108>: call 0x401242 <secret_phase>
0x0000000000401635 <+113>: mov $0x402558,%edi
0x000000000040163a <+118>: call 0x400b10 <puts@plt>
0x000000000040163f <+123>: mov 0x68(%rsp),%rax
0x0000000000401644 <+128>: xor %fs:0x28,%rax
0x000000000040164d <+137>: je 0x401654 <phase_defused+144>
0x000000000040164f <+139>: call 0x400b30 <__stack_chk_fail@plt>
0x0000000000401654 <+144>: add $0x78,%rsp
0x0000000000401658 <+148>: ret
和之前的做法一样,将汇编代码翻译为 C 语言风格的伪代码,同时打印程序中用到的一些字符串:
%rax = Mem[%fs + 0x28];
Mem[%rsp + 0x68] = %rax;
if (Mem[%rip + 0x202181] == 6) { // num_input_strings
%r8 = %rsp + 0x10;
%rcx = %rsp + 0xc;
%rdx = %rsp + 0x8;
%rdi = 0x603870;
sscanf(%rdi, "%d %d %s", %rdx, %rcx, %r8);
if (%eax == 3) {
%rdi = %rsp + 0x10;
if (strings_not_equal(%rdi, "DrEvil") == 0) {
puts("Curses, you've found the secret phase!");
puts("But finding it and solving it are quite different...");
%eax = 0;
secret_phase();
}
}
puts("Congratulations! You've defused the bomb!");
}
%rax = Mem[%rsp + 0x68];
if (%rax != Mem[%fs + 0x28]) {
__stack_chk_fail();
}
仔细分析上述代码的逻辑,当输入的字符串个数等于 6 时,即解决了 phase_1 ~ phase_6 所有关卡后,程序调用 sscanf
从地址 0x603870
处读取以空格分隔的两个整数和一个字符串,分别存入寄存器 %rdx
、%rcx
和 %r8
中,当函数返回值为 3,即成功匹配了 3 个值,且匹配到的第三个值(字符串)等于 "DrEvil" 时,即可进入隐藏关卡。
但是上面我们已经打印了地址 0x603870
处的字符串,为 3 0
,只有两个,无法使得匹配数为 3. 我最开始想到的解决方法就是在调试过程中手动更改该地址处的值,但是这样的做法也只具备调试作用,进入隐藏关卡密码仍然无法得到。
换个角度来思考,这个 3 0
有没有可能不是硬编码的数据,而是我们手动输入的?记得之前 phase_4
的正确密码之一就是 3 0
。
将断点设置在 phase_4 处,并打印 %rdi
寄存器的值, 发现正好就是 0x603870
,因此 phase_4 的完整密码应该是 3 0 DrEvil
(正如前面所说,前两位也可以是 1 0
、7 0
等)。
注意末尾的 DrEvil 在 phase_4 中并不会被读取,因为模式字符串为 “%d %d”,因此匹配成功的值最多为 2,不会影响
cmp $0x2, %eax
的判断。
经过前面的准备,终于可以着手解决隐藏关卡了,相信有了前面这些关卡的锻炼,隐藏关卡不会显得太难。
0x0000000000401242 <+0>: push %rbx
0x0000000000401243 <+1>: call 0x40149e <read_line>
0x0000000000401248 <+6>: mov $0xa,%edx
0x000000000040124d <+11>: mov $0x0,%esi
0x0000000000401252 <+16>: mov %rax,%rdi
0x0000000000401255 <+19>: call 0x400bd0 <strtol@plt>
0x000000000040125a <+24>: mov %rax,%rbx
0x000000000040125d <+27>: lea -0x1(%rax),%eax
0x0000000000401260 <+30>: cmp $0x3e8,%eax
0x0000000000401265 <+35>: jbe 0x40126c <secret_phase+42>
0x0000000000401267 <+37>: call 0x40143a <explode_bomb>
0x000000000040126c <+42>: mov %ebx,%esi
0x000000000040126e <+44>: mov $0x6030f0,%edi
0x0000000000401273 <+49>: call 0x401204 <fun7>
0x0000000000401278 <+54>: cmp $0x2,%eax
0x000000000040127b <+57>: je 0x401282 <secret_phase+64>
0x000000000040127d <+59>: call 0x40143a <explode_bomb>
0x0000000000401282 <+64>: mov $0x402438,%edi
0x0000000000401287 <+69>: call 0x400b10 <puts@plt>
0x000000000040128c <+74>: call 0x4015c4 <phase_defused>
0x0000000000401291 <+79>: pop %rbx
0x0000000000401292 <+80>: ret
read_line();
strtol(%rax, 0, 0xa);
%rbx = %rax;
%eax = %rax - 1;
if (%eax > 0x3e8) { // 无符号比较
explode_bomb();
}
fun7(0x6030f0, %ebx);
if (%eax != 2) {
explode_bomb();
}
puts(0x402438);
phase_defused();
可以看到,隐藏关卡的代码逻辑还是比较清晰的:读取一行,应该是隐藏关卡的密码,将其转换为 long
类型,然后又是和之前类似的范围限定语句,随后调用函数 fun7
,如果返回值为 2,则密码输入正确。
问题的关键还是在于函数 fun7
,其代码如下:
0x0000000000401204 <+0>: sub $0x8,%rsp
0x0000000000401208 <+4>: test %rdi,%rdi
0x000000000040120b <+7>: je 0x401238 <fun7+52>
0x000000000040120d <+9>: mov (%rdi),%edx
0x000000000040120f <+11>: cmp %esi,%edx
0x0000000000401211 <+13>: jle 0x401220 <fun7+28>
0x0000000000401213 <+15>: mov 0x8(%rdi),%rdi
0x0000000000401217 <+19>: call 0x401204 <fun7>
0x000000000040121c <+24>: add %eax,%eax
0x000000000040121e <+26>: jmp 0x40123d <fun7+57>
0x0000000000401220 <+28>: mov $0x0,%eax
0x0000000000401225 <+33>: cmp %esi,%edx
0x0000000000401227 <+35>: je 0x40123d <fun7+57>
0x0000000000401229 <+37>: mov 0x10(%rdi),%rdi
0x000000000040122d <+41>: call 0x401204 <fun7>
0x0000000000401232 <+46>: lea 0x1(%rax,%rax,1),%eax
0x0000000000401236 <+50>: jmp 0x40123d <fun7+57>
0x0000000000401238 <+52>: mov $0xffffffff,%eax
0x000000000040123d <+57>: add $0x8,%rsp
0x0000000000401241 <+61>: ret
unsigned fun7(unsigned x, unsigned y) {
if (x == 0) {
return 0xffffffff;
}
int z = *x;
if (z > y) {
return 2 * fun7(*(x + 8), y);
}
else if (z == y) {
return 0;
}
else {
return 2 * fun7(*(x + 16), y) + 1;
}
}
又是一个递归函数,不过和 phase_4 不同,这个函数的代码显得很有规律,看到 *(x + 8)
和 *(x + 16)
这样的表达式很容易想到可能又是某种链接结构,不妨打印 0x6030f0
处的内容:
这下结果很明确了,每个结点包含两个链接(指针)域,没错,正是二叉树。为了分析的方便,我根据上图的数据内容绘制了一个等价的二叉树,如下图所示:
可以看到,每个结点由 4 个 8 字节组成,前三个应该分别是值域、左孩子、右孩子,最后一个全为 0 的 8 字节貌似很多余,个人推测应该是 C 语言结构体的 字节对齐 导致的。
最后再回到函数 fun7 中,要使得最终结果等于 2,一种可能的计算方法如下:
我们只需要保证二叉树遍历时依次遍历左孩子、右孩子、左孩子,且输入密码正好等于叶子结点即可,0x14
正好就满足条件,因此隐藏关卡的密码为 20.
至此,”炸弹“ 成功被”拆除“。