断点是用于帮助程序员分析bug和调试程序的一种技术,让程序在合适的地方暂停运行,查看内存信息。最开始是只顾着用了,没有了解过背后的原理。今天就来简单了解一下背后的机制原理。
软断点
首先看一条指令
1 | 0x44332211 8BC3 MOV EAX, EBX |
这时我们设置一个软断点,一般我们是通过中断指令INT3来实现,设置后指令如下
1 | 0x44332211 CCC3 MOV EAX, EBX |
这里要明白一个概念:指令的机器码=操作码+操作数
CPU在执行完INT3指令后会触发异常,此异常会使操作系统从中断向量表中调用3号中断处理程序,首先检查是否存在调试器,如果存在调试器把异常交给调试器,调试器处理结束再返回。如果调试器不处理或者就不存在调试器则异常会传递到程序自身的异常处理中(如果最后依然没处理就会进行异常的第二次分发)。
但我们平常使用INT3断点调试时,汇编指令并没有发生变化,而是维持原样,之所以我们下断点的地址处字节没有发生任何变化是因为调试器为了维持汇编代码的可读性并没有将改变后的指令进行重新反汇编。我们先用程序验证一下,随便加载一个程序,在地址0x4015CE处下一个断点。
接下来把即将运行的下一条指令改为:
1 | mov al , byte ptr ds:[0x4015CE] |
将下完断点地址处的值读入到寄存器$al$中
当我们执行完地址0x4015B1
的代码后,可以看到$al$的值为0xCC
。所以当$cpu$执行到断点(0x4015CE)时就会执行0xCC
(INT3指令),接着产生异常去执行函数nt!KiTrap03( )
,接着会调用nt!KiDisPatchException( )
函数并将异常分发给调试器,其刚执行完0xCC
此时$eip$指向0xCC
的下一个字节,调试器会让$eip$减一,然后$eip$重新指向0xCC
(断点处)而调试器将先还原此断点处的原字节,然后使返回程序将停在此断点处等待用户的进一步操作。
我们来验证一下这个过程。
这次我们把断点处的汇编指令改为:
1 | mov al , byte ptr ds:[0x4015CE] |
对应的原字节A0
,我们运行到断点后查看寄存器EAX
$al$的值等于A0
,说明断点处的字节已被修复。
为了让这个断点一直生效,程序会利用单步异常来把断点处的值再改为0xCC
。其在执行完断点处的指令后,会产生单步异常从而被调试器捕捉,然后调试器会将此断点处的值更改为0xCC
。我们可以利用如下方法验证。
将断点后的指令改为:
1 | mov al , byte ptr ds:[0x4015CE] |
我们在断点执行语句后看到$al$值为A0
执行后就改为0xCC
实际上,一般情况下,调试器维护了一大组调试断点,并把他们都换成了INT 3。在被运行结束后,会回填回去,并通过现在的地址判断是到了那个断点。软件断点没有数目限制。
硬断点
硬件断点是通过位于 CPU 上的一组特殊寄存器来实现的,称为调试寄存器,与被调试程序无关。比如 x86 架构的 CPU 上有 8 个调试寄存器(DR0-DR7),分别用于设置和管理硬件断点。硬件断点比软件断点的功能更强,除了函数断点外,还可以数据断点,可以指定当数据被读或写时中断。硬件断点的本质就是在指定内存下断点,内存可以位于代码段(函数断点)也可以是数据段(数据断点)。可以设置事件有执行、写入、读写时中断。
- DR0-DR3 负责存储硬件断点的内存地址,所以最多只能同时使用 4 个硬件断点。
- DR4 和 DR5 保留使用。
- DR6 为调试状态寄存器,记录上一次断点触发所产生的调试事件类型信息。
- DR7 是硬件断点的激活开关,存储着各个断点的触发信息条件。 与软断点不同的是,硬件断点使用 1 号中断(INT1)实现,INT1 一般被用于硬件断点和单步事件。
CPU 每次试图执行一条指令时,都会首先检查当前指令所在地址是否被设置了有效的硬件断点,除此之外还会检查当前指令包含的操作数是否位于被设置了硬件断点的内存地址。
内存断点
内存断点本质上不是一个真正的断点。当调试器设置一个内存断点时,实际上是改变一个内存区域或一个内存页的权限。操作系统对内存页会设置访问权限,可执行、可读、可写、保护页,这些访问权限可以组合。
保护页的特性可以帮助我们实现断点机制。
任何对于有页保护的区域的内存访问都会导致 CPU 暂停执行当前进程并处发一个保护页调试异常,然后我们就可以对访问缓冲取得指令代码进行仔细的检查,并判断出应用程序如何处理缓冲区中的内容。