内容纲要

定义

其基本思想是在某一个位置设置一个陷阱,当CPU执行到此位置时,中断到调试器中,让调试者分析和调试,之后恢复执行。

软件断点

int 3指令

x86 系列处理器从其第一代产品英特尔 8086 开始就提供了一条专门用来支持调试的指令,即 INT 3。简单地说,这条指令的目的就是使 CPU 中断(break)到调试器,以供调试者对执行现场进行各种分析。

调试器设置断点

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

当 CPU 执行到 INT 3 指令时,由于 INT 3 指令的设计目的就是中断到调试器,因此,CPU 执行这条指令的过程也就是产生断点异常(breakpoint exception,简称#BP)并会保存当前的执行上下文,转去执行异常处理例程的过程。对于 Windows来说,INT 3 异常的处理函数是操作系统的内核函数(KiTrap03)。
在调试器收到调试事件后,它会根据调试事件数据结构中的程序指针得到断点异常的发生位置,然后在自己内部的断点列表中寻找与其匹配的断点记录。如果能找到,则说明这是“自己”设置的断点,执行一系列准备动作后,便允许用户进行交互式调试。如果找不到,就说明导致这个异常的 INT 3 指令不是调试器动态替换进去的,因此会显示的对话框,意思是说一个“用户”插入的断点被触发了。
当用户结束分析希望恢复被调试程序执行时,调试器通过调试 API 通知调试子系统,这会导致系统内核的异常分发函数返回到异常处理例程,然后异常处理例程通过IRET/IRETD 指令触发一个异常返回动作,使 CPU 恢复执行上下文,从发生异常的位置继续执行。

断点API

Windows 操作系统提供了 API 供应用程序向自己的代码中插入断点。在用户模式下,可以使用 DebugBreak() API,在内核模式下可以使用 DbgBreakPoint()或者DbgBreakPointWithStatus()。

这些 API 在 x86 平台上其实都只是对 INT 3 指令的简单包装。

条件断点

在普通断点的基础上,增加限定条件。适用于某一下断处会被多地方调用,则加上限定条件(例:[esp-4]!=某一地址),以达到真正需要断下时触发。

硬件断点

依靠处理器本身的功能实现,所以人们习惯上把使用调试地址寄存器DR0-DR7设置的断点叫做硬件断点。
硬件断点有很多优点,但是也有不足,最明显的就是数量限制,因为只有4个断点地址寄存器。

image-20210610212722393

DR0-DR3为设置断点的地址,DR4和DR5为保留,DR6为调试异常产生后显示的一些信息DR7保存了断点是否启用、断点类型和长度等信息。

断点的长度设置到DR7的LEN0-LEN3中,将断点的类型设置到DR7的RW0-RW3中,将是否启用断点设置到DR7的L0-L3中。

typedef struct _DBG_REG7
{
        /*
        // 局部断点(L0~3)与全局断点(G0~3)的标记位
        */
        unsigned L0 : 1;  // 对Dr0保存的地址启用 局部断点
        unsigned G0 : 1;  // 对Dr0保存的地址启用 全局断点
        unsigned L1 : 1;  // 对Dr1保存的地址启用 局部断点
        unsigned G1 : 1;  // 对Dr1保存的地址启用 全局断点
        unsigned L2 : 1;  // 对Dr2保存的地址启用 局部断点
        unsigned G2 : 1;  // 对Dr2保存的地址启用 全局断点
        unsigned L3 : 1;  // 对Dr3保存的地址启用 局部断点
        unsigned G3 : 1;  // 对Dr3保存的地址启用 全局断点
                                          /*
                                          // 【以弃用】用于降低CPU频率,以方便准确检测断点异常
                                          */
        unsigned LE : 1;
        unsigned GE : 1;
        /*
        // 保留字段
        */
        unsigned Reserve1 : 3;
        /*
        // 保护调试寄存器标志位,如果此位为1,则有指令修改条是寄存器时会触发异常
        */
        unsigned GD : 1;
        /*
        // 保留字段
        */
        unsigned Reserve2 : 2;

        unsigned RW0 : 2;  // 设定Dr0指向地址的断点类型 
        unsigned LEN0 : 2;  // 设定Dr0指向地址的断点长度
        unsigned RW1 : 2;  // 设定Dr1指向地址的断点类型
        unsigned LEN1 : 2;  // 设定Dr1指向地址的断点长度
        unsigned RW2 : 2;  // 设定Dr2指向地址的断点类型
        unsigned LEN2 : 2;  // 设定Dr2指向地址的断点长度
        unsigned RW3 : 2;  // 设定Dr3指向地址的断点类型
        unsigned LEN3 : 2;  // 设定Dr3指向地址的断点长度
}DBG_REG7, *PDBG_REG7;

保存DR0-DR3地址所指向位置的断点类型(RW0-RW3)与断点长度(LEN0-LEN3),状态描述如下:
00:执行 01:写入 11:读写
00:1字节 01:2字节 11:4字节

单步执行标志

x86处理器引入的PSW寄存器,有一个陷阱标志位,名为Trap Flag,简称TF
当TF为1时,CPU每执行一条指令便会产生一个调试异常,中断到调试异常处理程序。
调试器的单步执行功能大多是依靠这一机制来实现的。

内存断点

通过修改内存的属性来达到触发异常。可设置触发条件为读、写、执行。但该断点会消耗较大资源,因为内存断点会将目标地址所在的一整个内存页中下断点,当同内存页非下断位置被读、写、访问也会触发,此时调试器会对比数据,看触发的断点位置是否为当初下断处。虽然内存断点的效率经常很不理想,但是因为仅仅是修改了一个内存属性,所以内存断点可以下数量非常多、单断点范围非常大。这是它的优势。

本质是修改页属性,触发页异常,走0E号中断

 **1. 设置内存断点:

**

    页属性如下:

#define PAGE_NOACCESS           0x01   
#define PAGE_READONLY           0x02   
#define PAGE_READWRITE          0x04   
#define PAGE_WRITECOPY          0x08   
#define PAGE_EXECUTE            0x10   
#define PAGE_EXECUTE_READ       0x20   
#define PAGE_EXECUTE_READWRITE  0x40   
#define PAGE_EXECUTE_WRITECOPY  0x80

我们调用 VirutalProtectEx 函数来修改页属性。

比如,当我们设置内存访问断点,我们将相应的地址所在的页设置为 PAGE_NOACCESS。

VirtualProtectEx(handle, (PVOID)debugAddress, 1, PAGE_NOACCESS, &oldProtote)

之后,程序访问该地址会触发 ACCESS_VIOLATION(c0000005)错误,会走0E号中断,然后包装加入到 DEBUG_OBJECT.EventLink,通知调试器有事件需要处理。

参考资料

https://blog.csdn.net/gengzhikui1992/article/details/111856016

https://www.52pojie.cn/forum.php?mod=viewthread&tid=846934&extra=page%3D3%26filter%3Dauthor%26orderby%3Ddateline

https://codingnote.cc/p/99145/

https://www.cnblogs.com/onetrainee/p/11987083.html