前情提要

最近学校布置了一道实验 —— 超级二进制炸弹2018版,基础的有四关,每一关都需要输入一个字符串来拆弹。因为每一关的算法都不同,所以需要逆向分析找出合适的字符串来通关。

在前四关都完成后,程序会询问是否要进行第五关也就是其所谓的不可能任务。

bomb

第五关完成后,还会提示有隐藏彩蛋,其实彩蛋也是通过第五关输入的字符串来控制显示的,本文对第五关与彩蛋通关过程进行了分析。

PS. 资源在文末


开始之前

必须先声明的是 GenerateRandomNumber 函数从原理上来说并不是随机的,main 函数的开头会根据你的命令行参数(也就是学号)来初始化 rand1_l 和 rand1_h

bomb

从图中可以看到,调用了一个 stoul 函数,将学号转换成了无符号长整型(32 位 long 是 4 字节),并赋值给全局变量 rand1_h,同时 rand1_l 被赋值为 666

之后每调用一次 GenerateRandomNumber,rand1_h 与 rand1_l 都会根据自身当前的值被修改,这个随机数函数的实现细节这里就不赘述了。因为第五关我是纯 ida 静态分析的,并没有进行调试,所以随机数都是用 python脚本来生成的。在这里提一句,就是为了之后展示脚本做铺垫。

如果你偏好于直接调试看返回结果,也可以通过一些手段来绕过 IsDebuggerPresent 的检测,就无需关心随机函数的实现细节了。当然,如果你感兴趣,也可以试着分析一下,我后面也有写这个函数的 python 版本。

那么我们开始吧!


流程分析

贴一张 phase_impossible 函数的伪代码

bomb

首先调用 GetTickCount 函数来获取一个时间,一般这个函数是配套出现的,代码前面放一个,后面放一个,然后两个时间一减就知道代码执行时间了。但这里只有开头有,迷惑.jpg,这就是所谓的执行超时检测吗,先放一边,接着分析

接下来的一个 do-while 循环不难看出是用来计算第五关输入字符串(a1)长度的,而且长度不能大于 768

bomb

然后判断是否存在调试器,存在就直接引爆,看来这就是反调试的地方了,怎么绕过后面再说

bomb

再接下来就是对输入字符串的处理和判断了

bomb

  • 声明了一个局部字符串数组 v2,并将其中的内容全部初始化为 0(memset函数)

  • 调用 tohex 函数,其有两个参数,一个是刚初始化了的 v2,另一个是输入字符串,初步猜测可能是将输入处理后放到 v2 中

  • 调用 GenerateRandomNumber 修改 rand_div 的值

  • 调用 check_buf_valid 检查 v2 字符串是否合法,同时传了个 rand_div 的值,猜测是否合法可能与 rand_div 有关

最后一部分:

bomb

生成一个 0 ~ 2 的随机数

  • 等于 0,依次调用 goto_buf_0,goto_buf_1,goto_buf_2
  • 等于 1,依次调用 goto_buf_1,goto_buf_2
  • 等于 2,调用 goto_buf_2
  • 最后调用 explode_bomb

就这么看起来好像不管输什么最后都会爆,那就挑 goto_buf_0 来分析一下

bomb

可以看到,这个函数很简单,就是一条 jmp eax 指令,那么 eax 是什么呢,返回到 phase_impossible 中看看调用前做了什么

bomb

在调用 goto_buf_0 前,将 eax 赋值为 ebp - 0x100,由下图可知,ebp - 0x100 是变量 v2 的地址

bomb

也就是说在调用 goto_buf_0 后,程序会跳转到 v2 处去执行,由前面的分析可知,v2 是的内容是可以控制的,且和我们的输入有关,所以这里就是提示中所说的动态生成指令部分了

我们应该分析此时的栈结构,编写相应的汇编代码,并使其在被 tohex、check_buf_valid 处理后能够执行,这段代码要能使程序的执行流返回到 main 函数中,显示第五关通关的信息

总的流程如上,接下来我们细化到函数细节,进一步分析


GetTickCount 函数

我们来找找另一个配套的 GetTickCount 函数在哪里

ida 伪代码中点一下开头的 GetTickCount( ),然后按 x 查看交叉引用

bomb

来看靠上的两个地址,第一个是 phase_impossible + F,也就是 phase_impossible 在开头调用的这个

第二个红框框起来的地址是 .text: loc_4013D6,我们双击点过去看看

bomb

可以看到程序确实有第二次调用 GetTickCount 的想法,而且代码执行时间限制在 1s(cmp eax, 1000),然而这一堆红色地址的代码已经在 phase_impossible 函数的外面了,也就是说不会被执行

就汇编代码的分析来看,phase_impossible 正常情况下一定会从 explode_bomb 退出程序,因为 explode_bomb 中调用了 exit,所以 GetTickCount 函数没有起到什么作用,我们可以忽略它


绕过 IsDebuggerPresent

该函数的作用是检测是否存在调试器,具体代码如下

bomb

我们不关心它是怎么实现的,如果存在调试器,返回值 eax 的值不会是零,想要绕过它就需要 jz 跳转指令成功跳转,有两种方法

  • test eax, eax 时让 eax 等于零
  • jz 跳转时让 ZF 标志位等于 1

因为我用的 ollydbg 里装了防检测调试器的插件,所以这里检测不到我在调试 = =,也就不好演示了,这些直接在调试器里改就好了,test 的时候把 eax 改成 0,或者 jz 的时候把 ZF 改成 1


tohex 函数

伪代码:

bomb

看起来比较复杂,其实实现的功能很简单,来一步步分析

bomb

首先给四个局部变量赋值,a1 是 tohex 的第一个参数,也就是在 phase_impossible 中初始化为全零的 v2 的地址,这里的局部变量 v2 可与之视作相同变量处理,以后提到 v2 就指代这个数组

之后是一个 while( 1 ) 循环,退出条件如下

bomb

v7 是什么呢,上面一行定义了 v7 = (char *) (v6 + a2),是个字符指针,而刚进入 while 循环时,v6 = v4 = 0

所以第一次循环时 v7 = (char *) (a2 + 0),a2 是 tohex 的第二个参数,即我们输入的字符串,这样的表示或许有些陌生,我们换一种表示方式,v7 = a2[0],是不是亲切了许多呢?

因为 v6 = v4++,下一次循环 v4 变成了 1,所以下一次循环的 v7 = a2[v6] = a2[1],结合 while 循环的退出条件可知,这个循环在遍历我们输入的字符串,当读到字符串末尾时退出


那遍历字符串做了什么处理呢?来看第二个 if 嵌套的判断条件

bomb

又是一个熟悉的表达方式 *(数组 + 偏移),注意这里的 ctypetab 是一个 2 字节(word *)的指针,所以它指向的那个地址才是真正的数组,双击 ctypetab 过去看看

bomb

ctypetab 里存的是地址 0x40D8EE,再双击这个 unk_40D8EE,我们就可以看到一个类似数组的东西

bomb

因为 ctypetab 是一个 2 字节的指针,每次访问两个字节,所以 unk_40D8EE 这个数组的元素都是 2 字节的

又由于小端序的问题,所以 unk_40D8EE[0] = 0x0010,unk_40D8EE[9] = 0x0130

bomb

理解了这个我们再回到第二个 if 嵌套的判断条件

bomb

这里的 *(ctypetab + v7) 即为 unk_40D8EE[v7],v7为我们输入的字符串中的一个字符,将其 ascii 码作为索引,在 unk_40D8EE 这个数组里寻找元素,拿来和 2 进行与运算,只有结果不为零的字符才能进入下层 if 中处理

于是乎,这个 if 判断的作用便一目了然了,这里其实是在检查输入字符的有效性,如果 unk_40D8EE 数组长度为 256,给这个数组每个元素都赋上一个值,保证能作为输入字符的 ascii 码的偏移对应的值和 2 与了之后不为 0,其他非法字符在数组中对应的值和 2 与了之后为 0,就可以建立 chr( 0 ) 到 chr( 255 ) 字符的白名单

在 ida 中将 unk_40D8EE 这个数组全部选中,shift + e 导出为不带空格的 hex 字符串

bomb

用 python 处理一下

1
2
3
4
5
6
7
8
9
10
with open("export_results.txt") as f:
data = f.read()

base = []
for i in range(0, len(data), 4):
base.append(int(data[i+2:i+4] + data[i:i+2], 16))

for i in range(1, 127):
if base[i] & 2:
print(chr(i), end='')

输出的结果为:0123456789ABCDEFabcdef

也就是说,只有这些字符才会被接受,这个结果也符合我们的直观感受,因为 16 进制就是这些字符


剩下的部分就是在对合法的字符进行处理了

bomb

每种字符的处理方法如下

  • 如果是字符 ‘0’ ~ ‘9’,则减去 ‘0’ 的 ascii 变成数值 0 ~ 9
  • 如果是字符 ‘a’ ~ ‘f’,则减去 ‘W’ 的 ascii 变成数值 10 ~ 15
  • 如果是字符 ‘A’ ~ ‘F’,则减去 ‘7’ 的 ascii 变成数值 10 ~ 15

同时使用 v3 作为 flag,当 v3 = 1,也就是处理到 16 进制一个字节的 “十位” 时,将其记录在 v9 中,v3 置 0,下次循环取到这个字节的 “个位” ,用 16 * “十位” + “个位” 就能得到内存中真正的 16 进制形式的一字节,并将其放在局部变量指针 v2 的相应字节处(也就是 phase_impossible 的 v2),v2++ 指向下一空字节,v3 置 1,以便下一次循环取下一字节的 “十位”

举个例子,假如我们输入 “8ED3”,那么第一次循环取到字符 ‘8’,减 ‘0’ 的 ascii 后得到 8,放到局部变量 v9 中,v3 置 0,第二次循环取到字符 ‘E’,减去 ‘E’ 的 ascii 得到 14,拿 14 + 16 * 8 就等于 0x8E,存储到 v2[0] 中,v3 置 1,同理,v2[1] = 0xD3

函数最后的 *v2 = 0 就是将字符串数组加上 \x00 结束符


check_buf_valid 函数

伪代码:

bomb

a1 和 a2 分别是 tohex 处理后的 v2 数组和 rand_div

有了前面的叙述,这个应该很好理解了,用 python 翻译一下:

1
2
3
4
5
def check_buf_valid(a1, a2):
v3 = 0
for i in range(256):
v3 ^= a1[i]
return v3 == a2

这里就出现了一个问题,v3 是一个字节,a2 是 rand_div,是 4 个字节,它们怎么进行比较?

汇编代码:

bomb

cl 和 al 符号拓展为 4 字节后比较必须相等,也就是说 cl 和 al 本身就应该相等,即 v3 要和 rand_div 的最低字节相等,这一点从函数的参数类型声明也可以看出

bomb

这里的 a2 类型为 char,也就是只取 rand_div 的最低字节


动态生成指令

伪代码:

bomb

生成 0 ~ 2 的一个数,然后分别去向 goto_buf_0,goto_buf_1,goto_buf_2

bomb

可以看到,这三个函数作用都相同,都相当于 jmp eax,也就是不管进哪个分支,最后程序都会跳转到我们布置的 v2 去执行,所以分析此时的栈分布就显得尤为重要

从 main 函数开始

bomb

红框中为 main 的函数序言及保存部分寄存器,在 main 的末尾也有还原现场和函数尾声:

bomb

所以此时 main 的栈帧如下:

bomb

然后 main 调用了 phase_impossible

bomb

同理,此时栈的分布为:

bomb

现在 phase_impossible 要调用 goto_buf_0(三个都一样,挑一个来说)

bomb

而 goto_buf_0 就一句 jmp eax,所以在跳之前,栈是这样的:

bomb

由下图可知,只要 phase_impossible 函数正常返回,就能输出第五关通关信息

bomb

所以构造如下汇编:

1
2
3
mov esp,ebp
pop ebp
ret

即可完成 phase_impossible 函数的函数尾声

在调试器中随便找个空白的地方将汇编写入,得知其机器码,如图,为 “8BE55DC3”

bomb

如果我们就将这个字符串作为输入,经过 tohex 处理后,在内存中的确为 8BE55DC3,但是会过不了 check_buf_valid,我们还需要在其后添加一个字节,这个字节的值要等于 v3 ^ rand_div 的最低字节

以学号 001044 为例,经过计算(也可以直接调试),GenerateRandomNumber(0x400) 后,rand_div 为 0x1C7,最低字节为 C7,此时的 v2 数组前 4 个为 8B E5 5D C3,后面全是 0,一个数与 0 异或还是其本身,所以 v3 的值就等于前面的 4 个字节异或的结果

此时要求 v3 = rand_div 的最低字节,所以应该在 8B E5 5D C3 后加一个与 v3 异或后等于 rand_div 最低字节的值,由 v3 ^ 0xC7 得出,值为 0x37,拼接在 payload 后面,得到最终输入 “8BE55DC337”

这些步骤可以由 python 得出(仅适用于这个学号)

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
def in1t(num):
global rand_l, rand_h, rand_div
rand_l = 666
rand_h = int(num)
rand_div = 0

def GenerateRandomNumber(mod):
global rand_l, rand_h, rand_div
res = hex(rand_l + 1791398085 * rand_h)[2:].rjust(16, '0')
rand_l = int(res[:8], 16)
rand_h = int(res[8:], 16)
if mod == 0:
rand_div = 0
else:
rand_div = rand_h % mod

def test(payload):
in1t("001044")
GenerateRandomNumber(0)
for i in range(10):
GenerateRandomNumber(2)
GenerateRandomNumber(0x1A)
GenerateRandomNumber(0xD)
GenerateRandomNumber(0xA)
GenerateRandomNumber(0xE)
GenerateRandomNumber(8)
GenerateRandomNumber(0xC8)
GenerateRandomNumber(0x14)
GenerateRandomNumber(7)
GenerateRandomNumber(0x400)

v2 = [0] * 256
v3 = 0
for i in range(0, len(payload), 2): # tohex
v2[i//2] = int(payload[i:i+2], 16)
for i in range(256): # check_buf_valid
v3 ^= v2[i]
lsb_of_div = int(hex(rand_div)[2:].rjust(2,'0')[-2:], 16)
last_byte = hex(v3 ^ lsb_of_div)[2:].rjust(2,'0').upper()
print(payload + last_byte)

if __name__ == "__main__":
payload = "8BE55DC3"
test(payload)

将这个运行结果输入,第五关就算是完成了

bomb


彩蛋

翻一翻 ida 的函数左侧的函数名称窗口,可以找到一个名为 phase_secret 的函数,它的作用就是输出一个彩蛋字符串

bomb

我们只要构造好汇编代码,控制程序的返回流先正常返回到 main,输出第五关通关,然后再跳转到 phase_secret 去输出彩蛋信息就行了,所以答案不唯一

我的思路是这样的,首先让执行流返回到 main,输出第五关过关,所以我需要两条指令:

1
2
push 0x401240
ret

来到 0x401240顺着执行下来,输出过关信息,然后遇到 main 的函数尾声

bomb

下面的红框执行到 retn 时,栈分布为:

bomb

esp 指向的 phase_impossible 返回地址其实也是 0x401240,所以程序又会输出一遍第五关过关

那如果把 phase_impossible 的返回地址改为 phase_secret 函数的地址,函数在输出完第五关通关后,就会输出彩蛋,这也正是我们要的顺序,所以我们让栈帧先回退一下,修改完返回地址再还原

1
2
3
4
5
6
7
8
9
10
11
mov esp,ebp	; 回退栈帧
pop ebp ; 回退栈帧
pop ebx ; 将 esp + 4,使得能够覆盖返回地址
push 0x4014F0 ; 将 phase_impossible 返回地址覆盖为 phase_secret 地址
push ebp ; 还原栈帧
mov ebp,esp ; 还原栈帧
push 1 ; 对应 pop ebx
push 2 ; 对应 pop esi
push 3 ; 对应 pop edi
push 0x401240 ; 输出第五关通关
ret ; 返回到 0x401240

这样看似完美了,在输出完第五关通关后,确实输出了彩蛋,但是输出完彩蛋,程序无法正常返回

bomb

原因是当 phase_secret 执行到 retn 时,esp 正指向 main 保存的 edi

bomb

这个值是多少无从知晓,但一般都不是什么正常的地址,我们就丢失了对程序的控制,所以在原来的代码基础上,我们再将这个位置的值改为 0x40125B

bomb

就能使 main 正常返回了,增加两条指令

1
2
3
4
5
6
7
8
9
10
11
12
13
mov esp,ebp
pop ebp
pop ebx
pop ebx ; 多 pop 一下
push 0x40125B ; 先覆盖 main 保存 edi 的位置
push 0x4014F0 ; 再将 phase_impossible 返回地址覆盖为 phase_secret
push ebp
mov ebp,esp
push 1
push 2
push 3
push 0x401240
ret

bomb

所以 payload 为 “8BE55D5B5B685B12400068F0144000558BEC6A016A026A036840124000C3”

再用上面的 python 脚本算一下最后一个字节要补什么,以 001044 为例,要补 B8

bomb

到这里就算全部完成了

撒花✿✿ヽ(°▽°)ノ✿


资源

链接:网盘
提取码:rwrv