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

本文深入探讨了基于Rust开发的两种Windows虚拟机技术:UEFI基础的illusion-rs和内核驱动基础的matrix-rs,详细解析了它们如何利用EPT实现内存监控、系统调用钩子和逆向工程,而无需修改客户机内存。

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

memN0ps · 2025年6月2日

引言

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

我们首先确定如何在ntoskrnl.exe中可靠检测系统服务描述符表(SSDT)完全初始化的时机,从而安全安装钩子而不会导致系统崩溃。Illusion和Matrix在触发和重定向执行方面有所不同。Illusion使用单个EPT和原地修补,结合VMCALL等VM退出指令,以及监控陷阱标志(MTF)单步执行来安全重放原始字节。相比之下,Matrix使用双EPT模型,其中主EPT映射读/写内存,次EPT重新映射包含跳板钩子的仅执行影子页面。执行通过INT3断点和EPT违规期间的动态EPTP切换进行重定向。两种方法都通过基于EPT的重映射和由CPU指令(如INT3、VMCALL或CPUID)触发的VM退出,将内联钩子隐藏在客户虚拟内存中,并将执行流重定向到攻击者控制的代码(如shellcode或处理函数)。

在虚拟机开发中,影子化指的是创建客户内存的第二个、由虚拟机控制的视图。当页面被影子化时,虚拟机会创建原始页面的副本(通常称为影子页面),并更新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加载的时机
      • 在Intel处理器上,执行路径可靠(通过Windows 11版本26100的Binary Ninja分析验证)
      • 在AMD处理器上,路径是条件性的(通过Windows 11版本26100的Binary Ninja分析验证)
    • 设置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退出配置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之后开发的,具有更简单的设计、更好的结构,并专注于控制执行而不触及客户内存。

与matrix不同,matrix在内核模式下运行,支持跨所有逻辑处理器共享的双EPT,而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退出时,我们检查MSR ID是否对应于IA32_LSTAR。如果是,我们提取MSR值并从该地址向后扫描内存以定位MZ签名,该签名标记ntoskrnl.exe PE映像的开始,从而确定其基虚拟地址。拦截IA32_LSTAR的目的不是修改系统调用行为,而是在早期启动期间可靠地提取内核的加载基地址。这是一个可靠的锚点,因为Windows总是在早期启动期间写入此MSR以设置KiSystemCall64。

需要注意的是,这不是内联钩子 - 而是由MSR写入触发的基于VM退出的拦截。以下代码显示了在虚拟机初始化期间如何应用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退出时,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的钩子之前,确保Windows内核已完全初始化系统服务描述符表(SSDT)非常重要。否则会引入竞争条件:如果钩子应用得太早,当虚拟机尝试通过SSDT的系统调用号解析函数地址时(仅当函数不在ntoskrnl.exe的导出表中时使用的回退),存在目标无效内存的风险。这可能导致系统崩溃。对ntoskrnl.exe内执行路径的分析揭示了SSDT初始化之后的一个可靠点,但仍然足够早于内核设置以监控其他软件调用这些函数。

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

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

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

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

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

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

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

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

如果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退出。为避免重复的钩子设置,虚拟机使用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
11
12
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);

    trace!("Ntoskrnl base VA: {
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计