调试寄存器
一、通过调试寄存器检测调试器
在我们调试程序的过程中,会用到硬件断点,最多能使用四个硬件断点,而硬件断点就是通过调试寄存器来保存的,因此我们可以通过检测调试寄存器来检测是否存在硬件断点,如果存在硬件断点,我们就可以判定,程序正在被调试。
调试寄存器一共包括 8 个,分别是 DR0
~DR7
,如下图所示:
在 32 位模式下,它们都是 32 位的;在 64 位模式下,它们都是 64 位的。在这 8 个调试寄存器中,DR4
和 DR5
是保留的,可以忽略。
注:当调试扩展功能被启用(CR4 寄存器的 DE 位设为 1),任何对 DR4 和 DR5 的引用都会导致一个非法指令异常(#UD),当此功能被禁止时,DR4 和 DR5 分别是 DR6 和 DR7 的别名寄存器,即等价于访问后者。
其他 6 个寄存器分别如下:
- 4 个 32 位的调试地址寄存器(DR0~DR3),用来指定断点的内存(线性地址)或 I/O 地址(64 位下是 64 位的)。
- 1 个 32 位的调试状态寄存器(DR6),当调试事件发生时,向调试器报告事件的详细信息,以供调试器判断发生的是何种事件(64 位时,高 32 位保留未用)。
- 1 个 32 位的调试控制寄存器(DR7),用来进一步定义断点的中断条件(64 位时,高 32 位保留未用)。
注:调试寄存器只能保存线性地址,不能保存物理地址。
我们首先来看一下 DR7
里面各个位的作用:
再看看 DR6
里面各个位的作用:
注:因为单步执行、硬件断点等多种情况触发的异常使用的都是一个向量号(即 1 号),所以调试器需要使用调试状态寄存器来判断到底是什么原因触发的异常。
通过上面的介绍,我们对调试寄存器有了一个深入的了解,所以我们只需要检测调试地址寄存器 DR0
~DR3
是否有值就能够判断程序是否处于调试状态。
那是不是直接获取调试寄存器的值就行了呢?其实不然,只有再实模式或保护模式的内核优先级(ring 0)下才能访问调试寄存器,否则便会导致保护性异常。那么有朋友就有疑问了,那我们平时在应用层调试程序的时候,也一样可以下硬件断点,它们是如何实现的呢?其实是通过访问线程的上下文(CONTEXT)数据来间接访问调试寄存器的。
因此,我们也可以通过线程上下文来对调试寄存器进行检测。
1 通过代码得到线程上下文
我们可以通过调用系统的 API 来得到特定进程中线程的上下文,间接访问调试寄存器:
/* 通过代码获取线程上下文检测调试寄存器 */
CONTEXT context{ 0 };
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &context);
if (context.Dr0 != 0 || context.Dr1 != 0 || context.Dr2 != 0 || context.Dr3 != 0)
{
printf("检测到硬件断点\r\n");
}
当在调试器中打开程序后并在系统断点
断下后:
我们点击 运行
,继续让程序执行,此时会在 _mainCRTStartup
这个函数跳转这个入口断点
再次断下来,这时才真正的进入了我们程序主线程的上下文中,而硬件断点是保存在对应线程的上下文中的,如果在此函数之前下硬件断点,那么不会保存在我们主线程的调试寄存器中,从而不会命中断点!!!
此时,我们只需要在程序待执行代码的任意地址下硬件执行断点,都会被我们的程序检测到:
当然,这种通过调用系统 API 检测调试寄存器的方式很容易被破解,因此我们需要采用更加隐蔽的方式。
2 通过向量化异常得到线程上下文
我们可以通过注册向量化异常处理例程,然后在程序主线程中任意触发一个异常(如除零错误),从而接管代码流程,在异常处理例程中获取主线程上下文环境,检测调试器。
通过这种方式来进行检测的一个好处是非常的隐蔽,Windows 系统中无时无刻都在发生异常,想要从海量的异常中找到特定的异常犹如大海捞针:
// 向量化异常
LONG WINAPI VehFilter(LPEXCEPTION_POINTERS pException)
{
PCONTEXT pContext = pException->ContextRecord;
if (pContext->Dr0 != 0 || pContext->Dr1 != 0 ||
pContext->Dr2 != 0 || pContext->Dr3 != 0)
{
printf("检测到硬件断点\r\n");
}
#ifdef _WIN64
pContext->Rip += 2; // 跳过除零异常代码
#else
pContext->Eip += 2;
#endif
return EXCEPTION_EXECUTE_HANDLER;
}
int main()
{
/* 通过向量化异常获取线程上下文检测调试器 */
// 注:x64 系统下命令行界面无法正常结束
// 注册向量化异常处理器
PVOID veh = AddVectoredExceptionHandler(1, VehFilter); // 1 表示注册为第一个,0 表示注册为最后一个
// 触发异常
int a = 5;
a = a / 0;
// 移除向量化异常处理器
RemoveVectoredExceptionHandler(veh);
printf("程序正常结束\r\n");
}
3 通过结构化异常得到线程上下文
除了向量化异常外,我们还可以通过结构化异常来得到线程的上下文:
// 结构化异常
int SehFilter(LPEXCEPTION_POINTERS pException)
{
PCONTEXT pContext = pException->ContextRecord;
if (pContext->Dr0 != 0 || pContext->Dr1 != 0 ||
pContext->Dr2 != 0 || pContext->Dr3 != 0)
{
printf("检测到硬件断点\r\n");
}
#ifdef _WIN64
pContext->Rip += 2;
#else
pContext->Eip += 2;
#endif
return EXCEPTION_EXECUTE_HANDLER;
}
int main()
{
__try
{
// 触发异常
int a = 5;
a = a / 0;
}
__except(SehFilter(GetExceptionInformation()))
{
printf("执行了异常处理块代码\r\n");
}
printf("程序正常结束\r\n");
}
4 通过顶层异常过滤器得到线程上下文
除了以上三种方法外,还可以通过注册顶层异常过滤器来检测调试器,异常传递的优先级为:向量化异常处理器 → 结构化异常处理器 → 顶层异常过滤器,也就是说顶层异常过滤器是处理程序异常的最后一道屏障,代码如下:
// 顶层异常过滤器
LONG WINAPI myUnhandleExceptionFilter(LPEXCEPTION_POINTERS pException)
{
PCONTEXT pContext = pException->ContextRecord;
if (pContext->Dr0 != 0 || pContext->Dr1 != 0 ||
pContext->Dr2 != 0 || pContext->Dr3 != 0)
{
printf("检测到硬件断点\r\n");
}
#ifdef _WIN64
pContext->Rip += 2;
#else
pContext->Eip += 2;
#endif
return EXCEPTION_EXECUTE_HANDLER;
}
int main()
{
/* 注册顶层异常过滤器(结构化异常) */
// 注:顶层异常过滤器被覆盖
SetUnhandledExceptionFilter(myUnhandleExceptionFilter);
/*BOOL bRet = PreventSetUnhandledExceptionFilter();
printf("bRet=%d\r\n", bRet);*/
// 触发异常
int a = 5;
a = a / 0;
printf("程序正常结束\r\n");
}
在新版本的 VS 编译器中,注册的顶层异常处理器会被程序的 C 运行时库注册的异常处理器覆盖掉,待后续处理。