在不了解页表机制的情况下像个弱智一样实现无痕内存读取

本教程将详细介绍如何在 Windows 内核态开发中,通过页表项 (PTE) 实现无痕的物理内存读取。我们将手拿把掐指导从环境配置到代码实现。

Part 1 - 开发环境配置

首先,我们需要配置合适的开发环境,包括 Visual Studio 2022、Windows SDK 和 WDK。

1. 安装 Visual Studio 2022

  1. 访问 Visual Studio Community 2022 下载页面
  2. 下载并安装 Visual Studio Installer,选择“C++ 桌面开发”工作负载。
  3. 在单独组件中,选中以下项:
    • MSVC v 143 - VS 2022 C++ ARM 64/ARM 64 EC Spectre 缓解库
    • MSVC v 143 - VS 2022 C++ x 64/x 86 Spectre 缓解库
    • 带有 Spectre 缓解库的适用于最新 v 143 生成工具的 C++ ATL (ARM 64/ARM 64 EC)
    • 带有 Spectre 缓解库的适用于最新 v 143 生成工具的 C++ ATL (x 86 & x 64)
    • 带有 Spectre 缓解库的适用于最新 v 143 生成工具的 C++ MFC (ARM 64/ARM 64 EC)
    • 带有 Spectre 缓解库的适用于最新 v 143 生成工具的 C++ MFC (x 86 & x 64)
    • Windows 驱动程序工具包 (WDK)

2. 安装 Windows SDK 和 WDK

  1. 下载并安装 Windows SDK Windows SDK 10.0.26100.1
  2. 下载并安装 WDK WDK 10.0.26100.1591

    安装 WDK 时,取消勾选“Install Windows Driver Kit Visual Studio extension”以避免潜在的兼容性问题。

3. 配置 Visual Studio 项目

  1. 打开 Visual Studio,新建项目,选择 KMDF, Empty 模板。
  2. 右键项目,选择“管理 NuGet 包”,搜索并安装 Musa.Runtime 及其依赖项。

Part 2 - 项目设置

1. 配置项目属性

  1. 右键项目,选择“属性”,将 C++ 语言标准设置为 C++20 或更高版本。
  2. 将入口点设置为 DriverMain。这是 Musa 的要求,否则编译不通过。

2. 关闭不必要的安全设置

如果你使用 Kdmapper 加载驱动程序,你可能需要关闭某些安全设置,具体参见 Kdmapperreadme

Part 3 - 编码实现

本节介绍如何编写代码,通过操作页表项(PTE)实现物理内存读取,避免调用操作系统 API。

1. 使用 IDA 获取关键函数偏移

我们需要通过 IDA 获取 MiGetPteAddressMmAllocateIndependentPages 的地址。这两个函数分别用于获取页表项地址和分配独立的物理页。

  1. 打开 ntoskrnl.exe(位于 C:\Windows\System32)文件。
  2. 搜索 MiGetPteAddress,双击进入函数,记下左下角的偏移(的低 24 位)。
  3. 搜索 MmAllocateIndependentPages,同样记下其偏移。

2. 到 Musa.Core 抄代码

  1. 搜索代码 GetLoadedModule,复制粘贴其函数,删去 Musa_API。
  2. 复制粘贴 Musa.Core 中 GetLoadedModule 的唯一用法,以初始化 ntoskrnl.exe 基指针。
  3. 到 Readme 复制粘贴 DriverEntry 函数,将名称改为 DriverMain。

    如果你使用 Kdmaapper,你应该将复制的 DriverEntry 改为 CustomEntry,并在 DriverMain 中执行 CustomEntry。这是为了避免 Kdmapper 内存映射导致的输出问题。

2. 核心代码实现

#include <Musa.Core.h>
#include <Veil.h>

PVOID Mapper = 0;
PTE_64* MapperPte = 0;
u64 MapperPteOrigPfn = 0;

// 函数指针声明
typedef PTE_64* (*MiGetPteAddress_t)(PVOID VirtualAddress);
typedef PVOID (*MmAllocateIndependentPages_t)(SIZE_T NumberOfBytes, ULONG_PTR ZeroBits);

// 指针初始化
MiGetPteAddress_t MiGetPteAddress = nullptr;
MmAllocateIndependentPages_t MmAllocateIndependentPages = nullptr;

// 初始化内存映射,获取页表项
VOID Init()
{
    // 分配独立页,作为物理内存映射区域
    Mapper = MmAllocateIndependentPages(0x1000, -1);
    memset(Mapper, 0, 0x1000);

    // 获取页表项的地址
    MapperPte = MiGetPteAddress(Mapper);
    MapperPteOrigPfn = MapperPte->PageFrameNumber;
}

// 读取物理内存
VOID ReadPhysicalMemoryInPage(PVOID Buffer, u64 Phys, u64 Size)
{
    // 提升 IRQL 以防止调度到其他核心
    KIRQL OldIrql = KeRaiseIrqlToDpcLevel();

    // 修改页表项,使其指向目标物理地址
    MapperPte->PageFrameNumber = Phys >> PAGE_SHIFT;
    
    // 刷新本核的 TLB,确保地址映射生效
    __invlpg(Mapper);

    // 拷贝目标物理内存内容到缓冲区
    __movsb((PUCHAR)Buffer, (PUCHAR)Mapper + (Phys & 0xFFF), Size);

    // 恢复原来的页表项
    MapperPte->PageFrameNumber = MapperPteOrigPfn;
    
    // 再次刷新本核的 TLB,确保地址一致性
    __invlpg(Mapper);

    // 恢复 IRQL
    KeLowerIrql(OldIrql);
}
3. 代码详细解读
  1. 初始化 Init() 函数

    • 通过调用 MmAllocateIndependentPages 分配独立的物理页,并用作物理内存映射缓冲区。
    • 调用 MiGetPteAddress 获取分配内存的页表项地址,并保存页表项的原始物理帧编号。
  2. 物理内存读取函数 ReadPhysicalMemoryInPage()

    • 首先通过 KeRaiseIrqlToDpcLevel() 提升 IRQL(中断请求级别),防止当前线程在多核系统中被调度到其他核心。
    • 修改 MapperPte 的页帧号,使其指向目标物理内存地址。随后使用 __invlpg 刷新当前核心的 TLB(地址转换缓冲区),确保映射生效。
    • 使用 __movsb 将目标物理内存中的内容拷贝到指定缓冲区。
    • 恢复 MapperPte 的原始页帧号,并再次刷新 TLB,确保页面映射正确。
    • 最后,恢复 IRQL 以允许正常的系统调度。

Part 4 - 测试驱动程序

  1. 编译代码:在 Visual Studio 中编译项目,确保所有设置正确。
  2. 加载驱动:使用 Kdmapper 或其他工具加载驱动程序。
  3. 测试内存读取功能:使用测试程序调用 ReadPhysicalMemoryInPage 函数,验证是否能够正确读取物理内存内容。

总结

本文详细介绍了如何在不深入理解页表机制的情况下,使用 C++ 编写内核态驱动程序来实现无痕的物理内存读取。通过现成的工具、简单的步骤和函数指针初始化,完成了对页表项 (PTE) 的操作。

  1. 代码来自 Musa.Core,以及不调用系统 api 读写物理内存的方法
  2. 对于 MiGetPteAddress:由于它是无任何依赖的内核函数,可以考虑手动实现,以进一步减少调用。只需要考虑 Windows 的 ASLR 机制,动态解析出 MiGetPteAddress 中存储的 PTE 基址即可。详见:Exploit Development: Leveraging Page Table Entries for Windows Kernel Exploitation
  3. 本文由 4o 优化输出。