实验目的与要求
1. 增强学生对于程序的机器级表示、汇编语言、调试器和逆向工程等方面原理与技能的掌握。
2. 掌握使用gdb调试器和objdump来反汇编炸弹的可执行文件,并单步跟踪调试每一阶段的机器代码,从中理解每一汇编语言代码的行为或作用,进而设法“推断”出拆除炸弹所需的目标字符串。
3. 需要拆除尽可能多的炸弹。
实验原理与内容
一个“binary bombs”(二进制炸弹,下文将简称为炸弹)是一个Linux可执行C程序,包含了7个阶段(phase1~phase6和一个隐藏阶段)。炸弹运行的每个阶段要求学生输入一个特定的字符串,若的输入符合程序预期的输入,该阶段的炸弹就被“拆除”,否则炸弹“爆炸”并打印输出 "BOOM!!!"字样。实验的目标是拆除尽可能多的炸弹层次。
每个炸弹阶段考察了机器级语言程序的一个不同方面,难度逐级递增:
- 阶段1:字符串比较
- 阶段2:for循环
- 阶段3:switch分支
- 阶段4:递归函数
- 阶段5:数组元素按序访问
- 阶段6:链表
- 隐藏阶段:只有在阶段4的拆解字符串后再附加一特定字符串后才会出现(作为最后一个阶段)
为了完成二进制炸弹拆除任务,需要使用gdb调试器和objdump来反汇编炸弹的可执行文件,并单步跟踪调试每一阶段的机器代码,从中理解每一汇编语言代码的行为或作用,进而设法“推断”出拆除炸弹所需的目标字符串。这可能需要在每一阶段的开始代码前和引爆炸弹的函数前设置断点,以便于调试。
拆弹密码的输入分文两种模式。
模式1:正常手动输入,每次程序运行到某一阶段会停下来要求用户输入数据。这种方式比较原始,不推荐使用。如果使用这种做法,在程序调试到后期时,每次为了进入后期的断点位置都需要在之前的每一个阶段进行手动输入,极其浪费时间。
模式2:采用输入重定向。首先将答案文本写至一个.txt文本中,每个阶段的拆弹密码占一行。
在调试程序时直接使用输入重定向指令,例如(假设密码已被写入到之前的拆弹密码文本文件solution.txt中):
./bomb < solution.txt
通过执行以上指令即可直接根据屏幕输出来判断程序正确地进行了几个阶段或者在第几个阶段出现了错误。如果密码全部正确,提示结果如下图所示:
实验过程与结果(可贴图)
阶段1:字符串比较
解题思路:
入栈口调整,它通过将栈指针(%rsp)减去8来在栈上分配8字节的空间,用于存储局部变量或保持寄存器值的备份;lea指令将相对于当前指令地址的一个偏移量加载到%rsi寄存器中,这个地址可能指向一个字符串。
最后有一行冗余的jmp指令,如果执行到explode_bomb,程序就结束,会自动跳转到释放栈空间和返回的部分。这里jmp指令的作用可能是编译过程中的某种遗留或占位符。
随后调用strings_not_equal函数来比较两个字符串,第二个字符串可能是程序中的某个全局变量或静态字符串。接下来会执行条件跳转。
查看strings_not_equal函数的返回值,返回值决定是否跳转,它的返回值存储在%eax中。如果字符串不相等,则跳过爆炸部分,继续执行;如果字符串相等,调用explode_bomb函数,爆炸并且释放栈空间并返回。
操作步骤:进入图形化界面之后,gdb通过反汇编bomb可执行程序的反汇编代码,在第一题,进入调试命令窗口,输入的命令以及执行命令的效果都可以在调试窗口显示。比如程序执行到的位置,查看寄存器和内存中储存的值等等都可以显示出来。
进入命令调试窗口之后,先随便输入字符串,按回车键,使用si指令,单行执行;当执行到光标停在callq <string_not_equal>时,我们查看上面的rsi的值,也可以输入命令p/s $rsi来查找, 然后会显示出rsi的值,我们复制rsi的值,再通过 x/s 命令查找rsi 的值,程序就先显示出第一题的答案。
输入第一题的答案,显示闯关成功!
阶段2:for循环
解题思路:看到题目,和第一题一样需要输入字符串,然后代码对比检测是否一样,然后对比结果正确,才能得出谜底。本题这段代码起到栈保护检查和防止栈溢出攻击,逻辑复杂,首先通过read_six_numbers函数读取六个数字,并对这些数字进行一系列检查。如果这些数字满足特定的条件,通过循环和比较进行检查之后,程序将继续执行并正常返回;如果不满足条件,则会调用explode_bomb函数。
可以看到上面这些代码表示栈顶指针申请了很大的空间,出现一些基本的断点和一些寄存器的压栈,段寄存器形成一些基本的保护机制。
还看到栈内出现了金丝雀,修改了栈的一些基本东西,形成栈保护机制,作用是防止缓冲区溢出。
接着往下看,把%rex清零,%rsp的值首先减去0x28,然后将减去之后得到的值赋值给%rsi,调用<read_six_numbers>函数。
查看一下read_six_numbers函数内部,会发现里面调用了sscanf函数。
sscanf函数和scanf函数两者是有很大的区别,scanf函数C语言中的一个标函数,输入的内容可以是整型,字符串,双精度类型,scanf函数会将从标准输入中读取格式化数据存储到对应的变量中。sscanf函数是C语言中的一个标准库函数,输入的内容字符串类型,sscanf函数将从一个字符串中按照指定的格式提取数据存储到对应的变量中。注意sscanf函数从字符串中读取数据而不是从标准输入中读取。使用sscanf时,需要指定输入字符串的格式,它会将数据提取到对应类型的变量rsp数组中。
可以看到17ee行的%rdi,实际上程序将我们输入的值赋值给%rdi,%rdi表示我们输入的内容或者输入的字符串,%rcx表示一些形式参数,%rax表示压栈,%rax在下面也有出现,在17fa也可以看到,表示压栈2次。%r8,%r9也表示压栈,在这个过程中出现了多次参数压栈的情况。
详细讲解read_six_numbers函数中的six表示六个参数,第1个参数- rdi表示输入或传入的字符串首地址;第2个参数- rsi将输入的内容进行格式化,由寄存器内指针值决定,值为`0x5555555569c3`地址的字符串决定,一般都是`%d %d %d %d %d %d`,表示有六个数字;第3个参数- rdx,由rsi给出,rsi由phrase2的rsp给出,所以phrase2中的rsp地址处存放sscanf中第一个输入的值,第4个参数- rcx,phrase2中的rsp+0x4处存放第二个值,第5个参数- r8,phrase2中的rsp+0x8存放第三个值,第6个参数- r9,phrase2中的rsp+0xc存放第四个值。其中 第五个、第六个参数的值所在的地址需要通过压栈传参,由栈帧压栈顺序是从右向左压栈,可知,phrase2中的rsp+0x10存放第五个值、phrase2中的rsp+0x14存放第六个值。
函数的返回值传给了eax,然后返回值会与5进行比较,判断函数的返回值是否小于等于5,如果小于等于5的话,就会跳转到炸弹程序,炸弹就会爆炸。如果返回值大于5,就跳转到phase_2函数,执行phase_2函数跳转前的语句,如下图所示。
跳转到phase_2函数后,将读取到的数据进行逐个的比较,开始读取到的是%rsp数组,里面的第一个数据进行跟0比较,如果不等于0的话,就会跳转到1252行炸弹程序,炸弹就会爆炸。如果等于0的话,就会跳转到124d行继续比较。
解题过程:步骤跟第一题的一样,设置断点,两个%eax的时候输出第一个eax的值,eax的值即为第一个字符串,输出之后用si单步执行语句继续执行,寻找下一个eax的值,继续输出,一直循环即可。这里需要注意的是:eax的值都是用十六进制来输出的,闯关输入答案的时候需要转化为十进制在输入。
综上,汇总eax输出的值即答案,将这六个字符串输入之后显示闯关成功!
阶段3:switch分支
这里调用的函数__isoc99_sscanf表示从输入中读取两个整数,并将它们存储再栈上。
解题思路:查看反汇编代码,发现出现了金丝雀栈保护机制,以及sscanf的字样。为局部变量分配栈空间,会设置栈帧将基址指针rbp和rbx寄存器压栈,读取fs段中的某个地址处的值,并将其存储在栈上,这是用于检测栈溢出的保护机制也就是金丝雀保护机制。通过调用read_six_numbers函数读取六个整数,这些整数被存储在栈上。检查第一个整数是否为0(或负数),如果不是,则调用explode_bomb。使用ebx作为循环计数器,从1开始遍历输入的六个整数。对于每个整数,检查它是否严格大于前一个整数。如果当前整数不大于前一个整数,则调用explode_bomb。最后,在所有输入验证完成后,检查栈保护值是否被修改。如果没有被修改,则恢复栈指针,弹出之前保存的寄存器值,并正常返回。
解题过程:在第三关设置断点,进入命令调试窗口之后,输入run按回车 或者si单步执行指令,让调试界面的跑起来,先随便输入字符串,按回车键,使用si指令,单行执行;找到callq之后,输入x/s $rsi 查看答案有几位数字,几个“%d”就是几个字符串。
当运行到cmp 时,输出p/x $eax的值即可,这里输出的值时十六进制的,答案记得转化成十进制哦。第一个eax输出的数是2,第二个eax输出的数是0xffffffb4,转化为十进制是-17,所以答案是2 -17 输入之后显示闯关成功。
综上:输入答案2 -17,显示通过成功。
阶段4:递归函数
解题思路:前面首先进行了一系列的参数传递操作,再调用了sscanf函数。接下来将%eax中的返回值与2进行比较,如果输入的数的个数不是2个数,会爆炸。如果输入的第一个数是负数,js跳转会爆炸。如果输入的第一个数大于14,也会爆炸。所以可以确定输入的第一个数的范围是0≤n≤14。接着又进行了一系列传参,然后调用了func4函数,第一个参数是我们输入的第一个数n,第二个参数是0,第三个参数是14。
func4函数,它有三个参数,第一次从phase_4进入这个函数时,%eax中存的是n,%edx中存的是0,%esi中存的是14。n就是我们要查找的值,0是查找范围的下界,14是查找范围的上界,所以0<=n<=14。
函数中计算值大于rdi的时候,是左子树,用递归函数2*func4()表示。计算值小于rdi的时候,是右子树,用递归函数2*func4()+1表示。可以理解为,往左边查找的时候,就是2*func4(),往右边查询的时候就是2*func4()+1。
我们已知函数的返回值是10,所以我们可以倒过来想。函数范围值要怎么计算才等于14。要在二叉树上往右边往左边再往右边查找。当输入的第一个参数是10的时候才正确。
解题过程:进入程序之后就单步执行的命令,执行到lea $0x15c7(%rip),%rsi这一步之后,就用x/s输出rsi的值,显示多少个%d这题的答案就有多少个数,我这里答案是两个字符串。
当执行到cmp $0x25,%eax或者jne跳转这一行输出eax的值,还有一种方法是 $0x25的十进制是37,第二个数有可能是37,确定这个数的之后,再次进入程序,相同的操作,可以查到第一个数是10。
综上:输入10 37 会显示正确,出现闯关成功的提示!
阶段5:数组元素按序访问
解题思路:进入调试界面,执行到141e行发现需要进入一个string_length函数,要输入一串字符串,接着往下1423行会返回一个长度,长度必须为6,否则就发生会爆炸。这里我们尝试输入123456。发现函数的确在做一个长度的计算,返回值就是字符串的长度,这里返回值是6。
解题过程:步骤一样进入调试界面之后,让程序跑起来,%eax的前面显示的位数,我这里是6位数,然后再查看%ecx前面的十六进制数字,十六进制的0x2a转化为十进制是42。
当程序执行到movzbl (%rax),%edx这一行的时候,输入命令x/24dw $sri 查看输出结果,然后将前6个的数字 2 10 6 1 12 16中随机选六个数字组合,可以重复选择,加起来的值要等与0x2a的值,我选的是 2 10 1 1 12 16,2在数组中的位置是0,10在数组中的位置是1,6在数组中的位置是2,6不符合本题,1在数组中的位置是3,12在数组中的位置是4,16在数组中的位置是5,最后的答案是013345。
综上:输入答案013345,显示通过成功!
阶段6:链表
查看反汇编,发现需要我们输入六个数字,输入六个数字之后,返回了一个数组的地址,然后做了一个间接跳转,跳转到14b2。
应该做的是一个数组之间每一个元素的对比,看看每一个元素是否相等,这里是外循环,控制数组的移动。
程序执行到14f2的时候,我们发现了一个奇怪的值,并且发现了node1的字样。尝试查看这个地址周围的三十二个地址,发现了其他地址储存的值,似乎就是一个链表,破解查看其值。
解题过程:当程序执行到node1这一行的时候,输入x/32wd 加上后面显示的值,输出的结果第一列从上往下看。
然后在输入x/32wd 加上后面显示的值,后三位变成110,,看到输出的结果有1个6,2个0,表示是顺序输出的,所以只有两种可能,一种是从大到小输出,一种是从小到大输出,按照node1的值,按照从大到小排列,661对应的值是1,959对应的值是2,453对应的值是3,360对应的值是4,350对应的值是5,232对应的值是6,所以从大到小排列,959到232,输入2 1 3 4 5 6 发现答案错误,所以答案只能是从小到大输入,也就是6 5 4 3 1 2发现正确。
综上:全部输入正确之后显示,全部通过成功!
有问题可以私聊小编,小编看到后会第一时间回复大家,请耐心等待~