0%
毅种循环

模块 71 - 反调试技术 - 多种检测技术

恶意软件开发课程 - 反调试技术 - 多种检测技术


模块 71 - 反调试技术 - 多种检测技术#

反调试技术 - 多种检测技术#

简介#

安全研究员和恶意软件分析师通常会使用调试(Debugging)来增强对恶意软件样本的理解。这使他们能够针对这些样本编写更精准的探测规则。作为恶意软件开发者,你应该时刻武装反调试技术,使分析过程对分析师来说更加耗时。

本模块将讨论几种反调试技术。

💡 初学者提示:为什么恶意软件需要“反调试”?

想象一下你是正在潜入敌营的间谍:

  • 调试(Debugging) 就像是敌方的监控摄像头或搜身。分析师会通过调试器观察你走的每一步、检查你口袋里的每一个工具。
  • 反调试(Anti-Debugging) 则是你的反侦察手段。比如:
    • 检查是否有摄像头(检测调试器进程)。
    • 制造各种干扰让摄像头失效(干扰调试器运行)。
    • 如果发现被包围了,就销毁所有线索并撤退(自删除或停止运行)。

反调试的核心目的:

  1. 增加分析成本:让分析师花几倍的时间才能理清程序逻辑。
  2. 躲避自动化沙箱:许多沙箱在发现程序有反调试行为时,可能无法通过动态分析获取有效数据。

1. 利用 IsDebuggerPresent 探测调试器#

最简单的反调试技术之一是使用 IsDebuggerPresent WinAPI。如果调试器已附加到调用进程,该函数返回 TRUE;否则返回 FALSE。以下代码段展示了该函数的使用方法。

if (IsDebuggerPresent()) {
  printf("[i] IsDebuggerPresent 探测到调试器 \n");
  // 执行无害代码或直接退出..
}
c

📚 知识扩展:IsDebuggerPresent 的局限性

虽然 IsDebuggerPresent 很常用,但它在专业的恶意软件分析中作用有限:

  1. 极其容易被 Hook:分析师可以轻松修改它的返回值,让你永远以为没有调试器。
  2. 反反调试工具(Anti-Anti-Debug):像 ScyllaHide 这样的插件会自动拦截这个 API 的调用。
  3. 静态特征明显:扫描导入表(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;
}
c

3. IsDebuggerPresent 替代方案 (2)#

另一种创建自定义 IsDebuggerPresent 的方法是利用同样位于 PEB 结构中的未公开字段 NtGlobalFlag。如果进程正在被调试,NtGlobalFlag 成员会被设置为 0x70(十六进制),否则为 0。

[!IMPORTANT] 必须注意:只有当进程是由调试器 创建 时,NtGlobalFlag 才会设置为 0x70。因此,如果调试器是在程序执行后才“附加”(Attach)上去的,此方法将失效。

0x70 是由以下标志位组合而成的:

  • FLG_HEAP_ENABLE_TAIL_CHECK - 0x10
  • FLG_HEAP_ENABLE_FREE_CHECK - 0x20
  • FLG_HEAP_VALIDATE_PARAMETERS - 0x40

IsDebuggerPresent3 函数在 NtGlobalFlag 元素为 0x70 时返回 TRUE

💡 初学者提示:PEB(进程环境块)为何如此重要?

PEB (Process Environment Block) 是 Windows 系统为每个运行中的程序维护的一张“身份证”和“状态表”:

  • 它直接位于内存中。
  • 许多底层 API(如 IsDebuggerPresent)其实就是去读取 PEB 表中的某个字段。
  • 恶意软件开发者的思路: 既然 API 只是读取内存,那我不调用 API,直接自己去读那块内存,这样分析师就很难通过拦截 API 的方式来欺骗我了。

4. 利用 NtQueryInformationProcess 探测调试器#

我们可以利用 NtQueryInformationProcess 系统调用通过两个标志位来探测调试器:ProcessDebugPortProcessDebugObjectHandle

回想一下 NtQueryInformationProcess 的定义:

NTSTATUS NtQueryInformationProcess(
  IN    HANDLE           ProcessHandle,               // 要检索信息的进程句柄
  IN    PROCESSINFOCLASS ProcessInformationClass,     // 要检索的进程信息类型
  OUT   PVOID            ProcessInformation,          // 接收信息的缓冲区指针
  IN    ULONG            ProcessInformationLength,    // 缓冲区大小
  OUT   PULONG           ReturnLength                 // 实际返回的信息大小
);
c

ProcessDebugPort 标志#

微软关于 ProcessDebugPort 标志的文档指出: 检索一个 DWORD_PTR 值,该值是该进程调试器的端口号。非零值表示该进程正在 ring 3 调试器的控制下运行。

换句话说,如果 NtQueryInformationProcess 通过 ProcessInformation 参数返回了一个非零值,则该进程正在被积极调试。

ProcessDebugObjectHandle 标志#

未公开标志 ProcessDebugObjectHandle 与前者类似。它用于获取当前进程的调试对象句柄。如果进程正在被调试,则会创建此句柄。通过 NtQueryInformationProcess 获得的非零值意味着进程正处于活动调试状态。

如果 NtQueryInformationProcess 无法检索到调试对象句柄,意味着它没有探测到调试器,并会返回错误代码 0xC0000353(对应 STATUS_PORT_NOT_SET)。

系统调用探测代码#

NtQIPDebuggerCheck 函数同时使用这两个标志探测调试器。

5. 利用硬件断点 (Hardware Breakpoints) 探测#

硬件断点利用了 CPU 内部的调试寄存器(Dr0 - Dr3)。这是一种由微处理器提供的功能,当触发特定的内存地址或事件时,会暂停进程执行。硬件断点比软件断点更高效。

当设置了硬件断点时,特定的寄存器值会发生变化。如果 Dr0, Dr1, Dr2Dr3 中任何一个包含非零值,就意味着设置了硬件断点。

下图展示了在 xdbg 中对 NtAllocateVirtualMemory 设置硬件断点后,Dr0 寄存器的值从 0 变成了目标地址。

xdbg 硬件断点演示
xdbg 硬件断点演示

获取寄存器值#

我们可以使用 GetThreadContext WinAPI 来获取 Dr 寄存器的值。回想一下 线程劫持 模块,我们在那里使用它来获取线程的 CONTEXT 结构。该结构中就包含了 Dr0Dr3 的值。

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;
}
c

6. 进程黑名单检测#

另一种方法是检查当前运行的所有进程名称,将其与硬编码的已知调试器名单进行比对。

我们可以通过 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 远程调试器
};
c

BlackListedProcessesCheck 函数实现:

7. 基于时间的检测 (GetTickCount64)#

断点用于暂停程序执行。这种执行暂停可以通过 GetTickCount64 探测。该函数返回系统启动后的毫秒数。通过测量两段代码之间的执行时长,可以判断是否存在人为的调试干预。

探测延迟原理#

我们可以计算 T1 - T0 的平均值并硬编码为阈值。如果实际耗时超过此值(例如在主机上通常为 20ms,但在运行时超过了 50ms),则极有可能是因为断点触发导致的延迟。

💡 初学者提示:计时检测是如何工作的?

想象一下你在跑 100 米:

  • 正常情况下:你跑完只需要 12 秒。
  • 调试情况下:当你跑到一半,裁判(分析师)突然喊了“停!”(触发断点),然后他围着你拍照、记录、思考,过了 10 分钟才让你继续跑。
  • 检测方法:如果终点计时器显示你花了 10 分 12 秒才跑完 100 米,你就知道中途肯定被干扰了。这就是 GetTickCount64 检测调试的基本逻辑。

8. 利用 QueryPerformanceCounter 探测#

QueryPerformanceCounter 系统接口提供了更高精度的硬件计数器(可达纳秒级),而 GetTickCount64 只有毫秒级精度。

9. 利用 DebugBreak 探测#

DebugBreak 会引发一个 EXCEPTION_BREAKPOINT 异常。正常情况下,该异常应由调试器处理。我们的技术是主动触发它,并观察是谁处理了它。

使用 __try__except 块:

  1. 如果捕获到 EXCEPTION_BREAKPOINT,说明没有调试器截获,异常传到了我们自己的异常处理程序中。
  2. 如果异常未被我们捕获,说明调试器已经“拦截”并处理了它。
BOOL DebugBreakCheck() {

	__try {
		DebugBreak();
	}
	__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
		// 返回 FALSE 表示异常由我们自己处理,即没有发现调试器
		return FALSE;
	}
	
	// 如果代码能走到这里,说明我们的 __except 块没生效,调试器接管了它
	return TRUE;
}
c

10. 利用 OutputDebugString 探测#

OutputDebugString 用于向调试器发送字符串并显示。

如果没有调试器,调用此函数并配合 GetLastError 可以探测其存在。如果 GetLastError 在调用后返回 0(成功),通常意味着字符串被调试器成功接收;如果返回非零值,则意味着没有调试器。

BOOL OutputDebugStringCheck() {

	SetLastError(1); // 预设一个非零值
	OutputDebugStringW(L"MalDev Academy");
	
	// 如果 GetLastError 为 0,说明函数成功执行并将字符串传给了调试器
	if (GetLastError() == 0) {
		return TRUE;
	}

	return FALSE;
}
c

🎯 总结#

在本模块中,我们深入学习了:

  1. PEB 内部侦查: 通过直接访问内存读取 BeingDebuggedNtGlobalFlag 绕过常见的 API Hook。
  2. 系统级查询: 利用 NtQueryInformationProcess 系统调用进行静默探测。
  3. 硬件层感知: 通过读取 CPU 调试寄存器以及高精度计时器发现断点痕迹。
  4. 异常陷阱与交互: 使用 DebugBreak 主动引出调试器并通过 OutputDebugString 测试其反应。

📚 下一步学习

下一个模块将探讨 Module 72 - Anti-Debugging - Self-Deletion。掌握一种优雅且彻底的“自毁”技术。