二进制炸弹第五关+彩蛋
前情提要
最近学校布置了一道实验 —— 超级二进制炸弹2018版,基础的有四关,每一关都需要输入一个字符串来拆弹。因为每一关的算法都不同,所以需要逆向分析找出合适的字符串来通关。
在前四关都完成后,程序会询问是否要进行第五关也就是其所谓的不可能任务。
第五关完成后,还会提示有隐藏彩蛋,其实彩蛋也是通过第五关输入的字符串来控制显示的,本文对第五关与彩蛋通关过程进行了分析。
PS. 资源在文末
开始之前
必须先声明的是 GenerateRandomNumber 函数从原理上来说并不是随机的,main 函数的开头会根据你的命令行参数(也就是学号)来初始化 rand1_l 和 rand1_h
从图中可以看到,调用了一个 stoul 函数,将学号转换成了无符号长整型(32 位 long 是 4 字节),并赋值给全局变量 rand1_h,同时 rand1_l 被赋值为 666
之后每调用一次 GenerateRandomNumber,rand1_h 与 rand1_l 都会根据自身当前的值被修改,这个随机数函数的实现细节这里就不赘述了。因为第五关我是纯 ida 静态分析的,并没有进行调试,所以随机数都是用 python脚本来生成的。在这里提一句,就是为了之后展示脚本做铺垫。
如果你偏好于直接调试看返回结果,也可以通过一些手段来绕过 IsDebuggerPresent 的检测,就无需关心随机函数的实现细节了。当然,如果你感兴趣,也可以试着分析一下,我后面也有写这个函数的 python 版本。
那么我们开始吧!
流程分析
贴一张 phase_impossible 函数的伪代码
首先调用 GetTickCount 函数来获取一个时间,一般这个函数是配套出现的,代码前面放一个,后面放一个,然后两个时间一减就知道代码执行时间了。但这里只有开头有,迷惑.jpg,这就是所谓的执行超时检测吗,先放一边,接着分析
接下来的一个 do-while 循环不难看出是用来计算第五关输入字符串(a1)长度的,而且长度不能大于 768
然后判断是否存在调试器,存在就直接引爆,看来这就是反调试的地方了,怎么绕过后面再说
再接下来就是对输入字符串的处理和判断了
声明了一个局部字符串数组 v2,并将其中的内容全部初始化为 0(memset函数)
调用 tohex 函数,其有两个参数,一个是刚初始化了的 v2,另一个是输入字符串,初步猜测可能是将输入处理后放到 v2 中
调用 GenerateRandomNumber 修改 rand_div 的值
调用 check_buf_valid 检查 v2 字符串是否合法,同时传了个 rand_div 的值,猜测是否合法可能与 rand_div 有关
最后一部分:
生成一个 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 来分析一下
可以看到,这个函数很简单,就是一条 jmp eax 指令,那么 eax 是什么呢,返回到 phase_impossible 中看看调用前做了什么
在调用 goto_buf_0 前,将 eax 赋值为 ebp - 0x100,由下图可知,ebp - 0x100 是变量 v2 的地址
也就是说在调用 goto_buf_0 后,程序会跳转到 v2 处去执行,由前面的分析可知,v2 是的内容是可以控制的,且和我们的输入有关,所以这里就是提示中所说的动态生成指令部分了
我们应该分析此时的栈结构,编写相应的汇编代码,并使其在被 tohex、check_buf_valid 处理后能够执行,这段代码要能使程序的执行流返回到 main 函数中,显示第五关通关的信息
总的流程如上,接下来我们细化到函数细节,进一步分析
GetTickCount 函数
我们来找找另一个配套的 GetTickCount 函数在哪里
ida 伪代码中点一下开头的 GetTickCount( ),然后按 x 查看交叉引用
来看靠上的两个地址,第一个是 phase_impossible + F,也就是 phase_impossible 在开头调用的这个
第二个红框框起来的地址是 .text: loc_4013D6,我们双击点过去看看
可以看到程序确实有第二次调用 GetTickCount 的想法,而且代码执行时间限制在 1s(cmp eax, 1000),然而这一堆红色地址的代码已经在 phase_impossible 函数的外面了,也就是说不会被执行
就汇编代码的分析来看,phase_impossible 正常情况下一定会从 explode_bomb 退出程序,因为 explode_bomb 中调用了 exit,所以 GetTickCount 函数没有起到什么作用,我们可以忽略它
绕过 IsDebuggerPresent
该函数的作用是检测是否存在调试器,具体代码如下
我们不关心它是怎么实现的,如果存在调试器,返回值 eax 的值不会是零,想要绕过它就需要 jz 跳转指令成功跳转,有两种方法
- test eax, eax 时让 eax 等于零
- jz 跳转时让 ZF 标志位等于 1
因为我用的 ollydbg 里装了防检测调试器的插件,所以这里检测不到我在调试 = =,也就不好演示了,这些直接在调试器里改就好了,test 的时候把 eax 改成 0,或者 jz 的时候把 ZF 改成 1
tohex 函数
伪代码:
看起来比较复杂,其实实现的功能很简单,来一步步分析
首先给四个局部变量赋值,a1 是 tohex 的第一个参数,也就是在 phase_impossible 中初始化为全零的 v2 的地址,这里的局部变量 v2 可与之视作相同变量处理,以后提到 v2 就指代这个数组
之后是一个 while( 1 ) 循环,退出条件如下
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 嵌套的判断条件
又是一个熟悉的表达方式 *(数组 + 偏移),注意这里的 ctypetab 是一个 2 字节(word *)的指针,所以它指向的那个地址才是真正的数组,双击 ctypetab 过去看看
ctypetab 里存的是地址 0x40D8EE,再双击这个 unk_40D8EE,我们就可以看到一个类似数组的东西
因为 ctypetab 是一个 2 字节的指针,每次访问两个字节,所以 unk_40D8EE 这个数组的元素都是 2 字节的
又由于小端序的问题,所以 unk_40D8EE[0] = 0x0010,unk_40D8EE[9] = 0x0130
理解了这个我们再回到第二个 if 嵌套的判断条件
这里的 *(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 字符串
用 python 处理一下
1 | with open("export_results.txt") as f: |
输出的结果为:0123456789ABCDEFabcdef
也就是说,只有这些字符才会被接受,这个结果也符合我们的直观感受,因为 16 进制就是这些字符
剩下的部分就是在对合法的字符进行处理了
每种字符的处理方法如下
- 如果是字符 ‘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 函数
伪代码:
a1 和 a2 分别是 tohex 处理后的 v2 数组和 rand_div
有了前面的叙述,这个应该很好理解了,用 python 翻译一下:
1 | def check_buf_valid(a1, a2): |
这里就出现了一个问题,v3 是一个字节,a2 是 rand_div,是 4 个字节,它们怎么进行比较?
汇编代码:
cl 和 al 符号拓展为 4 字节后比较必须相等,也就是说 cl 和 al 本身就应该相等,即 v3 要和 rand_div 的最低字节相等,这一点从函数的参数类型声明也可以看出
这里的 a2 类型为 char,也就是只取 rand_div 的最低字节
动态生成指令
伪代码:
生成 0 ~ 2 的一个数,然后分别去向 goto_buf_0,goto_buf_1,goto_buf_2
可以看到,这三个函数作用都相同,都相当于 jmp eax,也就是不管进哪个分支,最后程序都会跳转到我们布置的 v2 去执行,所以分析此时的栈分布就显得尤为重要
从 main 函数开始
红框中为 main 的函数序言及保存部分寄存器,在 main 的末尾也有还原现场和函数尾声:
所以此时 main 的栈帧如下:
然后 main 调用了 phase_impossible
同理,此时栈的分布为:
现在 phase_impossible 要调用 goto_buf_0(三个都一样,挑一个来说)
而 goto_buf_0 就一句 jmp eax,所以在跳之前,栈是这样的:
由下图可知,只要 phase_impossible 函数正常返回,就能输出第五关通关信息
所以构造如下汇编:
1 | mov esp,ebp |
即可完成 phase_impossible 函数的函数尾声
在调试器中随便找个空白的地方将汇编写入,得知其机器码,如图,为 “8BE55DC3”
如果我们就将这个字符串作为输入,经过 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 | def in1t(num): |
将这个运行结果输入,第五关就算是完成了
彩蛋
翻一翻 ida 的函数左侧的函数名称窗口,可以找到一个名为 phase_secret 的函数,它的作用就是输出一个彩蛋字符串
我们只要构造好汇编代码,控制程序的返回流先正常返回到 main,输出第五关通关,然后再跳转到 phase_secret 去输出彩蛋信息就行了,所以答案不唯一
我的思路是这样的,首先让执行流返回到 main,输出第五关过关,所以我需要两条指令:
1 | push 0x401240 |
来到 0x401240顺着执行下来,输出过关信息,然后遇到 main 的函数尾声
下面的红框执行到 retn 时,栈分布为:
esp 指向的 phase_impossible 返回地址其实也是 0x401240,所以程序又会输出一遍第五关过关
那如果把 phase_impossible 的返回地址改为 phase_secret 函数的地址,函数在输出完第五关通关后,就会输出彩蛋,这也正是我们要的顺序,所以我们让栈帧先回退一下,修改完返回地址再还原
1 | mov esp,ebp ; 回退栈帧 |
这样看似完美了,在输出完第五关通关后,确实输出了彩蛋,但是输出完彩蛋,程序无法正常返回
原因是当 phase_secret 执行到 retn 时,esp 正指向 main 保存的 edi
这个值是多少无从知晓,但一般都不是什么正常的地址,我们就丢失了对程序的控制,所以在原来的代码基础上,我们再将这个位置的值改为 0x40125B
就能使 main 正常返回了,增加两条指令
1 | mov esp,ebp |
所以 payload 为 “8BE55D5B5B685B12400068F0144000558BEC6A016A026A036840124000C3”
再用上面的 python 脚本算一下最后一个字节要补什么,以 001044 为例,要补 B8
到这里就算全部完成了
撒花✿✿ヽ(°▽°)ノ✿
资源
链接:网盘
提取码:rwrv