为了读懂 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。
现在拿起我们的炸弹瞧一瞧。容易发现有一些库函数,比如:fprintf
、fgets
、printf
;一些名字怪异,很有可能是编译器自动生成的函数;再往下翻翻就找到了 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
一番扫视让我们了解到炸弹大概类似于:
只有 6 次正确的输入才能解除炸弹,否则就会……
第一阶段
我们看到 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
寄存器中。那么 8048b3a
和 8048b3c
两行的作用就很明显了,根据 test %eax, %eax
的结果决定跳转到 8048b43
,还是运行 8048b3e
的指令。通过 Google 80386 test
很容易就能找到 test
指令的作用,test
和 je
两行结合起来正好就是上述的 if
。所以解除第一阶段的问题就简化成了:让 line
和 CONST_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
|
我们尝试打印一下 0x80497c0
和 eax
指向的字符串:
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
所指向的数字,不难发现 8048b6e
到 8048b79
这 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。上述重复结构很容易让我们想到,会不会是挨个挨个检查输入,然后所有输入都匹配才成功通过?这一点很容易否决,因为:
- 这些相似结构不止出现了 3 次,而我们的输入只有 2 个整数,1 个字符;
- 其中出现的条件跳转并不是往下跳,而是跳转到同一个位置——
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);
|
为了进一步简化,我们需要知道 ebx
、ecx
、esi
的值。通过 x/s
、x/8x
指令发现:
ebx
:指向输入hello5
ecx
:指向一个占 8 字节的数组,数组已经被初始化为 0esi
:指向字符串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
指向 giants
。giants
对应于 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);
|
光从这段代码很难推断出什么有用信息,我们只能知道:
ebx
是个计数变量ptr
是个指针,看着像是指向一个整型数组
往下看:
1 2 3 | 8048dfc: 47 inc %edi
8048dfd: 83 ff 05 cmp $0x5,%edi
8048e00: 7e be jle 8048dc0 <phase_6+0x28>
|
这三句告诉我们上面分析的两段代码其实是处于一个大的循环中,结合之前的分析,我们把 8048dc0
到 8048e00
这段代码翻译出来,然后写出它的等价逻辑:
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-0x18
、ebp-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();
}
}
|
所以 8048dc0
到 8048e00
告诉我们输入的 6 个整数必须满足:
- 小于等于 6
- 互不相等
依葫芦画瓢是个好办法,但是我们先不急着翻译,看看各种跳转把执行流导向了哪里:
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-0x34
、ebp-0x30
,查看他们的值发现无一例外都指向一个地址。对照翻译的逻辑跑一遍,发现 16 到 19 行代码很有规律,跟踪几次迭代后会发现 17 和 18 两行代码很像链表的操作:
1 2 3 | ptr->next = node;
ptr = node;
node = node->next;
|
而 ebp-0x30
似乎就是用来构造链表的结构体数组,要搞清楚这一点,最简单的办法莫过于查看这个数组的每一个值,看看相互之间有没有关联。为此,我们回头找初始化的代码,注意到第 8 行对 ebp-0x30
、ebp-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……这里不详细说明了,照着上面的办法,慢慢的尝试,花点时间也能做出来。下面给几个提示,有兴趣的同学可以尝试尝试:
- 在第四阶段加点料才能进入隐藏阶段
成功解决这个炸弹相信非常有成就感,不过其实拆除炸弹有个十分简单的办法——Ctrl+C
:P