第十七章 反调试技术(二):时间检测、异常处理反调试、TLS回调
上一章我们聊了 IsDebuggerPresent 和 NtQueryInformationProcess 这些 API 层面的反调试。说实话,那都是入门级的东西。稍微有点经验的逆向工程师,随手就能 patch 掉。
这一章我们上点硬菜。时间检测、异常处理、TLS 回调——这三板斧才是真正让调试器头疼的东西。我个人习惯把这三种技术称为「反调试三件套」,因为它们在实战中出现的频率极高,而且组合起来效果惊人。
17.1 时间检测:RDTSC 指令
先说说时间检测。原理很简单:程序正常执行的速度,和在被调试状态下执行的速度,差太多了。
你想想看,调试器每执行一条指令,背后都要做多少事?单步异常、上下文切换、断点命中……这些开销加起来,会让代码执行时间暴增几十甚至上百倍。
RDTSC 是 x86 指令集里的一条指令,全称是 Read Time-Stamp Counter。它读取 CPU 内部的时间戳计数器,这个计数器从 CPU 上电开始,每个时钟周期递增一次。
用法很简单:
__asm {
rdtsc
mov start_time, eax
; 这里放一段关键代码
rdtsc
sub eax, start_time
cmp eax, threshold
jg debugger_detected
}
我在项目中遇到过一种很狡猾的写法——不是直接比较时间差,而是把 RDTSC 的结果作为解密密钥的一部分。调试器一介入,时间差变了,密钥算出来就是错的,程序直接崩溃。你连断点都来不及下。
核心思路:正常执行时,两段 RDTSC 之间的差值很小(几十到几百个时钟周期)。调试状态下,这个差值会暴涨到几万甚至几十万。
注意:RDTSC 在多核 CPU 上有个坑——不同核心的时间戳计数器可能不同步。所以有些程序会强制绑定 CPU 核心,或者用 rdtscp 指令(带核心 ID 的版本)。
嗯,这里还要提一句。有些调试器会尝试 hook RDTSC 指令,返回伪造的时间戳。但说实话,这招成功率不高。因为 RDTSC 是 CPU 指令级的操作,hook 它需要在异常处理层面做文章,性能开销太大了。
17.2 异常处理反调试
异常处理反调试,这招玩的是心理战。调试器处理异常的方式,和正常程序完全不一样。
正常程序遇到异常,要么自己处理(SEH),要么崩溃。但调试器呢?它会先截获异常,让你看看,你再决定要不要交给程序处理。
利用这个差异,我们可以设计出很多反调试技巧。
17.2.1 主动触发异常
最常见的做法是主动触发一个异常,然后在异常处理函数里检查环境。
__try {
// 故意触发除零异常
int x = 0;
int y = 1 / x;
}
__except(EXCEPTION_EXECUTE_HANDLER) {
// 正常执行到这里
// 但如果被调试器单步跟踪,异常会被调试器拦截
// 程序行为会不同
}
我曾经见过一个很精妙的实现:程序先触发一个 INT 3 断点异常,然后在异常处理函数里检查 CONTEXT 结构体中的 Dr0-Dr7 寄存器。如果这些寄存器非零,说明调试器设了硬件断点。这招防不胜防。
17.2.2 异常链篡改
Windows 的异常处理机制依赖一个链表——SEH 链。调试器在调试时,会把自己的处理函数插入到这个链里。
我们可以遍历这个链,检查有没有陌生的处理函数。如果有,大概率是被调试了。
// 获取当前线程的 SEH 链
NT_TIB* tib = (NT_TIB*)__readfsdword(0x18);
EXCEPTION_REGISTRATION_RECORD* record = tib->ExceptionList;
while (record != (EXCEPTION_REGISTRATION_RECORD*)0xFFFFFFFF) {
// 检查每个处理函数
if (IsSuspiciousHandler(record->Handler)) {
// 发现调试器
ExitProcess(0);
}
record = record->Next;
}
小技巧:我建议把异常处理反调试和 RDTSC 结合起来用。先触发异常,在异常处理函数里执行 RDTSC 检测。这样调试器既要处理异常,又要面对时间差检测,双重压力。
17.3 TLS 回调
TLS 回调,全称是 Thread Local Storage Callback。这玩意儿是 Windows PE 文件里一个很特殊的存在。
正常情况下,程序的入口点是 main 或 WinMain。但 TLS 回调会在入口点之前执行。这意味着什么呢?意味着调试器还没来得及在 main 函数下断点,TLS 回调里的反调试代码已经跑完了。
我刚开始做逆向的时候,就吃过这个亏。有一次分析一个恶意软件,在 main 函数下了断点,结果程序直接退出了。折腾了半天才发现,人家在 TLS 回调里做了反调试检测。
TLS 回调的注册方式:
// TLS 回调函数
void NTAPI TlsCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
if (Reason == DLL_PROCESS_ATTACH) {
// 在这里执行反调试检测
if (IsDebuggerPresent()) {
ExitProcess(0);
}
}
}
// 告诉链接器这是 TLS 回调
#pragma comment(linker, "/INCLUDE:__tls_used")
#pragma data_seg(".CRT$XLB")
PIMAGE_TLS_CALLBACK pTlsCallback = TlsCallback;
#pragma data_seg()
关键点:TLS 回调的执行时机比任何用户代码都早。调试器如果只关注入口点,根本拦不住 TLS 回调里的反调试逻辑。
对付 TLS 回调反调试,调试器也有办法。比如在系统断点(ntdll!LdrInitializeThunk)处下断点,或者直接修改 PE 文件里的 TLS 表。但这些都是高级技巧了,一般的逆向工程师还真不一定搞得定。
17.4 三种技术的组合应用
实战中,这三种技术很少单独使用。我见过一个比较典型的组合方案:
- TLS 回调:在入口点之前执行第一轮反调试检测,检测到调试器直接退出。
- 异常处理:在程序运行过程中,随机触发异常,在异常处理函数里做二次检测。
- 时间检测:在关键代码段(比如解密、校验)前后插入 RDTSC,确保没有被单步跟踪。
这个组合拳打下来,大部分调试器都得跪。我记得有一次分析一个商业软件的保护方案,就是这种三层结构。我花了整整两天才找到绕过的方法。
下面这张图展示了这三种反调试技术的执行流程和相互关系:
实战建议:如果你在逆向一个加了这种保护的程序,我建议从 TLS 回调入手。先用工具(比如 LordPE 或 CFF Explorer)查看 PE 文件的 TLS 表,找到回调函数的地址,直接 NOP 掉。然后再处理异常和时间检测。
好了,这一章的内容就到这里。时间检测、异常处理、TLS 回调——这三种技术单独拿出来都不算难,但组合起来就是一道铁壁。希望你能在实际分析中灵活运用这些知识。
公众号:蓝海资料掘金营,微信deep3321