基于Rust的Windows内存监控与逆向工程虚拟机监控器技术解析

本文深入探讨了基于Rust开发的两种虚拟机监控器技术:UEFI基础的illusion-rs和Windows内核驱动基础的matrix-rs,详细解析了如何利用EPT技术实现无痕内存监控和函数钩子,包括SSDT检测、影子页面创建和执行流重定向等核心机制。

基于虚拟机监控器的内存监控与逆向工程技术 | Secret Club

memN0ps · 2025年6月2日

引言

本文探讨了基于Rust开发的虚拟机监控器(hypervisor)在Windows平台上进行内存监控和逆向工程的设计与实现。我们涵盖了两个项目:illusion-rs(基于UEFI的虚拟机监控器)和matrix-rs(基于Windows内核驱动的虚拟机监控器)。两者都利用扩展页表(EPT)实现隐秘的控制流重定向,而无需修改客户机内存。

我们首先识别如何可靠检测系统服务描述符表(SSDT)在ntoskrnl.exe中的完全初始化时机,从而安全安装钩子而不会导致系统崩溃。Illusion和Matrix在触发和执行重定向方式上有所不同。

Illusion使用单一EPT和原地修补技术,结合VMCALL等VM-exit指令,以及监控陷阱标志(MTF)单步执行来安全重放原始字节。相比之下,Matrix采用双EPT模型:主EPT映射读写内存,次EPT重新映射包含跳板钩子的仅执行影子页面。执行通过INT3断点和EPT违规期间的动态EPTP切换进行重定向。

两种方法都通过基于EPT的重新映射和由INT3、VMCALL或CPUID等CPU指令触发的VM-exit,将内联钩子隐藏在客户机虚拟内存中,并将执行流重定向到攻击者控制的代码(如shellcode或处理函数)。

在虚拟机监控器开发中,影子化(shadowing)指创建客户机内存的第二个、由虚拟机监控器控制的视图。当页面被影子化时,虚拟机监控器创建原始页面的副本(通常称为影子页面),并更新EPT以将访问重定向到此副本。这使得虚拟机监控器能够拦截、监控或重定向内存访问,而无需修改原始客户机内存。影子化通常用于注入钩子、隐藏修改或在细粒度级别控制执行流。客户机和影子页面保持独立:客户机认为它正在访问自己的内存,而虚拟机监控器控制实际看到或执行的内容。

我们演示了如何使用仅执行权限来捕获指令获取,使用仅读/写权限来捕获访问违规,以及使用影子页面来注入跳板重定向。为了进行监控和控制传输,我们依赖于指令级陷阱,如VMCALL、CPUID和INT3,具体取决于上下文。在Illusion中,指令重放通过监控陷阱标志(MTF)单步执行来处理,以安全恢复被覆盖的字节。

虽然这些技术在游戏黑客社区中广为人知,但在信息安全领域仍未得到充分利用。本文旨在通过提供早期启动时和内核模式EPT钩子技术的实用、可复现的演练来弥合这一差距。使用的所有技术都是公开、稳定的,不依赖于未文档化的内部实现或特权SDK。

所采用的方法优先考虑极简主义和可复现性。我们假设读者对分页、虚拟内存以及Intel VT-x和EPT的基础知识有实际理解。虽然某些概念可能适用于AMD SVM和NPT,但本文仅专注于Intel平台。两种虚拟机监控器都完全避免修改客户机内存,保持系统完整性,并绕过如PatchGuard等内核保护。这使得能够使用EPT支持的重新映射从客户机控制之外隐秘监控NtCreateFile和MmIsAddressValid等函数。

目录

  • Illusion:基于UEFI的EPT钩子虚拟机监控器

    • 初始化期间设置IA32_LSTAR MSR钩子(initialize_shared_hook_manager())
    • 设置内核镜像基地址和大小(set_kernel_base_and_size())
    • 检测ntoskrnl.exe中SSDT加载时机
    • 设置EPT钩子(handle_cpuid())
    • 解析目标和分发钩子(manage_kernel_ept_hook())
    • 二级地址转换(SLAT):EPT(Intel)和NPT(AMD)
    • EPT钩子概述(build_identity())
    • 安装钩子载荷(ept_hook_function())
    • 映射大页面(map_large_page_to_pt())
    • 步骤1 - 拆分页面(is_large_page() -> split_2mb_to_4kb())
    • 影子化页面(is_guest_page_processed() -> map_guest_to_shadow_page())
    • 步骤2 - 克隆代码(unsafe_copy_guest_to_shadow())
    • 步骤3 - 安装内联钩子
    • 步骤4 - 撤销执行权限(modify_page_permissions())
    • 步骤5 - 无效化TLB和EPT缓存(invept_all_contexts())
    • 步骤6和7 - 通过EPT违规捕获执行(handle_ept_violation())
    • 步骤8 - 处理VMCALL钩子(handle_vmcall())
    • 步骤9 - 使用监控陷阱标志单步执行(handle_monitor_trap_flag())
    • 捕获读/写违规(handle_ept_violation())
    • Illusion执行跟踪:概念验证演练
    • 通过超级调用控制EPT钩子
  • Matrix:使用双EPT的Windows内核驱动虚拟机监控器

    • 初始化和次EPT(virtualize_system())
    • 步骤1和2 - 创建影子钩子和设置跳板(hook_function_ptr())
    • 步骤3、4、5和6 - 用于影子执行的双EPT重新映射(enable_hooks())
    • 步骤7 - 为断点VM-exit配置VMCS(setup_vmcs_control_fields())
    • 步骤8 - 使用动态EPTP切换处理EPT违规(handle_ept_violation())
    • 步骤9 - 通过断点处理程序重定向执行(handle_breakpoint_exception())
    • 步骤10 - 通过跳板返回到原始客户机函数(mm_is_address_valid()和nt_create_file())
    • Matrix执行跟踪:概念验证演练
  • 钩子重定向技术:INT3、VMCALL和JMP

  • 虚拟机监控器检测向量

  • 附录

    • 客户机辅助钩子模型
    • 比较EPT钩子模型:每核与共享
      • Matrix(所有逻辑处理器共享EPT)
      • Illusion(每逻辑处理器EPT与MTF)
  • 结论

  • 致谢、参考文献和灵感

    • 文章、工具和研究参考文献
    • 社区研究和灵感
    • 致谢
  • 文档和规范

Illusion:基于UEFI的EPT钩子虚拟机监控器

Illusion是一个基于UEFI的虚拟机监控器,设计用于早期启动时内存监控和系统调用钩子。它在matrix-rs之后开发,具有更简单的设计、更好的结构,并专注于在不接触客户机内存的情况下控制执行。

与在内核模式下运行并在所有逻辑处理器之间共享双EPT支持的matrix不同,illusion从UEFI固件运行,并为每个逻辑处理器使用单个EPT来影子化和转移客户机执行。一些虚拟机监控器通过使用一个、两个、三个或更多EPT来进一步扩展此设计 - 例如,为不同的执行阶段或进程上下文维护单独的EPT。其他还实现每逻辑处理器EPT隔离以实现更严格的控制。

Illusion中的钩子使用仅执行影子页面结合VMCALL和监控陷阱标志(MTF)单步执行进行内存监控。虽然Illusion优先考虑早期启动可见性和最小客户机干扰,但它也支持通过用户模式CPUID超级调用进行运行时控制。与所有基于EPT的钩子技术一样,该架构在设计、可维护性、复杂性和检测风险方面存在权衡 - 但这些细微差别超出了本文的范围。

下图显示了此技术在illusion-rs虚拟机监控器中的实现方式,特别是如何使用EPT来钩住内核内存。虽然在此示例中它应用于启动过程的早期,但如果虚拟机监控器收到启用或禁用钩子的信号,相同的钩子逻辑也可以在稍后触发 - 例如从用户模式。

图1:Illusion UEFI虚拟机监控器中基于EPT的函数钩子控制流

图中显示的每个步骤在以下部分详细解释。

初始化期间设置IA32_LSTAR MSR钩子(initialize_shared_hook_manager())

为了解析Windows内核的物理和虚拟基地址以及大小,我们拦截对IA32_LSTAR MSR的写入。此寄存器保存系统调用处理程序的地址,Windows将其设置为其内核模式分发器KiSystemCall64。当发生WRMSR VM-exit时,我们检查MSR ID是否对应于IA32_LSTAR。如果是,我们提取MSR值并从该地址向后扫描内存以定位MZ签名,这标记了ntoskrnl.exe PE镜像的开始,从而确定其基虚拟地址。

拦截IA32_LSTAR的目的不是修改系统调用行为,而是在早期启动期间可靠地提取内核的加载基地址。这是一个可靠的锚点,因为Windows总是在早期启动期间写入此MSR以设置KiSystemCall64。

需要注意的是,这不是内联钩子 - 而是由MSR写入触发的基于VM-exit的拦截。以下代码显示了在虚拟机监控器初始化期间如何应用IA32_LSTAR拦截:

代码参考(hook_manager.rs)

1
2
3
4
trace!("Modifying MSR interception for LSTAR MSR write access");
hook_manager
    .msr_bitmap
    .modify_msr_interception(msr::IA32_LSTAR, MsrAccessType::Write, MsrOperation::Hook);

处理对IA32_LSTAR的WRMSR在[handle_msr_access()]中:解除钩子并调用[set_kernel_base_and_size()]

当在早期内核设置期间由IA32_LSTAR钩子触发WRMSR VM-exit时,handle_msr_access()函数解除MSR钩子并调用set_kernel_base_and_size()来解析内核的基地址和大小。

代码参考(msr.rs)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if msr_id == msr::IA32_LSTAR {
    trace!("IA32_LSTAR write attempted with MSR value: {:#x}", msr_value);
    hook_manager.msr_bitmap.modify_msr_interception(
        msr::IA32_LSTAR,
        MsrAccessType::Write,
        MsrOperation::Unhook,
    );

    hook_manager.set_kernel_base_and_size(msr_value)?;
}

在启动的此时,系统调用入口点(KiSystemCall64)已被内核完全解析。我们使用其地址作为扫描基址来定位PE镜像的开始,并计算ntoskrnl.exe的物理基址。

设置内核镜像基地址和大小(set_kernel_base_and_size())

我们将MSR值传递给set_kernel_base_and_size,它在内部调用get_image_base_address向后扫描内存以查找MZ(IMAGE_DOS_SIGNATURE)头。然后使用pa_from_va_with_current_cr3使用客户机的CR3将虚拟基地址转换为物理地址,最后调用get_size_of_image从OptionalHeader.SizeOfImage字段检索ntoskrnl.exe的大小。这些操作本质上是危险的,因此传入正确的值至关重要 - 否则可能导致系统崩溃。

代码参考(hook_manager.rs)

1
2
3
self.ntoskrnl_base_va = unsafe { get_image_base_address(guest_va)? };
self.ntoskrnl_base_pa = PhysicalAddress::pa_from_va_with_current_cr3(self.ntoskrnl_base_va)?;
self.ntoskrnl_size = unsafe { get_size_of_image(self.ntoskrnl_base_pa as _).ok_or(HypervisorError::FailedToGetKernelSize)? } as u64;

检测ntoskrnl.exe中SSDT加载时机

在对NtCreateFile等内核函数执行基于EPT的钩子之前,确保系统服务描述符表(SSDT)已由Windows内核完全初始化非常重要。否则会引入竞争条件:如果钩子应用得太早,当虚拟机监控器尝试通过SSDT的系统调用号解析函数地址时(仅当函数从ntoskrnl.exe的导出表中缺失时使用的回退),存在目标无效内存的风险。这可能导致系统崩溃。

对ntoskrnl.exe内部执行路径的分析揭示了SSDT初始化之后的一个可靠点,但仍然足够早于内核设置以监控其他软件调用这些函数。

对KiInitializeKernel(负责在每个处理器上初始化内核的核心例程)的分析显示,它通过调用KeCompactServiceTable最终确定SSDT。从此时起,安装钩子变得安全。然而,仍然需要一个可靠且可重复的触发器 - 理想情况下,任何在KeCompactServiceTable调用后不久发生的无条件VM-exit。

图2:使用Binary Ninja在KiInitializeKernel()中观察到的KeCompactServiceTable()和KiSetCacheInformation(),确认了后SSDT调用序列。

这就是KiSetCacheInformation变得有用的地方。它在SSDT设置后立即调用,并触发一个明确定义的序列,包括CPUID指令。在Intel CPU上,KiSetCacheInformation调用KiSetStandardizedCacheInformation,后者开始发出cpuid(4, 0)来查询缓存拓扑。CPUID指令在Intel处理器上无条件导致VM-exit,在AMD处理器上可能根据拦截配置导致VM-exit,提供了一个可靠且确定性的点来同步EPT钩子安装。这使得CPUID成为同步状态转换或触发早期虚拟机监控器逻辑而无需客户机合作的方便指令。

图3:在Binary Ninja中观察到的KiSetCacheInformation()和KiSetCacheInformationAmd(),两者都在SSDT设置后调用执行CPUID的KiSetStandardizedCacheInformation()。

历史上,Intel系统使用路径KiSetCacheInformation -> KiSetCacheInformationIntel -> KiSetStandardizedCacheInformation。在最近的Windows 10和11版本上,对KiSetCacheInformationIntel的中间调用似乎已被移除 - KiSetCacheInformation现在在Intel平台上直接调用KiSetStandardizedCacheInformation。

在Intel处理器上,执行路径可靠(通过Binary Ninja分析Windows 11版本26100验证):

1
2
3
4
5
KiInitializeKernel
-> KeCompactServiceTable
-> KiSetCacheInformation
-> KiSetStandardizedCacheInformation
-> cpuid(4, 0)

在AMD处理器上,路径是条件性的(通过Binary Ninja分析Windows 11版本26100验证):

如果CPUID(0x80000001).ECX中的位22(TopologyExtensions)被设置:

1
2
3
4
5
6
KiInitializeKernel
-> KeCompactServiceTable
-> KiSetCacheInformation
-> KiSetCacheInformationAmd
-> KiSetStandardizedCacheInformation
-> cpuid(0x8000001D, 0)

此位指示处理器支持CPUID(0x8000001D),它以标准化方式枚举缓存和拓扑信息。如果未设置,操作系统必须回退到0x80000005 / 0x80000006。

否则(没有TopologyExtensions支持的回退路径):

1
2
3
4
5
KiInitializeKernel
-> KeCompactServiceTable
-> KiSetCacheInformation
-> KiSetCacheInformationAmd
-> cpuid(0x80000005) and cpuid(0x80000006)

虽然在测试的Windows版本中,Intel上的cpuid(4, 0)和AMD上的cpuid(0x8000001D, 0)在SSDT设置后不久执行,但此虚拟机监控器使用cpuid(2, 0)代替。这是从早期开发中延续下来的错误 - cpuid(0x2)不是相同KiSetCacheInformation()路径的一部分,也不是SSDT完成的确定性指示器。它在测试系统上的启动期间碰巧可靠触发,这使得它当时"足够好"。由于项目不再积极维护,代码保持原样 - 但对于任何将其适应生产用途的人,钩住cpuid(4, 0)或cpuid(0x8000001D)是正确的路径。

设置EPT钩子(handle_cpuid())

CPUID指令在早期启动期间执行多次,这可能导致冗余的VM-exit。为避免重复的钩子设置,虚拟机监控器使用has_cpuid_cache_info_been_called标志。钩子只需要在SSDT初始化后运行一次,使其成为一个简单稳定的时序标记。

代码参考(cpuid.rs)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
match leaf {
    leaf if leaf == CpuidLeaf::CacheInformation as u32 => {
        trace!("CPUID leaf 0x2 detected (Cache Information).");
        if !hook_manager.has_cpuid_cache_info_been_called {
            hook_manager.manage_kernel_ept_hook(
                vm,
                crate::windows::nt::pe::djb2_hash("NtCreateFile".as_bytes()),
                0x0055,
                crate::intel::hooks::hook_manager::EptHookType::Function(
                    crate::intel::hooks::inline::InlineHookType::Vmcall
                ),
                true,
            )?;
            hook_manager.has_cpuid_cache_info_been_called = true;
        }
    }
}

这确保我们只在SSDT初始化后应用EPT函数钩子,并保证后续的CPUID调用不会重新触发钩子逻辑。

解析目标和分发钩子(manage_kernel_ept_hook())

让我们分解manage_kernel_ept_hook函数的功能。它管理在目标内核函数(如NtCreateFile)上安装或移除扩展页表(EPT)钩子。

逻辑很简单:给定一个哈希的函数名和系统调用号,它首先尝试使用get_export_by_hash解析函数的虚拟地址,该函数检查ntoskrnl.exe的导出表。如果失败,它回退到通过系统服务描述符表(SSDT)使用其系统调用号解析函数。

如果enable == true,它调用ept_hook_function(),通过影子化客户机内存和修改EPT权限来安装钩子 - 更多内容稍后介绍。如果enable == false,它调用ept_unhook_function()来恢复原始映射并解除函数钩子。

代码参考(hook_manager.rs)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pub fn manage_kernel_ept_hook(
    &mut self,
    vm: &mut Vm,
    function_hash: u32,
    syscall_number: u16,
    ept_hook_type: EptHookType,
    enable: bool,
) -> Result<(), HypervisorError> {
    let action = if enable { "Enabling" } else { "Disabling" };
    debug!("{} EPT hook for function: {:#x}", action, function_hash
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计