个人网站 | 思想驻留地

0%

暴力型选手Debug的心路历程

被一个作业中的小bug一直吊着吊了好些天,今天终于获得老天眷顾解决了。

背景

这学期选修了计科的计算机系统基础(ICS)课程,其课程实验PA(Programming Assignment)的核心任务是使用C语言模拟完成一个基于Intel-i386指令集架构的“软件机器”NEMU,并最终使得可以运行一些小的应用软件,前一周做到了该实验的第四章,即实现IO与外设的部分。在实现从硬盘装载测试用例到内存的功能之后,即实现了将二进制可执行文件作为硬盘,将内建数组作为虚拟内存,将二进制可执行文件的需要装载的内容(指令、数据)以字符串形式拷贝到内建数组中的功能之后,就是在这个读取“硬盘”的过程中,CPU的EIP寄存器(Extended Instruction Pointer)、即指示CPU下一条要执行的指令的地址寄存器突变为0x00000000,指向了Linux虚拟内存空间的底部,而程序中的代码(.text)部分都装载于虚拟地址0x008048000以上,在一般机器中这样的转变会引起所谓的段错误Segmentation Fault,但在我们实现的机器中因为CPU无法解析该地址而终止了该程序的运行。

Debug

在寻找为什么EIP突变的过程中使出了浑身解数,尝试了几乎每一个拍脑门可以想到的Debug方法,终于在今天山重水复疑无路,柳暗花明又一村,找到了问题的触发点。

空间:设置断点,查看变量突变的位置

这应该是我面对这种数值变化所能想到最直接的解决方案了,初步判断EIP突变的函数,从函数外部逻辑向内逐渐设置BREAK_POINT,直到程序能够触发断点,在这个断点与前一个断点之间,则是EIP变量发生突变的地方,突变使得程序无法再继续执行下去。

由于程序行为的不确定,即每次运行到EIP突变的位置,NEMU给出了不一样的输出,而且EIP指针还会在有限的范围内变化(从0x00000000到0x0000000f),这种程序行为的变化让我很是不解,而且每次行为的变化导致断点不是次次触发,给程序的进一步定位带来了困难,于是我决定放弃采用这种方法,直接通过程序指令来判断突变位置。

时间:跟踪打印变量,直接找到突变时机

NEMU中,CPU执行遵循流水线过程:

1
取指令→解析指令(取源操作数,计算结果,移动到目的操作数,返回指令长度)→移动到下一条指令

在每次返回指令长度之后添加打印EIP变量的值的语句,即可动态地查看到EIP指针在执行过程中的动态变化过程,想必最后一句的打印信息即是EIP发生突变的时候(因为此时CPU已经无法继续取指令了)。使用objdump反汇编测试程序,定位到这个发生变化的EIP地址,发现突变的原因是这条指令的执行:

1
2
0x08048xxx: pop
0x08048xxx: ret ⬅ 执行该指令后突变了

根据ret指令的功能,即

从用户栈中弹出旧EIP的值,并且使EIP指针指向该值,从而实现函数过程的转换

想必是在进入该函数,即push eip的过程中发生了错误,使得栈中存了一个零值,因为所有指令的取值计算返回的过程都是我使用C语言实现的。(或者说压根就没有push:这个原因是我之后才想到的,这也可以解释程序行为的不确定)

顺着程序栈的变化思路,基本可以确定是调用该函数的函数出现了问题,但是说实话,当时脑子一热,压根没想到去考虑这个问题,决定采用大海捞针的方法,查看所有已经写好的与esp(栈指针)eip寄存器变化的寄存器的实现情况,看看是不是哪里出现了问题,一行一行地查看确实浪费了我好长时间,不过也让我对这个NEMU框架代码的理解又更深刻了许多,对于CPU与外设之间的交互过程也有了很多新的理解。

次暴力方法:汇编语句单步执行,对比变量的值

上述两个方法听起来朗朗上口,但都没有帮我解决这个问题,我决定从装载可执行文件的函数开始,一步步查看EIP值的变化,并结合我对于相关指令的理解,判断哪里出了问题。

…………………………………………….

这样查看了很长时间,约摸是一个下午,感觉自己已经快精通i386架构所有基础指令的功能了,仍然没有在浩如烟海的汇编代码中察觉出一丝异常,我想起一句话人总是倾向于在问题来临前做好充足的准备,却在即将成事的关键时刻掉以轻心,就是在这个鸡汤(划掉)的浇灌下,我硬是盯着电脑一行一行看了数小时,终于在精疲力竭时选择放弃,重新思考问题的原因。

暴力方法:使用参考实现强行对比

我想起方法二找到的突变语句ret时,这个问题已经拖了好几天了,我决定入手从espeip寄存器变化的角度来考虑。因为老师在发布框架代码时已经给出了封装好的指令函数,我决定尝试采用逐一替换所有与espeip寄存器有关的指令为参考封装函数的方法来判断是那个指令出了问题。

想法是美好的,现实是残酷的,尝试一番之后,我惊奇地发现,采用参考指令函数(如__ref_add)逐一替换所有现有指令(如add)之后,竟然出现了缺页错误,不过当我直接采用调用参考指令数组方法的时候,程序却被完美执行,其实相当于你照着答案写了一遍,却还是AC不了,实用diff指令对比了一下我写的版本与参考版本,发现没有不同,我百思不得其姐,最后决定重新修改一遍,cp回我备份好的文件,从第一条add指令开始,逐一修改,逐一执行,就这样查了十几二十条指令之后,竟有种上瘾的感觉,两只手在键盘上不断敲打着,不知不觉就替换完了所有指令,在敲完最后一条指令之后,终于回过神来,我不知道在什么时候就已经停止查看输出信息了,所以EIP结果修复的那条指令我并没有注意到

函数调用的机器级表示

调整心情之后,又逐一替换回之前替换掉的指令,终于找到了与EIP值突变直接相关的call指令,原因竟然是我在实现该指令的时候,只调整了esp的值,没有将旧eip的值push到栈中,导致函数执行结束ret时,从栈中pop的值为一个不确定的值,如下所示:

图片来源:https://nobelsharanyan.files.wordpress.com/

图中的Return应是被调用函数的返回地址,则是调用函数的下一条地址,也就是call指令应该push的值,在我实现该指令的时候(可能是俩个月前),并没有意识到这一点。

总结

在查找这个BUG的过程中碰了很多坑,但是同时也对机器代码、函数调用、i386指令集等有了更深刻的理解,浪费了很多时间,但也算是有所值。

参考资料

[1]. 计算机系统基础-第二版-袁春风
[2]. Function call – stack implementation
[3]. Stack Overflow - Why do virtual memory addresses for linux binaries start at 0x08048000
[4]. Virtual Memory and Linux - eLinux.org