首页
社区
课程
招聘
[原创]Vectored Overloading 技术分析:基于 SEC_IMAGE、VEH 与硬件断点的 Windows Loader 加载流程操纵
发表于: 2025-12-28 16:37 19675

[原创]Vectored Overloading 技术分析:基于 SEC_IMAGE、VEH 与硬件断点的 Windows Loader 加载流程操纵

2025-12-28 16:37
19675

看到 CheckPoint 对恶意软件GachiLoader 的分析文章,提到了一种新的 PE 注入技术“Vectored Overloading(向量化重载)”,即通过 VEH 和对内存中的载荷进行直接映射,借助合法的 DLL 内存实现注入,从而规避检测。CheckPoint 提供了这项技术的测试代码,以下是对这项技术的学习记录。

读取 C:\Windows\System32\calc.exe 的文件内容到内存,这是之后要注入的 Payload。

GetProcessHeap 函数获取调用进程的默认堆的句柄。 进程可以使用此句柄从进程堆分配内存,而无需先使用 HeapCreate 函数创建专用堆

ReadFile 函数从指定的文件或输入/输出(I/O)设备读取数据

然后 exe 文件变成 dll 文件。calc.exe原本是 exe 文件,FileHeader.Characteristics 的字段IMAGE_FILE_DLL 为 0。

calc.exe 原本属于 EXE 文件, PE 文件头的 Characteristics 字段通常未设置 IMAGE_FILE_DLL(0x2000)标志。为了使后续系统将该 exe 文件按照 DLL 加载路径处理,通过按位或操作设置 IMAGE_FILE_DLL 标志,并暂时将 OptionalHeader.AddressOfEntryPoint 设置为 0,以配合后续的加载及入口点处理。

首先需要调用函数NtCreateSection。 NtCreateSection 属于 Windows NT Native API,用于创建 Section Object(节对象)。该接口在用户态由 ntdll.dll 导出,Windows 内核通过相应的系统服务完成实际的节对象创建。ntdll.lib 不是 Visual Studio 标准 C++ 桌面开发环境默认包含的库。它是 Windows Driver Kit (WDK) 的一部分,所以需要先声明,并且说明函数的名称、参数类型、返回值、调用约定

(这种隐式链接的方式很容易在逆向分析时被发现,这里应该是为了方便没有用 LoadLibrary + GetProcAddress 这种显式链接的方式获取NtCreateSection 地址了)

函数 NtCreateSection 负责创建节对象。

其中需要注意的参数是AllocationAttributes,用于确定节的分配属性,可以设置为 SEC_COMMIT(0x8000000)、SEC_IMAGE(0x1000000)、SEC_IMAGE_NO_EXECUTE(0x11000000)、SEC_LARGE_PAGES(0x80000000)等 7 个属性值。

这里程序将节对象的分配属性设置为SEC_IMAGE,也就是说告诉操作系统把这个文件当成 “可执行程序(Image)” 来解析和映射,别当成普通文本文件。SEC_IMAGE 属性需要与页面保护值(如 PAGE_READONLY)结合使用。

为什么不用VirtualAlloc,而是要用NtCreateSection ?

调用NtCreateSection函数时,Windows 内核会在内核空间创建一个 Section Object (节对象)。这个对象本身是存放在内核空间的,用户态程序无法直接操作。NtCreateSection 不能独自完成内存分配工作,还需要结合函数NtMapViewOfSection。

现在拿到的 gSectionHandle 只是一个句柄,程序后面紧接着调用的 NtMapViewOfSection,才能把节对象映射到当前进程的用户空间虚拟内存里。

使用 NtMapViewOfSection 将这个基于 wmp.dll 的节映射到当前进程内存

简单说,NtMapViewOfSection这个函数是把 SectionHandle 指定的文件,映射到 ProcessHandle 指定的进程里,大小由 ViewSize 决定,地址由 BaseAddress 接收。

映射内存后,紧接着使用 VirtualProtect 修改内存权限,清空原有 wmp.dll 的内容,并将 calc.exe 的 PE 头和各节(Section)手动复制进去,修复 calc.exe 的重定位,设置各节的属性。

虽然把内存里的内容全部清空并替换了,但这块内存的属性依然是 MEM_IMAGE,并且在操作系统眼中,这块内存区域依然关联着 wmp.dll 这个文件。

这里修改了内存,硬盘上的 wmp.dll 文件会不会也被改掉?并不会

Windows 有一种机制叫写时复制 (Copy-on-Write)。“写时复制”是一种内存优化技术,让多个进程共享同一份物理内存页面,直到其中一个进程尝试写入(修改)数据时,系统才真正创建一份独立的副本,从而节省内存和时间。

程序在调用memset、memcpy 等函数把数据写进 wmp.dll 的映射内存时,操作系统会申请一块新的物理页,把旧页面的内容拷贝到新页面,并将新页面的权限设置为可写入,CPU 重新执行写入指令,完成对新页面的数据修改。

主要流程如下:注册VEH 异常处理函数,然后在函数NtOpenSection设置硬件断点,调用 loadlibrary 加载一个系统的 dll 触发断点,让程序进入VEH 异常处理函数,修改部分代码劫持程序执行流程,让程序跳转到之前设置好的 Payload,也就是映射 calc.exe 的内存,最后执行 Payload 弹出计算器窗口。

首先我们需要理了解VEH(Vectored ExceptionHandler,向量化异常处理)。简单说一下,从Windows XP开始,在以前的SEH(Structured Exception Handling)结构化异常处理的基础上, 微软又增加了一种新的异常处理VEH。

注册一个 VEH (Vectored Exception Handler):InjectHandler

1u 表示 ULONG 类型的 1, 表示只要有异常发生,第一个通知这里注册的异常处理程序InjectHandler。一旦异常发生,操作系统就会暂停当前的程序,转而去调用这个函数InjectHandler。

注册的 InjectHandler 异常处理函数实现逻辑如下:

真正的 NtOpenSection 根本没有执行!操作系统压根不知道程序想打开 amsi.dll。调用者收到了 STATUS_SUCCESS (Rax=0),并且多了一个 Handle,操作系统以为成功打开了 amsi.dll,但其实是之前构造的填充了 calc.exe 的 Section。

根据 x64 调用约定,最左边 4 个位置的整数值参数从左到右分别在 RCX、RDX、R8 和 R9 中传递

NtOpenSection 也是一个未公开的函数,和之前的NtCreateSection 需要提前声明

怎么人为地让异常发生呢?前面已经提过,需要设置硬件断点。

CPU 内部有 8 个调试寄存器:

使用调试寄存器在NtOpenSection函数的地址上设置硬件断点;因为 NtOpenSection 是系统函数,软件断点通常需要修改目标位置的机器码,因此可能被针对代码完整性的检测机制发现。硬件断点则利用处理器调试寄存器监控指定执行地址,不需要直接修改目标函数的机器码。利用硬件断点 + VEH(异常处理),可以在不修改任何一个字节代码的情况下,劫持系统函数的执行流程。

调用 LoadLibraryW(L"amsi.dll"),触发 Windows 加载器(Ldr)内部的 NtOpenSection 和 NtMapViewOfSection 调用

当 LoadLibrary试图打开 amsi.dll时会调用NtOpenSection,触发硬件断点。异常处理程序拦截执行,将输出参数SectionHandle 替换为之前创建的 gSectionHandle(即那个伪装的wmp.dll)。然后修改 RIP指针跳过真正的系统调用。

操作系统以为它成功打开了 amsi.dll,但实际上拿到的是篡改过的 Handle。

VEH 异常处理流程随后在 NtMapViewOfSection 下断点。当加载器试图映射这个 Handle 时,处理程序再次拦截,将映射的基址(BaseAddress)替换为已经准备好的地址。

LoadLibrary认为它加载了amsi.dll,但实际上通过劫持,它“复用”了那块已经写入 calc.exe 代码的内存。

运行成功

GachiLoader: Defeating Node.js Malware with API Tracing

VectoredOverloading/main.cpp at main · CheckPointSW/VectoredOverloading(完整代码在这个项目链接中,可供各位读者参考)

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);
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;
}
#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);
__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  //(可选)指定打开的文件对象的句柄
);
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);
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                 //内存保护属性
);
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);

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

最后于 2026-10-20 14:44 被ZyOrca编辑 ,原因: 调整排版,修正错误
收藏
点赞 ・13
打赏
分享
最新回复 (9)
雪    币: 1525
活跃值: (2555)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2 楼
mark
2025-12-28 17:03
0
雪    币: 232
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3 楼
mark
2025-12-29 14:58
0
雪    币: 807
活跃值: (5820)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
4 楼
妙哇
2025-12-29 22:04
0
雪    币: 97
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5 楼
mark
2025-12-31 13:20
0
雪    币: 4368
活跃值: (5361)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6 楼
mark
2026-1-1 12:45
0
雪    币: 261
活跃值: (1826)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7 楼
mark 
2026-1-1 14:53
0
雪    币: 6899
活跃值: (6612)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
8 楼
这玩意貌似就是当年我提的MemoryDll变种:https://bbs.kanxue.com/thread-274307.htm
2026-1-3 19:33
1
雪    币: 221
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
9 楼
这个用来加载一个shellcode可以吗,我尝试运行弹calc可以,但是运行cs的shellcode不行
2026-1-10 18:38
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10 楼
是module stomping的变体加载方式吧
2026-5-20 18:21
0
游客
登录 | 注册 方可回帖
返回