为了读懂 JOS 的代码,最近学了一波汇编。主要是通过粗略的过一遍 OpenSecurityTraining.info 上的 Introductory Intel x86 课程 来了解大概,因为已经学过一次,所以只要捡起来直接看代码就行了,遇到模糊不清的地方就详细看看 PC Assembly Tutorial

Introductory Intel x86 课程上有个很有趣的逆向工程作业——拆除「炸弹」。「炸弹」是一个二进制可执行文件,由 6 个阶段和 1 个隐藏阶段组成,每个阶段都会读取用户输入,只有特定的输入才能通过这个阶段,在通过所有阶段后,炸弹就被成功拆除了。之所以说它有趣,是因为:

  • 只有二进制文件,所以只能通过反汇编后读 x86 汇编来拆除炸弹,难度不小
  • 每一阶段难度递增,隐藏阶段只有达到特定条件才能进入,像极了闯关游戏
  • 通过汇编考察了很常见的程序结构和数据结构,十分考验 C 语言功底

本文将一步一步介绍怎么拆除炸弹。关于炸弹的详细说明请戳:CMU binary bomb challenge,下载「炸弹」请戳:bomb32.tar,查看答案或者帮助请戳:loggerhead/CMU-binary-bomb-challenge

准备工作

既然是拆除炸弹这种专业的任务,自然需要专业的工具才能解决。不过炸弹只能运行在 Linux 系统上,而大部分的 Linux 发行版本都自带了这些工具,所以也不用自己再去特意安装啦。下面介绍一下这些工具:

  • gdb: GNU 出品的一款著名的命令行调试工具,可以用来调试包括 C/C++ 在内的一系列语言。功能十分强大,可以自定义函数,查看寄存器的值等等。
  • objdump: 用于显示可执行文件的各种信息,包括:符号表、反汇编结果等等。
  • strings: 显示文件中包含的所有字符串。

在着手拆除炸弹之前,我们先看一看炸弹的大致构造。为此,我们执行以下命令生成 AT&T 语法的汇编:

1
2
3
4
# 解压
tar xvf bomb32.tar
# 反汇编
objdump -d bomb > bomb.asm

也可以通过以下命令生成 intel 语法的汇编:

1
objdump -M intel -d bomb > bomb.asm

一般没有特别说明是 AT&T 语法的话,接触到的汇编都是 intel 语法的。两者只有些许不同,详情见:Intel and AT&T Syntax

现在拿起我们的炸弹瞧一瞧。容易发现有一些库函数,比如:fprintffgetsprintf;一些名字怪异,很有可能是编译器自动生成的函数;再往下翻翻就找到了 main 函数,随便一扫,开头这堆汇编看不太出来是做什么的,但是很快我们看到一个熟悉的身影:

1
 80489e1:   e8 9a fe ff ff          call   8048880 <fopen@plt>

fopen 应该是从文件读取输入,再看到:

1
 80489bd:   83 f8 01                cmp    $0x1,%eax

应该是判断命令行参数个数是不是等于 1。如果你有好好看一下炸弹的 详细说明,那么应该注意到下面这段话:

The bomb ignores blank input lines. If you run the bomb with a command line argument such as psol.txt, it will read the input lines from psol.txt until it reaches EOF, and then switch over tostdin. In a moment of weakness, Dr. Evil added this feature so you don't have to keep retyping the solutions to phases you have already defused.

所以我们猜测这段代码是用来从文件读取输入的。继续往下翻,发现有好几个结构类似的片段:

1
2
3
4
5
 8048a52:   e8 a5 07 00 00          call   80491fc <read_line>
 8048a57:   83 c4 f4                add    $0xfffffff4,%esp
 8048a5a:   50                      push   %eax
 8048a5b:   e8 c0 00 00 00          call   8048b20 <phase_1>
 8048a60:   e8 c7 0a 00 00          call   804952c <phase_defused>

用 C 表示就是:

1
2
3
char *line = read_line();
phase_1(line)
phase_defused()

这三行代码到底做了些什么,我们暂时不清楚,不过对 main 一番扫视让我们了解到炸弹大概类似于:

bomb_main.jpg

只有 6 次正确的输入才能解除炸弹,否则就会……

bomb_exploded.jpg

第一阶段

我们看到 phase_1 函数,结构比较简单,关键在于下面这几行在做什么:

1
2
3
4
5
 8048b32:   e8 f9 04 00 00          call   8049030 <strings_not_equal>
 8048b37:   83 c4 10                add    $0x10,%esp
 8048b3a:   85 c0                   test   %eax,%eax
 8048b3c:   74 05                   je     8048b43 <phase_1+0x23>
 8048b3e:   e8 b9 09 00 00          call   80494fc <explode_bomb>

从函数名字来看,似乎是比较两个字符串是否相等,然后根据结果决定要不要引爆炸弹。随便尝试几次,发现结果都是爆炸,所以这部分的逻辑很可能是:

1
2
3
4
5
if (strings_not_equal(line, CONST_STRING)) {
    explode_bomb();
} else {
    // do something
}

按照惯例,函数的输出会保存在 eax 寄存器中。那么 8048b3a8048b3c 两行的作用就很明显了,根据 test %eax, %eax 的结果决定跳转到 8048b43,还是运行 8048b3e 的指令。通过 Google 80386 test 很容易就能找到 test 指令的作用,testje 两行结合起来正好就是上述的 if。所以解除第一阶段的问题就简化成了:让 lineCONST_STRING 相等。为了搞明白这两个参数来自哪里,我们往上看,注意到两个疑似传参的 push 指令:

1
2
 8048b2c:   68 c0 97 04 08          push   $0x80497c0
 8048b31:   50                      push   %eax

0x80497c0 看着像是字符串常量 CONST_STRING 的地址,eax 寄存器估计是存放了输入字符串的地址。另外,根据传参的原则——最先 push 的是最右边的参数,而且两个参数正好对应两次 push,这也进一步肯定了我们的想法。

接下来我们通过 gdb 来验证一下。依次输入以下 gdb 命令,运行到 8048b32 这一行:

1
2
b *0x8048b32
r

我们尝试打印一下 0x80497c0eax 指向的字符串:

1
2
x/s 0x80497c0
x/s $eax

发现输出和我们预料的一样,0x80497c0 果然存放了字符串的地址,其指向的字符串是:

1
Public speaking is very easy.

eax 也和我们的输入一模一样。我们再试试把输入换成上述字符串,看能不能解除第一阶段。成功!

Phase 1 defused. How about the next one?

第二阶段

不管你解除第一阶段时心情是怎样的,反正我是异常兴奋的。第一阶段主要考察函数的调用过程,第二阶段就不只是这么简单咯。简洁起见,接下来不会再对基本的汇编知识进行详细解释了,如果还不太熟悉,可以按之前说的去掌握汇编的基础知识。

粗略的看一眼代码,发现 phase_2 有三个条件跳转,和一个新函数 read_six_numbers。一下子看不出来什么,再用命令 strings bomb 看看有些什么字符串,虽然不能直接找到答案,但是我们可以看到一串熟悉的字符串——%d %d %d %d %d %d,再根据 read_six_numbers 的函数名,可以猜测 read_six_numbers 把读入的一行输入转换成了 6 个数字。我们把断点设在 8048b5b,发现疑似参数的寄存器 edx 的值就是我们的输入,而 eax 的值似乎没有规律,再通过 p/x $eax 查看一下 eax 中存放的十六进制值,也没有什么规律。

先不管这么多,通过命令 ni 接着往下调试,遇到第一个条件跳转:

1
2
3
 8048b63:   83 7d e8 01             cmpl   $0x1,-0x18(%ebp)
 8048b67:   74 05                   je     8048b6e <phase_2+0x26>
 8048b69:   e8 8e 09 00 00          call   80494fc <explode_bomb>

这里的逻辑可以表示为:

1
2
3
4
5
if (*(ebp-0x18) == 0x1) {
    // do something
} else {
    explode_bomb();
}

我们通过 x/x $ebp-0x18 看看地址 $ebp-0x18 处存放的值,发现正好和我们输入的第一个数字是一样的,这一点可以通过多次输入不同的值来验证。

1
2
3
4
 8048b6e:   bb 01 00 00 00          mov    $0x1,%ebx
 8048b73:   8d 75 e8                lea    -0x18(%ebp),%esi
 8048b76:   8d 43 01                lea    0x1(%ebx),%eax
 8048b79:   0f af 44 9e fc          imul   -0x4(%esi,%ebx,4),%eax

翻译成 C 就是:

1
2
3
4
5
6
ebx = 1;
// 第一个数字的地址
esi = ebp - 0x18;
// 2
eax = ebx + 0x1;
eax *= *(-0x4 + esi + ebx*4);

之前提到 read_six_numbers 把输入的字符串转换成了 6 个数字,但是这 6 个数字是以什么样的方式存储呢?返回值又是什么?如果熟悉 C 语言,很快就能想到一种实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int *read_six_numbers(char *line)
{
    static int nums[6];
    sscanf(line, "%d %d %d %d %d %d", &nums[0]
                                    , &nums[1]
                                    , &nums[2]
                                    , &nums[3]
                                    , &nums[4]
                                    , &nums[5]);
    return nums;
}

再对照着上面的翻译和 ebp - 0x18所指向的数字,不难发现 8048b6e8048b79 这 4 行代码实际上是:

1
2
3
4
5
6
7
// ebx
i = 1;
esi = &nums[0];
eax = i + 0x1 = 2;
// int 占 4 个字节
// eax *= *(esi + (ebx-1)*4)
eax *= nums[i-1];

看到这两行跳转:

1
2
 8048b7e:   39 04 9e                cmp    %eax,(%esi,%ebx,4)
 8048b81:   74 05                   je     8048b88 <phase_2+0x40>

是不是就是:

1
if (nums[i] == eax)

再看到接下来三行:

1
2
3
 8048b88:   43                      inc    %ebx
 8048b89:   83 fb 05                cmp    $0x5,%ebx
 8048b8c:   7e e8                   jle    8048b76 <phase_2+0x2e>

继续我们的翻译:

1
2
3
4
i++;
if (i <= 5) {
    goto 0x8048b76;
}

把这几行结合到一起看:

1
2
3
4
5
6
7
8
 8048b76:   8d 43 01                lea    0x1(%ebx),%eax
 8048b79:   0f af 44 9e fc          imul   -0x4(%esi,%ebx,4),%eax
 8048b7e:   39 04 9e                cmp    %eax,(%esi,%ebx,4)
 8048b81:   74 05                   je     8048b88 <phase_2+0x40>
 8048b83:   e8 74 09 00 00          call   80494fc <explode_bomb>
 8048b88:   43                      inc    %ebx
 8048b89:   83 fb 05                cmp    $0x5,%ebx
 8048b8c:   7e e8                   jle    8048b76 <phase_2+0x2e>

也就是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
eax = i + 1;
eax *= nums[i-1];
if (nums[i] == eax) {
    // go on
} else {
    explode_bomb();
}
i++;
if (i <= 5) {
    goto first_line;
}

亦即:

1
2
3
4
5
6
do {
    if (nums[i] != (i+1)*nums[i-1]) {
        explode_bomb();
    }
    i++;
} while (i <= 5);

这下逻辑就很清晰了,翻译成人话就是:第 i 个数等于前一个数乘以 i+1。所以答案就是:

1 2 6 24 120 720

OK,第二阶段主要考察了循环。

第三阶段

瞟一眼,发现有很多重复结构:

1
2
3
4
5
 8048be0:   b3 71                   mov    $0x71,%bl
 8048be2:   81 7d fc 09 03 00 00    cmpl   $0x309,-0x4(%ebp)
 8048be9:   0f 84 a0 00 00 00       je     8048c8f <phase_3+0xf7>
 8048bef:   e8 08 09 00 00          call   80494fc <explode_bomb>
   8048bf4: e9 96 00 00 00          jmp    8048c8f <phase_3+0xf7>

看到一个 sscanf,按着老套路来,容易发现要求输入符合 %d %c %d 这样的格式,8048bbf 开始的三行代码是检查 sscanf 成功解析的次数是否大于 2。上述重复结构很容易让我们想到,会不会是挨个挨个检查输入,然后所有输入都匹配才成功通过?这一点很容易否决,因为:

  1. 这些相似结构不止出现了 3 次,而我们的输入只有 2 个整数,1 个字符;
  2. 其中出现的条件跳转并不是往下跳,而是跳转到同一个位置——8048c8f

第二点让我们联想到 C 语言中的 switch-case 结构,至于是不是,我们先看看 8048c8f 后面的几行代码:

1
2
3
 8048c8f:   3a 5d fb                cmp    -0x5(%ebp),%bl
 8048c92:   74 05                   je     8048c99 <phase_3+0x101>
 8048c94:   e8 63 08 00 00          call   80494fc <explode_bomb>

翻译成 C:

1
2
3
4
5
if (bl == *(ebp-0x5)) {
    return;
} else {
    explode_bomb();
}

告诉我们成功条件是 bl == *(ebp-0x5)。回到一开始:

1
2
3
4
 8048bc9:   83 7d f4 07             cmpl   $0x7,-0xc(%ebp)
 8048bcd:   0f 87 b5 00 00 00       ja     8048c88 <phase_3+0xf0>
 8048bd3:   8b 45 f4                mov    -0xc(%ebp),%eax
 8048bd6:   ff 24 85 e8 97 04 08    jmp    *0x80497e8(,%eax,4)

等价于:

1
2
3
4
5
6
7
if (*(ebp-0xC) > 7) {
    goto 0x8048c88;
} else {
    eax = *(ebp-0xC);
    addr = *(0x80497e8 + eax*4);
    goto addr;
}

因为这几行代码是必经之地,所以我们将断点设在这里,输入 11 a 13 调试看看。一步一步调试过去,发现 *(ebp-0xC) 是输入的第一个数(输入不同值可以验证),跳转到 8048c88 以后,这两行代码告诉我们:

1
2
 8048c88:   b3 78                   mov    $0x78,%bl
 8048c8a:   e8 6d 08 00 00          call   80494fc <explode_bomb>

只要第一个数大于 7,直接爆炸。所以上述逻辑可以简化为:

1
2
3
4
5
6
if (num1 <= 7) {
    addr = *(0x80497e8 + num1*4);
    goto addr;
} else {
    explode_bomb();
}

注意到 0 到 7 有八个数,好像重复结构也差不多是七八个,数一数发现正好有 8 个重复结构。很自然想到是不是 goto addr 就相当于 switch 的作用,而重复结构就是 case,其中出现的无条件跳转 jmp 8048c8f 就是 break。我们换一个输入 7 a 13 看看到底是怎样的,单步运行会发现跳转到了:

1
2
3
4
5
 8048c76:   b3 62                   mov    $0x62,%bl
 8048c78:   81 7d fc 0c 02 00 00    cmpl   $0x20c,-0x4(%ebp)
 8048c7f:   74 0e                   je     8048c8f <phase_3+0xf7>
 8048c81:   e8 76 08 00 00          call   80494fc <explode_bomb>
 8048c86:   eb 07                   jmp    8048c8f <phase_3+0xf7>

这几行代码很简单,就不翻译了吧……关键在于 cmpl $0x20c,-0x4(%ebp) 这一句,如果两个操作数不相等就会爆炸,否则就 break 了。在 gdb 输入 x/x $ebp-4 命令发现 ebp-0x4 是对应于输入的第三个整数,0x20c 的十进制表示是 524,所以这次我们把输入换成 7 a 524 再试试。继续一步一步看过去,发现最后跳转到了 8048c8f,也就是最终成功条件的判断——bl == *(ebp-0x5)。因为 bl 占 1 个字节,正好对应于输入的字符,接着通过 x/c $ebp-5 可以确认这一点。而最近对 bl 的一次赋值是上面的第一行代码,0x62 对应于 b,所以正确的输入是 7 b 524

整体的逻辑等价于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
char b;
int a, c;
if (sscanf(line, "%d %c %d", &a, &b, &c) > 2) {
    if (a <= 7) {
        switch(a) {
            case 0: 
                    ...;
                    break;
            case ...: 
                    ...;
                    break;
            case 7: 
                    bl = 0x62;
                    if (c != 0x20c) 
                        explode_bomb();
                    break;
        }

        if (bl == b)
            return;
    }
}

explode_bomb();

第四阶段

Halfway there!

终于闯过了一半,真是不容易……来到第四阶段,发现代码挺短,瞬间信心大增。又看到了 sscanf,它附近的代码又在干同样的事情,这次是读取一个整数。随便输入一个整数 42,然后将断点设在 8048d03,看看 -0x4(%ebp) 是什么内容。容易验证 $ebp-4 是我们输入的那个整数,如此一来,phase_4 的逻辑就明了了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
int num;
if (sscanf(line, "%d", &num) != 1) {
    explode_bomb();
} else {
    if (num > 0) {
        eax = func4(num);
        if (eax == 0x37) {
            return;
        } else {
            explode_bomb();
        }
    } else {
        explode_bomb();
    }
}

拆除第四阶段的关键就转换为了:如何使 func4(num) == 0x37。我们继续运行,进入 func4,直到遇到 8048cab 的跳转,查看 ebx 的值,发现是我们的输入,也就是说 $ebp+0x8 是输入的数字。

1
2
 8048cab:   83 fb 01                cmp    $0x1,%ebx
 8048cae:   7e 20                   jle    8048cd0 <func4+0x30>

不难发现这两行的逻辑是:

1
2
3
4
5
if (num <= 1) {
    return 1;
} else {
    // do something
}

所以关键是 else 后面做了些什么,让返回值是 0x37,也就是 55 的。else 后面的语句是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 8048cb3:   8d 43 ff                lea    -0x1(%ebx),%eax
 8048cb6:   50                      push   %eax
 8048cb7:   e8 e4 ff ff ff          call   8048ca0 <func4>
 8048cbc:   89 c6                   mov    %eax,%esi
 8048cbe:   83 c4 f4                add    $0xfffffff4,%esp
 8048cc1:   8d 43 fe                lea    -0x2(%ebx),%eax
 8048cc4:   50                      push   %eax
 8048cc5:   e8 d6 ff ff ff          call   8048ca0 <func4>
 8048cca:   01 f0                   add    %esi,%eax
 8048ccc:   eb 07                   jmp    8048cd5 <func4+0x35>

除去传参的 push 和调整堆栈值以外,剩下部分的逻辑是:

1
2
3
4
5
6
eax = ebx - 1;
esi = func4(eax)
eax = ebx - 2;
eax = func4(eax)
eax += esi;
return eax;

合并化简一下:

1
return func4(ebx-1) + func4(ebx-2);

回想一下你学 C 语言的时候做过的那些「数学题」,这个式子不就是 Fibonacci 数列的递推公式吗?这下 func4 就明了了:

1
2
3
4
5
if (num <= 1) {
    return 1;
} else {
    return func4(ebx-1) + func4(ebx-2);
}

结合到一起,phase_4 等于在问:第几个数对应的 Fibonacci 数等于 55?所以答案是 9。这一阶段考察的是递归,难点在于对函数的传参和返回是否熟悉,堆栈的变化是否清楚,比如:add 0xfffffff4,%esp 等价于 sub $0xC,%esp。此外出现了一些等价于 nop 的指令,也会对分析产生干扰,如:

1
mov %esi,%esi

不过这些问题都可以通过耐心的进行「翻译」,以及 Google 进行解决。

第五阶段

路漫漫其修远兮,吾将上下而求索

只剩最后两阶段了,让我们看看第五阶段又有哪些花样。一眼就看到了 string_length 这个函数,那么我们这次的输入应该是字符串,不再是数字了,紧跟着的 cmp 0x6,%eax 告诉我们字符串长度是 6。有了这些信息,我们就可以开始调试看看了,先输入 hello5,将断点设到 8048d43,单步调试,发现如我们所料,跳转到了 8048d4d

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 8048d4d:   31 d2                   xor    %edx,%edx
 8048d4f:   8d 4d f8                lea    -0x8(%ebp),%ecx
 8048d52:   be 20 b2 04 08          mov    $0x804b220,%esi
 8048d57:   8a 04 1a                mov    (%edx,%ebx,1),%al
 8048d5a:   24 0f                   and    $0xf,%al
 8048d5c:   0f be c0                movsbl %al,%eax
 8048d5f:   8a 04 30                mov    (%eax,%esi,1),%al
 8048d62:   88 04 0a                mov    %al,(%edx,%ecx,1)
 8048d65:   42                      inc    %edx
 8048d66:   83 fa 05                cmp    $0x5,%edx
 8048d69:   7e ec                   jle    8048d57 <phase_5+0x2b>

注意到 8048d69 的跳转是往回跳,意味着很有可能是个循环。至于是不是,我们先翻译一遍:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// edx
i = 0
ecx = ebp - 0x8;
esi = 0x804b220;

do {
    al = *(ebx + i) & 0xF;
    al = *(esi + al);
    *(ecx + i) = al;

    i++;
} while (i <= 5);

为了进一步简化,我们需要知道 ebxecxesi 的值。通过 x/sx/8x 指令发现:

  • ebx:指向输入 hello5
  • ecx:指向一个占 8 字节的数组,数组已经被初始化为 0
  • esi:指向字符串 isrveawhobpnutfg\260\001

因为这几个寄存器都是一些字符串,所以类似于 *(ebx+i) 的表达式其实就是 ebx[i],并且这里的 do-while 循环可以转换为 for 循环。进一步简化得到:

1
2
3
4
5
6
// $ebp-8, $ebp-7, ..., $ebp-1
char tmp[8];
for (i = 0; i <= 5; i++) {
    num = line[i] & 0xF;
    tmp[i] = "isrveawhobpnutfg\260\001"[num];
}

上面的代码根据我们输入字符串从 isrveawhobpnutfg(注意 num 小于 16)选出了 5 个字符。接着看到 strings_not_equal 这个函数,让我们不禁猜想是不是拿这 5 个字符与另一个字符串进行比较,相等就拆除这一阶段。不管这段逻辑是不是,我们尝试一把,先搞清 0x804980b 指向 giantsgiants 对应于 isrveawhobpnutfg 的位置如下表:

g i a n t s
15 0 5 11 13 1
'o' & 0xF 'p' & 0xF 'e' & 0xF 'k' & 0xF 'm' & 0xF 'a' & 0xF

我们将输入换成 opekma,成功!看来这部分逻辑和猜想的一样,就不用再看这部分代码了。

Good work! On to the next...

第六阶段

phase_6 给人的第一印象是:有点长,各种跳转,看来并不简单。在深入研究之前,我们先去掉一些等价于 nop 的指令:

1
2
lea 0x0(%esi),%esi
lea 0x0(%esi,%eiz,1),%esi

将断点设在 8048dc3,我们看看跳转之前做了些什么。

1
2
3
4
5
 8048dc0:   8d 45 e8                lea    -0x18(%ebp),%eax
 8048dc3:   8b 04 b8                mov    (%eax,%edi,4),%eax
 8048dc6:   48                      dec    %eax
 8048dc7:   83 f8 05                cmp    $0x5,%eax
 8048dca:   76 05                   jbe    8048dd1 <phase_6+0x39>

上面的代码等价于:

1
2
3
4
5
6
// ptr = ebp - 0x18;
if (*(ptr + edi*4) - 1 <= 5) {
    // do something
} else {
    explode_bomb();
}

容易发现 $ebp-0x18 是个指针,指向的值是 1,正好是我们输入的第一个整数,当然也有可能是程序中的一个常量。具体是哪种情况,暂不清楚,先放在一边,继续单步执行,我们遇到了一个循环。

1
2
3
4
5
6
7
8
 8048de6:   8b 55 c8                mov    -0x38(%ebp),%edx
 8048de9:   8b 04 32                mov    (%edx,%esi,1),%eax
 8048dec:   3b 04 9e                cmp    (%esi,%ebx,4),%eax
 8048def:   75 05                   jne    8048df6 <phase_6+0x5e>
 8048df1:   e8 06 07 00 00          call   80494fc <explode_bomb>
 8048df6:   43                      inc    %ebx
 8048df7:   83 fb 05                cmp    $0x5,%ebx
 8048dfa:   7e ea                   jle    8048de6 <phase_6+0x4e>

并不清楚是在做什么,不管这么多,先写出它的等价 C 代码:

1
2
3
4
5
6
7
8
9
// 8048de3: ptr = ebp - 0x18
do {
    // 8048dec
    if (*(ptr + *(ebp-0x38)) == *(ptr + ebx*4)) {
        explode_bomb();
    }

    ebx++;
} while (ebx <= 5);

光从这段代码很难推断出什么有用信息,我们只能知道:

  1. ebx 是个计数变量
  2. ptr 是个指针,看着像是指向一个整型数组

往下看:

1
2
3
 8048dfc:   47                      inc    %edi
 8048dfd:   83 ff 05                cmp    $0x5,%edi
 8048e00:   7e be                   jle    8048dc0 <phase_6+0x28>

这三句告诉我们上面分析的两段代码其实是处于一个大的循环中,结合之前的分析,我们把 8048dc08048e00 这段代码翻译出来,然后写出它的等价逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ptr = ebp - 0x18;
// i = edi;
// j = ebx;

// 8048db8: edi = 0;
for (i = 0; i <= 5; i++) {
    if (*(ptr + i*4) - 1 > 5)
        explode_bomb();
    for (j = i + 1; j <= 5; j++) {
        if (*(ptr + i*4) == *(ptr + j*4))
            explode_bomb();
    }
}

我们从 8048dc0 往上看,8048db8 告诉我们 i 被初始化了 0,所以循环总共迭代了 6 次,可能是对我们输入的 6 个整数做了处理。再看看 ptr + i*4,也就是 ebp-0x18ebp-0x14、...、ebp-0x04 所指向的值,发现正好对应于输入的 6 个整数,所以 *(ptr + i*4) 等价于 nums[i]

1
2
3
4
5
6
7
8
for (i = 0; i <= 5; i++) {
    if (nums[i] > 6)
        explode_bomb();
    for (j = i + 1; j <= 5; j++) {
        if (nums[i] == nums[j])
            explode_bomb();
    }
}

所以 8048dc08048e00 告诉我们输入的 6 个整数必须满足:

  1. 小于等于 6
  2. 互不相等

依葫芦画瓢是个好办法,但是我们先不急着翻译,看看各种跳转把执行流导向了哪里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
8048e02
...
8048e10
...
8048e1f
if (ebx >= *(eax + ecx)) {
    goto 8048e38;
} else {
    8048e26
    8048e29
    do {
        8048e30
        8048e33
    } while (ebx < eax);

    8048e38
    ... 
    8048e3e

    if (edi <= 5) {
        goto 8048e10;
    } else {
        8048e44
        ...
        8048e4f

        do {
            8048e52
            ...
            8048e5a
        } while (edi <= 5);

        8048e60
        ...
        8048e70
        8048e73

        if (eax >= *edx) {
            8048e7e
            8048e81

            if (edi <= 4) {
                goto 8048e70;
            } else {
                return;
            }
        } else {
            explode_bomb();
        }
    }
}

乍看之下,这个控制流十分复杂,但在进一步规约后,我们会发现都是些很简单的结构:

 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
35
36
37
38
39
40
41
8048e02
...
8048e0d
do {
    8048e10
    ...
    8048e1f
    if (ebx < *(eax + ecx)) {
        8048e26
        8048e29
        do {
            8048e30
            8048e33
        } while (ebx < eax);
    }
    8048e38
    ... 
    8048e3e
} while (edi <= 5);

8048e44
...
8048e4f
do {
    8048e52
    ...
    8048e5a
} while (edi <= 5);

8048e60
...
8048e6c
do {
    8048e70
    8048e73
    if (eax < *edx)
        explode_bomb();
    8048e7e
    8048e81
} while (edi <= 4);
return;

我们耐心的把这些汇编翻译过来,然后化简就得到了:

 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
// i = edi
// j = ebx
// ptr = esi
for (i = 0; i <= 5; i++) {
    ptr = *(ebp - 0x34);
    for (j = 1; j < nums[i]; j++) {
        ptr = *(ptr + 0x8);
    }
    *(ebp - 0x30 + i*4) = ptr;
}

ptr = *(ebp - 0x30);
*(ebp - 0x34) = *(ebp - 0x30);

for (i = 1; i <= 5; i++) {
    *(ptr + 0x8) = *(ebp - 0x30 + i*4);
    ptr = *(ebp - 0x30 + i*4);
}

*(ptr + 0x8) = 0;
ptr = ebp - 0x34;

for (i = 0; i <= 4; i++) {
    if (*ptr < **(ptr + 0x8))
        explode_bomb();
    ptr = *(ptr + 0x8);
}

什么都看不出来,但是我们发现有些地址反复出现,比如:ebp-0x34ebp-0x30,查看他们的值发现无一例外都指向一个地址。对照翻译的逻辑跑一遍,发现 16 到 19 行代码很有规律,跟踪几次迭代后会发现 17 和 18 两行代码很像链表的操作:

1
2
3
ptr->next = node;
ptr = node;
node = node->next;

ebp-0x30 似乎就是用来构造链表的结构体数组,要搞清楚这一点,最简单的办法莫过于查看这个数组的每一个值,看看相互之间有没有关联。为此,我们回头找初始化的代码,注意到第 8 行对 ebp-0x30ebp-0x2c、...、ebp-0x1c 进行了赋值,且 ebp-0x34 的值在 8048da4 处被赋值成了一个地址 0x804b26c。我们跟踪第 6 行,发现有 6 个地址相互有关联:

1
2
3
4
5
6
*(0x804b26c + 0x8) = 0x804b260
*(0x804b260 + 0x8) = 0x804b254
*(0x804b254 + 0x8) = 0x804b248 
*(0x804b248 + 0x8) = 0x804b23c
*(0x804b23c + 0x8) = 0x804b230
*(0x804b230 + 0x8) = 0

这显然是一个链表结构,而 +0x8 简直和 ->next 一模一样,替换一下:

 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
// i = edi
// j = ebx
// ptr = esi
for (i = 0; i <= 5; i++) {
    ptr = nodes[0];
    for (j = 1; j < nums[i]; j++) {
        ptr = ptr->next;
    }
    *(ebp - 0x30 + i*4) = ptr;
}

ptr = *(ebp - 0x30);
nodes[0] = *(ebp - 0x30);

for (i = 1; i <= 5; i++) {
    ptr->next = *(ebp - 0x30 + i*4);
    ptr = *(ebp - 0x30 + i*4);
}

ptr->next = 0;
ptr = &nodes[0];

for (i = 0; i <= 4; i++) {
    if (*ptr < *ptr->next)
        explode_bomb();
    ptr = ptr->next;
}

是不是感觉整个世界都明朗了,哈哈。接着就只需要弄明白,*(ebp - 0x30 + i*4) 到底是个什么东西。注意到第 9 行,因为 ptr 是个指针,而 i*4 在前面已经见过了,种种这些都暗示我们 ebp - 0x30 是个指针数组,我们再据此改写一下:

 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
// i = edi
// j = ebx
// ptr = esi
// ptrs = ebp - 0x30
// nodes = {
//     0x804b26c, 0x804b260, 0x804b254,
//     0x804b248, 0x804b23c, 0x804b230,
// }
for (i = 0; i <= 5; i++) {
    ptr = nodes[0];
    for (j = 1; j < nums[i]; j++) {
        ptr = ptr->next;
    }
    ptrs[i] = ptr;
}

ptr = ptrs[0];
for (i = 1; i <= 5; i++) {
    ptr->next = ptrs[i];
    ptr = ptrs[i];
}
ptr->next = NULL;

ptr = ptrs[0];
for (i = 0; i <= 4; i++) {
    if (*ptr < *ptr->next)
        explode_bomb();
    ptr = ptr->next;
}

这下一切都清楚了,第 5 到 11 行根据我们输入的数字将指针数组 ptrs 初始化,第 13 到 18 行将 ptrs 中的结构体依次链接起来,第 20 到 25 行告诉我们链接起来形成的链表必须满足递减的规律。接下来的事情就简单了,打印出 nodes 中那些地址指向的值,然后排序以后得出答案:4 2 6 3 1 5

Address Value Order
0x804b26c 0x000000fd 5
0x804b260 0x000002d5 2
0x804b254 0x0000012d 4
0x804b248 0x000003e5 1
0x804b23c 0x000000d4 6
0x804b230 0x000001b0 3

Congratulations! You've defused the bomb!

至此,我们已经成功拆除了炸弹。

隐藏阶段

第六阶段非常难,没有动手写过一些 C 语言代码,几乎很难察觉到其中的奥秘。不过隐藏阶段才是真正的 BOSS……这里不详细说明了,照着上面的办法,慢慢的尝试,花点时间也能做出来。下面给几个提示,有兴趣的同学可以尝试尝试:

  • 在第四阶段加点料才能进入隐藏阶段
  • fun7_tree

成功解决这个炸弹相信非常有成就感,不过其实拆除炸弹有个十分简单的办法——Ctrl+C :P