0. 前言
与常规断点不同,硬件断点主要用于调试目的,它们不需要任何代码修改,并且具有更多功能。因此,在对使用了反调试策略的目标进行调试的过程中,会经常用到这种方法。在本文中,将详细介绍Windows上硬件断点的内部工作原理,并介绍一些常用用法和检测方法。 本文中的研究过程是在64位Windows 10 20H1上进行的。在32位Windows上,某些技术可能非常相似,但不作为本文的重点进行阐述。并且,由于体系结构的差异,这些技术在其他操作系统上(例如Linux和macOS)可能会相差较大。
母亲节快乐
第0-3位是根据触发的硬件断点而进行设置,可以用于快速检查触发了哪个断点。 第13位称为
第8位和第9位分别称为
1. 调试寄存器快速入门
熟悉调试寄存器的读者可以跳至2.0章。 硬件断点同时适用于x86和x64架构,它是通过8个调试寄存器(分别称为DR0到DR7)来实现的。这些寄存器在x86和x64架构上的长度分别为32位和64位。下图展示了x64架构上的寄存器布局。如果大家觉得这个布局有些难懂,请不要担心,我们将在后面详细介绍每个寄存器。如果想要了解有关调试寄存器的更多信息,建议参考Intel SDM和AMD APM,这些都是不错的资源。 x64上调试寄存器的布局:1.1 DR0-DR3
DR0到DR3被称为“调试地址寄存器”或“地址断点寄存器”,它们非常简单,其中仅包含断点的线性地址。当该地址与指令或数据引用匹配时,将发生中断。调试寄存器DR7可用于对每个断点的条件进行更细粒度的控制。因为寄存器需要填充线性地址,所以即使关闭分页,它们也可以正常工作。在这种情况下,线性地址将与物理地址相同。 由于这些寄存器中只有4个是可用的,因此每个线程最多只能同时具有4个断点。1.2 DR4-DR5
DR4和DR5被称为“保留的调试寄存器”。尽管它们的名称中有“保留”字样,但实际上却不总是保留的,仍然可以使用。它们的功能取决于控制寄存器CR4中DE
字段的值。在启用此位后,将启用I/O断点,如果尝试访问其中一个寄存器将会导致
#UD
异常。但是,如果未启用
DE
位时,调试寄存器DR4和DR5分别映射到DR6和DR7.这样做的目的是为了与旧版本处理器的软件相兼容。
1.3 DR6
在触发硬件断点时,调试状态存储在调试寄存器DR6中。也正因如此,该寄存器被称为“调试状态寄存器”。其中包含用于快速检查某些事件是否被触发的位。第0-3位是根据触发的硬件断点而进行设置,可以用于快速检查触发了哪个断点。 第13位称为
BD
,如果由于访问调试寄存器而触发当前异常,则会将其置为1。必须在DR7中启用
GD
位,才能触发此类异常。 第14位称为
BS
,如果由于单个步骤而触发当前异常,则会设置这一位。必须在
EFLAGS
寄存器中启用TF标志,才能触发此类异常。 第15位称为
TS
,如果由于当前任务切换到了启用调试陷阱标志的任务而触发了当前异常,则会设置这一位。
1.4 DR7
DR7被称为“调试控制寄存器”,允许对每个硬件断点进行精细控制。其中,前8位控制是否启用了特定的硬件断点。偶数位(0、2、4、6)称为L0-L3,在本地启用了断点,这意味着仅在当前任务中检测到断点异常时才会触发。奇数位(1、3、5、7)称为G0-G3,在全局启用了断点,这意味着在任何任务中检测到断点异常时都会触发。如果在本地启用了断点,则在发生硬件任务切换时会删除相应的位,以避免新任务中出现不必要的断点。在全局启用时不会清除这些位。第8位和第9位分别称为
LE
和
GE
,是沿用的传统功能,在现代处理器上无法执行任何操作。这些位用于指示处理器检测断点发生的确切指令。在现代处理器上,所有断点条件都是精确的。为了与旧硬件兼容,建议始终将这两个位都设置为1。 第13位被称为
GD
,这一位非常值得关注。如果这一位被启用,则当每一条指令尝试访问调试寄存器时,都会生成调试异常。为了将这种类型的异常与普通的硬件断点异常区分开来,在调试寄存器DR6中设置了
BD
标志。这一位通常用于阻止程序干扰调试寄存器。关键点在于,异常发生在指令执行之前,并且当进入调试异常处理程序时,该标志会被处理器自动删除。但是,这样的解决方案并不完美,因为它只能使用MOV指令来访问调试寄存器。这些在用户模式下是不可访问的,并且根据我的测试,
GetThreadContext
和
SetThreadContext
函数不会触发该事件。这样一来,这种检测就无法在用户模式下使用。 第16-31位用于控制每个硬件断点的条件和大小。每个寄存器有4位,分为4个2位字段。前2位用于确定硬件断点的类型。仅能在指令执行、数据写入、I/O读写、数据读写时才能生成调试异常。仅有在启用了控制寄存器CR4的
DE
字段时,才启用I/O读写功能,否则这种情况是不确定的。大小可以使用后2位来控制,并用于指定特定地址处内存位置的大小。可用的大小有1字节、2字节、4字节和8字节。
1.5 用法
调试寄存器的用法非常简单。有一些特定的指令,可以将内容从通用寄存器移动到调试寄存器,反之亦然。但是,这些指令只能在特权级别0上执行,否则会生成#GP(0)
异常。为了允许用户模式应用程序更改调试寄存器,Windows使用
SetThreadContext
和
GetThreadContext
API以支持对这些寄存器的更改。下面的代码片段演示了这些函数的示例用法。
/* Initialize context structure */
CONTEXT context = { 0 };
context.ContextFlags = CONTEXT_ALL;
/* Fill context structure with current thread context */
GetThreadContext(GetCurrentThread(), &context);
/* Set a local 1-byte execution hardware breakpoint on 'test_func' */
context.Dr0 = (DWORD64)&test_func;
context.Dr7 = 1 << 0;
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
/* Set the context */
SetThreadContext(GetCurrentThread(), &context);
母亲节快乐