做逆向分析的人都绕不开反调试。CTF Pwn 题里经常埋各种反调试,商业软件更是把反调试做成了一套体系。你不会反反调试,连主函数都看不到。

这篇文章按”从用户态到内核态、从简单到复杂”的顺序,梳理 Windows 下常见的反调试手段和对应的绕过方法。不是教程式的罗列,而是讲清楚每个手段的原理——原理懂了,变种一眼就能看出来。

1. 最简单的:检查 BeingDebugged 标志位

原理

PEB(Process Environment Block)的偏移 0x02 处有一个 BeingDebugged 字节。如果进程被调试器附加,这个字节为 1。

1
2
3
4
5
; 经典反调试代码
mov eax, fs:[30h] ; 读 PEB 基址(TEB->ProcessEnvironmentBlock)
movzx eax, byte ptr [eax+2] ; BeingDebugged
test eax, eax
jnz being_debugged

绕过

方法一:运行时改 PEB。在调试器中把 BeingDebugged 字节改为 0。

1
2
# x64dbg 中直接在命令行执行
# 把 PEB.BeingDebugged 设为 0

方法二:Hook NtQueryInformationProcess。很多反调试代码不直接读 PEB,而是通过这个 API 查 ProcessDebugPort。Hook 它返回正常值。

方法三:用 ScyllaHide 这类插件,一键 patch 所有常见的反调试检测点。

2. NtGlobalFlag

原理

PEB 偏移 0x68(64位)处的 NtGlobalFlag。被调试时通常是 0x70(FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | FLG_HEAP_VALIDATE_PARAMETERS)。

1
2
3
4
mov eax, fs:[30h]
mov eax, [eax+68h] ; NtGlobalFlag
and eax, 0x70
jnz being_debugged

绕过

直接把这个字节 patch 成 0。或者在程序运行前用注册表设置 NtGlobalFlag 为非调试值。

3. CheckRemoteDebuggerPresent

原理

调用 CheckRemoteDebuggerPresent(GetCurrentProcess(), &bDebugged),系统会通过内部逻辑判断有没有调试器。

绕过

这个 API 内部调用 NtQueryInformationProcess,所以 Hook NtQueryInformationProcess 就能同时绕过。

1
2
3
4
5
6
7
8
# Frida hook 示例
Interceptor.attach(Module.findExportByName('kernel32.dll', 'CheckRemoteDebuggerPresent'), {
onLeave: function(retval) {
// 把 pbDebuggerPresent 写为 FALSE
var args = this.context;
// ... 修改输出参数
}
});

4. 时间检测

原理

调试器会打断程序执行,导致两段代码之间的时间间隔异常长。反调试代码测量两段代码之间的时间差,如果超过阈值就判定被调试。

1
2
3
4
5
6
DWORD t1 = GetTickCount();
// ... 被调试时这里会停下来
DWORD t2 = GetTickCount();
if (t2 - t1 > 1000) {
ExitProcess(0);
}

更精确的用 QueryPerformanceCounter 或 rdtsc 指令。

绕过

  • 硬件断点:用条件断点而不是 INT3,单步执行时时间差不会太大
  • Hook 时间 API:Hook GetTickCount、QueryPerformanceCounter,让它返回伪造的时间
  • patch 比较:直接把 jg 改成 jmp 或 nop 掉

5. INT3 检测

原理

调试器常用 int 3(0xCC)设断点。反调试代码扫描自己的代码段,看有没有 0xCC 字节。

1
2
3
4
5
6
7
8
9
10
bool has_int3 = false;
DWORD old;
VirtualProtect(code_start, code_size, PAGE_EXECUTE_READWRITE, &old);
for (int i = 0; i < code_size; i++) {
if (code_start[i] == 0xCC) {
has_int3 = true;
break;
}
}
VirtualProtect(code_start, code_size, old, &old);

绕过

  • 用硬件断点(DR0-DR7),不修改代码
  • 扫描到 0xCC 后 patch 成原来的字节(调试时注意备份)
  • 用条件断点代替 INT3

6. 硬件断点检测

原理

硬件断点设置在 DR0-DR7 寄存器中。反调试代码读取线程上下文,看 DR0-DR3 是否非零。

1
2
3
4
5
6
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(GetCurrentThread(), &ctx);
if (ctx.Dr0 != 0 || ctx.Dr1 != 0 || ctx.Dr2 != 0 || ctx.Dr3 != 0) {
ExitProcess(0);
}

绕过

  • Hook GetThreadContext,把 DR 寄存器清零后返回
  • 不使用硬件断点,用软件断点(但要处理 INT3 检测)
  • 用 ScyllaHide 自动处理

7. 父进程检测

原理

正常情况下,程序的父进程是 explorer.exe(双击运行)或 cmd.exe(命令行启动)。如果父进程是 x64dbg、ollydbg 等调试器,就说明被调试了。

通过 NtQueryInformationProcess 的 ProcessBasicInformation 可以拿到父进程 PID。

绕过

  • Hook 父进程 PID 查询
  • 用调试器启动时伪装父进程(ScyllaHide 支持)
  • patch 比较逻辑

8. 异常处理反调试

原理

利用 SEH(结构化异常处理)和调试器的交互:

  • 程序故意触发异常(如 int 3、除零、非法指令)
  • 如果被调试,异常会先被调试器捕获,程序自己的 SEH 处理不到
  • 如果没被调试,SEH 正常处理异常
1
2
3
4
5
6
__try {
__asm int 3
} __except (EXCEPTION_EXECUTE_HANDLER) {
// 没被调试
}
// 如果被调试,int 3 被调试器捕获,这里不会执行

绕过

  • 在调试器中设置”忽略异常”,让异常传给程序自己的 handler
  • x64dbg:Options → Exceptions → 勾选忽略 INT3

9. 内核态反调试

商业软件常用驱动级反调试:

  • 注册回调:驱动注册 PsSetCreateProcessNotifyRoutine 等回调,监控调试器进程
  • 隐藏调试端口:修改 EPROCESS 的 DebugPort 为 NULL
  • 检测调试对象:检查 EPROCESS 中的 DebugObject 指针
  • 硬件断点在内核态检测:读 KTRAP_FRAME 中的 DR 寄存器

绕过

  • 用 PatchGuard 兼容的方式修改内核结构(有风险)
  • 用虚拟机(VMware + VT-x),驱动在虚拟机里看不到宿主机调试器
  • 用 Hyper-V 级别的调试(KD),很多驱动级反调试检测不到内核调试

10. 反反调试工具

10.1 ScyllaHide

x64dbg/ollydbg 的插件,自动 patch 上百个常见的反调试检测点。基本开了它,80% 的反调试就废了。

10.2 TitanEngine

用 TitanEngine 作为调试引擎,它本身内置了反反调试支持。

10.3 Frida

用 Frida 动态 hook 各种 API,适合对付没有固定模式的反调试:

1
2
3
4
5
6
7
8
9
10
11
12
// Hook NtQueryInformationProcess
var NtQueryInformationProcess = Module.findExportByName('ntdll.dll', 'NtQueryInformationProcess');
Interceptor.attach(NtQueryInformationProcess, {
onEnter: function(args) {
this.infoClass = args[1].toInt32();
},
onLeave: function(retval) {
if (this.infoClass === 7) { // ProcessDebugPort
// 把输出写为 0(无调试端口)
}
}
});

11. 实战流程

拿到一个有反调试的程序,一般按这个顺序:

  1. 先跑一下:看有没有提示”检测到调试器”之类的弹窗
  2. 开 ScyllaHide:一键 patch 常见检测点
  3. 找反调试代码:用 x64dbg 的”反调试”插件自动扫描
  4. 静态分析:看代码段里有没有 fs:[30h]、NtQueryInformationProcess、rdtsc 等特征
  5. 动态 trace:从入口点单步走,遇到奇怪的调用就进去看
  6. patch:找到检测点后,把条件跳转 nop 掉或反转

总结

反调试的本质是利用调试器和正常运行时的行为差异。理解了这个本质,所有变种都能归到这几类:

  • 读调试状态标志(PEB、NtGlobalFlag、调试端口)
  • 测时间差(调试器打断执行)
  • 扫描代码(INT3、硬件断点)
  • 利用异常机制(SEH 被调试器截获)
  • 查父进程/进程列表

反反调试没有银弹。最简单的办法是开 ScyllaHide,复杂情况就靠 Frida hook 和手动 patch。多练几个 CTF Reverse 题,反调试见多了就熟了。