WEBKT

绕过VT-x:如何通过物理内存安全扫描检测内核隐藏驱动

5 0 0 0

在内核安全对抗中,驱动隐藏是一项经典技术。无论是恶意的 Rootkit 还是某些反作弊系统的保护驱动,最常用的手段就是通过**直接内核对象操作(DKOM)**从 PsLoadedModuleList(已加载模块双向链表)中将自己摘除。

一旦摘链,常规的内核 API(如 AuxKlibQueryModuleInformationCreateToolhelp32Snapshot)就无法感知其存在。

为了检测这些隐藏驱动,许多安全从业者会求助于基于硬件虚拟化(VT-x)的 EPT 视图切换或内存监控。然而,VT-x 技术开发难度极高,且极易与开启了 Hyper-V、WSL2 的系统环境发生冲突,导致兼容性雪崩。

事实上,利用物理内存扫描(基于 \Device\PhysicalMemory 或内核底层映射机制) 依然是一种无需 VT-x、且兼容性极佳的强力检测手段。毕竟,无论如何在软件层面隐藏链表,驱动的二进制代码和 PE 结构必须真实存在于物理内存中。

本文将探讨如何在不依赖虚拟化技术的前提下,安全地通过物理内存扫描定位隐藏驱动,并重点解决扫描过程中的“系统蓝屏(BSOD)”与“缓存一致性”痛点。


一、 为什么不能盲目扫描物理内存?

在内核态,如果我们尝试直接从物理地址 0x0 暴力循环扫描到 MaxPhysicalAddress,系统几乎 100% 会触发蓝屏(常见的有 PAGE_FAULT_IN_NONPAGED_AREAMACHINE_CHECK_EXCEPTION)。原因有两点:

  1. 非内存物理地址(MMIO):物理地址空间并不等于内存条(RAM)空间。很多物理地址被映射到了显卡、网卡等硬件设备的寄存器上(即 Memory-Mapped I/O)。直接读取这些地址会导致硬件挂起或触发 CPU 异常。
  2. 缓存属性冲突(Cache Coherency):Windows 内存管理对不同区域的物理内存定义了不同的缓存行为(如 Cached、Write-Combine、Non-Cached)。如果用错误的缓存属性去映射并读取某段物理内存,会直接引发 CPU 的 Machine Check 异常导致崩盘。

因此,“安全扫描”的核心前提是:只扫描合法的 RAM 区段,并使用匹配的缓存属性进行映射。


二、 步骤一:获取安全的物理内存区间

在 Windows 内核中,不能使用硬编码的物理地址范围。我们需要动态向系统查询当前真正挂载的 RAM 物理内存区间。

这可以通过内核未公开(但可以通过导出的结构体获取)的 API MmGetPhysicalMemoryRanges 来实现。

#include <ntddk.h>

typedef struct _PHYSICAL_MEMORY_RANGE {
    PHYSICAL_ADDRESS BaseAddress;
    LARGE_INTEGER NumberOfBytes;
} PHYSICAL_MEMORY_RANGE, *PPHYSICAL_MEMORY_RANGE;

// 声明未公开 API
extern PPHYSICAL_MEMORY_RANGE MmGetPhysicalMemoryRanges();

void GetSafePhysicalRanges() {
    PPHYSICAL_MEMORY_RANGE ranges = MmGetPhysicalMemoryRanges();
    if (ranges == NULL) {
        KdPrint(("[-] 无法获取物理内存范围\n"));
        return;
    }

    for (int i = 0; ranges[i].NumberOfBytes.QuadPart != 0; i++) {
        KdPrint(("[+] 物理内存区间 [%d]: 起始地址: 0x%llX, 大小: %lld MB\n", 
            i, 
            ranges[i].BaseAddress.QuadPart, 
            ranges[i].NumberOfBytes.QuadPart / 1024 / 1024));
    }
}

这段代码会返回当前系统所有可安全读取的物理内存(RAM)区间,完美避开了硬件设备的 MMIO 区域。


三、 步骤二:安全地映射与读取物理内存

获取了物理区间后,我们需要分段将这些物理页面映射到内核虚拟空间,以便进行读取和比对。

虽然可以通过操作 \Device\PhysicalMemory 设备对象来获取句柄,但在现代 Windows(特别是 Win10/11 开启了内核防护)上,直接在用户态甚至内核态打开这个设备对象都会受到极为严格的权限限制。

更为稳妥和自主的方式是在内核驱动中直接使用内存管理器提供的映射函数

PVOID MapPhysicalPage(PHYSICAL_ADDRESS physicalAddress, SIZE_T size) {
    // 必须使用 MmCached 属性,因为标准 RAM 默认是 Cached 属性
    // 使用错误的 MmNonCached 会引发 CPU 缓存冲突导致 BSOD
    PVOID mappedAddress = MmMapIoSpace(physicalAddress, size, MmCached);
    return mappedAddress;
}

void UnmapPhysicalPage(PVOID virtualAddress, SIZE_T size) {
    if (virtualAddress != NULL) {
        MmUnmapIoSpace(virtualAddress, size);
    }
}

注意:MmMapIoSpace 在一些严格的 ED/XDR 监控下可能会被挂钩拦截。如果需要更高的隐蔽性,可以通过自行解析当前进程的页表(CR3),临时修改或构造页表项(PTE)指向目标物理页面来实现纯手工映射。


四、 步骤三:特征识别与 PE 结构重建

有了安全读取物理内存的能力,接下来就是扫描策略。如果我们一字节一字节地去比对,效率极其低下。

由于驱动程序在物理内存中依然是**以 4KB 页面对齐(Page-Aligned)**存放的,这意味着驱动的 MZ 头(PE 文件的起始)必定位于一个 4KB 页面的起始位置(即物理地址低 12 位为 0x000)。

我们可以按 4KB 为步长进行跳跃式扫描:

#define PAGE_SIZE_4K 0x1000

void ScanPhysicalMemoryForDrivers() {
    PPHYSICAL_MEMORY_RANGE ranges = MmGetPhysicalMemoryRanges();
    if (!ranges) return;

    for (int i = 0; ranges[i].NumberOfBytes.QuadPart != 0; i++) {
        ULONG64 startAddress = ranges[i].BaseAddress.QuadPart;
        ULONG64 endAddress = startAddress + ranges[i].NumberOfBytes.QuadPart;

        for (ULONG64 currentAddr = startAddress; currentAddr < endAddress; currentAddr += PAGE_SIZE_4K) {
            PHYSICAL_ADDRESS pa;
            pa.QuadPart = currentAddr;

            // 映射一个页面 (4KB)
            PVOID mappedPage = MapPhysicalPage(pa, PAGE_SIZE_4K);
            if (!mappedPage) continue;

            __try {
                // 检测是否具有 MZ 标志
                PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)mappedPage;
                if (dosHeader->e_magic == IMAGE_DOS_SIGNATURE) { // 'MZ'
                    
                    // 进一步验证 NT 头
                    PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((PUCHAR)mappedPage + dosHeader->e_lfanew);
                    
                    // 必须做好边界防护,防止 e_lfanew 越界导致读写异常
                    if ((PUCHAR)ntHeaders < (PUCHAR)mappedPage + PAGE_SIZE_4K - sizeof(IMAGE_NT_HEADERS)) {
                        if (ntHeaders->Signature == IMAGE_NT_SIGNATURE) { // 'PE\0\0'
                            // 发现可疑 PE 镜像,提取导出表或特征
                            AnalyzeFoundDriver(currentAddr, ntHeaders);
                        }
                    }
                }
            } __except (EXCEPTION_EXECUTE_HANDLER) {
                // 捕获可能由于页面换出或其他原因引起的异常
            }

            UnmapPhysicalPage(mappedPage, PAGE_SIZE_4K);
        }
    }
}

五、 步骤四:与正常模块交叉比对(Cross-Referencing)

当我们通过物理扫描抓取到了内存中所有的 PE 映像(获取了它们的入口点、导出函数名、驱动尺寸等信息)后,如何判定哪一个是“隐藏驱动”?

我们需要将扫描结果与系统的正常加载列表进行交叉对比:

  1. 获取官方列表:遍历 PsLoadedModuleList 或者通过 ZwQuerySystemInformation 获取 SystemModuleInformation
  2. 转换虚拟地址:正常列表里的驱动地址是内核虚拟地址(如 0xFFFFF80...)。我们需要通过当前系统的页目录基址(CR3)或 MmGetPhysicalAddress,将这些官方驱动的虚拟地址转成物理地址。
  3. 比对排查
    • 如果我们物理内存扫描发现了一个 PE 头,其物理地址在官方列表转化后的物理地址区间内,说明它是正常驱动。
    • 如果该 PE 头的物理地址不在任何已知正常驱动的物理区间内,且其 PE 结构中的驱动特征明显(例如导出了 DriverEntry,包含内核 API 导入表),那么这百分之百是一个被隐藏的内核驱动

六、 进阶:如何获取隐藏驱动的真实名字与文件路径?

由于 DKOM 摘链往往会抹去驱动对应的 LDR_DATA_TABLE_ENTRY,此时我们直接看链表是找不到其名字的。但我们可以通过重建 PE 头来获取它的“原始文件名”:

在提取出隐藏驱动的内存数据后,定位其 PE 结构的 .rdata 节(或资源节),解析其版本信息(Version Info)。通常,未经过深度混淆的驱动都会保留 OriginalFilename 字段。

此外,由于隐藏驱动的创建必须要经历加载过程,其在注册表(如 HKLM\System\CurrentControlSet\Services)中留下的痕迹,以及通过 Minifilter 监控到的 .sys 文件加载记录,都可以作为辅助的取证线索。


七、 总结与防御对抗

利用物理内存扫描检测隐藏驱动,本质上是以空间确定性对抗应用层/内核层的信息遮蔽。只要隐藏驱动想要执行,就必须在 RAM 中驻留代码。

为了在实际开发中确保该技术的稳定与安全,请务必遵守以下准则:

  • 必须通过 MmGetPhysicalMemoryRanges 获取范围,严禁越界扫描。
  • 映射时必须使用 MmCached 模式,避免 CPU Cache 属性不一致引发的硬伤蓝屏。
  • 对任何解析 PE 头(尤其是 e_lfanew 偏移)的操作,必须置于 __try __except 保护之下,防止格式混淆型 Rootkit 故意构造畸形 PE 头引发扫描器崩溃。

这种非 VT-x 的检测方案不仅实现成本相对适中,而且不占用宝贵的硬件虚拟化通道,是构建轻量级主机安全 Agent(EDR)及反作弊内核模块时非常实用的底层技术。

零环行者 Windows内核安全防御内存扫描

评论点评