本教程将详细介绍如何在 Windows 内核态开发中,通过页表项 (PTE) 实现无痕的物理内存读取。我们将手拿把掐指导从环境配置到代码实现。
Part 1 - 开发环境配置
首先,我们需要配置合适的开发环境,包括 Visual Studio 2022、Windows SDK 和 WDK。
1. 安装 Visual Studio 2022
- 访问 Visual Studio Community 2022 下载页面。
- 下载并安装 Visual Studio Installer,选择“C++ 桌面开发”工作负载。
- 在单独组件中,选中以下项:
- 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
- 下载并安装 Windows SDK Windows SDK 10.0.26100.1。
- 下载并安装 WDK WDK 10.0.26100.1591。
安装 WDK 时,取消勾选“Install Windows Driver Kit Visual Studio extension”以避免潜在的兼容性问题。
3. 配置 Visual Studio 项目
- 打开 Visual Studio,新建项目,选择 KMDF, Empty 模板。
- 右键项目,选择“管理 NuGet 包”,搜索并安装
Musa.Runtime
及其依赖项。
Part 2 - 项目设置
1. 配置项目属性
- 右键项目,选择“属性”,将 C++ 语言标准设置为 C++20 或更高版本。
- 将入口点设置为
DriverMain
。这是 Musa 的要求,否则编译不通过。
2. 关闭不必要的安全设置
如果你使用 Kdmapper
加载驱动程序,你可能需要关闭某些安全设置,具体参见 Kdmapper
的 readme。
Part 3 - 编码实现
本节介绍如何编写代码,通过操作页表项(PTE)实现物理内存读取,避免调用操作系统 API。
1. 使用 IDA 获取关键函数偏移
我们需要通过 IDA 获取 MiGetPteAddress
和 MmAllocateIndependentPages
的地址。这两个函数分别用于获取页表项地址和分配独立的物理页。
- 打开
ntoskrnl.exe
(位于C:\Windows\System32
)文件。 - 搜索
MiGetPteAddress
,双击进入函数,记下左下角的偏移(的低 24 位)。 - 搜索
MmAllocateIndependentPages
,同样记下其偏移。
2. 到 Musa.Core 抄代码
- 搜索代码 GetLoadedModule,复制粘贴其函数,删去 Musa_API。
- 复制粘贴 Musa.Core 中 GetLoadedModule 的唯一用法,以初始化 ntoskrnl.exe 基指针。
- 到 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. 代码详细解读
-
初始化
Init()
函数:- 通过调用
MmAllocateIndependentPages
分配独立的物理页,并用作物理内存映射缓冲区。 - 调用
MiGetPteAddress
获取分配内存的页表项地址,并保存页表项的原始物理帧编号。
- 通过调用
-
物理内存读取函数
ReadPhysicalMemoryInPage()
:- 首先通过
KeRaiseIrqlToDpcLevel()
提升 IRQL(中断请求级别),防止当前线程在多核系统中被调度到其他核心。 - 修改
MapperPte
的页帧号,使其指向目标物理内存地址。随后使用__invlpg
刷新当前核心的 TLB(地址转换缓冲区),确保映射生效。 - 使用
__movsb
将目标物理内存中的内容拷贝到指定缓冲区。 - 恢复
MapperPte
的原始页帧号,并再次刷新 TLB,确保页面映射正确。 - 最后,恢复 IRQL 以允许正常的系统调度。
- 首先通过
Part 4 - 测试驱动程序
- 编译代码:在 Visual Studio 中编译项目,确保所有设置正确。
- 加载驱动:使用
Kdmapper
或其他工具加载驱动程序。 - 测试内存读取功能:使用测试程序调用
ReadPhysicalMemoryInPage
函数,验证是否能够正确读取物理内存内容。
总结
本文详细介绍了如何在不深入理解页表机制的情况下,使用 C++ 编写内核态驱动程序来实现无痕的物理内存读取。通过现成的工具、简单的步骤和函数指针初始化,完成了对页表项 (PTE) 的操作。
注
- 代码来自 Musa.Core,以及不调用系统 api 读写物理内存的方法。
- 对于
MiGetPteAddress
:由于它是无任何依赖的内核函数,可以考虑手动实现,以进一步减少调用。只需要考虑 Windows 的 ASLR 机制,动态解析出MiGetPteAddress
中存储的 PTE 基址即可。详见:Exploit Development: Leveraging Page Table Entries for Windows Kernel Exploitation。 - 本文由 4o 优化输出。