很明显可以看到eax内容为来自标准输入的参数的地址,而直接入栈的地址很有可能就是答案。直接kill掉并重新加载bomb,在0x8048b3a处设置断点,由图4所示代码可以看出如果eax为0则通过。运行并输入测试字符串:
图4 phase_1检测部分代码
图5 测试字符串运行结果
如图 $eax==0 , 所以第一个关卡的答案就是 ”Public speaking is very easy.” 了。
3. phase_2
观察phase_2,可以看到如下片段:
图6 phase_2部分代码
入栈eax和edx可以看出是两个地址,而read_six_numbers可以看出这个关卡的答案是6个数字。所以就用1 2 3 4 5 6 来进行测试,并read_six_number()函数运行前后对参数进行追踪和对比:
图7 进行phase_2测试
图8 read_six_number()函数运行前
图8 read_six_number()函数运行后
对比可以看到输入的6个数字参数被存在以0xffffd0e0开始的24个字节中。接下来有如下代码:
图9 phase_2的第一个炸弹点
可以看到-18($ebp)是第一个数字的地址,而判断语句就是判断第一个数字是不是1,不是则爆炸。幸运的是测试的数字第一个数字是1,所以可以继续运行:
图10 运行到0x8048b73
图11 对phase_2核心代码的初步解释
如图11所示,由于ebp的值为0xffffd0f8,把输入的6个数字看作数组num[6],则可以将所示代码总结为以下C代码:
图12 phase_2的等价形式
根据图10 和11推断出第一个数字一定是1,根据循环算出后面的数字分别是1*2,2*3,6*4,24*5,120*6,即1 2 6 24 120 720。按照推断出的答案进行测试,在bomb_explode函数处设置断点,如果错误就可以直接Kill重新运行而不会爆炸:
图13 测试phase_2
可以看到并未触发断点,则结果正确。
4. phase_3
首先当然还是先观察phase_3的汇编代码,看是不是调用了有关输入的参数的函数:
图14 phase_3参数的读取部分
首先,将三个地址参数入栈,其中的数据大小为32位,4位,32位,则中间一个参数为单个字符型。在sscanf函数调用后检查$eax,因为sscanf在参数匹配成功后会将匹配成功的参数的个数放入eax中返回,所以检查eax是否大于2,即至少应匹配三个参数才能过第一个爆炸点。
图15 有关其余两个参数的调用
从图15可以看出第一和第三个参数全部都与一个立即数做比较,所以可以推测这两个32位的参数不是指针,而是数字。且可以看出第一个参数如果大于7会跳转绝对寻址到bomb。所以可以得出第三个炸弹的答案格式是 小于8的整数,一个字符,一个数字。此外由于sscanf需要一个格式串来作为匹配,所以通过查看入栈的立即数地址可以得到格式串,即参数的格式:
图16 查找sscanf()的参数串
此外从整体看phase_3可以看出有9个分段,每个分段都类似于一下片段:
图17 phase_3重复片段
且都由一处语句控制跳转内存:
图18 控制跳转语句
控制语句通过将第一个参数的值放入eax中,通过第一个值控制跳转,进一步控制第三个参数与哪个数字比较。如果第三个参数匹配失败就会跳到末尾的explode_bomb()中。所以可以看出是一个switch语句。整理后可以得到以下结构:
图19 phase_3等价的switch语句
所以可以算出第三个关卡共有8个答案,即从后三行中对应每一列为一个答案。任选一个答案,在explode_bomb处设下断点进行测试
:
图20 测试phase_3的答案
未触发断点,即成功通过。提示已经过了一半啦。
5. phase_4
观察phase_4的汇编代码首部,也调用了sscanf函数。由汇编代码可以看出参数是一个4字节的参数,通过调出sscanf的格式串可以看出是一个整数:
图21 phase_4中的参数调用
图22 sscanf的格式串
图23 对参数的判断
此外由图23可以看出参数(存放在 -0x4($ebp)的内存中 )需要大于0。而对$eax的判断是为了查看是否成功匹配到一个整数。( $eax是默认sscanf()函数返回值的存储位置,sscanf()的返回值是匹配成功的参数个数 ) 。接下来可以看到将整数入栈后进入func4函数中:
图24 参数入栈并进入func4()中
接下来就转战func4函数,可以看出它是一个递归函数。对于递归函数首先要找到特殊出口:
图25 特殊出口
图26 phase_4对func4()返回值的判断
可以看到当$ebx也就是输入的参数小于1时直接返回1。观察phase_4函数的部分( 图26 )可以看出返回值应该等于55(0x37)。
所以还是要研究一下递归部分。根据以下代码可以看到递归部分有两层,且都要进行:
图27 func4的递归部分
不过代码较短又只涉及到输入的参数,所以可以直接看出等价以下C代码:
图28 模拟func4的递归代码
经过计算,想要返回值为55,则i应该为9。测试一下结果的正确性( 还是要在explode_bomb()处设置断点,否则错误就会bomb,然后就会扣分啦。。) :
图29 phase_6测试结果
因为没有触发explode_bomb断点,所以phase_4就这样被KO啦。
6. phase_5
根据上面四关总结出的套路,首先还是观察phase_5首部:
图30 phase_5首部
由入栈参数可以看出是一个4字节变量,因为调用了string_length,所以推断是一个字符串的地址。而$eax应该是string_length的返回值,因为phase_5并未给$eax赋值。根据判断语句可以得出参数应该是一个长度为6的字符串。接下来是第一个循环:
图31 phase_5循环体
首先还是要用gdb看一下所涉及到的参数都有什么信息,在explode_bomb() 和 phase_5() 处设置断点,用字符串"abcdef" 来测试一下:
图32 phase_5第一个循环体的参数及内存观察
如图,在mov $0x804b220, %esi 之后进入循环,而在立即数地址参数中存放着一个字符串,在循环中的参数$ebx则是输入的字符串参数。由于循环过程中没有explode_bomb的入口,不怕直接被炸掉,所以先不探究循环过程,继续运行,看一下什么时候会进入explode_bomb()函数:
图33 在进入strings_not_equal()后的参数变化
可以看到strings_not_equal() 函数,此时已经跳出循环。这个函数在第一个关卡已经出现过,作用是判断两个参数字符串是否相等,检查两个参数,其中0x804980b中存放的应该是不会变的标准串,而$eax存放地址中存放的串是循环后得出的串,很明显不对。所以kill掉。由于图32看到了第一个循环前准备的参数,所以直接来分析汇编代码:
图34 分析phase_5的循环代码
如图,在循环中,取输入串的每个字符的ascii码转换成二进制的低四位,拓展成32位整数后作为偏移量在模式串中取对应位置的字符,存放在0xffffd0e0中。当最后的字符串为”giants”时即可。根据计算,opukma符合此条件。同事可以看出答案不止一个,只要符合就可。接下来用计算出来的字符串进行测试:
图35 phase_5测试
可以看出并没有触发explode_bomb断点,即搞定啦。
7. phase_6
接下来问题来了,这么长的phase_6,什么套路都要崩溃了。。因为比较长,所以先总结一下:参数读取,一个双层循环,三个单层循环,且炸弹入口只出现在双层循环前的参数准备和最后一个循环中。分了这几部分后就不会有无法入手的感觉了。接下来就来挑战一下吧:
首先是读取参数:
图36 phase_6读取参数部分
可以看到给read_six_numbers的是两个参数,根据mov和lea指令可以看出$edx是一个四字节数值,$eax是一个地址,而读入的应该是6个数字,所以用1 2 3 4 5 6进行测试如下:
图37 测试phase_6并查看参数变化
可以看到$eax由地址变成了数字6,所以应该是在read_six_numbers时被当作参数返回的值。可以看到原来$eax保存的地址的内容已经变成了输入的6个数字。继续观察接下来的汇编代码,就到了双层循环部分,即如下图所示代码段:
图38 双层循环部分代码
根据图37观察到的内存等信息,再结合判断条件和调用方式等顺序可以做出等效C代码如下图,其中一层解释更像源代码,二层更加简单粗暴加直接:
图39 双层循环的递进分析
可以看出这段循环的作用就是规范输入的6个数字:最大为6各不相等。又因为图中所表示的比较为无符号比较,所以这6个数字也不能有0。综上即1-6。数字是确定的,所以唯一做评判标准的应该就是顺序了。观察接下来的一部分汇编代码并用gdb观察变量和内存的变化
:
图40 下一个循环前的准备
图41 准备阶段的相关内存的分析
可以看到$eax $ecx 和 0xffffd0bc 处的内容被更改了。接下来进入下一个循环:
图42 第一个单循环的代码
可以看到涉及到的最小的内存是($ebp-0x3c),由于多为内存变化,根据汇编代码无法看出内存变化,所以用gdb查看一下循环前后的该处内存变化:
图43 相关内存的变化
可以发现在0xffffd0c8 – 0xffffd0dc的24个内存发生了变化。而0xffffd0e0 – 0xffffd0f4是输入的6个数字所在的内存。根据变化结果和汇编代码,通过计算可以得到如下总结:
图44 第一个单层循环解析
根据对整个单层循环的单步调试,对过程中所操作的内存的信息进行查看:
图45 46 相关内存的查询
根据以上信息,可以大概总结出如下内存和寻址的模式:
图47 寻址方式总结和内存变化
这个循环基本就搞清楚了,接下来是下一个单层循环,同样由于多为内存操作,只能先用gdb单步运行来查看内存的变化:
图48 49 循环中内寄存器和内存的变化
经过观察发现此次循环过程的内存与上一个循环中被改变的内存有关,经过整理后可以发现如下内存变化:
图50 第三次循环执行完毕相关内存变化
可以看到这次循环是根据上一次循环更改的相关内存进行第二次的修改,间接与输入的6个数字有关。接下来是phase_6的最后一个循环:
图51 phase_6最后一个循环
炸弹现在才出现,可以看出来是内存间的访问和比较,根据图47 和图50可以得到下面的流程模拟图:
图52 最后一个循环的运行模拟和参数推算
可以看到这个循环是在测试根据输入的6个数字排好序的地址中存放的数字,输入的6个数字应该使 0x804b230– 0x804b26c 这6个地址中的数字按照降序排列,而输入的6个数字是在图47所示的循环过程中控制6个地址在 0xffffd0c8 – 0xffffd0dc 中的顺序 进而通过图50所示过程控制6个地址存放在以0x804b开始的地址。所以正确输入顺序的推算应是 4 2 6 3 1 5 。如下图所示:
图53 推算正确结果
接下来就要测试一下推断出的结果了,如下图把phase_6的所有explode_bomb()的入口语句设置断点,并在每个循环阶段设置断点:
图54 设置断点并输入推算答案测试
图55 第二个循环阶段完成
如上图,第一阶段未触发explode_bomb()函数,二阶段后检查内存发现在0xffffd0c8 -- 0xffffd0dc段的地址排列正像推算的一样。
图57 第三阶段运行后检测其他相关内存
第三阶段后,根据图57和58可以得出下图所示的图示:
图58 内存图示和比较顺序推算
最后运行后显示成功拆除炸弹并退出程序:
图59 提示成功拆除炸弹并退出
8. phase_defused()
在研究phase_1 - phase_6时发现,在汇编代码中每个关卡通过后都会进入phase_defused()中,且在此函数中还有一个secret_phase() 函数的入口。所以来分析一下phase_defused()函数:
图60 phase_defused()入口语句
首先还是要观察一下0x804b480这里存的是什么。所以在phase_defused()函数入口加断点运行一遍:
图61 观察0x804b480的变化
可以看到随着关卡的通过,0x804b480存储的值在不断增1,到第六个关卡时为6,但并未进入secret_phase(),所以单步运行看一下哪里出了问题:
图62 异常跳过secret_phase()入口
可以看到是
因为$eax的值不对导致跳过入口,由于$eax作为sscanf()函数的返回值,所以推算需要匹配两个部分,但
在sscanf()运行过程中并未手动输入新的字符串且sscanf()的参数不一定来自标准输入,所以需要查看之前入栈的参数来推算sscanf()的参数有哪些。
图63 查找sscanf()的参数
可以看到模式串是数字+字符串,而收到的匹配串是一个数字9,由于并未手动输入新串,所以排除是程序自带的字符串,所以观察6个关卡的答案,发现第四关的答案是9,而且第四个关卡的参数串是%d,所以有多余的字符串也不会影响匹配的个数,所以推测需要改变第四个答案。在第四关时多输入任意一个字符串:
图64 测试任意字符串
图65 通过参数匹配测试
图66 再次异常跳转
可以看到在输入任意字符串后,在参数匹配个数部分已经成功,但在单步执行到strings_not_equal()时由于返回的参数$eax不为0,所以再次异常跳转,所以观察入栈的两个参数,发现一个字符串是标准输入的字符串,另一个是绝对地址存放的字符串,所以推定应该在第四关输入的字符串就是该处地址显示的字符串,即 “9 austinpowers”。用推算的答案进行测试:
图67 测试推算的答案
图68 成功进入secret_phase()入口
可以看到已经成功运行到了secret_phase()入口处。
9. secret_phase()
.首先观察入口部分的代码:
图69 入口部分代码
可以看到有两次调用函数,第一次是read_line(),从而可以推断出此关卡应该是一个字符串作为参数。用gdb单步调试观察第二个调用函数的参数:
图70 第二个调用的函数参数
可见是刚才输入的字符串,继续运行:
图71 发现错误
由于在调用第二个函数后$eax已经发生改变,所以$eax的值应该是该函数的返回值,由 cmp语句可知返回值应该为0x3e8+1,为1001。再
观察第二个函数的解析符号,为<__strtol_internal@plt>,所以推测应该为 strtol()函数。
strtol()函数的参数原型为long int strtol(const char *nptr,char **endptr,int base) ,作用是将一个字符串转换成一个长整数,又因为参数 0xa 被入栈,所以推测这个函数是将输入的字符串转换成十进制长整数赋给$eax作为返回值,且jbe 8048f14<secret_phase+0x2c> 表明 1000>=$eax-1>=0 ,所以初步推定
输入的字符串应该是1-1001的任意一个数字。
以数字1作为字符串进行测试:
图72 以 1 做参数进行测试
图73 成功通过并进入fun7()
可以看到已经成功通过,并运行到fun7()函数入口,对参数进行分析发现是输入的数字和0x24。由于除了参数匹配的检测fun7()中没有其他的爆炸带你,所以先直接完成fun7()函数回到secert_phase()函数,发现fun7()返回的值未通过测试:
图74 fun7()函数返回值错误
观察fun7()的汇编代码:
图75 一种完成fun7()的方式
由图75可看到
如果传入的参数$edx为0,则直接完成函数并返回$eax,此时返回值$eax为0xffffffff = -1 ,gdb测试观察$eax和$edx的值:
图76 查看fun7()参数
可以看到$edx的值是传入的地址,所以首次进入时不会发生edx=0的情况。所以继续观察fun7()其他代码发现又是一个双层递归,但属于分支情况,分析可得如下图所示C模式:
图77 fun7()的三分支模式
根据三分支模式在分支处设置断点并运行测试:
图78 设置断点并测试
图79 根据三分支模式寻找递归返回点
如图,由于fun7()是三分支模式,
除相等时直接返回0外,只有在传入地址参数为0时才会返回-1。所以
查找所有可以递归的地址点,直到某个地址点的左右分支均为0则为结束点。由上图可知该递归模式是四层二叉树递归模式,则有如下的二叉树形态:
图80 内存的二叉树形态
由于secret_phase()判断返回值应为7,所以根据三分支的返回值公式,有且仅有7 = ((0*2+1)*2+1)*2+1。所以应该为 $eax > 0x24 且$eax>0x6b 且 $eax==0x3e9,则输入的数字应该为0x3e9,即1001。strtol()函数在转换时会舍弃无法转换的部分,所以1001后可接第一个字符不为0-9的其他字符串。
根据推算的结果输入字符串进行测试:
图81 测试secret_phase()
由图81可以看到并未触发secret_phase()和fun7()内的explode_bomb()入口,即破解成功。
结尾
从破解过程来看,需要至少要 "还可以" 的水平的汇编代码阅读能力,还要不怕麻烦的对许多地址进行递归的查询,很多时候对于 “查询哪个地址可以看到需要的东西” 的问题只有在大致的范围上不断寻找和计算才能准确找到能完美表现变化的地址。所以还是需要很大的耐心的。由于不需要大量的指令,只要懂得gdb的基本使用和查看内存的指令并且耐心调试就可以,所以这篇文章并没有大量的代码段,一切都可以在亲自实践中搞定。