上回讲解了如何实现断点功能,这回讲解如何实现与断点紧密相关的单步执行功能。单步执行有三种类型:StepIn,StepOver和StepOut,它们的实现方式比较多样化,单独实现它们的话并不困难,但是将它们整合到一起就比较困难了,特别是加上断点功能之后,程序的逻辑更加难以理解。本文首先单独讲解每种单步执行的原理,最后讲解如何将它们整合到一起。这都是我个人的实现方法,大家可以用来参考。(注意:本文所讲的单步执行是源代码级别的,而不是指令级别的。)
StepIn原理
StepIn即逐条语句执行,遇到函数调用时进入函数内部。当用户对调试器下达StepIn命令时,调试器的实现方式如下:
①通过调试符号获取当前指令对应的行信息,并保存该行的信息。
②设置TF位,开始CPU的单步执行。
③在处理单步执行异常时,获取当前指令对应的行信息,与①中保存的行信息进行比较。如果相同,表示仍然在同一行上,转到②;如果不相同,表示已到了不同的行,结束StepIn。
行信息可以使用上一篇文章介绍过的SymGetLineFromAddr64函数来获取。StepIn的原理比较简单,需要注意的一点是,断点也是使用TF位来达到重新设置的目的,所以在处理单步执行异常时需要判断是否重新设置断点。另外,如果进行StepIn的那行代码存在断点,调试器只要恢复该断点所在指令就可以了,不需要通知用户;如果那行代码含有__asm { int 3};语句,调试器只要忽略它即可,也不需要通知用户。
StepOver原理
StepOver即逐条语句执行,遇到函数调用时不进入函数内部。当用户对调试器下达StepOver命令时,调试器的实现方式如下:
①通过调试符号获取当前指令对应的行信息,并保存该行的信息。
②检查当前指令是否CALL指令。如果是,则在下一条指令设置一个断点,然后让被调试进程继续运行;如果不是,则设置TF位,开始CPU的单步执行,跳到④。
③处理断点异常时,恢复断点所在指令第一个字节的内容。然后获取当前指令对应的行信息,与①中保存的行信息进行比较,如果相同,跳到②;否则停止StepOver。
④处理单步执行异常时,获取当前指令对应的行信息,与①中保存的行信息进行比较。如果相同,跳到②;否则停止StepOver。
StepOver的原理与StepIn基本相同,唯一的不同就是遇到CALL指令时跳过,跳过的方法是在下一条指令设置断点,然后令被调试进程全速执行,触发断点之后再设置TF位,进行CPU的单步执行。在全速执行时,用户设置的断点和被调试进程中的断点不应该忽略,而应该像正常的执行那样中断并通知用户,此时应该停止StepOver。在进行CPU的单步执行时,则应该像StepIn那样忽略断点。StepOver需要断点来实现,所以应该将StepOver使用的断点和其它类型的断点区分开来。
StepOver的一个难点就是如何知道当前指令是否CALL指令,以及获取它的长度。这是比较困难的问题,需要一些指令格式方面的知识,可以参考《CALL指令有多少种写法》(http://blog.ftofficer.com/2010/04/n-forms-of-call-instructions)一文。在这篇文章的最后提供了一张CALL指令的格式表,我们只需要按照这张表来写代码进行判断即可。
StepOut原理
StepOut即跳出当前正在执行的函数,立即回到上一层函数。当用户对调试器下达StepOut命令时,调试器的实现方式如下:
①获取当前函数的RET指令地址,并在该指令设置断点,让被调试进程继续执行。
②处理断点异常时,恢复断点所在指令的第一个字节。从线程栈的顶部中获取返回地址,在该地址设置断点,然后让被调试进程继续执行。
③再次处理断点异常,恢复断点所在指令的第一个字节,结束StepOut。
StepOut有两个难点:一是如何获取RET指令的地址,二是如何获取函数的返回地址。对于第一个问题,可以使用SymFromAddr函数来获取函数的相关信息。SymFromAddr函数的作用是由地址获取相应符号的信息,符号可以是变量或者函数。其声明如下:
BOOL WINAPI SymFromAddr(
HANDLE hProcess,
DWORD64 Address,
PDWORD64 Displacement,
PSYMBOL_INFO Symbol
);
第一个参数是符号处理器的标识符。第二个参数是地址。第三个参数是输出参数,函数调用成功之后返回Address相对于符号起始地址的偏移。第四个参数是指向SYMBOL_INFO结构体的指针,函数调用成功后与符号相关的信息都保存在这个结构体中。该结构体的定义在上一篇文章中已有提及。
参数Address可以是任意地址,如果该地址属于一个变量,那么函数返回该变量的符号信息;如果该地址属于一个函数,那么返回该函数的符号信息;如果该地址不对应任何符号,函数返回FALSE。
我们可以将当前被调试进程的EIP作为地址传给SymFromAddr,以得到当前函数的信息。SYMBOL_INFO的Address字段保存了函数第一条指令的地址,Size字段保存了函数所有指令的字节长度,由于RET指令是函数的最后一条指令,所以Address加上Size再减去RET指令的长度就是RET指令的地址。那么接下来的问题就是要获得RET指令的长度。RET指令比CALL指令简单得多,只有两种形式,一种只有一个字节长,十六进制值为0xC3或0xCB;另一种有三个字节长,第一个字节的十六进制值为0xC2或0xCA,后面两个字节是ESP将要加上的值。
第二个难点是获取函数的返回地址。如果你熟悉函数的调用过程,肯定知道在即将执行RET指令时,线程栈的顶部,也就是ESP所指的内存位置,必定是函数的返回地址。这就是在RET指令设置断点的目的。
在进行StepOut时,由于被调试进程是全速执行的,所以不能忽略其它断点,而且在触发断点时要取消StepOut使用的断点。另外StepOut使用的断点也要与其它类型的断点区分开来。
整合
上文介绍的三种单步执行都需要断点以及CPU单步执行的支持,这意味着它们的处理逻辑都集中在断点异常和单步执行异常的处理函数中,这会使代码的逻辑复杂,难以理解。在这里我尝试理清这个思路,让大家对示例代码有更好的理解。
上一篇文章将断点分成了以下三种类型:
初始断点
被调试进程中的断点
调试器设置的断点
除了初始断点之外,另外两种断点在下文中统称为普通断点。添加了单步执行的功能之后,新增了两种类型的断点:
StepOver断点
StepOut断点
断点类型的增加使得断点异常的处理函数变得更加复杂,因此有必要将所有断点的处理都放在第一次接收断点异常时,以降低复杂度,所以此时就需要手动将EIP减1了。
上文说过,在被调试进程逐条指令执行时,应该忽略所有普通断点,忽略的意思是不通知用户。为了达到此目的,需要保存被调试进程的执行状态(全速执行还是逐条指令执行),然后对这些状态进行检查。
下图是断点异常的处理过程:
下图是单步执行异常的处理过程:
上面两幅流程图中,StepOver断点的处理和单步执行异常的处理有相同的逻辑,在实现时应该将这些共同逻辑抽取到一个函数里。
in StepIn
over StepOver
out StepOut