调试原理

以大家所熟知的OllyDbg为例进行讲解

软件断点

x86系列处理器从其第一代英特尔8086开始就提供了一条专门用来支持调试的指令,即INT3。简单的说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。当我们调试程序时,可以在可能有问题的地方插入一条INT3指令,使CPU执行到这一点时停下来。这便是软件调试中经常用到的断点功能,因此INT3指令又称为断点指令。

当我们在调试器中对代码的某一行设置断点时,调试器会把这里的本来指令的第一个字节保存起来,然后写入一条INT3指令。因为INT3指令的机器码是0xCC,仅有一个字节,所以设置和取消断点时也只需要保存和恢复一个字节。

当CPU执行到INT3指令时,由于INT3指令的设计目的就是中断到调试器,因此,CPU执行执行这条指令的过程也就是产生断点异常并转去执行异常处理的过程。在跳转到处理历程之前,CPU会保存当前的执行上下文,包括段寄存器,程序指针寄存器等内容。

在保护模式下,在保存当前执行上下文之后,cpu会从IDTR寄存器中获得IDT的地址,在IDT表中查询异常处理函数。

在Windows系统中,INT 3异常处理函数是操作系统内核函数KiTrap03。因此遇到INT 3会导致执行nt!KiTrap03函数。因为我们现在讨论的是应用程序调试,断点指令位于用户模式下的应用程序代码中,因此CPU会从用户模式转入内核模式。接下来,经过几个内核函数的分发和处理,因为这个异常来自用户模式,而且该异常的拥有进程正在被调试,所以内核例程会把这个异常通过调试子系统以调试事件的形式分发给用户模式的调试器,通知完用户模式调试器后,内核的调试子系统函数会等待调试器的回复。受到调试器的回复后,调试子系统的函数会层层返回,最后返回到异常处理例程,异常处理例程执行中断返回指令,使被调试的程序继续执行。

在调试器收到调试事件后,会根据调试事件数据结构中的程序指针,得到断点发生的位置,然后在自己的断点列表中寻找与其匹配的项。如果能找到说明是自己设置的断点。如果找不到,则说明导致这个异常的INT 3指令不是自己放进去的。会告诉用户:一个用户插入的断点被触发了。

在调试器下,我们看不到动态替换到程序的INT 3指令。大多数调试器的做法是在调试目标被中断到调试器之前,会先将所有断点位置被替换为INT 3的指令恢复成原来的指令,然后再把控制权交给用户。

当用户结束分析希望恢复被调试程序时,调试器通过调试API通知调试子系统,这会导致系统内核的异常分发函数返回到异常处理例程,然后异常处理例程通过IRET/IRETD指令触发一个异常返回动作,使CPU恢复执行上下文,从发生异常的位置继续执行。注意,这时的程序指针是指向断点所在的那条指令的,此时刚才的断点指令已经被替换成本来的指令,于是程序会从断点位置的原来指令继续执行。

软件断点虽然使用方便,但是也有局限性:

  • 属于代码类断点,即可以让CPU执行到代码段内的某个地址是停下来,不使用于数据段和I/O控件

  • 对于ROM中执行的程序,无法动态增加软件断点。因为目标内存是只读的,无法动态写入断点指令。这时就需要使用硬件断点。

  • 在中断向量表或中断描述表(IDT)没有准备好或遭破坏的情况下,这类断点是无法或不能正常工作的,比如系统刚刚启动时或IDT被病毒篡改后,这时只能用硬件级的调试工具

硬件断点

IA-32处理器定义了8个调试寄存器,分别称为DR0-DR7。在32位模式下,他们都是32位的;在64位模式下,都是64位的。下面以32位的情况来介绍。

DR4和DR5是保留的,当调试扩展功能被启用(CR4寄存器的DE位设为1)时,任何对DR4和DR5的引用都会导致一个非法指令异常,当此功能被禁止时,DR4和DR5分别是DR6和DR7的别名寄存器,即等价于访问后者。其他的6个寄存器:

  • 4个32位的调试地址寄存器(DR0-DR3),64位下是64位的

  • 1个32位调试控制寄存器(DR7),64位时,高32位保留未用

  • 1个32位调试状态寄存器(DR6),64位时,高32位保留未用

通过以上寄存器可以最多设置4个断点,基本分工是DR0-DR3用来指定断点的内存(线性地址)或I/O地址。DR7用来进一步定义断点的中断条件。DR6的作用是当调试事件发生时,向调试器报告事件的详细信息,以供调试器判断发生的是何种事件。

调试地址寄存器(DR0-DR3)用来指定断点的地址。对于设置在内存中的断点,这个地址应该是断点的线性地址而不是物理地址,因为CPU是在线性地址被翻译为物理地址之前来做断点匹配工作的。这意味着,在保护模式下,我们不能使用调试寄存器来针对一个物理内存地址设置断点。

调试控制寄存器(DR7)中,有24位被划分成四组分别与四个调试地址寄存器相对应。

R/W0-R/W3:读写域,分别与DR0-DR3四个调试地址寄存器相对应,用来指定被监控的访问类型,含义如下:

  • 00:仅当执行对应地址的指令时中断

  • 01:仅当向对应地址写数据时中断

  • 10:当向相应地址进行输入输出(即I/O读写)时中断

  • 11:当向相应地址读写数据时都中断,但是从该地址读取指令除外

LEN0-LEN3:长度域。分别与DR0-DR3四个调试地址寄存器相对应,用来指定要监控的区域长度,含义如下:

^ 00:1字节长

  • 01:2字节长

  • 10:8字节长或未定义(其他处理器)

  • 11:4字节长

L0-L3:局部断点启用:分别与DR0-DR3四个调试地址寄存器相对应,用来启用或禁止对应断点的局部匹配,如果该值设为1,当CPU在当前任务中检测到满足所定义的断点条件时便中断,并且自动清除此位,如果该位设为0,便禁止此断点。

G0-G3:全部断点启用。分别与DR0-DR3四个调试地址寄存器相对应,用来全局启用和禁止对应的断点。如果该位设为1,当CPU在任何任务中检测到满足所定义的断点条件时都会中断;如果该位设为0,便禁止此断点。与L0-L3不同,断点条件发生时,CPU不会自动清除此位。

LE和GE:这个在P6以下系列CPU上不被支持,在升级版的系列里面:如果被置位,那么cpu将会追踪精确的数据断点。LE是局部的,GE是全局的。

GD:启用或禁止调试寄存器的保护。当设为1时,如果CPU检测到将修改调试寄存器(DR0-DR7)的指令时,CPU会在执行这条指令前产生一个调试异常。

我们可以通过设置读写域来指定断点的访问类型。读写域占两个二进制位,可以指定4中访问方式。这里介绍三种典型的方式:

读/写内存中的数据时中断:这种断点又被称为数据访问断点。利用数据访问断点,可以监控全局变量或局部变量的读写操作。

执行内存中的代码时中断:这种断点又被称为代码访问断点或指令断点。代码访问断点在实现的功能上看与软件断点类似,都是当CPU执行指定地址开始的指令时中断。但是通过寄存器实现的代码访问断点与软件断点相比有个优点,就是不需要像软件断点那样像目标代码中插入断点指令。例如:当我们调试位于ROM上的代码(比如BIOS中的POST程序)时,根本没有办法向那里插入软件断点(INT3)指令,因为目标内存是只读的。另外,软件断点的另一个局限性是,只有当目标代码被加载进内存后才可以向该区域设置软件断点。而调试寄存器断点没有这些限制,因为只要把需要终端的内存地址放入调试地址寄存器(DR0-DR3),并设置好调试控制寄存器(DR7)的相应位就可以了。

读写I/O(输入输出)端口时中断:这种断点又被称为I/O访问断点。I/O访问断点对于调试使用输入输出端口的设备驱动程序非常有用。也可以利用I/O访问断点来监视I/O空间的非法读写操作,提高系统的安全性。因为某些恶意程序在实现破坏动作时,需要对特定的I/O端口进行读写操作。

单步执行

SEH即结构化异常处理(Structured Exception Handling),当程序出现错误时,系统把当前的一些信息压入堆栈,然后转入我们设置好的异常处理程序中执行,在异常处理程序中我们可以终止程序或者修复异常后继续执行。

异常处理处理分两种,顶层异常处理和线程异常处理,下面介绍的是线程异常处理。每个线程的FS: [0]处都是一个指向包含异常处理程序的结构的指针,这个结构又可以指向下一个结构,从而形成一个异常处理程序链。当发生异常时,系统就沿着这条链执行下去,直到异常被处理为止。

下面以最常见的OllyDbg调试器为例讲解调试器单步执行时的工作方式。

当在调试器中选择“步过”某条指令时,程序自动在下一 条语句停下来,这其实也属于一种中断,而且可以说是最常用的一种形式了,当我们需要对某段语句详细分析,想找出程序的执行流程和注册算法时必须要进行这一 步。是80386以上的INTEL CPU中EFLAGS寄存器,其中的TF标志位表示单步中断。当TF为1时,CPU执行完一条指令后会产生单步异常,进入异常处理程序后TF自动置0。调试器通过处理这个单步异常实现对程序的中断控制。持续地把TF置1,程序就可以每执行一句中断一次,从而实现调试器的单步跟踪功能。

单步执行中包含StepIn和StepOver两种:

StepIn:

StepIn即逐条语句执行,遇到函数调用时进入函数内部,其实现方式如下:

1.通过调试符号获取当前指令对应的行信息,并保存该行的信息。 2.设置TF位,开始CPU的单步执行。 3.在处理单步执行异常时,获取当前指令对应的行信息,与1)中保存的行信息进行比较。如果相同,表示仍然在同一行上,转到2);如果不相同,表示已到了不同的行,结束StepIn。 StepOver:

StepOver即逐条语句执行,遇到函数调用时不进入函数内部,其实现方式如下:

1.通过调试符号获取当前指令对应的行信息,并保存该行的信息。 2.检查当前指令是否CALL指令。如果是,则在下一条指令设置一个断点,然后让被调试进程继续运行;如果不是,则设置TF位,开始CPU的单步执行,跳到4)。 3.处理断点异常时,恢复断点所在指令第一个字节的内容。然后获取当前指令对应的行信息,与1)中保存的行信息进行比较,如果相同,跳到2);否则停止StepOver。 4.处理单步执行异常时,获取当前指令对应的行信息,与①中保存的行信息进行比较。如果相同,跳到2);否则停止StepOver。