CTF逆向常见反调试技术总结

反调试主要为了干扰动态调试

反调试是一种用于阻碍程序动态调试的技术,首先大致说明一下反调试的工作原理。

在操作系统内部提供了一些API,用于调试器调试。当调试器调用这些API时系统就会在被调试的进程内存中留下与调试器相关的信息。一部分信息是可以被抹除的,也有一部分信息是难以抹除的。

当调试器附加到目标程序后,用户的很多行为将优先被调试器捕捉和处理。其中大部分是通过异常捕获通信的,包括断点的本质就是异常。如果调试器遇到不想处理的信息,一种方式是忽略,另一种方式是交给操作系统处理。

那么目前为止,程序就有两种方式检测自己是否被调试:

  • 检测内存中是否有调试器的信息。
  • 通过特定的指令或触发特定异常,检测返回结果。

通常来说,存在反调试的程序,当检测到自身处于调试状态时,就会控制程序绕过关键代码,防止关键代码被调试,或者干脆直接退出程序。

API反调试

1
BOOL IsDebuggerPresent();

返回值为1表示当前进程被调试的状态,反之为0.

1
2
3
call IsDebuggerPresent
test al, al
jne being_debugged

实际上, 这个函数只是单纯地返回了BeingDebugged标志的值. 检查BeingDebugged标志位的方法也可以用以下 32 代码位代码检查 32 位环境来实现:

32位

1
2
3
mov eax, fs:[30h] ;Process Environment Block
cmp b [eax+2], 0 ;check BeingDebugged
jne being_debugged

64位:

1
2
3
4
5
push 60h
pop rsi
gs:lodsq ;Process Environment Block
cmp b [rax+2], 0 ;check BeingDebugged
jne being_debugged

或使用 32 位代码检测 64 位环境

检查段寄存器中的fs:[0x30] (32位)GS:[0x60] (64位)也是一样的效果

我们只要改掉判断条件或者nop掉就行

1
BOOL CheckRemoteDebuggerPresent(HANDLE hProcess, PBOOL pbDebuggerPresent);

返回值为1表示当前进程被调试的状态,反之为0.

kernel32CheckRemoteDebuggerPresent()函数用于检测指定进程是否正在被调试. Remote在单词里是指同一个机器中的不同进程.

如果调试器存在 (通常是检测自己是否正在被调试), 该函数会将pbDebuggerPresent指向的值设为0xffffffff.

可以用以下 32 位代码检测 32 位环境

1
2
3
4
5
6
7
push eax
push esp
push -1 ;GetCurrentProcess()
call CheckRemoteDebuggerPresent
pop eax
test eax, eax
jne being_debugged

或 64 位代码检测 64 位环境

1
2
3
4
5
6
7
enter 20h, 0
mov edx, ebp
or rcx, -1 ;GetCurrentProcess()
call CheckRemoteDebuggerPresent
leave
test ebp, ebp
jne being_debugged

CheckRemoteDebuggerPresent通过调用NtQueryInformationProcess来实现对调试器的检测,当参数设置为7时,NtQueryInformationProcess会返回远程调试器的端口

比如有如下的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, char *argv[])
{
BOOL isDebuggerPresent = FALSE;
if (CheckRemoteDebuggerPresent(GetCurrentProcess(), &isDebuggerPresent ))
{
if (isDebuggerPresent )
{
std::cout << "Stop debugging program!" << std::endl;
exit(-1);
}
}
return 0;
}

我们可以直接修改isDebuggerPresent的值或修改跳转条件来绕过 (注意不是CheckRemoteDebuggerPresent的 izhi, 它的返回值是用于表示函数是否正确执行).

但如果要针对CheckRemoteDebuggerPresent这个 api 函数进行修改的话. 首先要知道CheckRemoteDebuggerPresent内部其实是通过调用NtQueryInformationProcess来完成功能的. 而我们就需要对NtQueryInformationProcess的返回值进行修改.

NtQueryInformationProcess这个函数会在后面重点介绍

OutputDebugString

在有调试器存在和没有调试器存在时,OutputDebugString函数表现会有所不同。最明显的不同是, 如果有调试器存在,其后的GetLastError()的返回值为零

PEB反调试

当程序处于Ring3(低权限)时, FS:[0] 寄存器指向TEB(Thread Environment Block),即线程环境块结构体,TEB向后偏移0x30字节的位置保存的是PEB(Process Environment Block ),即进程环境块的结构体地址。PEB中的部分成员是与调息相关的成员,当调试器通过 Windows提供的API调试目标程序时,Windows会将一部分调试信息写人这个结构体中。

1
2
3
4
5
6
7
8
9
10
11
12
13
kd>dt_TEB
nt! _TEB
...
+0x030 ProcessEnvironmentBlock :Ptr32_PEB
...
kd>dt_TEB
...
+0x002 BeingDebugged :UChar
...
+Ox018 ProcessHeap :Ptr32 Void
...
+0x068 NtGlobalF1ag :Uint4B
...

NtGlobalFlag

在 32 位机器上, NtGlobalFlag字段位于PEB(进程环境块)0x68的偏移处, 64 位机器则是在偏移0xBC位置. 该字段的默认值为 0. 当调试器正在运行时, 该字段会被设置为一个特定的值. 尽管该值并不能十分可信地表明某个调试器真的有在运行, 但该字段常出于该目的而被使用.

1
mov eax, fs:[30h] → [eax+2] (BeingDebugged);[eax+68h] (NtGlobalFlag)

该字段包含有一系列的标志位. 由调试器创建的进程会设置以下标志位:

1
2
3
FLG_HEAP_ENABLE_TAIL_CHECK (0x10)
FLG_HEAP_ENABLE_FREE_CHECK (0x20)
FLG_HEAP_VALIDATE_PARAMETERS (0x40)

因此, 可以检查这几个标志位来检测调试器是否存在. 比如用形如以下的 32 位的代码在 32 位机器上进行检测:

1
2
3
4
5
mov eax, fs:[30h] ;Process Environment Block
mov al, [eax+68h] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

以下是 64 位的代码在 64 位机器上的检测代码:

1
2
3
4
5
6
7
push 60h
pop rsi
gs:lodsq ;Process Environment Block
mov al, [rsi*2+rax-14h] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

要注意的是, 如果是一个 32 位程序在 64 位机器上运行, 那么实际上会存在两个 PEB: 一个是 32 位部分的而另一个是 64 位. 64 位的 PEB 的对应字段也会像在 32 位的那样而改变.

于是我们就还有以下的, 用 32 位的代码检测 64 位机器环境:

1
2
3
4
5
6
7
mov eax, fs:[30h] ; Process Environment Block
;64-bit Process Environment Block
;follows 32-bit Process Environment Block
mov al, [eax+10bch] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

过检测方法:

有以下 3 种方法来绕过NtGlobalFlag的检测

  • 手动修改标志位的值 (FLG_HEAP_ENABLE_TAIL_CHECK, FLG_HEAP_ENABLE_FREE_CHECK, FLG_HEAP_VALIDATE_PARAMETERS)
  • 在 Ollydbg 中使用hide-debug插件
  • 在 Windbg 禁用调试堆的方式启动程序 (windbg -hd program.exe)

在PEB结构体中中,BeingDebuggedProcessHeapNtGlobalFlag是与调试信息相关的三个重要成员。

  • BeingDebugged:当进程处于被调试状态时,值为1,否则为0。
  • ProcessHeap:指向Heap结构体,偏移0xC处为Flags成员,偏移0x10处为ForceFlags成员。通常情况下,Flags的值为2.ForceFlags的值为0,当进程被调试时会发生改变
  • NGlobalFlag:占四个字节,默认值为0。当进程处于被调试状态时,第一个字节会被置为0x70。

通过FS.Base能够定位到TEB,再通过TEB+0x30能够定位PEB。通过在内存中检测或修改相关成员的值,便可达到反试、反反调试的效果。

TLS反调试

[原创]TLS回调函数(Note)-软件逆向-看雪论坛-安全社区|非营利性质技术交流社区

TLS (Thread Local Storage),即线程局部存储是Windows提供的一种处理机制,每进行一次线程切换,便会调用一次TLS回调。它本意是想给每个线程都提供访问全局变量的机会。例如,需要统计当前程序进行了多少次线程切换,但并不想让其他线程访问到这个计数变量,使用TLS进行计数,便能够解决这个问题,一个程序能设置多个TLS.

TLS 回调是 PE 格式里的一个机制:加载器在初始化模块并在调用 EntryPoint(或 DllMain)前,会先调用注册在 PE 的 TLS Directory 中的一组函数(回调)。因此这些回调在程序入口之前就会执行——这是它被用作反调试、反分析、解密解包的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <windows.h>
#include <iostream>

// TLS回调函数定义
void NTAPI TLS_Callback(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
if (Reason == DLL_PROCESS_ATTACH) {
// 检测是否存在调试器
if (IsDebuggerPresent()) {
std::cout<<"Debugger detected!\n";
ExitProcess(1); // 如果检测到调试器则退出
} else {
std::cout<<"No debugger detected.\n";
}
}
}

// 声明TLS回调函数
#ifdef _MSC_VER
#pragma const_seg(".CRT$XLB")
EXTERN_C const PIMAGE_TLS_CALLBACK pTLS_CALLBACK = TLS_Callback;
#pragma const_seg()
#else
__attribute__((section(".CRT$XLB"))) PIMAGE_TLS_CALLBACK pTLS_CALLBACK = TLS_Callback;
#endif


int main() {
std::cout<<"Program started.\n";
return 0;
}

  • TLS_Callback:TLS的回调函数,每当一个新线程创建时,或者进程加载时,它都会被调用。
  • IsDebuggerPresent:这是Windows提供的API,用来检查当前进程是否被调试。
  • #pragma const_seg:用于指定TLS回调函数的位置,它被放在.CRT$XLB节中,这样Windows在加载时会自动执行。
  • 也可以加PEB反调试

tls函数的第一个参数表示模块句柄,第二个参数表示调用TLS回调函数的原因。

image-20251007141000563

分析:

在创建主线程的时候,就调用了回调函数,此时的调用Reason就是1

然后创建子线程的时候,此时又调用了回调函数,此时的调用Reason就是2

之后子线程执行完毕,调用回调函数,调用Reason就是3

最后主线程执行完毕,调用回调函数,调用原因是0

进程名反调试

当使用调试器调试程序时,调试器是一个独立的进程,运行在内存中。若在程序执行到某一反调试方法。阶段时遍历当前系统中的进程列表,检测是否存在与调试器相关的进程名,也不失为一种可行的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <windows.h>
#include <tlhelp32.h>
#include <iostream>
#include <string>

// 检查是否存在指定的调试器进程
bool IsDebuggerProcessRunning() {
const char* debuggerNames[] = { "ollydbg.exe", "x64dbg.exe", "ida.exe", "windbg.exe" };

// 创建进程快照
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
return false;
}

PROCESSENTRY32 pe32;
pe32.dwSize = sizeof(PROCESSENTRY32);

// 遍历进程列表
if (Process32First(hSnapshot, &pe32)) {
do {
// 遍历已知的调试器名称
for (const auto& debuggerName : debuggerNames) {
// 将进程名转换为小写以进行匹配
std::string processName = pe32.szExeFile;
for (auto& c : processName) c = tolower(c);

if (processName == debuggerName) {
CloseHandle(hSnapshot);
return true; // 找到匹配的调试器进程
}
}
} while (Process32Next(hSnapshot, &pe32));
}

CloseHandle(hSnapshot);
return false; // 未找到调试器进程
}

int main() {
if (IsDebuggerProcessRunning()) {
std::cout << "Debugger process detected! Exiting...\n";
ExitProcess(1); // 检测到调试器,退出程序
} else {
std::cout << "No debugger detected.\n";
}

// 正常程序逻辑
std::cout << "Program is running.\n";
return 0;
}

窗口名反调试

检测已打开窗口的窗口也是一种较为常用的反调试手段。示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <windows.h>
#include <iostream>

// 定义要检查的调试器窗口名称
const char* debuggerWindowNames[] = {
"OllyDbg", "x64dbg", "IDA", "WinDbg"
};

// 枚举系统中所有窗口,查找是否有调试器窗口存在
BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam) {
char windowTitle[256];
GetWindowTextA(hwnd, windowTitle, sizeof(windowTitle));

// 遍历已知的调试器窗口名称
for (const auto& debuggerWindowName : debuggerWindowNames) {
if (strstr(windowTitle, debuggerWindowName)) {
std::cout << "Debugger window detected: " << windowTitle << "\n";
return FALSE; // 找到调试器窗口,停止枚举
}
}

return TRUE; // 继续枚举其他窗口
}

// 检查是否存在调试器窗口
bool IsDebuggerWindowOpen() {
return !EnumWindows(EnumWindowsProc, 0);
}

int main() {
if (IsDebuggerWindowOpen()) {
std::cout << "Debugger window detected! Exiting...\n";
ExitProcess(1); // 检测到调试器窗口,退出程序
} else {
std::cout << "No debugger window detected.\n";
}

std::cout << "Program is running.\n";
return 0;
}


  • EnumWindows:这是Windows API,允许遍历系统中所有的顶层窗口。每找到一个窗口,就会调用回调函数EnumWindowsProc
  • EnumWindowsProc:这是枚举窗口的回调函数。通过GetWindowTextA函数获取窗口标题,然后使用strstr来判断窗口名是否包含已知调试器窗口名称。
  • ExitProcess:如果检测到调试器窗口,程序直接退出。

时间戳反调试

正常情况下,CPU的执行速度是非常快的,每秒能执行数条指令,每条指令的执行时间非常短。而在调试状态下,由于软件中断、单步调试等因素,可能会造成指令间的执行间隔远大于正常时间,分别记录两条指令执行前后的时间戳,利用时间戳的差值便能够判断当前进程是否处于被调试状态。

时间戳反调试有三种常用手段。

  • rdtsc: 汇编指令,能够以纳秒级记录系统启动以来的时间戳,返回值保存在EDX:EAX(高位保存到EDX,低位保存到EAX)中。
  • QueryPerformanceCounter:能够以微秒为单位高精度计时。
  • GetTickCount:返回值为自系统启动以来所经过的毫秒数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <windows.h>
#include <iostream>

int main() {
DWORD time1 = GetTickCount();
int result, a = 1, b = 2;
__asm(
"movl %1, %%ebx\n\t"
"addl %%ebx, %0"
: "=r" (result)
: "r" (b), "0" (a)
);
DWORD time2 = GetTickCount();
if(time2-time1>0x10)
ExitProcess(0);
std::cout << "Program started.\n";
std::cout << result;
return 0;
}

程序执行完内联汇编后,会计算前后时间差。如果时间差超过16毫秒(0x10),程序会调用ExitProcess强制退出。这可以用于反调试:调试器往往会减缓程序的执行速度,因此通过这种时间检测方法可以检测到调试行为。

硬件断点检测反调试

硬件断点是调试器常用的手段之一,它通过CPU的调试寄存器(如DR0-DR7)设置断点。可以通过检查这些寄存器是否有断点设置来检测调试器。使用GetThreadContext API来获取当前线程的上下文,检查调试寄存器(DR0DR3)是否设置断点。

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

利用GetThreadContext获取上下文,检查硬件断点寄存器的值是否为0

软断点

一条常见的指令

1
2
0x44332211:    8BC3    MOV EAX, EBX
#指令地址 操作码 汇编指令

为了在此设置一个软断点,使得 CPU 执行到此能够停止,没错,需要中断指令 INT。这里采用的方式是将双字节操作码替换为 INT3 中断指令(0xCC),设置成为断点后:

1
2
0x44332211:    CCC3    MOV EAX, EBX
#指令地址 操作码 汇编指令

注意这里操作码发生了变化

但是需要注意一个问题,当你修改了可执行程序的某个字节数据后,也就改变了他的 CRC(循环冗余校验)值。CRC 用于校验一个程序是否被篡改,一些程序会在程序执行前检验程序是否被篡改,还有一些软件往往会检测自己在内存中运行代码的 CRC,一旦检测被篡改就会立即自行崩溃,这是一个有效防御软断点的技术。

硬件断点

硬件断点是通过位于 CPU 上的一组特殊寄存器来实现的,称为调试寄存器。比如 x86 架构的 CPU 上有 8 个调试寄存器,分别用于设置和管理硬件断点。

  • DR0-DR3 负责存储硬件断点的内存地址,所以最多只能同时使用 4 个硬件断点。
  • DR4 和 DR5 保留使用。
  • DR6为调试状态寄存器,记录上一次断点触发所产生的调试事件类型信息。
  • DR7 是硬件断点的激活开关,存储着各个断点的触发信息条件。 与软断点不同的是,硬件断点使用 1 号中断(INT1)实现,INT1 一般被用于硬件断点和单步事件。

CPU 每次试图执行一条指令时,都会首先检查当前指令所在地址是否被设置了有效的硬件断点,除此之外还会检查当前指令包含的操作数是否位于被设置了硬件断点的内存地址。

内存断点

内存断点本质上不是一个真正的断点。当调试器设置一个内存断点时,实际上是改变一个内存区域或一个内存页的权限。操作系统对内存页会设置访问权限,可执行、可读、可写、保护页,这些访问权限可以组合。

异常处理反调试

调试器通常会捕获异常,反调试技术可以通过故意引发异常并检查其处理方式来检测调试器。通过引发INT 3指令(断点中断)或其他异常(如除零异常),查看是否有异常处理程序被插入,然后使用SetUnhandledExceptionFilter设置自定义的异常处理程序。

1
2
3
4
5
6
__try {
__asm { int 3 } // 触发断点异常
} __except (EXCEPTION_EXECUTE_HANDLER) {
// 如果捕获了异常,则说明没有调试器
std::cout << "No debugger detected." << std::endl;
}

程序 RaiseException / __asm int 3 / 故意执行非法指令,然后在自己的 VEH/SEH 中捕获并设置某标志。

如果调试器拦截并处理异常(在 first-chance 阶段),用户的 handler 可能不会被调用 → 程序可据此判断“被调试”。

插入 INT3 或触发 STATUS_SINGLE_STEP,调试器与程序对这些事件的处理不同(调试器可能暂停、显示用户界面等)。

Windows 下重要概念(简短)

  • First-chance / Second-chance:异常首先作为 first-chance 通知给调试器。调试器若处理并继续,程序的用户态 handler 可能就不会看到异常。第二次(未处理)称为 second-chance,会导致程序崩溃或被调试器捕获。
  • SEH(x86)链:传统的结构化异常链(x86 上链表存放于 FS:[0])。x64 的 SEH 实现为表格;但 VEH 在 x86/x64 都有效。
  • VEH(Vectored Exception Handler):通过 RtlAddVectoredExceptionHandler / AddVectoredExceptionHandler 注册,优先于 SEH 被调用(可返回 CONTINUE_EXECUTION)。
  • UnhandledExceptionFilter / SetUnhandledExceptionFilter:未被捕获异常的最后处理点,可被修改或 hook。

VEH

  1. CPU捕获异常
  2. 通过KiDispatchException进行分发(3环异常将EIP修改为KiUserExceptionDispatcher)
  3. KiUserExceptionDispatcher调用RtlDispatchException
  4. RtlDispatchException查找VEH处理函数链表,并调用相关处理函数
  5. 代码返回到ZwContinue再次进入0环
  6. 线程再次返回3环后,从修正的位置开始执行

SEH

  1. FS:[0]指向SEH链表的第一个成员

  2. SEH的异常处理函数必须在当前线程的堆栈中

  3. 只有当VEH中的异常处理函数不存在或者不处理才会到SEH链表中查找

VEH

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <windows.h>
#include <stdio.h>

volatile LONG g_flag = 0;

LONG WINAPI MyVeh(EXCEPTION_POINTERS *ep) {
// 我们只处理特定异常码
if (ep->ExceptionRecord->ExceptionCode == 0xDEADBEEF) {
// 标记表示 handler 执行过
InterlockedExchange(&g_flag, 1);
// 跳过异常,继续执行
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}

int main(void) {
// 注册 VEH
PVOID h = AddVectoredExceptionHandler(1, MyVeh);

// 触发自定义软件异常
g_flag = 0;
RaiseException(0xDEADBEEF, 0, 0, NULL);

// 如果调试器在 first-chance 阶段拦截并继续(或改变处理),可能导致 g_flag == 0
if (g_flag == 1) {
printf("No debugger detected (VEH ran).\n");
} else {
printf("Debugger likely present (VEH not executed as expected).\n");
}

// cleanup
RemoveVectoredExceptionHandler(h);
return 0;
}

流程:

  1. 异常发生 → 内核产生异常记录。
  2. 如果有调试器 attached,内核会向调试器发送 first-chance 通知。
  3. 调试器可以在 first-chance 阶段中:
    • 停住程序供用户查看(默认行为),导致程序暂停;或直接处理并让程序继续(模拟处理),这可能阻止用户注册的 VEH/SEH 执行,或改变异常分派路径。
  4. 如果调试器处理并继续,程序的 handler 就可能 永远不被调用(或被修改后的上下文执行),程序据此推断“被调试”。

注意:不同调试器/设置对 first-chance 的行为可配置(x64dbg/Olly/WinDbg 可以选择是否在特定异常上停止),因此这种检测不是 100% 稳妥,但在默认设置下通常有效。

使用内核调试 / 更高级的调试器有些反调试技术试图作出区别,使用 KD(内核调试)通常更难被用户模式反调试检测到。

如果没有调试器 attached:内核不会把异常先发给调试器,异常会直接按进程内的异常分派流程走 —— 先调用 VEH(vectored handlers),然后是 SEH(structured handlers),最后才是未处理异常(可能触发默认终止/UnhandledExceptionFilter)。

有调试器 attached 时:内核会把异常先以 first-chance 事件发给调试器。调试器在接到这个事件后可以选择“告诉内核:我已处理(DBG_CONTINUE)”或“我没处理(DBG_EXCEPTION_NOT_HANDLED)”。如果调试器告诉内核“已处理”,那么内核就不会继续把异常分派给进程的 VEH/SEH —— 因而进程注册的 handler 可能不会被调用。这就是为什么调试器的存在会改变异常分派路径。

举个常见场景:程序在某处 RaiseException(0xDEADBEEF),并寄望自己的 VEH 在该异常发生时被执行来设置某个标志 g_flag = 1。这本来在“无调试器”时会发生。

  • 无调试器:异常直接被内核派送 → VEH 执行 → g_flag 被置 1 → 程序继续,判断无调试器。
  • 有调试器,且调试器在 first-chance 阶段选择“已处理”或用户按了“继续(并让调试器返回 DBG_CONTINUE)”:内核把异常视为已处理 → 不再调用进程的 VEHg_flag 保持 0 → 程序判断“被调试”。

线程触发异常(RaiseException、非法内存访问、INT3、单步等)。

CPU/内核产生异常记录并开始异常分派流程。

内核先检查该进程是否被调试(有调试器 attached)

  • 如果没有调试器,内核直接把异常按进程内的顺序派发给 VEH → SEH → UnhandledFilter。
  • 如果有调试器,内核会先把一个 DebugEvent(first-chance exception)发给调试器(例如 x64dbg)。

调试器收到这个 first-chance 事件后,通过 ContinueDebugEvent 返回一个状态给内核(DBG_CONTINUEDBG_EXCEPTION_NOT_HANDLED)。

  • DBG_EXCEPTION_NOT_HANDLED → 内核继续把异常派给进程的 VEH/SEH(让程序自身处理)。
  • DBG_CONTINUE → 内核认为异常已被处理,不再把异常派给进程的用户态 handler(因此 VEH/SEH 可能不会被调用)。

既然如此,为什么程序不直接查内核来探知调试器呢?

在 Windows 下,每个被调试的进程,内核都会维护一些标志与结构:

  • EPROCESS 结构中:
    • 有个成员叫 DebugPort,指向一个 _DEBUG_OBJECT
    • 还有 DebugFlags 等字段;
    • 只要这个指针非空,就表示该进程正在被调试。
  • 同时,线程 (ETHREAD) 结构中也可能会保存调试事件的上下文。

因此,内核态确实完全知道调试器是谁、什么时候附加、附加到哪个进程

但这些信息不暴露给用户态

用户态程序无法直接读取这些内核结构,原因有三:

保护边界(Ring3 vs Ring0)

  • EPROCESS, ETHREAD, _DEBUG_OBJECT 都在 Ring0(内核态)。
  • 用户态(Ring3)程序没有权限直接访问内核虚拟地址空间。
  • 即使知道符号(EPROCESS 在 ntoskrnl.exe 中),也无法直接 mov 读取它。

API 层被“裁剪”了

Windows 提供的用户态 API,比如 IsDebuggerPresent, CheckRemoteDebuggerPresent, NtQueryInformationProcess 等,其实都是“内核信息的受限投影”。

它们内部通过 NtQueryInformationProcess (info class = ProcessDebugPort, ProcessDebugFlags, ProcessDebugObjectHandle 等) 向内核询问部分信息。

但这些接口是受控的 —— 内核返回的内容经过过滤(不能读内核地址,只能拿到 0 或句柄)。

安全与稳定性考虑

  • 如果任何进程都能直接查询“系统内核结构”来判断调试器存在,会破坏用户态和内核态的隔离。
  • Windows 的调试系统是由 调试端口机制(Debug Port / Debug Object) 实现的,受对象句柄表管理。
  • 内核保证只有通过 NtDebugActiveProcess 等系统调用(必须有合适权限)才能建立这种关系。

绕过方法:

方法 A — 改变 x64dbg 的异常处理策略

目的:让调试器不在 first-chance 阶段处理/截断那类异常,直接让进程的 VEH/SEH 运行。

步骤(通用,x64dbg UI 名称略有不同,但在菜单里都能找到):

  1. 在 x64dbg 菜单里打开 Debug -> Exceptions(或在 Options/Preferences 下找 Debugging → Exceptions)。
  2. 在异常列表中查找你关心的异常码:
    • 常见的软件异常(RaiseException(0xDEADBEEF))用自定义异常码(例如 0xDEADBEEF),
    • 硬件/系统异常有 STATUS_ACCESS_VIOLATION (0xC0000005)STATUS_BREAKPOINT (0x80000003)STATUS_SINGLE_STEP (0x80000004) 等。
  3. 把这些异常的 “First chance” 行为改为 “Pass to program / Not handled / Don’t break on first-chance”(x64dbg 中叫法可能是取消勾选“Break on first-chance”或将动作设为 Ignore / Pass)。
  4. 重新运行程序(或重新附加)。这时当异常发生,x64dbg 不会拦截为 first-chance,而会让内核把异常交给进程(VEH/SEH)——你的 handler 就会执行,反调试检测失败或被绕过。

验证:在程序触发异常的点,x64dbg 不再弹出 first-chance 对话,程序按原设计继续,或 VEH 中的标志被置位。

方法 B — 在 handler 上下下断点 / 跟踪 VEH 注册

目的:直接观察并控制异常处理流程——如果 handler 本身在做重要解密/检测,可以在 handler 入口断点并修改内存/寄存器以“欺骗”检测。

步骤:

  1. 运行一次程序(或静态用 IDA/Ghidra)找出 RtlAddVectoredExceptionHandler / AddVectoredExceptionHandler 的调用位置,或在运行时为这些 API 下断点(在 x64dbg:右键模块导出 -> 在目标 API 上下断点)。
  2. 当执行到注册 handler 的地方时,记录 handler 地址(通常会以函数指针形式传给 API)。
  3. 在 handler 地址处下断点。下一次异常发生时调试器会在 handler 处停住,你可以:
    • 单步查看 handler 是否执行;
    • 直接在内存/寄存器里把被检测标志(比如 g_flag)设为期望值,继续执行;
    • 或修改 handler 的第一条指令为 ret / NOP 等跳过检测逻辑。

方法 C — 直接 patch 二进制(静态修改)

目的:彻底去掉检测调用(例如把 RaiseException 替换为 NOP),适合离线分析或需要重复运行的场景。

步骤:

  1. 在 IDA/x64dbg 找到 RaiseException / int 3 / 触发异常的指令地址。
  2. 在 x64dbg 的反汇编视图用 Assemble / Patch 功能把调用替成 NOPs(或把 call RaiseException 改为 xor ecx,ecx / mov reg,0 等)或直接跳过分支。
  3. 保存修改(x64dbg 支持 patch 并导出 patched file)。重新运行 patched 文件以验证绕过成功。

方法 D — 在异常发生点“跳过”到下一指令

用途:调试时临时跳过异常点而不修改文件。

步骤:

  1. 当异常刚被触发并且 x64dbg暂停(或在断点处),在反汇编窗口定位到异常指令的下一个有效指令地址。
  2. 使用 x64dbg 的“Set Next Instruction” / 修改 RIP/EIP 寄存器直接把指令指针改到下一条指令(这相当于跳过异常触发指令)。
  3. 继续运行。

方法 E — 写入内存“伪造”处理结果(最直接)

如果检测只是检查一个内存标志 g_flag,可以在异常发生前/中/后直接把该变量写成期望值(使用 x64dbg 的 Dump/Memory 编辑或 Debug -> Memory):

  1. 找到 g_flag 在内存中的地址(通过符号、字符串引用或观察堆栈/全局数据)。
  2. 在异常发生前写 1 到该地址,程序看到已被置位就会认为没有调试器。

方法 F — 更高级:隐藏调试器 / 使用内核调试

  • 插件方式(例如 ScyllaHide、HideDebugger)可以在某些样本上生效,但它们本身也可能被样本检测到或被 AV 标记。
  • 使用 内核调试(KD)WinDbg / KD over network 可避免用户态调试器引起的某些 first-chance 差异(更难被反调试检测到),但操作复杂。

单步检测反调试

单步检测反调试是一种通过检测CPU的单步执行(Trap Flag, TF)来判断是否有调试器介入的技术。当调试器单步执行目标程序时,CPU的TF标志会被设置为1,这会导致在每条指令执行完后触发一个调试中断。因此,程序可以通过监控TF标志的变化来检测调试行为。

Trap Flag(TF):当EFLAGS寄存器中的TF标志被置为1时,CPU会进入单步模式,每执行一条指令后都会产生一个调试中断(INT 1)。通过检查和控制TF标志,可以判断程序是否被调试器单步执行。如果调试器处于单步调试模式,TF标志会被置1,程序可以利用这一特性检测调试行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <windows.h>

int main() {
// 保存原来的EFLAGS寄存器值
unsigned int eflags;

__asm {
pushfd // 将EFLAGS压入栈中
pop eax // 将栈顶的EFLAGS值弹出到EAX寄存器
mov eflags, eax // 保存EFLAGS寄存器到eflags变量
or eax, 0x100 // 设置TF(Trap Flag)位为1,启用单步调试模式
push eax // 将修改后的EFLAGS值压回栈
popfd // 恢复EFLAGS寄存器,使Trap Flag生效
}

// 执行单步调试后检测
__asm {
nop // 一个空操作,用于单步执行检测
pushfd // 将当前的EFLAGS寄存器值压入栈
pop eax // 弹出EFLAGS到EAX
mov eflags, eax // 保存当前的EFLAGS值
}

// 检测Trap Flag是否被清除(如果有调试器在调试,该标志可能被复位)
if (eflags & 0x100) {
std::cout << "No debugger detected (TF still set)." << std::endl;
} else {
std::cout << "Debugger detected (TF cleared)." << std::endl;
ExitProcess(0); // 检测到调试器,退出程序
}

std::cout << "Program continues running..." << std::endl;
return 0;
}

如果程序在执行nop指令后,TF标志依然保持为1,则说明程序未被调试,输出“**No debugger detected (TF still set)”。如果程序发现TF标志被清除(调试器可能重置了该标志),则输出“Debugger detected (TF cleared)**”,并终止程序执行。

other

KUSER_SHARD_DATA

用户空间和内核空间有一块共享空间KUSER_SHARD_DATA,可以通过检查其中的内核调试检查位KdDebuggerEnabled来获取内核调试状态

KUSER_SHARED_DATA (ntddk.h) - Windows drivers | Microsoft Learn

SeDebugPrivileges

一般进程是默认禁用SeDebugPrivilege权限的,但是调试器会开启此权限,通过调试器开启的进程也会获得该权限,通过检查进程是否启用SeDebugPrivilege权限可以间接检测调试器

在 C++ 中启用和禁用特权 - Win32 apps | Microsoft Learn

Job Object

为了共享权限,调试器和被调试进程会放在同一job object中,通过检查进程同一object中的所有进程,可以枚举出调试器

Heap Flags

Heap Flags - CTF Wiki (ctf-wiki.org)

HeapFlag:大于2说明正在被调试

HeapForceFlag:大于0说明正在被调试

NtYeildExecution

这个函数可以让任何就绪的线程暂停执行,等待下一个线程调度。

当前线程放弃剩余时间,让给其他线程执行。如果没有其他准备好的线程,该函数返回false,否则返回true。

当前线程如果被调试,那么调试器线程若处于单步状态,随时等待继续运行,则被调试线程执行NtYieldExecution时,调试器线程会恢复执行。

此时NtYieldExecution返回true,该线程则认为自身被调试了

ZwSetInformationThread

将第二个参数设为0x11,可以将进程隐藏

ZwSetInformationThread - CTF Wiki (ctf-wiki.org)

LFH低碎片堆

堆碎片是一种状态,其中可用内存被分解为较小的非连续块。 当堆被碎片化时,即使堆中的可用内存总量足以满足请求,内存分配也可能会失败,因为没有一个内存块足够大。 低碎片堆 (LFH) 有助于减少堆碎片

调试工具无法启用LFH,会导致空间开辟错误,因此可以判断是否正在调试

例题:反调试技术例题 - CTF Wiki

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
int __cdecl main(int argc, const char **argv, const char **envp)
{
v23 = 0;
memset(&v24, 0, 0x3Fu);
v22 = 1;
printf("Input password >");
v3 = (FILE *)sub_40223D();
fgets(&v23, 64, v3);
strcpy(v21, "I have a pen.");
v22 = strncmp(&v23, v21, 0xDu); // 1. 直接比较明文字符串与输入字符串
if ( !v22 )
{
puts("Your password is correct.");
if ( IsDebuggerPresent() == 1 ) // 2. API: IsDebuggerPresent()
{
puts("But detected debugger!");
exit(1);
}
if ( sub_401120() == 0x70 ) // 3. 检测PEB的0x68偏移处是否为0x70. 检测NtGlobalFlag()
{
puts("But detected NtGlobalFlag!");
exit(1);
}

/* BOOL WINAPI CheckRemoteDebuggerPresent(
* _In_ HANDLE hProcess,
* _Inout_ PBOOL pbDebuggerPresent
* );
*/
v4 = GetCurrentProcess();
CheckRemoteDebuggerPresent(v4, &pbDebuggerPresent);
if ( pbDebuggerPresent ) // 4. API: CheckRemoteDebuggerPresent()
{
printf("But detected remotedebug.\n");
exit(1);
}
v13 = GetTickCount();
for ( i = 0; i == 100; ++i )
Sleep(1u);
v16 = 1000;
if ( GetTickCount() - v13 > 1000 ) // 5. 检测时间差
{
printf("But detected debug.\n");
exit(1);
}
lpFileName = "\\\\.\\Global\\ProcmonDebugLogger";
if ( CreateFileA("\\\\.\\Global\\ProcmonDebugLogger", 0x80000000, 7u, 0, 3u, 0x80u, 0) != (HANDLE)-1 )
{
printf("But detect %s.\n", &lpFileName); // 6. 检测ProcessMonitor
exit(1);
}
v11 = sub_401130(); // 7. API: CreateToolhelp32Snapshot()检测进程
if ( v11 == 1 )
{
printf("But detected Ollydbg.\n");
exit(1);
}
if ( v11 == 2 )
{
printf("But detected ImmunityDebugger.\n");
exit(1);
}
if ( v11 == 3 )
{
printf("But detected IDA.\n");
exit(1);
}
if ( v11 == 4 )
{
printf("But detected WireShark.\n");
exit(1);
}
if ( sub_401240() == 1 ) // 8. 通过vmware的I/O端口进行检测
{
printf("But detected VMware.\n");
exit(1);
}
v17 = 1;
v20 = 1;
v12 = 0;
v19 = 1 / 0;
ms_exc.registration.TryLevel = -2; // 9. SEH
printf("But detected Debugged.\n");
exit(1);
}
printf("password is wrong.\n");
return 0;
}

关闭ALSR有助于更方便的分析程序

关闭ASLR

NtQueryInformationProcess

1
2
3
4
5
6
7
NTSTATUS WINAPI NtQueryInformationProcess(
_In_ HANDLE ProcessHandle,
_In_ PROCESSINFOCLASS ProcessInformationClass,
_Out_ PVOID ProcessInformation,
_In_ ULONG ProcessInformationLength,
_Out_opt_ PULONG ReturnLength
);

通过NtQueryInformationProcess查询用户态调试器的存在

ProcessDebugPort

未公开的ntdllNtQueryInformationProcess()函数接受一个信息类的参数用于查询. ProcessDebugPort(7)是其中的一个信息类. kernel32CheckRemoteDebuggerPresent()函数内部通过调用NtQueryInformationProcess()来检测调试, 而NtQueryInformationProcess内部则是查询EPROCESS结构体的DebugPort字段, 当进程正在被调试时, 返回值为0xffffffff.

由于信息传自内核, 所以在用户模式下的代码没有轻松的方法阻止该函数检测调试器.

ProcessDebugObjectHandle

Windows XP 引入了debug对象, 当一个调试会话启动, 会同时创建一个debug对象以及与之关联的句柄. 我们可以使用ProcessDebugObjectHandle (0x1e)类来查询这个句柄的值

ProcessDebugFlags

ProcessDebugFlags (0x1f)类返回EPROCESS结构体的NoDebugInherit的相反数. 意思是, 当调试器存在时, 返回值为0, 不存在时则返回1.

image-20251007150621437

软件反调试(6)- 基于NtQueryInformationProcess的检测-CSDN博客

可以把7改成0

image-20251007150824545

1
2
3
4
5
6
7
8
9
10
11
12
13
xor ebp, ebp
enter 20h, 0
push 8 ;ProcessInformationLength
pop r9
push rbp
pop r8
push 7 ;ProcessDebugPort
pop rdx
or rcx, -1 ;GetCurrentProcess()
call NtQueryInformationProcess
leave
test ebp, ebp
jne being_debugged

调试器在调试进程时调用DebugActiveProcess与被调试程序建立连接。

DebugActiveProcess首先在0环创建一个DEBUG_OBJECT结构体作为调试进程与被调试进程建立连接的桥梁。

DEBUG_OBJECT结构体的句柄存放在TEB +0xF24的位置(3环只能存放句柄)

DEBUG_OBJECT的地址存放在被调试进程的EPROCESS.DebugPort中(0环可以存放地址)。

DEBUG_OBJECT的本质是桥。

image-20251007152102635

什么是句柄?

句柄就像“号码牌”或“引用凭证”

操作系统里有成千上万个对象(文件、线程、窗口……)。程序不可能直接去操作这些内核对象的真实内存地址(那是内核态的东西)。所以 Windows 给每个对象分配一个“编号”或“凭证”——这就是 句柄(Handle)

层次 对象 存放位置 权限
内核对象 (Kernel Object) 文件对象、进程对象、线程对象、互斥体等 Ring0(内核态) 操作系统管理
句柄 (Handle) 用户态用于引用这些对象的“索引” Ring3(用户态) 每个进程有独立的句柄表

句柄不是对象本身,而是一个指向对象的索引

每个进程有自己的句柄表(Handle Table),表项指向内核对象。

内核通过“引用计数”确保对象在还有句柄指向时不会被销毁。

当你调用:

1
HANDLE hFile = CreateFileA("test.txt", GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);

执行过程如下:

  1. 内核创建或打开一个文件对象(FileObject)。
  2. 在当前进程的句柄表中分配一个槽位(比如编号 0x74)。
  3. 返回 0x74(即句柄)给用户程序。
  4. 当你调用 ReadFile(hFile, …) 时,内核根据这个句柄去查句柄表,找到真正的文件对象。

程序的运行需要使用内存来存储数据和指令,cpu根据内存的地址来取对应的数据,然而物理内存的大小空间在安装好后是固定不变的,在程序运行中可能出现物理内存不够的情况,这个时候windows系统开发者就想出一个虚拟内存机制,通过该机制cpu不再直接用物理内存地址来访问内存数据,而是经过虚拟内存里逻辑地址来访问内存数据,虚拟内存里有一张映射关系表,存储了每个逻辑地址和物理地址的相应关系,有了这个映射表,就可以不再拘泥于物理内存的一些不足,例如空间不足,物理内存碎片不能连续分配地址等,用逻辑地址先找到对应物理地址后就能找到数据。

但是,虚拟内存机制虽然解决了内存的上述问题,却在后续的使用中还是有新的问题出现,这就是为什么又会出现句柄,下面来看一下原因。

如下图所示,物理地址灰色部分1,3,4,5,6等已经存上了数据,白色背景代表的2,7,9,10,15可以存数据但是已经被打断,如果借用虚拟内存就可以用连续不打断的一套逻辑地址来分配地址,这样就有了windows下的虚拟内存机制,但是虚拟内存管理灵活变动的优点也就意味着里面的地址对应关系会经常发生变动,像之前的逻辑地址1->物理地址2的关系 如果变成了 逻辑地址8->物理地址2的话,cpu如果不知道这个变动,还拿着之前的 逻辑地址1->物理地址2的关系 来找的话就会出错,所以虚拟内存机制还是不完善,这种映射关系的变动如果做个管理登记的话这个问题就好办了,句柄就有这样的作用。

image-20251007154452600

假如之前cpu 是通过 句柄1 -> 逻辑地址1 -> 物理地址2 这个关系一路找到数据,当逻辑地址1->物理地址2这个关系变成 逻辑地址8->物理地址2的时候, 系统在 句柄1的关系那也修改成 句柄1->逻辑地址8->物理地址2.

言归正传

Windows 的调试系统是 通过“调试端口 (Debug Port)”机制实现的。内核通过 DebugPort被调试进程调试器持有的调试对象 关联起来。

1
2
3
4
5
Debugger process  ---- owns a handle to ---->  DebugObject


EPROCESS.DebugPort ┘
(of the debuggee)

NtQuerySystemInformation

通过NtQuerySystemInformation查询内核调试器的存在

类型 调试对象 检测字段 典型场景
用户态调试 (User-mode Debugger) 像 x64dbg、WinDbg (user mode)、Visual Studio 调试器 EPROCESS.DebugPort / ProcessDebugPort 普通程序调试
内核态调试 (Kernel Debugger) KD、WinDbg(kernel mode)、KDNET、COM 连接 全局标志(KdDebuggerEnabled, KdDebuggerNotPresent 系统级调试、驱动分析

Windows 内核维护两个关键全局变量:

内核变量名 含义 典型值
KdDebuggerEnabled 是否启用了内核调试机制 1 = KD 启用
KdDebuggerNotPresent 调试器当前是否附加 0 = 调试器已连接,1 = 未连接

检测内核调试器用的是 **NtQuerySystemInformation**,而不是针对进程的 NtQueryInformationProcess

1
2
3
4
5
6
7
8
9
10
11
12
SYSTEM_KERNEL_DEBUGGER_INFORMATION info;
NTSTATUS status = NtQuerySystemInformation(
SystemKernelDebuggerInformation, // InfoClass = 0x23
&info,
sizeof(info),
NULL
);

if (NT_SUCCESS(status) && info.KernelDebuggerEnabled && !info.KernelDebuggerNotPresent) {
return TRUE; // 内核调试器已连接
}
return FALSE; // 未检测到内核调试器

SYSTEM_KERNEL_DEBUGGER_INFORMATION info; 创建一个结构体变量 info,用于接收内核关于内核调试器状态的两个布尔值:KernelDebuggerEnabledKernelDebuggerNotPresent

NTSTATUS status = NtQuerySystemInformation(SystemKernelDebuggerInformation, &info, sizeof(info), NULL); 通过 NtQuerySystemInformation 查询系统信息,SystemKernelDebuggerInformation(通常值 0x23)表示请求“内核调试器信息”。内核会把结果写入 info,并把返回状态放到 status 中。

NT_SUCCESS(status) 宏判断系统调用是否成功(返回状态码表示成功)。

info.KernelDebuggerEnabled 如果为 TRUE 表示系统启用了内核调试功能(Kd 被启用)。

!info.KernelDebuggerNotPresent``KernelDebuggerNotPresentFALSE 则表示内核调试器当前已连接/附着。因此 !info.KernelDebuggerNotPresentTRUE 时意味着“内核调试器正在连接中”。

合并判断:NT_SUCCESS(status) && info.KernelDebuggerEnabled && !info.KernelDebuggerNotPresent 三者都成立时(调用成功、KD 被启用、且当前已附着),函数就返回 TRUE —— 表示检测到内核调试器正在连接(attached)

常见的反反调试插件

sharpod

image-20251007164821109

Scyllahide

image-20251007164909401

此外还有Titanhide

这个是内核层面的反反调试了