第20章 反调试技术(二):断点检测、反附加、TLS回调与异常处理
各位好,欢迎来到反调试技术的第二讲。上一章我们聊了基础的反调试手段,比如 IsDebuggerPresent 和 NtQueryInformationProcess。说实话,那些只是开胃菜。真正的对抗,从断点检测开始。
我经常跟团队里的新人说:调试器最核心的能力就是断点。你想想看,没有断点,调试器就是个监视器,只能看不能停。所以,反调试的核心思路之一,就是让调试器不敢下断点,或者下了断点你也停不下来。
这一章我们深入四个方向:INT3断点检测、硬件断点检测、反附加技术、TLS回调,以及异常处理反调试。嗯,内容不少,我们一个一个来。
20.1 INT3断点检测:软件断点的死穴
INT3断点,也就是软件断点,是调试器最常用的手段。原理很简单:调试器把目标指令的第一个字节替换成 0xCC。CPU执行到 0xCC 时,触发异常,调试器接管控制权。
那怎么检测呢?说白了就是检查代码段里有没有不该出现的 0xCC。
核心思路:计算关键函数的校验和,如果发现被篡改(出现0xCC),就认为被下了断点。
我给大家看一段我实际用过的检测代码:
// 检测指定地址范围是否被下了INT3断点
BOOL CheckForINT3Breakpoints(PVOID pAddress, SIZE_T size) {
PBYTE pBytes = (PBYTE)pAddress;
for (SIZE_T i = 0; i < size; i++) {
if (pBytes[i] == 0xCC) {
return TRUE; // 发现断点!
}
}
return FALSE;
}
// 使用示例:检测MessageBoxA函数是否被下断
HMODULE hUser32 = GetModuleHandleA("user32.dll");
PVOID pMsgBox = GetProcAddress(hUser32, "MessageBoxA");
if (CheckForINT3Breakpoints(pMsgBox, 16)) {
// 有人在我身上下了断点,直接退出
ExitProcess(0);
}
这段代码简单粗暴,但有个问题:如果调试器在检测之后才下断点呢? 所以,我建议把检测逻辑放在循环里,或者配合定时器反复检查。
避坑指南:我曾经在一个项目里只检测了函数开头5个字节,结果调试器把断点下在了函数中间。后来我改成检测整个函数体,才堵住这个漏洞。记住,断点不一定只下在入口处。
20.2 硬件断点检测:DR寄存器的秘密
硬件断点比INT3高级多了。它不修改代码,而是利用CPU的调试寄存器(DR0~DR3)来设置断点。调试器把目标地址写入DR0~DR3,CPU执行到该地址时自动触发异常。
因为不修改内存,所以上面那种扫描 0xCC 的方法完全无效。那怎么检测?直接读取DR寄存器。
Windows下,我们可以通过 GetThreadContext 来获取线程的上下文,其中就包含DR寄存器的值:
BOOL CheckHardwareBreakpoints() {
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
HANDLE hThread = GetCurrentThread();
if (!GetThreadContext(hThread, &ctx)) {
return FALSE; // 获取失败,可能已经被调试
}
// 检查DR0~DR3是否有非零值
if (ctx.Dr0 != 0 || ctx.Dr1 != 0 || ctx.Dr2 != 0 || ctx.Dr3 != 0) {
return TRUE; // 发现硬件断点!
}
return FALSE;
}
这里有个细节:DR6和DR7也要检查。DR6记录断点触发状态,DR7控制断点启用。我见过一些调试器只清DR0~DR3,忘了清DR7,结果被我抓个正着。
注意:在64位系统上,GetThreadContext 需要调用者有 SeDebugPrivilege 权限。如果程序以普通用户权限运行,可能获取失败。我一般会配合提权代码一起使用。
20.3 反附加技术:不让调试器靠近
反附加,说白了就是阻止调试器通过 DebugActiveProcess 附加到进程。调试器附加时,会调用 DebugActiveProcess,系统会向目标进程发送一个调试事件。
怎么阻止?最经典的方法是调用 NtSetInformationProcess 设置进程为“调试端口已占用”状态:
typedef NTSTATUS (NTAPI *pNtSetInformationProcess)(
HANDLE ProcessHandle,
PROCESS_INFORMATION_CLASS ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength
);
void AntiDebugAttach() {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
pNtSetInformationProcess NtSetInformationProcess =
(pNtSetInformationProcess)GetProcAddress(hNtdll, "NtSetInformationProcess");
DWORD_PTR debugPort = 0; // 设置为0,表示没有调试器
NtSetInformationProcess(
GetCurrentProcess(),
ProcessDebugPort, // 0x07
&debugPort,
sizeof(debugPort)
);
}
这段代码执行后,系统认为当前进程已经有调试器占用了端口。当其他调试器尝试 DebugActiveProcess 时,系统会返回 STATUS_PORT_ALREADY_SET,附加失败。
嗯,这里要注意:这个操作是不可逆的。一旦设置了,当前进程就再也无法被正常调试了。所以,我一般只在关键代码段执行前调用一次。
20.4 TLS回调:在入口点之前执行
TLS(Thread Local Storage)回调是个很有意思的技术。它的执行时机比程序入口点(EntryPoint)还要早。这意味着,当调试器在入口点下断点时,TLS回调已经跑完了。
我经常用TLS回调来做两件事:
- 提前检测调试器:在入口点之前就执行反调试代码
- 动态修改代码:在调试器看到代码之前,把关键函数改掉
实现方式如下:
// TLS回调函数
void NTAPI TLSCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
if (Reason == DLL_PROCESS_ATTACH) {
// 在这里执行反调试检测
if (IsDebuggerPresent()) {
// 检测到调试器,直接退出
ExitProcess(0);
}
// 或者检测硬件断点
if (CheckHardwareBreakpoints()) {
ExitProcess(0);
}
}
}
// 告诉链接器这是TLS回调
#pragma comment(linker, "/INCLUDE:__tls_used")
#pragma comment(linker, "/INCLUDE:__tls_callback")
// 声明TLS回调数组
EXTERN_C IMAGE_TLS_DIRECTORY64 _tls_used;
EXTERN_C PIMAGE_TLS_CALLBACK _tls_callback[] = { TLSCallback, NULL };
我记得有一次做CTF比赛,题目程序用了TLS回调来检测调试器。我一开始在入口点下断点,结果程序直接退出了。后来才发现,人家在入口点之前就把我检测出来了。最后我只能用硬件断点,在系统加载TLS回调之前就断住——嗯,那是另一个故事了。
小技巧:TLS回调在调试器眼里是透明的。如果你用OllyDbg或x64dbg,可以在"断点"窗口里看到TLS回调的地址。但如果你用Windbg,默认不会显示。我建议在TLS回调里加一个 DebugBreak(),这样调试器无论如何都会断住。
20.5 异常处理反调试:让调试器疲于奔命
异常处理反调试,是我个人觉得最优雅也最恶心的技术。优雅在于它利用了Windows的异常处理机制,恶心在于调试器处理起来非常麻烦。
核心思路:故意触发异常,然后在自己的异常处理函数里执行关键代码。调试器如果接管了异常,就会错过关键逻辑;如果不接管,程序正常运行。
看个例子:
// 使用VectoredExceptionHandler
LONG CALLBACK VectoredHandler(PEXCEPTION_POINTERS pExceptionInfo) {
if (pExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_ACCESS_VIOLATION) {
// 如果是访问违例,我们在这里执行真正的逻辑
// 比如解密关键数据
DecryptCriticalData();
// 修复异常,让程序继续执行
pExceptionInfo->ContextRecord->Eip += 2; // 跳过触发异常的指令
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
void SetupExceptionTrap() {
AddVectoredExceptionHandler(1, VectoredHandler);
// 故意触发访问违例
// 注意:这里会触发异常,但我们的handler会处理
*(int*)0x12345678 = 0xDEADBEEF; // 故意写非法地址
}
这个技术的关键在于:调试器在单步跟踪时,遇到异常会停下来。如果调试器选择"让程序处理异常",那它就会错过异常处理函数里的关键代码。如果调试器选择"自己处理异常",那程序就会崩溃或者走不同的路径。
我见过最狠的做法是嵌套异常:在异常处理函数里再触发另一个异常,形成异常链。调试器要一层层跟进去,非常痛苦。
警告:异常处理反调试有个副作用——性能开销。每次触发异常,系统都要走一遍完整的异常分发流程。如果放在循环里,程序会明显变慢。我一般只在关键校验点使用,不会全程序铺开。
20.6 本章知识体系
为了让大家更直观地理解这些技术的关系,我画了一张图:
从这张图可以看出,这五种技术各有侧重:INT3和硬件断点检测是被动防御,反附加是主动阻止,TLS回调是时间差攻击,异常处理则是逻辑混淆。实际项目中,我通常会把它们组合使用,形成多层防御。
20.7 实战建议
最后,给大家几个实战中的建议:
- 不要过度依赖单一技术:INT3检测很容易被绕过(比如调试器用硬件断点代替软件断点)。我建议至少组合2-3种技术。
- 注意性能影响:特别是异常处理反调试,频繁触发异常会拖慢程序。我一般只在关键路径上使用。
- 考虑兼容性:有些反调试技术在Windows 7和Windows 10上表现不同。比如
NtSetInformationProcess在Win10上可能需要额外的权限。 - 留后门:给自己留一个环境变量或注册表项,可以临时关闭反调试。否则调试自己的程序时也会被拦住,那可就尴尬了。
好了,这一章的内容就到这里。反调试技术说到底是一场猫鼠游戏,没有绝对的安全。但掌握了这些技术,至少能让你的程序不那么容易被攻破。下一章我们会继续深入,聊聊代码混淆和虚拟化保护——嗯,那又是另一个层次的话题了。
公众号:蓝海资料掘金营,微信deep3321