CSAPP Bomb Lab

ops/2024/9/23 3:52:21/

本 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) {          // 读取成功的值个数小于2explode_bomb();
}
if (Mem[%rsp + 8] > 7u) { // 读取到的(输入的)第一个值大于7或小于0explode_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 2073 256 等等都是正确答案。注意不能写成 0 0xcf3 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 03 07 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
f9i
l15o
y14n
e5e
r6f
s7g

依次类推,一个满足条件的密码为: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 07 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.

至此,”炸弹“ 成功被”拆除“。


http://www.ppmy.cn/ops/114565.html

相关文章

Android 命令行关机

在 Android 设备上&#xff0c;可以通过以下命令行命令来关机&#xff1a; adb shell reboot -p其中&#xff1a; adb shell&#xff1a;通过 ADB 进入设备的命令行环境。reboot -p&#xff1a;执行关机操作&#xff0c;-p 表示关机而不是重启。 如果你是在设备本地的终端上而…

Linux(Centos7)系统下给已有分区进行扩容

本文详细介绍了&#xff0c;如何给Centos中已有分区进行扩容&#xff0c;简单的几条命令即可完成。 文章目录 1. 创建物理卷 (PV)2. 将新的物理卷添加到卷组 (VG)3. 扩展逻辑卷 (LV)4. 扩展文件系统4.1 查看文件系统类型4.2 扩展文件系统 完成 1、首先把vmware中的linux关机&am…

第三章 掌握MySQL数据库的基本操作

文章目录 一、关系数据库标准语言SQL1.1 SQL的发展历史与特点1.2 SQL的分类 二、数据库的管理2.1 创建数据库2.2 查看数据库2.3 选择数据库2.4 删除数据库 三、MySQL存储引擎3.1 MySQL支持的存储引擎3.2 InnoDB存储引擎3.3 MyISAM存储引擎3.4 选择存储引擎 四、表的管理4.1 数据…

AUTOSAR UDS NRC

UDS NRC NRC 含义如表格所示 NRC代码描述含义0x00Ok没有错误,请求已成功执行0x01~0x0FISOSAEReservedISO 保留,暂时未定义0x10General reject服务请求被拒绝,原因不明确0x11Service not supported请求的服务不被支持0x12Sub-function not supported请求的子功能不被支持0x13…

Linux bash 关联数组

目录 一. 关联数组定义二. 访问关联数组三. 元素的添加与删除四. 键值对的获取与遍历五. 实际应用5.1 读取封装配置文件内容5.2 收集系统信息 一. 关联数组定义 从 Bash 4.0 开始&#xff0c;Bash 支持关联数组。关联数组允许你将键和值配对&#xff0c;并通过键来访问值&…

TCP socket

TCP的socket和UDP大同小异&#xff0c;基本的代码结构都是相同的。一些相同的接口本文就不赘述了&#xff0c;例如&#xff0c;socket,bind&#xff0c;有需要看这篇文章UDP socket 服务端server 两步&#xff1a;初始化服务端&#xff0c;运行服务端 初始化服务端 创建soc…

python selenium网页操作

一、安装依赖 pip install -U seleniumselenium1.py&#xff1a; from selenium import webdriver from selenium.webdriver.common.by import Bydriver webdriver.Chrome() driver.get("https://www.selenium.dev/selenium/web/web-form.html") title driver.ti…

第七章 监听器

一、介绍 监听器的作用是被观察的对象发生某些情况时&#xff0c;自动触发代码的执行。监听器时GOF设计模式中&#xff0c;观察者模式的典型案例。观察者模式: 当被观察的对象发生某些改变时, 观察者自动采取对应的行动的一种设计模式。在JavaWeb中&#xff0c;可以使用监听器…