WEBKT

无VT-x保护:如何在Windows内核中安全检测PTE劫持与页表篡改

3 0 0 0

在Windows内核安全对抗中,页表劫持(PTE Hijacking)是Rootkit和游戏外挂常用于实现内存隐藏、无痕Hook以及绕过PatchGuard的底层手段。在拥有硬件虚拟化(VT-x/EPT)的环境下,我们可以通过二级地址翻译(SLAT)轻松对PTE所在的物理页面设置只读权限。然而,在不启用VT-x的环境下,如何安全、高效、且不引发蓝屏(BSOD)地检测PTE劫持?

本文将从Windows内核内存管理机制出发,深入探讨在纯软件层面(Ring 0)检测PTE劫持的几种可行方案,并给出关键实现思路。


一、 PTE劫持的常见手法与危害

在x64架构的Windows系统下,虚拟地址(VA)到物理地址(PA)的转换依赖于4级页表结构:PML4 -> PDPT -> PD -> PT -> Physical Page

攻击者通常通过以下方式篡改页表:

  1. 直接修改PTE指向的物理页框号(PFN):将合法模块(如某个受信任的DLL)的虚拟地址对应的PTE指向黑客自己的物理页面,实现无痕替换代码。
  2. TLB去同步(TLB Desynchronization):修改PTE后,故意不刷新转换旁路缓存(TLB)。CPU继续使用旧的TLB缓存运行原代码,而安全工具在尝试读取该内存时,由于触发了页表走步(Page Walk)或者使用了不同的访问路径,读取到的是被篡改后的物理页面。

二、 核心难点:绕过Windows 10+ 的随机化PTE基址

要检测PTE,首先必须能够读取PTE。在早期的Windows x64系统下,PTE的虚拟基址(PteBase)固定为 0xFFFFF68000000000。但在Windows 10 1607及以后的版本中,微软引入了PTE基址随机化。

如果无法定位动态的 MmPteBase,手动解析页表就无从谈起。我们可以通过特征码匹配 nt!MmGetPhysicalAddress 内部的指令,或者通过未导出函数来动态计算当前的 PteBase

动态获取 MmPteBase 的标准方法

以下是通过解析 nt!MmGetPhysicalAddress 定位 PteBase 的典型内核代码实现:

#include <ntddk.h>

ULONG_PTR GetPteBase() {
    UNICODE_STRING routineName = RTL_CONSTANT_STRING(L"MmGetPhysicalAddress");
    PVOID pMmGetPhysicalAddress = MmGetSystemRoutineAddress(&routineName);
    if (!pMmGetPhysicalAddress) {
        return 0;
    }

    // 在 MmGetPhysicalAddress 中搜索特征码
    // x64 下通常表现为: mov rdx, 0xFFFFF68000000000 (或动态随机化值)
    // 我们寻找 shl rx, 9 或类似的位移/掩码指令,或者直接定位特征偏移
    PUCHAR pInstruction = (PUCHAR)pMmGetPhysicalAddress;
    for (ULONG i = 0; i < 0x100; i++) {
        // 寻找满足 mov rax, [constant] 或者是特定移位操作的特征
        // 这里以常见特征为例(具体特征需根据不同内核版本适配)
        if (pInstruction[i] == 0x48 && pInstruction[i + 1] == 0xC1 && pInstruction[i + 2] == 0xE0 && pInstruction[i + 3] == 0x09) {
            // 找到了 shl rax, 9 
            // 进一步寻找包含 PteBase 的常量指令
            // 实际工程中建议使用反汇编引擎(如 Zydis)精准解析
        }
    }

    // 另一种更稳定的技巧是通过特权指令或已知合法VA的页表映射关系逆推
    // 例如,通过当前CR3与某一固定系统VA的关系计算
    return *(PULONG_PTR)((PUCHAR)MmGetVirtualForPhysical + 0x2); // 仅作原理示意
}

安全、免维护的另一种替代方案是利用未导出变量。在多数Win10/11版本中,nt!MmPteBase 是一个全局变量,可以通过解析内核PDB(不推荐在生产驱动中使用)或通过特征码扫描 MiFillPteHierarchy 等函数定位。


三、 无VT-x下的安全检测方案

在获取到 PteBase 后,我们可以实施以下三种不依赖硬件虚拟化的检测策略。

方案一:交叉验证法(API vs 手动解析)

这是最经典也是最有效的静态检测方法。

  • 原理:系统提供的 API MmGetPhysicalAddress 内部也是通过页表走步来获取物理地址的。如果攻击者仅篡改了特定进程的页表,但未全局 Hook MmGetPhysicalAddress,我们可以通过手动解析页表结构得到的物理地址,与调用 MmGetPhysicalAddress 得到的物理地址进行对比。
  • 步骤
    1. 锁定目标虚拟地址所在的内存区域,防止其被置换(Paged out)。
    2. 使用动态获取的 PteBase,手动计算该虚拟地址对应的 PML4e、PDPTe、PDe 和 PTE。
    3. 读取该 PTE 中的 Page Frame Number (PFN)
    4. 调用 MmGetPhysicalAddress 获取该虚拟地址的物理地址。
    5. 比较两者:手动PFN 是否等于 API返回的物理地址 >> 12。如果不等,说明存在劫持或篡改。
#define VA_TO_PTE_INDEX(va) (((ULONG_PTR)(va) >> 12) & 0x1FF)
#define VA_TO_PD_INDEX(va)  (((ULONG_PTR)(va) >> 21) & 0x1FF)
#define VA_TO_PDPT_INDEX(va)(((ULONG_PTR)(va) >> 30) & 0x1FF)
#define VA_TO_PML4_INDEX(va)(((ULONG_PTR)(va) >> 39) & 0x1FF)

// 安全读取物理地址并比对
BOOLEAN VerifyPteConsistency(PVOID VirtualAddress) {
    ULONG_PTR PteBase = GetPteBase(); // 动态获取的PteBase
    if (!PteBase) return FALSE;

    // 确保内存有效且不被置换
    PMDL pdml = IoAllocateMdl(VirtualAddress, sizeof(ULONG_PTR), FALSE, FALSE, NULL);
    if (!pdml) return FALSE;
    
    __try {
        MmProbeAndLockPages(pdml, KernelMode, IoReadAccess);
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        IoFreeMdl(pdml);
        return FALSE;
    }

    // 手动计算PTE地址
    ULONG_PTR va = (ULONG_PTR)VirtualAddress;
    PHARDWARE_PTE pPte = (PHARDWARE_PTE)(PteBase + ((va >> 9) & 0x7FFFFFFFF8));

    PHYSICAL_ADDRESS realPhysical = MmGetPhysicalAddress(VirtualAddress);
    ULONG_PTR apiPfn = realPhysical.QuadPart >> 12;

    BOOLEAN isConsistent = TRUE;
    if (pPte && pPte->Valid) {
        if (pPte->PageFrameNumber != apiPfn) {
            // 检测到不一致!
            isConsistent = FALSE;
        }
    }

    MmUnlockPages(pdml);
    IoFreeMdl(pdml);
    return isConsistent;
}

方案二:TLB 去同步(TLB Desync)检测

一些高级Rootkit利用TLB缓存未刷新的特性,在内存中保持两个不同的状态(CPU运行的是缓存在TLB中的原代码,而安全工具读取到的是被篡改后的PTE指向的物理页)。

  • 检测思路
    1. 主动刷新TLB:在读取关键内存前后,在特定的 CPU 核心上执行 __writecr3(__readcr3()) 或使用 __invlpg 指令强制刷新TLB。
    2. 跨核对比(IPI):通过发送发出发向所有核的进程间中断(IPI,Inter-Processor Interrupt),让每个核同时读取同一虚拟地址的数据,并与未刷新TLB的核心进行数据比对。如果不同核心在同一时刻读取到的数据不一致,基本可以判定存在TLB去同步劫持。

方案三:CR3 目录表基址(DirectoryTableBase)完整性审计

许多PTE劫持是通过替换整个进程的 CR3(即 DirectoryTableBase)来实现隐藏影子页表的。

  • 检测思路
    • 定时遍历活动进程链表(ActiveProcessLinks),获取每个进程的 KPROCESS 结构。
    • 读取其中的 DirectoryTableBase(即 CR3 寄存器的值)。
    • 将其与保存在系统正常结构中的、或首次启动时记录的 CR3 值进行对比。
    • 检查该 CR3 所在的物理页面属性是否正常(是否属于系统的 PFN 数据库管理的合法内存,而不是由攻击者通过未公开API申请的孤立物理页)。

四、 核心安全考量:如何避免系统蓝屏(BSOD)

在没有 VT-x 的 EPT 异常机制保护下,直接在 Ring 0 频繁读取和遍历页表存在极高的安全隐患。必须严格遵守以下准则:

  1. 防止页面置换(Page Out)
    在读取任意用户态或可分页内核态VA的PTE之前,必须使用 MmIsAddressValid 进行初步判断,并使用 MDL(内存描述符列表)对该虚拟地址进行锁定(MmProbeAndLockPages)。直接访问已被置换到磁盘上的页表会直接导致 PAGE_FAULT_IN_NONPAGED_AREA 蓝屏。
  2. 规避KPTI(Kernel Page Table Isolation)的影响
    自 Windows 10 引入 KPTI(用于缓解 Meltdown 漏洞)后,用户态和内核态拥有两套独立的页表。当你的驱动程序运行在系统线程上下文时,CR3 指向的是内核页表,此时去解析用户态进程的虚拟地址 PTE 可能会得到无效结果。
    • 解决方案:在读取特定进程的 PTE 时,必须先通过 KeStackAttachProcess 挂靠到目标进程的上下文空间中,确保 CR3 切换为该进程的页表后再进行解析。
  3. 高IRQL下的同步问题
    遍历页表或执行跨核 TLB 刷新时,应当将 IRQL 提升至 DISPATCH_LEVEL 或以上,以防止线程切换导致的竞争条件(Race Condition),但此时绝不能访问任何可分页内存。

五、 总结

在没有 Intel VT-x 或 AMD-V 的保驾护航时,对 Windows 内核 PTE 劫持的检测需要依赖更精细的内存管理结构解析和 CPU 指令特性。

通过动态定位 MmPteBase + 严格的 MDL 锁定 + 手动解析与 API 交叉校验,可以在纯软件层实现一套高强度、高稳定性的页表监控机制。对抗的关键在于处理好 KPTI 进程上下文切换TLB 去同步刷新 两个细节,如此方能在内核的安全红蓝对抗中立于不败之地。

极客内核 Windows内核PTE劫持内核安全

评论点评