反调试与反沙箱反虚拟机

本文为 Maldev Academy 中的 Module 70 ~ Module 75 小节的笔记,主要讲解常用的反调试技术、可执行文件自删除技术、反沙箱反虚拟机技术、延迟执行技术、API Hammering 技术。(完整学习内容请自行前往跳转链接查看)

作者原文章中已经对技术的讲解及代码的实现进行了很详细的讲解,只需要参考着学习完成即可。本文主要概括总结下相关技术点。

1、反调试技术

反调试技术是恶意软件开发者用来对抗安全研究员的核心防御手段。其主要目的是通过检测调试器的存在,使分析过程变得极其耗时且复杂,从而阻碍研究员提取有效的检测规则。一旦程序察觉到自己正处于受控的分析环境中,它会通过改变执行路径、隐藏真实恶意逻辑或直接终止运行,将原本直接的代码分析变成一场高成本的“猫鼠游戏”,为恶意软件争取更多的生存时间。

以下为一些常见的反调试技术。

1-1、IsDebuggerPresent

IsDebuggerPresent 是最基础且最易实现的 WinAPI 反调试手段,它通过调用 Windows 系统函数直接查询当前进程是否正被调试器(如 x64dbg 或 WinDbg)附加。如果返回值为真(TRUE),程序通常会立即触发防御机制(如退出或跳转至虚假代码),从而迫使分析人员必须手动修改内存或拦截 API 调用才能继续分析。

1
2
3
4
if (IsDebuggerPresent()) {
  printf("[i] IsDebuggerPresent detected a debugger \n");
  // Run harmless code..
}

1-2、自定义 IsDebuggerPresent(1)

由于直接调用 IsDebuggerPresent 极易被 ScyllaHide 等工具自动拦截并绕过,更高级的防御策略是实现其自定义版本。该技术避开了可疑的 API 调用,转而直接访问内存中的进程环境块 (PEB) 结构。通过定位 PEB 中的 BeingDebugged 标志位(值为 1 表示处于调试状态),程序可以在不触发任何系统 API 监控的情况下隐蔽地检测调试器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
BOOL IsDebuggerPresent1() {

  // getting the PEB structure
#ifdef _WIN64
	PPEB					pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32
	PPEB					pPeb = (PEB*)(__readfsdword(0x30));
#endif

  // checking the 'BeingDebugged' element
  if (pPeb->BeingDebugged == 1) 
    return TRUE;
	
   return FALSE;
}

ScyllaHide 工具十分好用,建议在 OllyDbg 中安装该插件。本文介绍的大部分反调试函数都可使用该插件进行绕过。

1-3、自定义 IsDebuggerPresent(2)

另一种更隐蔽的自定义检测方案是利用 PEB 结构中未公开的 NtGlobalFlag 字段。当进程由调试器直接启动时,系统会自动为该进程启用特定的堆调试标志,导致 NtGlobalFlag 的值被设为 0x700x70 由以下三者组合而成。

  • FLG_HEAP_ENABLE_TAIL_CHECK - 0x10
  • FLG_HEAP_ENABLE_FREE_CHECK - 0x20
  • FLG_HEAP_VALIDATE_PARAMETERS - 0x40
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// https://www.aldeid.com/wiki/PEB-Process-Environment-Block/NtGlobalFlag
// https://www.geoffchappell.com/studies/windows/win32/ntdll/api/rtl/regutil/getntglobalflags.htm
#define FLG_HEAP_ENABLE_TAIL_CHECK   0x10
#define FLG_HEAP_ENABLE_FREE_CHECK   0x20
#define FLG_HEAP_VALIDATE_PARAMETERS 0x40

BOOL IsDebuggerPresent2() {

  // getting the PEB structure
#ifdef _WIN64
	PPEB					pPeb = (PEB*)(__readgsqword(0x60));
#elif _WIN32
	PPEB					pPeb = (PEB*)(__readfsdword(0x30));
#endif

  // checking the 'NtGlobalFlag' element
  if (pPeb->NtGlobalFlag == (FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | FLG_HEAP_VALIDATE_PARAMETERS))
    return TRUE;
  
  return FALSE;
}

1-4、NtQueryInformationProcess

NtQueryInformationProcess 系统调用,通过查询进程的内核信息来检测调试器的存在,主要涉及以下两个关键 FLAG 标志位:

  • ProcessDebugPort: 根据微软官方说明,该标志位用于检索调试器的端口号。如果返回的 ProcessInformation非零值,则明确表示进程正处于 Ring 3 调试器的控制之下。
  • ProcessDebugObjectHandle: 这是一个未公开的标志位。当进程被调试时,系统会为其创建一个“调试对象句柄”。如果该系统调用成功获取到句柄(非零值),则判定存在调试;若返回错误代码 0xC0000353(即 STATUS_PORT_NOT_SET),则表示未探测到调试器。
 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
//---------------------------------------------------------------------------------------
// https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess

typedef NTSTATUS(WINAPI *fnNtQueryInformationProcess)(
	HANDLE           ProcessHandle,
	PROCESSINFOCLASS ProcessInformationClass,
	PVOID            ProcessInformation,
	ULONG            ProcessInformationLength,
	PULONG           ReturnLength
	);
// NtQueryInformationProcess

BOOL NtQIPDebuggerCheck() {

	NTSTATUS STATUS = NULL;
	fnNtQueryInformationProcess pNtQueryInformationProcess = NULL;
	DWORD64 dwIsDebuggerPresent = NULL;
	DWORD64 hProcessDebugObject = NULL;

	// getting NtQueryInformationProcess address
	pNtQueryInformationProcess = (fnNtQueryInformationProcess)GetProcAddress(GetModuleHandle(TEXT("NTDLL.DLL")), "NtQueryInformationProcess");
	if (pNtQueryInformationProcess == NULL) {
		printf("\n\t[!] GetProcAddress Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// (1) calling NtQueryInformationProcess with the 'ProcessDebugPort' flag
	STATUS = pNtQueryInformationProcess(GetCurrentProcess(), ProcessDebugPort, &dwIsDebuggerPresent, sizeof(DWORD64), NULL);
	
	// If STATUS is not 0
	if (STATUS != 0x0) {
		printf("\t[!] NtQueryInformationProcess [1] Failed With Status : 0x%0.8X \n", STATUS);
		return FALSE;
	}

	// If NtQueryInformationProcess returned a non-zero value, the handle is valid, which means we are being debugged
	if (dwIsDebuggerPresent != NULL) {
		// detected a debugger
		return TRUE;
	}

	// (2) calling NtQueryInformationProcess with the 'ProcessDebugObjectHandle' flag
	STATUS = pNtQueryInformationProcess(GetCurrentProcess(), ProcessDebugObjectHandle, &hProcessDebugObject, sizeof(DWORD64), NULL);
	
	// If STATUS is not 0 and not 0xC0000353 (that is 'STATUS_PORT_NOT_SET')
	if (STATUS != 0x0 && STATUS != 0xC0000353) {
		printf("\t[!] NtQueryInformationProcess [2] Failed With Status : 0x%0.8X \n", STATUS);
		return FALSE;
	}

	// If NtQueryInformationProcess returned a non-zero value, the handle is valid, which means we are being debugged
	if (hProcessDebugObject != NULL) {
		// detected a debugger
		return TRUE;
	}

	return FALSE;
}

1-5、Hardware Breakpoints

硬件断点(Hardware Breakpoints) 是利用 CPU 内部的调试寄存器(Debug Registers)实现的一种高效断点机制。与修改代码段(如 INT 3)的普通软件断点不同,硬件断点直接受处理器支持。

在现代处理器中,Dr0、Dr1、Dr2 和 Dr3 这四个寄存器专门用于存储硬件断点的内存地址。

  • 正常运行: 这些寄存器的值通常为 0。
  • 调试状态: 如果分析员在 x64dbg 等工具中设置了硬件断点(例如监控 NtAllocateVirtualMemory 函数),相应的 Dr 寄存器就会变为该函数的内存地址。

程序可以通过调用 GetThreadContext WinAPI 来获取当前线程的上下文(CONTEXT 结构体)。该结构体中包含了所有调试寄存器的实时数值。HardwareBpCheck 函数只需检查 Dr0 到 Dr3 中是否存在非零值,即可判定分析员是否正在利用硬件加速手段监控特定内存地址或函数调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
BOOL HardwareBpCheck() {

	CONTEXT Ctx = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };

	if (!GetThreadContext(GetCurrentThread(), &Ctx)) {
		printf("\t[!] GetThreadContext Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	if (Ctx.Dr0 != NULL || Ctx.Dr1 != NULL || Ctx.Dr2 != NULL || Ctx.Dr3 != NULL) {
		return TRUE;
	}
	
	return FALSE;
}

1-6、BlackListed Arrays

黑名单进程检测 (BlackListed Arrays) ,是一种基于特征标识的反调试手段。其核心逻辑是将系统中当前运行的进程名称与一个预先定义的已知调试器列表(Blacklist)进行比对。

检测机制

  • 进程枚举: 程序利用 CreateToolhelp32Snapshot 等技术获取系统当前运行的所有进程快照。
  • 比对匹配: 遍历快照中的每个进程名(如 x64dbg.exeWireshark.exeOllyDbg.exe 等),并与内置的 g_BlackListedDebuggers 数组进行匹配。
  • 防御触发: 一旦匹配成功,BlackListedProcessesCheck 函数将返回 TRUE,表明环境中存在威胁。
 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
#define BLACKLISTARRAY_SIZE 5

WCHAR *g_BlackListedDebuggers[BLACKLISTARRAY_SIZE] = {
	L"x64dbg.exe",
	L"ida.exe",
	L"ida64.exe",
	L"VsDebugConsole.exe",
	L"msvsmon.exe"
};

BOOL BlackListedProcessesCheck() {

	BOOL bSTATUS = FALSE;
	HANDLE hSnapShot = NULL;
	PROCESSENTRY32W ProcEntry = { .dwSize = sizeof(PROCESSENTRY32W)};
	
	hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
	if (hSnapShot == INVALID_HANDLE_VALUE) {
		printf("[!] CreateToolhelp32Snapshot Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}

	if (!Process32First(hSnapShot, &ProcEntry)) {
		printf("[!] Process32First Failed With Error : %d \n", GetLastError());
		goto _EndOfFunction;
	}
	do {

		for (int i = 0; i < BLACKLISTARRAY_SIZE; i++) {
			if (wcscmp(ProcEntry.szExeFile, g_BlackListedDebuggers[i]) == 0) {
				wprintf(L" [i] Found \"%s\" Of Pid : %d  ", ProcEntry.szExeFile, ProcEntry.th32ProcessID);
				bSTATUS = TRUE;
				break;   // breaking from the for loop
			}
		}

		if (bSTATUS) {
			break;  // breaking from the do-while loop
		}

	} while (Process32Next(hSnapShot, &ProcEntry));

_EndOfFunction:
	if (hSnapShot != NULL)
		CloseHandle(hSnapShot);
	return bSTATUS;
}

1-7、GetTickCount64

GetTickCount64 时间差检测,是一种利用调试行为会导致执行延迟的特性来发现调试器的技术。其核心逻辑在于:当分析员设置断点(Breakpoint)并停下来检查内存或寄存器时,代码执行的真实时间会远远超过正常运行时间。

检测机制

  • 记录时间戳: 程序在关键代码段的起始处调用 GetTickCount64(记录自系统启动以来经过的毫秒数)存为 $T_0$。
  • 执行代码: 运行一段预设的代码逻辑。
  • 再次记录: 在结束处再次调用获取 $T_1$。
  • 阈值比对: 计算差值 $\Delta T = T_1 - T_0$。如果 $\Delta T$ 超过了预设的硬编码阈值(例如正常执行仅需 50 毫秒,实际却消耗了数秒),程序则判定存在人工干预或断点调试。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
BOOL TimeTickCheck1(){

	DWORD dwTime1 = NULL;
	DWORD dwTime2 = NULL;

	dwTime1 = GetTickCount64();

	/*   
       Program Code
	*/

	dwTime2 = GetTickCount64();

	printf("\t[i] (dwTime2 - dwTime1) : %d  ", (dwTime2 - dwTime1));

	// 5 ms
	if ((dwTime2 - dwTime1) > 5) {
		return TRUE;
	}

	return FALSE;
}

1-8、QueryPerformanceCounter

QueryPerformanceCounter (QPC)GetTickCount64 的进阶版,用于实现更严苛的时间差分析。两者的核心逻辑相同,但 QPC 提供了更高的检测精度。

技术特性与差异:

  • 高分辨率: 与以毫秒(Millisecond)为单位的 GetTickCount64 不同,QPC 利用硬件提供的高精度计数器,其精度可达 纳秒(Nanosecond) 级别。
  • 度量单位: 该 API 返回的是硬件计数器的“滴答”数(Counts),而非直接的时间单位。

检测机制

程序通过在一段代码执行前后分别记录 Time1.QuadPartTime2.QuadPart,并计算两者的差值。如果差值超过了预设的阈值(例如示例中的 100,000 counts),则判定执行过程中存在调试断点或单步操作造成的异常延迟。

LARGE_INTEGER 结构体:

1
2
3
4
5
6
7
8
9
typedef union _LARGE_INTEGER {
    struct {
        DWORD LowPart;  // 低 32 位
        LONG HighPart;  // 高 32 位
    } u;
    LONGLONG QuadPart;  // 整个 64 位整数
} LARGE_INTEGER;

// Quad(Quad Word,四字)。一般指 4 个字 (4 Words),即 8 Bytes,即 64 bits。

完整示例:

 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
BOOL TimeTickCheck2() {

	LARGE_INTEGER Time1 = { 0 };
	LARGE_INTEGER Time2 = { 0 };

	if (!QueryPerformanceCounter(&Time1)) {
		printf("\t[!] QueryPerformanceCounter [1] Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	/*   
     Program Code
	*/

	if (!QueryPerformanceCounter(&Time2)) {
		printf("\t[!] QueryPerformanceCounter [1] Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	printf("\t[i] (Time2.QuadPart - Time1.QuadPart) : %d  ", (Time2.QuadPart - Time1.QuadPart));

	// 5,000,000 ns = 5,000 us = 5 ms
	if ((Time2.QuadPart - Time1.QuadPart) > 5000000) {
		return TRUE;
	}

	return FALSE;
}

1-9、DebugBreak

DebugBreak 异常探测法,是一种通过“主动触发异常”来探测调试器存在的技术。它调用 DebugBreak WinAPI 在当前进程中强制抛出一个 EXCEPTION_BREAKPOINT(通常对应 INT 3 指令)异常。

该技术利用结构化异常处理(__try / __except)机制来观察异常的“去向”:

  • 无调试器(安全运行)场景:当异常发生时,程序的 __except 块会捕获到该异常。如果捕获到的错误码正是 EXCEPTION_BREAKPOINT,则执行 EXCEPTION_EXECUTE_HANDLER。这证明异常是由程序自身的异常处理器处理的,说明没有调试器介入。
  • 存在调试器(被监控)场景: 如果调试器正在运行,它会优先抢占并处理这个断点异常。此时程序的 __try 块将无法捕获到预期的错误码。程序会执行 EXCEPTION_CONTINUE_SEARCH,将处理权交给调试器。如果程序逻辑发现异常“丢失”了,即可判定存在调试器

(1)try…except 语句

C语言标准(ANSI C/C99/C11)中,没有 try...except 这种内置的结构化异常处理机制。

代码中的__try__except ,是微软为了 Windows 操作系统专门开发的编译器扩展

(2)代码逻辑判断

__try 中通过 DebugBreak(),人为触发一个断点异常。

__except 括号里的表达式并不是在比较“异常码是否等于某个执行状态”,而是在根据异常码决定如何处理异常

  • EXCEPTION_BREAKPOINT: 这是一个异常错误码(十六进制通常是 0x80000003),代表程序遇到了断点异常。

  • EXCEPTION_EXECUTE_HANDLER (1): 这是一个控制指令。告诉系统:“我已经识别了这个异常,请执行我的 __except 代码块”。

    EXCEPTION_CONTINUE_SEARCH (0): 这是一个控制指令。告诉系统:“我不认识这个异常,请继续往上找处理程序(或者交给调试器)”。

(3)为什么能检测调试器?

  • 如果没有调试器: DebugBreak() 产生一个 EXCEPTION_BREAKPOINT 断点异常。因为没有调试器拦截,它直接传给程序的 __try/__except。过滤器判断异常码确实是 EXCEPTION_BREAKPOINT,于是返回 EXCEPTION_EXECUTE_HANDLER。程序进入 __except 块,执行 return FALSE
  • 如果有调试器: 调试器会优先拦截断点异常。调试器通常会消耗掉这个异常,不让它传回给程序的 SEH(结构化异常处理)链。因此,程序里的 __except 可能根本不会被触发,或者逻辑执行路径发生偏移,最终导致 return TRUE

(4)关于 EXCEPTION_CONTINUE_SEARCH

代码在 __try 块里只做了一件事:执行 DebugBreak()DebugBreak() 只会产生 EXCEPTION_BREAKPOINT 异常。既然只可能产生这一种异常,那么 GetExceptionCode() == EXCEPTION_BREAKPOINT 几乎永远成立。所以程序逻辑永远在 EXCEPTION_EXECUTE_HANDLER(处理异常)上跑。

虽然逻辑上“用不上”,但如果不写,会存在安全隐患:处理“意外”异常: 假设代码 __try 块里代码很复杂,万一发生了“内存访问违规”(EXCEPTION_ACCESS_VIOLATION),而代码没有判断异常类型,直接返回 EXCEPTION_EXECUTE_HANDLER,程序就会强行吞掉这个错误并返回 FALSE。这会导致程序在已经出错的情况下继续运行,可能引发不可预知的崩溃。 SEH 链的规则: EXCEPTION_CONTINUE_SEARCH 的真正作用是告诉操作系统:“这个异常不是我引起的,或者是某种我处理不了的严重错误,请交给其他的异常处理器(或者系统默认的‘程序已停止工作’窗口)”。

检测代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
BOOL DebugBreakCheck() {

	__try {
		DebugBreak();  // 人为触发一个断点异常
	}
	// 如果捕获到的异常码 GetExceptionCode() 是 EXCEPTION_BREAKPOINT (断点异常),
	// 则返回 EXCEPTION_EXECUTE_HANDLER,表示“我要处理这个异常”,从而进入下面大括号执行
	__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
		// 如果进入了这里,说明异常成功被程序捕获,说明没有调试器拦截它。
		// 因此返回 FALSE,表示“未检测到调试器”。
		return FALSE;
	}

	// 如果没有进入上面的 __except 块(例如被调试器优先处理并跳过了)
	// 则代码会继续向下执行,返回 TRUE,表示“检测到调试器”
	return TRUE;

}

1-10、OutputDebugString

OutputDebugString 是一种利用系统日志传输机制进行探测的隐蔽手段。该 WinAPI 的本职工作是将字符串发送给调试器进行显示。开发者可以利用它的执行结果来反向推断调试器的存在。

该技术的核心在于观察 OutputDebugString 执行后对系统错误代码(Last Error)的影响:

  • 环境准备: 程序首先调用 SetLastError(1),将错误状态预设为一个非零值。

  • 执行发送: 调用 OutputDebugStringW 发送一段随机字符串。

  • 状态校验: 紧接着通过 GetLastError() 获取结果。

    • 存在调试器: 函数执行成功,系统会将错误代码重置为 0 (ERROR_SUCCESS)

    • 不存在调试器: 字符串发送失败或无处接收,错误代码将保持为非零值。

现代调试器可能已经对这种老旧的检测方式免疫了。

检测代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
BOOL OutputDebugStringCheck() {

	SetLastError(1);  // 将最后一个错误值设置为1。为了确保在调用`OutputDebugString`之前 Error 是一个非零值,以减少误报。

	OutputDebugStringW(L"MalDev Academy");

	if (GetLastError() == 0) {
		return TRUE;
	}

	return FALSE;

}

2、自删除技术

自删除(Self-Deletion) 是一种非常经典且实用的“反取证”和“反调试”技术。其核心目标是在恶意程序执行完任务或检测到分析环境(如虚拟机、调试器)时,自动从磁盘上彻底抹除自身痕迹。

该部分之前已通过完整的一篇博客文章进行了讲解。请自行查看

3、反沙箱反虚拟机技术

虚拟化环境用于隔离调试和分析恶意软件样本的过程,使其比在真实网络中分析恶意软件更安全。沙盒也被视为虚拟环境,尽管它们不允许恶意软件分析师完全访问操作系统,而完整的虚拟环境则可以,比如 VMware 和 VirtualBox。

在虚拟环境中执行恶意代码也必须避免,因为它允许恶意软件分析师剖析代码并为它编写检测规则。

以下为一些常见的反沙箱反虚拟机技术。

3-1、Hardware Specs

反虚拟化是恶意软件用来识别自身是否运行在沙箱(Sandbox)或虚拟机(VM)中的策略。由于虚拟环境通常只分配有限的系统资源,通过检查硬件配置的“贫瘠”程度,可以有效识别非真实的宿主环境。

由于虚拟化环境通常不会模拟完整的硬件细节,该技术主要针对以下三个维度进行验证:

  • CPU 核心数: 检查处理器数量是否少于 2 个。大多数物理机即便配置较低,通常也会拥有多核心,而沙箱环境为了节省资源,往往只分配 1 个 CPU。
  • 内存 (RAM) 大小: 检查内存是否小于 2 GB。现代物理机几乎都拥有更大的内存,极小的内存容量是虚拟机环境的典型特征。
  • USB 挂载存储设备记录: 检查系统中历史挂载过的 USB 存储设备数量是否少于 2 个。真实的个人电脑通常有频繁使用 USB 存储设备的记录,而新创建的虚拟分析环境此类记录往往接近于零。

3-1-1、CPU Check

使用 WinAPI 中的 GetSystemInfo 函数可以进行CPU检查。此函数返回一个 SYSTEM_INFO 结构,其中包含有关系统的信息,包括处理器数量。

1
2
3
4
5
6
SYSTEM_INFO   SysInfo   = { 0 };
	
GetSystemInfo(&SysInfo);
if (SysInfo.dwNumberOfProcessors < 2){
  // possibly a virtualized environment
}

3-1-2、RAM Check

检查 RAM 存储可以通过 GlobalMemoryStatusEx WinAPI完成。此函数返回一个包含有关系统物理和虚拟内存当前状态的 MEMORYSTATUSEX 结构。RAM 存储可以通过ullTotalPhys成员找到。它包含当前物理内存的字节数。

“ullTotalPhys"是一个DWORDLONG(64位无符号整数),可以表示超过4GB的内存。

1
2
3
4
5
6
7
8
9
MEMORYSTATUSEX MemStatus = { .dwLength = sizeof(MEMORYSTATUSEX) };
  
if (!GlobalMemoryStatusEx(&MemStatus)) {
    printf("\n\t[!] GlobalMemoryStatusEx Failed With Error : %d \n", GetLastError());
}
  
if (MemStatus.ullTotalPhys < (2ULL * 1024 * 1024 * 1024)) {
    // Possibly a virtualized environment
}

注意,2ULL * 1024 * 1024 * 1024 是 2 GB 的字节大小。

注意,不能直接MemStatus.ullTotalPhys < (DWORDLONG)(2 * 1024 * 1024 * 1024)。因为将结果强制转换为 DWORDLONG,括号内的计算 (2 * 1024 * 1024 * 1024) 依然是基于 32 位整数(int) 进行的,会导致 正溢出。为了确保编译器从一开始就按照 64 位无符号整数来处理计算,必须在数字后面加上 ULL (Unsigned Long Long) 后缀。

3-1-3、Previously Mounted USBs Check

key,sub-key 可以理解为文件夹与子文件,如:HKEY_CURRENT_USER 或 Control Panel

Value(String Value, Binary Value, etc.):存放数据的名称(如MalDevAcademy)
Value Data:存放的具体数据内容

Name(Value)       Type            Data(Value Data)
MalDevAcademy     REG_BINARY      ...

(RegOpenKeyExA)、(RegSetValueExA)、(RegCloseKey)、(RegGetValueA)

每当一个新的 USB 存储设备(如 U 盘、SD 卡读卡器、移动硬盘)插入电脑时,Windows 驱动程序管理器会在 HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Enum\USBSTOR 下创建一个以设备硬件 ID 命名的子键。

dwUsbNumber 存储的是: 注册表路径 USBSTOR 下级子键(Subkeys)的总数。

 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
HKEY hKey = NULL;
DWORD dwUsbNumber = NULL;
DWORD dwOpenRegErr = NULL;
DWORD dwQueryRegErr = NULL;
  
dwOpenRegErr = RegOpenKeyExA(HKEY_LOCAL_MACHINE, "SYSTEM\\ControlSet001\\Enum\\USBSTOR", NULL, KEY_READ, &hKey);
	
if (dwOpenRegErr == ERROR_FILE_NOT_FOUND) {
    // There are no keys, so it's highly likely to be a pure sandbox.
}

if (dwOpenRegErr != ERROR_SUCCESS) {
    printf("\n\t[!] RegOpenKeyExA Failed With Error : %d | 0x%0.8X \n", dwOpenRegErr, dwOpenRegErr);
}

dwQueryRegErr = RegQueryInfoKeyA(hKey, NULL, NULL, NULL, &dwUsbNumber, NULL, NULL, NULL, NULL, NULL, NULL, NULL);

if (dwQueryRegErr == ERROR_SUCCESS) {
    // less than 2 usb's ever mounted
    if (dwUsbNumber < 2) {
        // possibly a virtualized environment
    }
    RegCloseKey(hKey);
} else {
    printf("\n\t[!] RegQueryInfoKeyA Failed With Error : %d | 0x%0.8X \n", dwQueryRegErr, dwQueryRegErr);
    }

最终,硬件规格检测完整函数代码:

 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
BOOL IsVenvByHardwareCheck() {

	SYSTEM_INFO SysInfo = { 0 };
	MEMORYSTATUSEX MemStatus = { .dwLength = sizeof(MEMORYSTATUSEX) };
	HKEY hKey = NULL;
	DWORD dwUsbNumber = NULL;
	DWORD dwOpenRegErr = NULL;
	DWORD dwQueryRegErr = NULL;

  // CPU CHECK
	GetSystemInfo(&SysInfo);
	// less than 2 processors
	if (SysInfo.dwNumberOfProcessors< 2) {
		return TRUE;
	}
 
  // MEMORY CHECK
	if (!GlobalMemoryStatusEx(&MemStatus)) {
		printf("\t [!] GlobalMemoryStatusEx Failed With Error : %d \n", GetLastError());
		return FALSE;
	}
	// less than 2GB of ram
	if (MemStatus.ullTotalPhys < (2ULL * 1024 * 1024 * 1024)) {
		return TRUE;
	}
 
  // NUMBER OF USB's MOUNTED
	dwOpenRegErr = RegOpenKeyExA(HKEY_LOCAL_MACHINE, "SYSTEM\\ControlSet001\\Enum\\USBSTOR", NULL, KEY_READ, &hKey);
	
	if (dwOpenRegErr == ERROR_FILE_NOT_FOUND) {
		// There are no keys, so it's highly likely to be a pure sandbox.
		return TRUE;
	}

	if (dwOpenRegErr != ERROR_SUCCESS) {
		printf("\n\t[!] RegOpenKeyExA Failed With Error : %d | 0x%0.8X \n", dwOpenRegErr, dwOpenRegErr);
		return FALSE;
	}

	dwQueryRegErr = RegQueryInfoKeyA(hKey, NULL, NULL, NULL, &dwUsbNumber, NULL, NULL, NULL, NULL, NULL, NULL, NULL);

	if (dwQueryRegErr == ERROR_SUCCESS) {
		// less than 2 usb's ever mounted
		if (dwUsbNumber < 2) {
			return TRUE;
		}
		RegCloseKey(hKey);
	} else {
		printf("\n\t[!] RegQueryInfoKeyA Failed With Error : %d | 0x%0.8X \n", dwQueryRegErr, dwQueryRegErr);
		return FALSE;
		}
		
	return FALSE;

}

3-2、Machine Resolution

屏幕分辨率检测 (Machine Resolution),是一种利用沙箱环境配置惯性进行防御的技术。在自动化沙箱或虚拟机中,为了节省资源或保持兼容性,屏幕分辨率往往被设置为固定的、较低的标准值(如 800 * 6001024 * 768),这与现代物理机多样化且通常较高的分辨率设置有显著差异。

检测机制

  • 设备枚举:从编程角度看,程序首先调用 EnumDisplayMonitors WinAPI 来枚举系统中所有的显示器。
  • 属性获取:针对每一个枚举到的显示器,获取其具体的显示属性和分辨率参数。
  • 风险判定:如果检测到主显示器的分辨率异常低,或者显示属性表现出明显的“标准化”特征,程序就会将其视为虚拟化分析环境的一个强有力指标。

EnumDisplayMonitors 函数原型:

1
2
3
4
5
6
BOOL EnumDisplayMonitors(
  [in] HDC             hdc,      // 设备上下文句柄(通常设为 NULL)
  [in] LPCRECT         lprcClip, // 裁剪区域(通常设为 NULL)
  [in] MONITORENUMPROC lpfnEnum, // 回调函数(每找到一个显示器就调用一次)
  [in] LPARAM          dwData    // 传递给回调函数的自定义数据
);
  • hdc

    • HDC 的全称是 Handle to a Device Context,译为“设备上下文句柄”。

    • 如果设为 NULL,函数会枚举系统中的所有显示器;如果指定了一个具体的 HDC,它只枚举与该 HDC 相关的显示器。

  • lprcClip:

    • 一个矩形区域(RECT)。如果 hdc 为 NULL,这个矩形使用虚拟屏幕坐标。

    • 通常设为 NULL,表示不进行区域过滤。

  • lpfnEnum:

    • 最重要的参数。这是一个指向回调函数的指针。Windows 每找到一个符合条件的显示器,就会调用自定义的这个回调函数。
  • dwData:

    • 用户自定义值。可以把一个结构体指针或类实例传进去,方便在回调函数中使用。

EnumDisplayMonitors 函数需要为每个检测到的显示监视器执行一个回调函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
BOOL CALLBACK MyMonitorEnumProc(
  HMONITOR hMonitor,  // 显示器句柄
  HDC      hdcMonitor, // 对应的设备上下文(通常用于绘图)
  LPRECT   lprcMonitor, // 显示器在虚拟屏幕上的坐标矩形
  LPARAM   dwData      // 你从 EnumDisplayMonitors 传进来的数据
) {
    // 在这里处理每个显示器的信息
    // ...
    return TRUE; // 返回 TRUE 继续枚举下一个,返回 FALSE 则停止
}

触发回调:

  • 发现显示器 A –> 调用自定义的回调函数 MyMonitorEnumProc
  • 发现显示器 B –> 再次调用自定义的回调函数 MyMonitorEnumProc

在这个回调函数中,必须调用 GetMonitorInfoW WinAPI。此函数检索显示监视器的分辨率。通过 GetMonitorInfoW 返回的信息以 MONITORINFO 结构形式获取,如下所示。

1
2
3
4
5
6
typedef struct tagMONITORINFO {
  DWORD cbSize;			// The size of the structure
  RECT  rcMonitor;		// 显示器矩形。该显示器在虚拟屏幕坐标系中的完整矩形区域
  RECT  rcWork;			  // 工作区矩形。排除掉 任务栏(Taskbar)和停靠窗口后的可用区域。
  DWORD dwFlags;		    // 标志位。MONITORINFOF_PRIMARY 为 1,表示这个显示器是系统的主显示器。
} MONITORINFO, *LPMONITORINFO;

rcMonitor成员包含所需的信息。此成员也是一个类型为 RECT 的结构,通过其左上角和右下角的 X 和 Y 坐标定义一个矩形。

在获取RECT结构体的值后,进行一些计算以确定显示的实际坐标(减法运算):

  • MONITORINFO.rcMonitor.right - MONITORINFO.rcMonitor.left - 这给出了宽度(X值)
  • MONITORINFO.rcMonitor.top - MONITORINFO.rcMonitor.bottom - 这给出了高度(Y值)

回调函数基本使用示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
BOOL CALLBACK MyMonitorEnumProc(HMONITOR hMonitor, HDC hdc, LPRECT lprc, LPARAM pData) {
    MONITORINFO mi;
    mi.cbSize = sizeof(MONITORINFO); // 必须初始化大小!

    if (GetMonitorInfoW(hMonitor, &mi)) {
        // 检查是否为主显示器
        bool isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY);
        
        // 打印分辨率
        int width = mi.rcMonitor.right - mi.rcMonitor.left;
        int height = mi.rcMonitor.bottom - mi.rcMonitor.top;
        
        printf("显示器范围: (%d, %d) - (%d, %d)\n", 
                mi.rcMonitor.left, mi.rcMonitor.top, width, height);
    }
    return TRUE;
}

屏幕分辨率检测完整函数代码:

 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
// the callback function called whenever 'EnumDisplayMonitors' detects an display
BOOL ResolutionCallback(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lpRect, LPARAM ldata) {

	int X = 0;
	int Y = 0;
	MONITORINFO MI = { .cbSize = sizeof(MONITORINFO) };

	if (!GetMonitorInfoW(hMonitor, &MI)) {
		printf("[!] GetMonitorInfoW Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// calculating the X coordinates of the Desplay
	X = MI.rcMonitor.right - MI.rcMonitor.left;

	// calculating the Y coordinates of the Desplay
	Y = MI.rcMonitor.top - MI.rcMonitor.bottom;

	// if numbers are in negative value, reverse them
	if (X < 0)
		X = -X;
	if (Y < 0)
		Y = -Y;

	/*
	if not :
		- 1920x1080	- 1920x1200	- 1920x1600	- 1920x900
		- 2560x1080	- 2560x1200	- 2560x1600	- 1920x900
		- 1440x1080	- 1440x1200	- 1440x1600	- 1920x900
	*/
	if ((X != 1920 && X != 2560 && X != 1440) || (Y != 1080 && Y != 1200 && Y != 1600 && Y != 900)){
		*((BOOL*)ldata) = TRUE;
	}

	return TRUE;
}


BOOL CheckMachineResolution() {

	BOOL SANDBOX = FALSE;

	EnumDisplayMonitors(NULL, NULL, (MONITORENUMPROC)ResolutionCallback, (LPARAM)&SANDBOX);

	return SANDBOX;
}

3-3、File Name

文件名命名特征检测 (File Name Check),是一种基于沙箱自动化管理逻辑的防御手段。安全沙箱在处理大量恶意软件样本时,通常会通过重命名文件(例如将其改为样本的 MD5SHA256 哈希值)来进行分类和索引。这种自动化的重命名过程往往会产生包含大量随机数字和字母组合的异常文件名。

检测机制

  • 获取完整路径: 调用 GetModuleFileNameA 获取当前运行程序的完整路径。
  • 提取文件名: 使用 PathFindFileNameA 从路径字符串中剥离出单纯的文件名(如 a1b2c3d4.exe)。
  • 统计数字: 利用 isdigit 函数遍历文件名中的每一个字符。
  • 阈值判定: 如果文件名中包含的数字数量超过 3 个ExeDigitsInNameCheck 函数就会认为该文件已被沙箱重命名,从而判定当前环境为分析环境并返回 TRUE

PathFindFileNameA 函数需要使用 Shlwapi.h 库。

检测代码:

 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
#include <Shlwapi.h>
#pragma comment(lib, "Shlwapi.lib")

BOOL ExeDigitsInNameCheck() {

	CHAR	Path			[MAX_PATH * 3];
	CHAR	cName			[MAX_PATH];
	DWORD   dwNumberOfDigits	= NULL;

	// Getting the current filename (with the full path)
	if (!GetModuleFileNameA(NULL, Path, MAX_PATH * 3)) {
		printf("\n\t[!] GetModuleFileNameA Failed With Error : %d \n", GetLastError());
		return FALSE;
	}
	
	// Prevent a buffer overflow - getting the filename from the full path
	if (lstrlenA(PathFindFileNameA(Path)) < MAX_PATH)
		lstrcpyA(cName, PathFindFileNameA(Path));

	// Counting number of digits
	for (int i = 0; i < lstrlenA(cName); i++){
		if (isdigit(cName[i]))
			dwNumberOfDigits++;
	}

	// Max digits allowed: 3 
	if (dwNumberOfDigits > 3){
		return TRUE;
	}

	return FALSE;
}

在 C/C++ 开发中,仅仅使用 #include <Shlwapi.h> 是不够的,添加 #pragma comment(lib, "Shlwapi.lib") 的原因在于 编译过程的两个不同阶段:编译(Compiling)与链接(Linking)。

  • #include <Shlwapi.h> 编译阶段,头文件里只有函数的声明(Declaration)。
  • #pragma comment(lib, "Shlwapi.lib") 链接阶段,库文件中包含函数的实现(Implementation)或导出信息。编译器把代码变成二进制目标文件(.obj)后,链接器(Linker)需要把这些目标文件和系统提供的二进制库文件“缝合”在一起。

为什么 printfstrlen 不需要这样写?

  • 标准 C/C++ 库(如 msvcrt.lib):开发环境(如 Visual Studio)默认就会自动链接这些最基础的库。
  • Windows API 扩展库:像 Shlwapi.dll(Shell 轻量级工具)、Wininet.dll(网络请求)或 Gdi32.dll(图形界面)属于 Windows 的扩展功能模块,它们不是标准 C 库的一部分,因此需要开发者明确指出要链接对应的 .lib

3-4、Number Of Running Processes

运行进程总数检测 (Number Of Running Processes),是一种通过评估系统“活跃度”来识别虚拟化环境的统计学方法。其核心逻辑在于:真实的 Windows 用户机器通常会安装大量的应用程序(如浏览器、社交软件、驱动程序等),而专门用于样本分析的沙箱或虚拟机环境通常保持极简配置,仅运行维持系统运转的必要进程。

检测机制

  • 进程枚举: 程序利用 EnumProcesses WinAPI 遍历并获取当前系统中所有正在运行的进程 ID。

  • 基准对比

    • 真实环境:一个典型的 Windows 系统通常会有 60-70 个 或更多的并发进程。

    • 虚拟环境: 进程数量往往非常稀少。

  • 判定阈值: 在 CheckMachineProcesses 函数中,开发者通常设定一个安全阈值(例如 50 个)。如果系统运行的进程总数低于 50,则高度怀疑该环境是一个“干净”的分析沙箱。

EnumProcesses 函数原型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
BOOL EnumProcesses(
  [out] DWORD   *lpidProcess,
  [in]  DWORD   cb,
  [out] LPDWORD lpcbNeeded
);

[out] lpidProcess
A pointer to an array that receives the list of process identifiers.
eg: 0x000000ea7095e090 {0, 4, 92, 132, 508, 696, 796, 804, 868, 944, 964, 980, 792, 592, 544, 1104, 1156, ...}

[in] cb
The size of the pProcessIds array, in bytes.
eg: DWORD adwProcesses[1024], 1024*2*2=4096

[out] lpcbNeeded
The number of bytes returned in the pProcessIds array.
eg: real is 820

检测代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
BOOL CheckMachineProcesses() {

	DWORD adwProcesses[1024];   // 1024*2*2 = 4096 bytes
	DWORD dwReturnLen = NULL;   // eg: real is 820
	DWORD dwNmbrOfPids = NULL;

	if (!EnumProcesses(adwProcesses, sizeof(adwProcesses), &dwReturnLen)) {
		printf("[!] EnumProcesses Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// eg: 820 / 4 = 205 
	dwNmbrOfPids = dwReturnLen / sizeof(DWORD);

	// less than 50 process, its sandbox
	if (dwNmbrOfPids < 50) {
		return TRUE;
	}

	return FALSE;
}

3-5、User Interaction

用户交互监测 (User Interaction),是一种识别“无头”(Headless)或全自动化分析环境的有效手段。由于大多数沙箱是通过脚本自动触发的,它们往往缺乏真实用户操作所产生的随机性行为(如移动鼠标、敲击键盘或点击图标)。

该技术利用 Windows 钩子机制(Hooks) 来实时追踪系统的输入事件。如果程序在运行过程中的一段特定时间内没有捕获到足够的交互行为,则判定为非人工操作环境。

  • 设置钩子:利用 SetWindowsHookExW 函数安装一个底层鼠标钩子(WH_MOUSE_LL),用于监听全局鼠标点击事件。

  • 数据采集:在后台运行 MouseClicksLogger 函数,持续记录用户点击次数,并使用 CallNextHookEx 确保系统正常的输入链不受影响。

  • 时耗比对:设定一个时间窗口(如 20 秒)和点击阈值(如 5 次)。

    • 真实环境:用户在打开程序或操作电脑时,通常会伴随多次鼠标点击。

    • 沙箱环境:如果 20 秒内点击次数不足 5 次,程序则判定当前处于缺乏人类交互的自动化沙箱中。

该 Hook 方法在之前的课程 API Hooking - Using Windows APIs 中有详细讲解,忘记的话可回去查看。

检测代码:

 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
#include <Windows.h>
#include <stdio.h>

#define MONITOR_TIME 20000   // monitor mouse clicks for 20 seconds


// global hook handle
HHOOK g_hMouseHook = NULL;
// global mouse clicks counter
DWORD g_dwMouseClicks = NULL;


// the callback function that will be executed whenever the user clicked a mouse button
LRESULT CALLBACK HookEvent(int nCode, WPARAM wParam, LPARAM lParam) {

	// WM_RBUTTONDOWN :         "Right Mouse Click"
	// WM_LBUTTONDOWN :         "Left Mouse Click"
	// WM_MBUTTONDOWN :         "Middle Mouse Click"

	if (wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN || wParam == WM_MBUTTONDOWN) {
		printf("[+] Mouse Click Recorded \n");
		g_dwMouseClicks++;
	}

	return CallNextHookEx(g_hMouseHook, nCode, wParam, lParam);
}


BOOL MouseClicksLogger() {

	MSG Msg = { 0 };

	// installing hook
	g_hMouseHook = SetWindowsHookExW(WH_MOUSE_LL, (HOOKPROC)HookEvent, NULL, NULL);

	if(!g_hMouseHook){
		printf("[!] SetWindowsHookExW Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	// process unhandled events
	while (GetMessageW(&Msg, NULL, NULL, NULL)) {
		TranslateMessage(&Msg);
		DispatchMessageW(&Msg);
	}
	/* other process unhandle events
	while (GetMessageW(&Msg, NULL, NULL, NULL)) {
		DefWindowProcW(Msg.hwnd, Msg.message, Msg.wParam, Msg.lParam);
	}
 */
	return TRUE;
}


int main() {

	HANDLE hThread = NULL;
	DWORD dwThreadId = NULL;

	// running the hooking function in seperate(分开) thread for 'MONITOR_TIME' ms
	hThread = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)MouseClicksLogger, NULL, NULL, &dwThreadId);
	if (hThread) {
		printf("\t <<>> Thread %d Is Created To Monitor Mouse Clicks For %d Seconds <<>> \n\n", dwThreadId, MONITOR_TIME);
		WaitForSingleObject(hThread, MONITOR_TIME);
	}

	// unhooking
	if (g_hMouseHook && !UnhookWindowsHookEx(g_hMouseHook)) {
		printf("[!] UnhookWindowsHookEx Failed With Error : %d \n", GetLastError());
	}

	// for test
	printf("[i] Monitored User's Mouse Clicks : %d ... ", g_dwMouseClicks);
	// if less than 5 clicks - its a sandbox
	if (g_dwMouseClicks > 5) {
		printf("[+] Passed The Test \n");
	}
	else {
		printf("[-] Possibly A Virtual Environment \n");
	}

	printf("[#] Press <Enter> To Quit ... ");
	getchar();

	return 0;

}

4、延迟执行技术

延迟执行 (Delay Execution),是一种利用沙箱“分析成本”限制的对抗技术。由于安全厂商每天需要处理成千上万个样本,自动化沙箱通常会对每个二进制文件设置严格的分析时间上限(通常为 2 到 5 分钟)。如果恶意程序在此时间内表现得完全“无害”,沙箱就会因超时而终止分析并给出安全的结论。

其逻辑非常简单直接:在执行真正的恶意载荷(Payload)之前,先引入一段长时间的停顿。

场景示例: 假设一个沙箱的分析限制是 120 秒。如果恶意软件在解密并运行 Payload 之前,先执行一个持续 180 秒的等待函数,那么沙箱在观察到任何威胁行为之前就已经关闭了。

由于恶意软件广泛利用延迟执行来规避分析,现代沙箱引入了 检测时间快进(Detecting Fast-Forwards) 技术作为反制。

逻辑时间:沙箱通常会通过 API 钩子(Hooking)拦截如 SleepWaitForSingleObjectNtDelayExecution 等函数。当恶意软件请求 Sleep(60000)(1分钟)时,沙箱会修改其参数(例如改为 1 毫秒,使程序认为时间已经过去,迫使其立即暴露真实行为。

物理时间GetTickCount64 返回自系统启动以来经过的毫秒数。由于它直接读取内核维护的计数器,沙箱如果只 Hook 了 Sleep 而没有同步伪造系统时钟,两者之间就会产生偏差。

为了应对这种检查,高级沙箱会采取更彻底的手段:

  • 时钟同步 Hook:当沙箱快进 Sleep 时,它会同时 Hook GetTickCountGetTickCount64QueryPerformanceCounter,手动修改这些 API 的返回值,使其看起来确实经过了 1 分钟。
  • 硬件计时器劫持:通过修改 RDTSC 指令(读取时间戳计数器)的结果或修改内核时间变量来欺骗恶意软件。

如果只依赖 GetTickCount64 容易被绕过,进一步可以结合多种数据源进行交叉比对:

  • 对比 GetTickCount64GetSystemTimeAsFileTime:沙箱可能只伪造了一个 API 的结果,而忽略了另一个。
  • 配合网络对时:尝试请求一个 NTP 服务器或知名网站(如 https://www.google.com/search?q=google.com)的 HTTP Header 中的 Date 字段。如果本地系统时间跳跃了但外部互联网时间没变,说明环境异常。
  • 利用鼠标/输入钩子:比如 WH_MOUSE_LL。则可以设置 Sleep(10000),如果在这一秒内系统报告时间流逝了 10 秒,但鼠标钩子在这“10秒”内没有捕捉到任何人类自然的微小抖动,那么这就是一个矛盾点。

GetTickCount64 判断代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
BOOL DelayFunction(DWORD dwMilliSeconds){

  DWORD T0 = GetTickCount64();
  
  // The code needed to delay the execution for 'dwMilliSeconds' ms
  
  DWORD T1 = GetTickCount64();
  
  // Slept for at least 'dwMilliSeconds' ms, then 'DelayFunction' succeeded
  if ((DWORD)(T1 - T0) < dwMilliSeconds)
    return FALSE;
  else
    return TRUE;
}

以下为一些常见的延迟执行技术。

4-1、WaitForSingleObject

在反沙箱策略中,直接使用 Sleep 函数往往会被简单的沙箱规则识别并跳过。因此,开发者常利用 WaitForSingleObject 这种功能更复杂的同步原语来达成相同的延迟目的,且更具隐蔽性。

该技术的核心在于创建一个永远不会被触发(Signaled)的对象,迫使函数进入“超时等待”状态。

检测机制

  • 创建无信号事件: 首先调用 CreateEvent 创建一个空的事件对象。只要不人为调用 SetEvent,这个对象将永远处于无信号状态。
  • 触发超时机制: 调用 WaitForSingleObject,将等待句柄设为该事件,并将超时时间(Timeout)设为预期的延迟时长(如 ftMinutes 转换后的毫秒数)。
  • 延迟验证: 函数会一直阻塞,直到达到预设的时间上限。

检测代码:

 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
/* examples on the 'ftMinutes' parameter :
	- 1.5 ; minute and half
	- 0.5 ; half a minute
	-  1  ; minute
	- 0.1 ; 6 seconds
	- 0.3 ; 18 seconds
*/

BOOL DelayExecutionVia_WFSO(FLOAT ftMinutes) {

	// converting minutes to milliseconds
	DWORD dwMilliSeconds = ftMinutes * 60000;  // 1 min = 60s, 0.1 min = 6s
	HANDLE hEvent = CreateEvent(NULL, NULL, NULL, NULL);
	DWORD _T0 = NULL;
	DWORD _T1 = NULL;

	printf("[i] Delaying Execution Using \"WaitForSingleObject\" For %0.3d Seconds", (dwMilliSeconds / 1000));

	_T0 = GetTickCount64();

	// sleeping for 'dwMilliSeconds' ms
	if (WaitForSingleObject(hEvent, dwMilliSeconds) == WAIT_FAILED) {
		printf("[!] WaitForSingleObject Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	_T1 = GetTickCount64();

	// slept for at least 'dwMilliSeconds' ms, the 'DelayExecutionVia_WFSO' succeeded, otherwise it failed
	if ((DWORD)(_T1 - _T0) < dwMilliSeconds) {
		return FALSE;
	}

	printf("\n\t>> _T1 - _T0 = %d \n", (DWORD)(_T1 - _T0));
	printf("[+] DONE \n");

	CloseHandle(hEvent);

	return TRUE;
}

4-2、MsgWaitForMultipleObjectsEx

MsgWaitForMultipleObjectsEx 是 Windows 线程同步机制中的“高级版”等待函数。与 WaitForSingleObject 相比,它不仅能等待句柄信号,还能让线程在等待期间响应特定的系统消息(如窗口重绘、鼠标点击等)。在反沙箱场景中,使用它来替代 SleepWFSO 能进一步提升程序的隐蔽性模拟真实运行的能力。

该技术同样利用“超时(Timeout)”逻辑来强制程序停顿。

检测机制

  • 构造无效等待: 同样创建一个永不触发的 CreateEvent 句柄作为参数。
  • 消息感知延迟: 调用 MsgWaitForMultipleObjectsEx。其特殊之处在于,你可以设置标志(如 QS_ALLINPUT),使线程在延迟期间依然能处理 UI 消息。这种行为更符合真实交互式软件的特征,不容易被简单的沙箱启发式算法判定为“恶意挂起”。
  • 超时返回: 当设定的时间(由 ftMinutes 转换而来)耗尽时,函数返回 WAIT_TIMEOUT

MsgWaitForMultipleObjectsEx 函数原型:

1
2
3
4
5
6
7
DWORD MsgWaitForMultipleObjectsEx(
  [in] DWORD        nCount,
  [in] const HANDLE *pHandles,
  [in] DWORD        dwMilliseconds,
  [in] DWORD        dwWakeMask,
  [in] DWORD        dwFlags
);
  • nCount [in]:

    • 要等待的句柄数组中的元素个数。最大不能超过 MAXIMUM_WAIT_OBJECTS - 1(通常是 63)。例如 1,表示只等待一个对象(如hEvent)。
  • pHandles [in]:

    • 一个指向句柄数组的指针。注意:如果 nCount 为 1,可以直接传 &hEvent
  • dwMilliseconds [in]:

    • 等待的时间。可以使用 INFINITE(永久等待)或具体的毫秒数。在反沙箱中,常用它来制造长达数分钟的延迟。
  • dwWakeMask [in]:

    • 关键参数。定义了哪些类型的 Windows 消息会唤醒等待。

    • 常见掩码:QS_ALLINPUT: 任何消息(键盘、鼠标、定时器、重绘)。QS_KEY: 只有按键消息。QS_MOUSE: 只有鼠标消息。QS_HOTKEY: 只有热键消息。QS_POSTMESSAGE: 只有通过 PostMessage 发送的消息。

  • dwFlags [in]:

    • 决定等待的逻辑行为:0: 只要有一个句柄受信,或有一条匹配的消息到达,就返回。MWMO_WAITALL: 必须所有句柄都受信,且有一条消息到达才返回。MWMO_INPUTAVAILABLE: 如果消息队列中已经有未读消息,立即返回。

检测代码

 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
BOOL DelayExecutionVia_MWFMOEx(FLOAT ftMinutes) {

	// converting minutes to milliseconds
	DWORD dwMilliSeconds = ftMinutes * 60000;  // 1 min = 60s, 0.1 min = 6s
	HANDLE hEvent = CreateEvent(NULL, NULL, NULL, NULL);
	DWORD _T0 = NULL;
	DWORD _T1 = NULL;

	printf("[i] Delaying Execution Using \"MsgWaitForMultipleObjectsEx\" For %0.3d Seconds", (dwMilliSeconds / 1000));

	_T0 = GetTickCount64();

	// sleeping for 'dwMilliSeconds' ms
	if (MsgWaitForMultipleObjectsEx(1, &hEvent, dwMilliSeconds, QS_HOTKEY, NULL) == WAIT_FAILED) {
		printf("[!] MsgWaitForMultipleObjectsEx Failed With Error : %d \n", GetLastError());
		return FALSE;
	}

	_T1 = GetTickCount64();

	// slept for at least 'dwMilliSeconds' ms, the 'DelayExecutionVia_MWFMOEx' succeeded, otherwise it failed
	if ((DWORD)(_T1 - _T0) < dwMilliSeconds) {
		return FALSE;
	}

	printf("\n\t>> _T1 - _T0 = %d \n", (DWORD)(_T1 - _T0));
	printf("[+] DONE \n");

	CloseHandle(hEvent);

	return TRUE;
}

4-3、NtWaitForSingleObject

NtWaitForSingleObject 是 Windows 执行体提供的底层原生 API(Native API)。作为 WaitForSingleObject 在内核层的实际实现者,直接使用此系统调用可以绕过用户态(Ring 3)中常见的 API 钩子(Hooks),使延迟行为对大多数基础监控软件“不可见”。

(1)核心差异:时间单位(Ticks)

与应用层 API 使用毫秒(ms)不同,NtWaitForSingleObject 使用的是 100 纳秒(100-nanosecond) 为单位的时间间隔,通常被称为 Ticks

  • 转换公式1 ms = 10,000 Ticks
  • 负值表示法: 在调用时,必须传递 负值 来表示相对时间(从当前时刻开始计算)。如果是正值,系统会将其视为自 1601 年 1 月 1 日起的绝对时间。计算示例:若要延迟 100 毫秒,传入的值应为 -1,000,000

(2)实现机制

  • 单位转换:将输入的分钟数(ftMinutes)转换为毫秒,再乘以 10,000 转换为 Ticks,最后取负值
  • 调用 Syscall:传入超时参数调用 NtWaitForSingleObject
  • 结果校验:同样结合 GetTickCount64QueryPerformanceCounter 验证实际流逝的时间,防止沙箱在内核层快进。

NtWaitForSingleObject 函数原型:

1
2
3
4
5
NTSTATUS NtWaitForSingleObject(
  [in] HANDLE         Handle,       // Handle to the wait object
  [in] BOOLEAN        Alertable,    // Whether an alert can be delivered when the object is waiting
  [in] PLARGE_INTEGER Timeout       // Pointer to LARGE_INTEGER structure specifying time to wait for
);

检测代码:

 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
typedef NTSTATUS(NTAPI* fnNtWaitForSingleObject)(
	HANDLE Handle,
	BOOLEAN Alertable,
	PLARGE_INTEGER Timeout
);

BOOL DelayExecutionVia_NtWFSO(FLOAT ftMinutes) {

	// converting minutes to milliseconds
	DWORD dwMilliSeconds = ftMinutes * 60000;  // 1 min = 60s, 0.1 min = 6s
	HANDLE hEvent = CreateEvent(NULL, NULL, NULL, NULL);
	LONGLONG Delay = NULL;
	NTSTATUS STATUS = NULL;
	LARGE_INTEGER DelayInterval = { 0 };
	fnNtWaitForSingleObject pNtWaitForSingleObject = (fnNtWaitForSingleObject)GetProcAddress(GetModuleHandleW(L"NTDLL.DLL"), "NtWaitForSingleObject");
	DWORD _T0 = NULL;
	DWORD _T1 = NULL;

	printf("[i] Delaying Execution Using \"NtWaitForSingleObject\" For %0.3d Seconds", (dwMilliSeconds / 1000));

	// converting from milliseconds to the 100-nanosecond - negative time interval
	Delay = dwMilliSeconds * 10000;
	DelayInterval.QuadPart = - Delay;

	_T0 = GetTickCount64();

	// sleeping for 'dwMilliSeconds' ms
	if ((STATUS = pNtWaitForSingleObject(hEvent, FALSE, &DelayInterval)) != 0x00 && STATUS != STATUS_TIMEOUT) {
		printf("[!] NtWaitForSingleObject Failed With Error : 0x%0.8X \n", STATUS);
		return FALSE;
	}

	_T1 = GetTickCount64();

	// slept for at least 'dwMilliSeconds' ms, the 'DelayExecutionVia_NtWFSO' succeeded, otherwise it failed
	if ((DWORD)(_T1 - _T0) < dwMilliSeconds) {
		return FALSE;
	}

	printf("\n\t>> _T1 - _T0 = %d \n", (DWORD)(_T1 - _T0));
	printf("[+] DONE \n");

	CloseHandle(hEvent);

	return TRUE;
}

4-4、NtDelayExecution

NtDelayExecution 是 Windows 内核提供的最底层、最纯粹的延迟执行函数。如果说 NtWaitForSingleObject 是带条件的“等待”,那么 NtDelayExecution 就是最直接的“休眠”。它是应用层 Sleep 函数在内核态的真正实现。

(1)技术特性

无需句柄:与 NtWaitForSingleObject 不同,它不需要创建任何事件对象(Event)或句柄,直接作用于当前线程的执行周期。

(2)实现机制

NtWFSO 一致,该函数使用 100 纳秒(Ticks) 作为时间单位。

  • 计算公式Ticks = Minutes * 60 * 1000 * 10000
  • 相对时间: 必须传入负值。例如,传入 -10,000,000 代表让当前线程休眠 1 秒。
  • 中断性(Alertable): 该函数包含一个 Alertable 布尔参数。通常设置为 FALSE 以确保休眠不会被普通的异步过程调用(APC)轻易中断。

检测代码:

 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
typedef NTSTATUS(NTAPI *fnNtDelayExecution)(
	BOOLEAN Alertable,
	PLARGE_INTEGER DelayInterval
	);

BOOL DelayExecutionVia_NtDE(FLOAT ftMinutes) {

	// converting minutes to milliseconds
	DWORD dwMilliSeconds = ftMinutes * 60000;  // 1 min = 60s, 0.1 min = 6s
	LONGLONG Delay = NULL;
	NTSTATUS STATUS = NULL;
	LARGE_INTEGER DelayInterval = { 0 };
	fnNtDelayExecution pNtDelayExecution = (fnNtDelayExecution)GetProcAddress(GetModuleHandleW(L"NTDLL.DLL"), "NtDelayExecution");
	DWORD _T0 = NULL;
	DWORD _T1 = NULL;

	printf("[i] Delaying Execution Using \"NtDelayExecution\" For %0.3d Seconds", (dwMilliSeconds / 1000));

	// converting from milliseconds to the 100-nanosecond - negative time interval
	Delay = dwMilliSeconds * 10000;
	DelayInterval.QuadPart = -Delay;

	_T0 = GetTickCount64();

	// sleeping for 'dwMilliSeconds' ms
	if ((STATUS = pNtDelayExecution(FALSE, &DelayInterval)) != 0x00 && STATUS != STATUS_TIMEOUT) {
		printf("[!] NtDelayExecution Failed With Error : 0x%0.8X \n", STATUS);
		return FALSE;
	}

	_T1 = GetTickCount64();

	// slept for at least 'dwMilliSeconds' ms, the 'DelayExecutionVia_NtDE' succeeded, otherwise it failed
	if ((DWORD)(_T1 - _T0) < dwMilliSeconds) {
		return FALSE;
	}

	printf("\n\t>> _T1 - _T0 = %d \n", (DWORD)(_T1 - _T0));
	printf("[+] DONE \n");

	return TRUE;
}

5、API Hammering 技术

API Hammering 是一种极具对抗性的反沙箱技术。它的核心思想不是简单的休眠,而是通过产生大量的“背景噪音”来干扰监控系统。它主要通过高频、快速地调用大量随机的合法 WinAPI 来达成目的。

核心原理

  • 延迟执行(Delay Execution):不同于传统的 Sleep(容易被沙箱快进),API Hammering 通过执行数以万计的真实 API 调用来消耗 CPU 时间。沙箱如果尝试快进每一个 API,可能会导致逻辑错误;如果不快进,则会因为分析超时而放弃。
  • 调用栈混淆(Call Stack Obfuscation):当恶意代码执行关键操作(如解密 Payload 或注入内存)时,如果安全软件在此时进行堆栈回溯(Stack Walk),它会发现调用栈中充斥着大量看似无害的、随机的系统调用(如获取时间、查询系统信息等)。这使得分析人员和自动化检测引擎很难定位到真正的恶意逻辑起点。

本节具体实现将在下篇文章“API Hammering 技术”中展开介绍。

updatedupdated2026-01-122026-01-12