14、反调试技术(下):时间检测、断点检测、异常处理反调试
上一章我们聊了反调试的“硬碰硬”手段,比如进程检测、端口检测这些。今天咱们继续往下挖,聊聊更隐蔽、更“阴”的几种手法。说白了,调试器想抓你,你总得给它制造点麻烦,对吧?
我个人习惯把反调试分成两类:一类是“你别来”,另一类是“你来了我也不让你好好干活”。今天讲的这三种,基本都属于后者。
14.1 时间检测:你慢下来了,我就知道有人搞鬼
这个思路其实很简单。程序正常跑,一段代码执行时间基本是固定的。但如果被调试器单步跟踪,或者下了断点,那执行时间就会明显变长。
为什么会这样?因为调试器每执行一条指令,都要停下来等用户操作。这个时间差,就是反调试的突破口。
14.1.1 常用的时间获取API
Windows下常用的时间检测API有这些:
| API | 精度 | 说明 |
|---|---|---|
| GetTickCount() | 毫秒级 | 系统启动以来的毫秒数,容易被patch |
| QueryPerformanceCounter() | 微秒级 | 高精度计时器,我比较推荐这个 |
| rdtsc指令 | CPU时钟周期 | 最底层,但容易被虚拟化影响 |
我在项目中遇到过用rdtsc做反调试的案例。那段代码先读一次时间戳,跑一段空循环,再读一次。如果两次差值超过某个阈值,就判定有调试器。
核心思路: 正常执行时间 vs 调试执行时间,差值过大就报警。
14.1.2 代码示例:简单的时间检测
BOOL CheckDebuggerByTime() {
DWORD start = GetTickCount();
// 跑一段固定次数的循环
for (int i = 0; i < 1000000; i++) {
// 空操作
}
DWORD end = GetTickCount();
DWORD elapsed = end - start;
// 正常情况应该在10-20ms之间
// 如果超过100ms,大概率有调试器
if (elapsed > 100) {
return TRUE; // 检测到调试器
}
return FALSE;
}
嗯,这里要注意。这个方法的缺陷也很明显——攻击者可以hook掉GetTickCount,让它返回伪造的值。所以更靠谱的做法是用rdtsc指令,因为它不经过系统调用。
小技巧: 用rdtsc时,记得加上cpuid指令来序列化CPU,否则结果可能不准。我曾经因为这个踩过坑,查了半天才发现是乱序执行搞的鬼。
14.2 断点检测:看看你的代码有没有被改
断点检测,说白了就是检查内存中的指令有没有被篡改。调试器下断点,通常会把原来的指令替换成0xCC(int 3)。那我们只要扫描一下关键代码段,看看有没有不该出现的0xCC,就能发现异常。
14.2.1 软件断点检测
软件断点就是int 3,机器码是0xCC。检测方法很简单:
BOOL CheckSoftwareBreakpoint() {
// 假设我们要检查的函数地址
BYTE* pFunc = (BYTE*)SomeCriticalFunction;
// 检查前几个字节
for (int i = 0; i < 16; i++) {
if (pFunc[i] == 0xCC) {
return TRUE; // 发现断点
}
}
return FALSE;
}
你想想看,如果攻击者在你函数开头下了断点,那第一个字节肯定是0xCC。你只要在函数执行前扫一遍,就能发现。
注意: 这种方法只能检测软件断点。硬件断点(DR0-DR3寄存器)不会修改内存,所以扫内存是没用的。
14.2.2 硬件断点检测
硬件断点用的是CPU的调试寄存器。要检测它,得用GetThreadContext这个API来读取寄存器状态。
BOOL CheckHardwareBreakpoint() {
CONTEXT ctx = {0};
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (GetThreadContext(GetCurrentThread(), &ctx)) {
// 检查DR0-DR3是否被设置
if (ctx.Dr0 != 0 || ctx.Dr1 != 0 ||
ctx.Dr2 != 0 || ctx.Dr3 != 0) {
return TRUE; // 有硬件断点
}
}
return FALSE;
}
我曾经在一个加壳软件里看到过这种检测。它每隔几毫秒就检查一次调试寄存器,一旦发现异常就直接崩溃。嗯,这种手法虽然粗暴,但确实有效。
14.3 异常处理反调试:让调试器疲于奔命
这个手法比较高级。它利用Windows的异常处理机制,故意制造异常,然后看异常是被程序自己处理了,还是被调试器截获了。
说白了,就是给调试器挖坑。你跳进去,我就知道你在;你不跳,程序也能正常跑。
14.3.1 利用VEH(向量化异常处理)
VEH是Windows提供的一种异常处理机制。我们可以注册一个VEH处理器,然后故意触发异常。如果异常被VEH处理了,说明没有调试器;如果异常被调试器截获了,那VEH就不会被调用。
// 异常处理函数
LONG CALLBACK VectoredHandler(PEXCEPTION_POINTERS pExceptionInfo) {
// 如果这个函数被调用,说明没有调试器
// 因为调试器会先截获异常
g_bDebugged = FALSE;
return EXCEPTION_CONTINUE_EXECUTION;
}
BOOL CheckDebuggerByVEH() {
g_bDebugged = TRUE;
// 注册VEH
PVOID pHandle = AddVectoredExceptionHandler(1, VectoredHandler);
// 故意触发一个除零异常
__try {
int a = 0;
int b = 1 / a; // 除零异常
} __except(EXCEPTION_EXECUTE_HANDLER) {
// 如果走到这里,说明异常被处理了
// 但VEH应该先被调用才对
}
RemoveVectoredExceptionHandler(pHandle);
return g_bDebugged;
}
这个逻辑有点绕,我解释一下。正常情况下,异常会先经过VEH,再经过SEH。如果调试器存在,它会先截获异常,VEH就不会被调用。所以如果VectoredHandler被执行了,说明没有调试器。
关键点: 异常处理链的顺序是:调试器 → VEH → SEH。利用这个顺序,就能判断调试器是否存在。
14.3.2 利用INT 3指令
这个更直接。我们在代码里主动插入int 3指令,然后看异常是怎么被处理的。
BOOL CheckDebuggerByInt3() {
__try {
__asm {
int 3 // 主动触发断点
}
} __except(EXCEPTION_EXECUTE_HANDLER) {
// 如果异常被SEH处理,说明没有调试器
return FALSE;
}
// 如果异常被调试器截获,不会走到这里
return TRUE;
}
嗯,这个手法有个问题。如果攻击者用OllyDbg之类的调试器,它默认会忽略int 3异常,直接交给程序处理。所以这个方法的可靠性其实一般。
14.4 知识体系总览
为了让你更直观地理解这三种反调试技术的关系,我画了一张图:
14.5 实战中的组合使用
在实际项目中,我建议你把这三种手法组合起来用。单一手法太容易被绕过,组合起来才能形成有效的防护。
我曾经在一个商业软件里看到过这样的组合:
- 先用时间检测,如果发现异常,不立即报错,而是记录日志
- 再用断点检测,扫描关键函数的前16个字节
- 最后用VEH异常处理,在程序运行过程中持续检测
这种分层检测的好处是,攻击者很难一次性绕过所有检测。而且你可以在不同阶段触发不同的反制措施,比如篡改数据、触发崩溃、或者干脆假装正常运行但输出错误结果。
避坑指南: 我曾经犯过一个错误——在时间检测里用了Sleep()函数。结果发现攻击者可以hook掉Sleep,让程序“瞬间”执行完。后来我改用rdtsc配合cpuid,才解决了这个问题。
14.6 小结
这三种反调试技术,说白了都是在利用调试器和正常程序之间的行为差异。时间检测看速度,断点检测看内存,异常处理看异常链。每一种都有它的优缺点,没有银弹。
嗯,最后提醒一句。反调试技术再强,也只是增加攻击者的成本。真正安全的软件,应该是反调试、代码混淆、加密、完整性校验等多层防护的组合。别指望一招鲜吃遍天。
公众号:蓝海资料掘金营,微信deep3321