模块 71 - 反调试技术 - 多种检测技术
恶意软件开发课程 - 反调试技术 - 多种检测技术
模块 71 - 反调试技术 - 多种检测技术#
反调试技术 - 多种检测技术#
简介#
安全研究员和恶意软件分析师通常会使用调试(Debugging)来增强对恶意软件样本的理解。这使他们能够针对这些样本编写更精准的探测规则。作为恶意软件开发者,你应该时刻武装反调试技术,使分析过程对分析师来说更加耗时。
本模块将讨论几种反调试技术。
💡 初学者提示:为什么恶意软件需要“反调试”?
想象一下你是正在潜入敌营的间谍:
- 调试(Debugging) 就像是敌方的监控摄像头或搜身。分析师会通过调试器观察你走的每一步、检查你口袋里的每一个工具。
- 反调试(Anti-Debugging) 则是你的反侦察手段。比如:
- 检查是否有摄像头(检测调试器进程)。
- 制造各种干扰让摄像头失效(干扰调试器运行)。
- 如果发现被包围了,就销毁所有线索并撤退(自删除或停止运行)。
反调试的核心目的:
- 增加分析成本:让分析师花几倍的时间才能理清程序逻辑。
- 躲避自动化沙箱:许多沙箱在发现程序有反调试行为时,可能无法通过动态分析获取有效数据。
1. 利用 IsDebuggerPresent 探测调试器#
最简单的反调试技术之一是使用 IsDebuggerPresent ↗ WinAPI。如果调试器已附加到调用进程,该函数返回 TRUE;否则返回 FALSE。以下代码段展示了该函数的使用方法。
if (IsDebuggerPresent()) {
printf("[i] IsDebuggerPresent 探测到调试器 \n");
// 执行无害代码或直接退出..
}c📚 知识扩展:IsDebuggerPresent 的局限性
虽然
IsDebuggerPresent很常用,但它在专业的恶意软件分析中作用有限:
- 极其容易被 Hook:分析师可以轻松修改它的返回值,让你永远以为没有调试器。
- 反反调试工具(Anti-Anti-Debug):像 ScyllaHide 这样的插件会自动拦截这个 API 的调用。
- 静态特征明显:扫描导入表(IAT)时,只要看到这个 API,几乎所有人都会知道你正在尝试检测调试器。
2. IsDebuggerPresent 替代方案 (1)#
即使通过 API Hashing 很好地隐藏了 IsDebuggerPresent WinAPI 调用。但这个 WinAPI 被认为是检测调试器的极基础方法,可以使用 ScyllaHide ↗(一个针对 xdbg 等调试器的反反调试插件)轻松绕过。
更好的方法是创建自定义版本的 IsDebuggerPresent。回想一下 Windows 进程 - 初学者模块,其中展示了 PEB 结构有一个 BeingDebugged 成员,当进程被调试时,该成员会被设置为 1。一个简单的 IsDebuggerPresent 替代方案就是直接检查 BeingDebugged 的值。
IsDebuggerPresent2 函数在 BeingDebugged 元素为 1 时返回 TRUE。
BOOL IsDebuggerPresent2() {
// 获取 PEB 结构
#ifdef _WIN64
PPEB pPeb = (PEB*)(__readgsqword(0x60)); // x64 下 PEB 在 GS:[0x60]
#elif _WIN32
PPEB pPeb = (PEB*)(__readfsdword(0x30)); // x86 下 PEB 在 FS:[0x30]
#endif
// 检查 'BeingDebugged' 成员
if (pPeb->BeingDebugged == 1)
return TRUE;
return FALSE;
}c3. IsDebuggerPresent 替代方案 (2)#
另一种创建自定义 IsDebuggerPresent 的方法是利用同样位于 PEB 结构中的未公开字段 NtGlobalFlag ↗。如果进程正在被调试,NtGlobalFlag 成员会被设置为 0x70(十六进制),否则为 0。
[!IMPORTANT] 必须注意:只有当进程是由调试器 创建 时,
NtGlobalFlag才会设置为0x70。因此,如果调试器是在程序执行后才“附加”(Attach)上去的,此方法将失效。
值 0x70 是由以下标志位组合而成的:
FLG_HEAP_ENABLE_TAIL_CHECK-0x10FLG_HEAP_ENABLE_FREE_CHECK-0x20FLG_HEAP_VALIDATE_PARAMETERS-0x40
IsDebuggerPresent3 函数在 NtGlobalFlag 元素为 0x70 时返回 TRUE。
#define FLG_HEAP_ENABLE_TAIL_CHECK 0x10
#define FLG_HEAP_ENABLE_FREE_CHECK 0x20
#define FLG_HEAP_VALIDATE_PARAMETERS 0x40
BOOL IsDebuggerPresent3() {
// 获取 PEB 结构
#ifdef _WIN64
PPEB pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32
PPEB pPeb = (PEB*)(__readfsdword(0x30));
#endif
// 检查 'NtGlobalFlag' 成员是否匹配组合标志
if (pPeb->NtGlobalFlag == (FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | FLG_HEAP_VALIDATE_PARAMETERS))
return TRUE;
return FALSE;
}c💡 初学者提示:PEB(进程环境块)为何如此重要?
PEB (Process Environment Block) 是 Windows 系统为每个运行中的程序维护的一张“身份证”和“状态表”:
- 它直接位于内存中。
- 许多底层 API(如
IsDebuggerPresent)其实就是去读取 PEB 表中的某个字段。- 恶意软件开发者的思路: 既然 API 只是读取内存,那我不调用 API,直接自己去读那块内存,这样分析师就很难通过拦截 API 的方式来欺骗我了。
4. 利用 NtQueryInformationProcess 探测调试器#
我们可以利用 NtQueryInformationProcess 系统调用通过两个标志位来探测调试器:ProcessDebugPort 和 ProcessDebugObjectHandle。
回想一下 NtQueryInformationProcess 的定义:
NTSTATUS NtQueryInformationProcess(
IN HANDLE ProcessHandle, // 要检索信息的进程句柄
IN PROCESSINFOCLASS ProcessInformationClass, // 要检索的进程信息类型
OUT PVOID ProcessInformation, // 接收信息的缓冲区指针
IN ULONG ProcessInformationLength, // 缓冲区大小
OUT PULONG ReturnLength // 实际返回的信息大小
);cProcessDebugPort 标志#
微软关于 ProcessDebugPort 标志的文档指出:
检索一个 DWORD_PTR 值,该值是该进程调试器的端口号。非零值表示该进程正在 ring 3 调试器的控制下运行。
换句话说,如果 NtQueryInformationProcess 通过 ProcessInformation 参数返回了一个非零值,则该进程正在被积极调试。
ProcessDebugObjectHandle 标志#
未公开标志 ProcessDebugObjectHandle 与前者类似。它用于获取当前进程的调试对象句柄。如果进程正在被调试,则会创建此句柄。通过 NtQueryInformationProcess 获得的非零值意味着进程正处于活动调试状态。
如果 NtQueryInformationProcess 无法检索到调试对象句柄,意味着它没有探测到调试器,并会返回错误代码 0xC0000353(对应 STATUS_PORT_NOT_SET)。
系统调用探测代码#
NtQIPDebuggerCheck 函数同时使用这两个标志探测调试器。
BOOL NtQIPDebuggerCheck() {
NTSTATUS STATUS = NULL;
fnNtQueryInformationProcess pNtQueryInformationProcess = NULL;
DWORD64 dwIsDebuggerPresent = NULL;
DWORD64 hProcessDebugObject = NULL;
// 从 NTDLL.DLL 获取 NtQueryInformationProcess 地址
pNtQueryInformationProcess = (fnNtQueryInformationProcess)GetProcAddress(GetModuleHandle(TEXT("NTDLL.DLL")), "NtQueryInformationProcess");
if (pNtQueryInformationProcess == NULL) {
printf("\t[!] GetProcAddress 失败,错误码 : %d \n", GetLastError());
return FALSE;
}
// 1. 使用 'ProcessDebugPort' 标志调用
STATUS = pNtQueryInformationProcess(
GetCurrentProcess(),
ProcessDebugPort,
&dwIsDebuggerPresent,
sizeof(DWORD64),
NULL
);
if (STATUS != 0x0) {
printf("\t[!] NtQueryInformationProcess [1] 失败,状态码 : 0x%0.8X \n", STATUS);
return FALSE;
}
// 如果返回非零值,说明调试端口有效,检测到调试器
if (dwIsDebuggerPresent != NULL) {
return TRUE;
}
// 2. 使用 'ProcessDebugObjectHandle' 标志调用
STATUS = pNtQueryInformationProcess(
GetCurrentProcess(),
ProcessDebugObjectHandle,
&hProcessDebugObject,
sizeof(DWORD64),
NULL
);
// 如果状态非零且不是 STATUS_PORT_NOT_SET (0xC0000353)
if (STATUS != 0x0 && STATUS != 0xC0000353) {
printf("\t[!] NtQueryInformationProcess [2] 失败,状态码 : 0x%0.8X \n", STATUS);
return FALSE;
}
// 如果返回非零值,说明调试对象句柄有效,检测到调试器
if (hProcessDebugObject != NULL) {
return TRUE;
}
return FALSE;
}c5. 利用硬件断点 (Hardware Breakpoints) 探测#
硬件断点利用了 CPU 内部的调试寄存器(Dr0 - Dr3)。这是一种由微处理器提供的功能,当触发特定的内存地址或事件时,会暂停进程执行。硬件断点比软件断点更高效。
当设置了硬件断点时,特定的寄存器值会发生变化。如果 Dr0, Dr1, Dr2 和 Dr3 中任何一个包含非零值,就意味着设置了硬件断点。
下图展示了在 xdbg 中对 NtAllocateVirtualMemory 设置硬件断点后,Dr0 寄存器的值从 0 变成了目标地址。

获取寄存器值#
我们可以使用 GetThreadContext WinAPI 来获取 Dr 寄存器的值。回想一下 线程劫持 模块,我们在那里使用它来获取线程的 CONTEXT 结构。该结构中就包含了 Dr0 到 Dr3 的值。
BOOL HardwareBpCheck() {
CONTEXT Ctx = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };
if (!GetThreadContext(GetCurrentThread(), &Ctx)) {
printf("\t[!] GetThreadContext 失败,错误码 : %d \n", GetLastError());
return FALSE;
}
// 只要 Dr0-Dr3 任何一个不是 NULL,就说明探测到了调试器
if (Ctx.Dr0 != NULL || Ctx.Dr1 != NULL || Ctx.Dr2 != NULL || Ctx.Dr3 != NULL)
return TRUE;
return FALSE;
}c6. 进程黑名单检测#
另一种方法是检查当前运行的所有进程名称,将其与硬编码的已知调试器名单进行比对。
我们可以通过 CreateToolhelp32Snapshot 来枚举进程。黑名单数组示例如下:
#define BLACKLISTARRAY_SIZE 5
WCHAR* g_BlackListedDebuggers[BLACKLISTARRAY_SIZE] = {
L"x64dbg.exe", // xdbg 调试器
L"ida.exe", // IDA 反汇编器
L"ida64.exe", // IDA 反汇编器
L"VsDebugConsole.exe", // Visual Studio 调试输出窗口
L"msvsmon.exe" // Visual Studio 远程调试器
};cBlackListedProcessesCheck 函数实现:
BOOL BlackListedProcessesCheck() {
HANDLE hSnapShot = NULL;
PROCESSENTRY32W ProcEntry = { .dwSize = sizeof(PROCESSENTRY32W) };
BOOL bSTATE = FALSE;
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
if (hSnapShot == INVALID_HANDLE_VALUE) {
printf("\t[!] CreateToolhelp32Snapshot 失败,错误码 : %d \n", GetLastError());
goto _EndOfFunction;
}
if (!Process32FirstW(hSnapShot, &ProcEntry)) {
printf("\t[!] Process32FirstW 失败,错误码 : %d \n", GetLastError());
goto _EndOfFunction;
}
do {
// 遍历黑名单对比进程名称
for (int i = 0; i < BLACKLISTARRAY_SIZE; i++){
if (wcscmp(ProcEntry.szExeFile, g_BlackListedDebuggers[i]) == 0) {
wprintf(L"\t[i] 发现 \"%s\" (PID: %d) \n", ProcEntry.szExeFile, ProcEntry.th32ProcessID);
bSTATE = TRUE;
break;
}
}
} while (Process32Next(hSnapShot, &ProcEntry));
_EndOfFunction:
if (hSnapShot != NULL)
CloseHandle(hSnapShot);
return bSTATE;
}c7. 基于时间的检测 (GetTickCount64)#
断点用于暂停程序执行。这种执行暂停可以通过 GetTickCount64 ↗ 探测。该函数返回系统启动后的毫秒数。通过测量两段代码之间的执行时长,可以判断是否存在人为的调试干预。
探测延迟原理#
我们可以计算 T1 - T0 的平均值并硬编码为阈值。如果实际耗时超过此值(例如在主机上通常为 20ms,但在运行时超过了 50ms),则极有可能是因为断点触发导致的延迟。
BOOL TimeTickCheck1() {
DWORD dwTime1 = (DWORD)GetTickCount64();
/* 这里放置要监控的代码段 */
DWORD dwTime2 = (DWORD)GetTickCount64();
printf("\t[i] 耗时 (dwTime2 - dwTime1) : %d 毫秒\n", (dwTime2 - dwTime1));
// 阈值设定为 50 毫秒(根据代码复杂度调整)
if ((dwTime2 - dwTime1) > 50) {
return TRUE;
}
return FALSE;
}c💡 初学者提示:计时检测是如何工作的?
想象一下你在跑 100 米:
- 正常情况下:你跑完只需要 12 秒。
- 调试情况下:当你跑到一半,裁判(分析师)突然喊了“停!”(触发断点),然后他围着你拍照、记录、思考,过了 10 分钟才让你继续跑。
- 检测方法:如果终点计时器显示你花了 10 分 12 秒才跑完 100 米,你就知道中途肯定被干扰了。这就是
GetTickCount64检测调试的基本逻辑。
8. 利用 QueryPerformanceCounter 探测#
QueryPerformanceCounter ↗ 系统接口提供了更高精度的硬件计数器(可达纳秒级),而 GetTickCount64 只有毫秒级精度。
BOOL TimeTickCheck2() {
LARGE_INTEGER Time1 = { 0 }, Time2 = { 0 };
if (!QueryPerformanceCounter(&Time1)) {
return FALSE;
}
/* 这里放置要监控的代码段 */
if (!QueryPerformanceCounter(&Time2)) {
return FALSE;
}
// 这里的单位是计数脉冲,可以根据具体机器性能设定阈值
if ((Time2.QuadPart - Time1.QuadPart) > 100000){
return TRUE;
}
return FALSE;
}c9. 利用 DebugBreak 探测#
DebugBreak ↗ 会引发一个 EXCEPTION_BREAKPOINT 异常。正常情况下,该异常应由调试器处理。我们的技术是主动触发它,并观察是谁处理了它。
使用 __try 和 __except 块:
- 如果捕获到
EXCEPTION_BREAKPOINT,说明没有调试器截获,异常传到了我们自己的异常处理程序中。 - 如果异常未被我们捕获,说明调试器已经“拦截”并处理了它。
BOOL DebugBreakCheck() {
__try {
DebugBreak();
}
__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
// 返回 FALSE 表示异常由我们自己处理,即没有发现调试器
return FALSE;
}
// 如果代码能走到这里,说明我们的 __except 块没生效,调试器接管了它
return TRUE;
}c10. 利用 OutputDebugString 探测#
OutputDebugString ↗ 用于向调试器发送字符串并显示。
如果没有调试器,调用此函数并配合 GetLastError 可以探测其存在。如果 GetLastError 在调用后返回 0(成功),通常意味着字符串被调试器成功接收;如果返回非零值,则意味着没有调试器。
BOOL OutputDebugStringCheck() {
SetLastError(1); // 预设一个非零值
OutputDebugStringW(L"MalDev Academy");
// 如果 GetLastError 为 0,说明函数成功执行并将字符串传给了调试器
if (GetLastError() == 0) {
return TRUE;
}
return FALSE;
}c🎯 总结#
在本模块中,我们深入学习了:
- PEB 内部侦查: 通过直接访问内存读取
BeingDebugged和NtGlobalFlag绕过常见的 API Hook。 - 系统级查询: 利用
NtQueryInformationProcess系统调用进行静默探测。 - 硬件层感知: 通过读取 CPU 调试寄存器以及高精度计时器发现断点痕迹。
- 异常陷阱与交互: 使用
DebugBreak主动引出调试器并通过OutputDebugString测试其反应。
📚 下一步学习
下一个模块将探讨 Module 72 - Anti-Debugging - Self-Deletion。掌握一种优雅且彻底的“自毁”技术。