首页
社区
课程
招聘
[原创]从0到1实现NPT无痕Hook——内存虚拟化Hook技术全解析
发表于: 2小时前 189

[原创]从0到1实现NPT无痕Hook——内存虚拟化Hook技术全解析

2小时前
189

本文从实现者的角度,讲清楚基于AMD NPT(Nested Page Tables)的内存虚拟化Hook技术"为什么这样设计"以及"应该怎么实现"。重点是思路,具体代码示例在我的 Github仓库 上。

Hook(钩子)技术几乎和Windows操作系统一样古老。从应用层的API Hook到内核层的SSDT Hook,从简单的地址替换到复杂的Inline Patch,二十多年来Hook技术一直在和"反Hook"技术做猫鼠游戏。理解这段历史,才能明白为什么NPT/EPT虚拟化Hook会出现,以及它解决了什么问题。

第一代:表项替换。 早期的Hook极其简单——操作系统用表来存储函数指针(比如SSDT系统服务表、IDT中断描述符表、驱动对象里的IRP处理函数指针),你只需要把表里的指针改成你自己的函数地址就完事了。这种方法在Windows XP时代非常流行,但致命问题是太容易被检测:反作弊和安全软件只要把表中的地址和磁盘上的原始文件对比一下,立刻就能发现指针被改过。

第二代:Inline Hook。 既然改表太显眼,那就不改表,直接改函数开头的机器码——在函数入口写入一条跳转指令(jmp rel32,5字节),CPU执行到这里就会跳到你的函数。执行完你的逻辑后,再通过一个"跳板"(trampoline)执行被覆盖的原始字节,跳回原函数继续执行。这就是经典的Inline Hook,也是Detours、MinHook等库的原理。Inline Hook比表项替换隐蔽得多——它不修改任何表指针,只是修改了代码字节。但问题随之而来:你改了代码字节,就一定有办法被检测到。任何对代码段做CRC校验或Hash比对的机制(比如Windows的PatchGuard/KPP、现代反作弊的.text段完整性校验)都能发现这5字节的jmp指令与磁盘上的原始文件不一致。

第三代:绕过修改。 为了对抗代码段校验,各种"无痕"手段被发明出来:

这些方法虽然在"不修改代码"上前进了一步,但都有各自的致命短板——要么能被轻易检测,要么能Hook的位置受限,要么性能差到无法接受。

第四代:硬件虚拟化Hook。 到这里,一个根本性的问题摆上台面:有没有一种方法,能让CPU在"读"某段代码时看到原始字节,但在"执行"这段代码时跳转到我们的逻辑——而且全程不修改任何内存?

答案在2005年前后出现了。Intel和AMD相继推出硬件辅助虚拟化技术(VT-x和AMD-V),其中包含一个关键特性——二级地址翻译(SLAT, Second Level Address Translation)。Intel叫它EPT(Extended Page Tables),AMD叫它NPT(Nested Page Tables)。这个机制原本是为了让虚拟机更高效地运行——Hypervisor通过EPT/NPT控制Guest物理地址到主机物理地址的映射,不需要像早期软件虚拟化那样"影子页表"。但安全研究者很快意识到:既然Hypervisor能控制每个物理页的"读/写/执行"权限,而且这个控制发生在CPU硬件层面、Guest完全看不见——那这不就是做Hook的完美武器吗?

2006年,Joanna Rutkowska提出了著名的"Blue Pill"概念——在运行时通过AMD-V把正在运行的操作系统整个包裹进一个Hypervisor里,操作系统对此完全无感知。这个概念震惊了安全界,也开启了硬件虚拟化Rootkit的研究。此后十多年里,基于EPT/NPT的内存Hook技术逐渐成熟:从学术项目(如2013年的SPIDER框架)到开源Hypervisor(如SimpleSvmHook、DdiMon、HyperDbg),虚拟化Hook从理论走向了工程实现。

相比前三代Hook技术,基于SLAT的虚拟化Hook有几个颠覆性的优势:

当然,NPT Hook也不是万能的——它需要在内核中加载一个驱动来初始化Hypervisor(虽然启动后可以移除驱动痕迹),会引入VMEXIT的性能开销,而且也不是完全不可检测(第14章会讨论检测与对抗)。但就目前而言,它是Windows平台上隐蔽性最强、最稳定的Hook手段之一。

本文要讲的,就是如何从零实现这样一个基于AMD NPT的无痕Hook框架的思路。

阅读本文前,假设你已经了解:

不需要提前了解SVM/AMD-V细节,我会在用到时讲。

做任何Hook,本质上都是要回答一个问题:如何让CPU在执行到某个地址时,不执行那里原本的指令,而是执行我们的指令?

传统Inline Hook的回答是:把那个地址的指令改掉,写一条jmp跳过来。简单粗暴,但有致命问题——任何人(PatchGuard、反作弊、你的调试器)只要去读那个地址,就能看到你写的jmp。

NPT Hook换了一个角度来回答这个问题:

不改指令,改CPU看到的物理内存。

x64 CPU在开启分页后执行一条指令,要经过"虚拟地址→物理地址"的翻译。这个翻译是CPU的内存管理单元(MMU)自动完成的。在没有虚拟化时,CR3指向一套页表,MMU按照这套页表把GVA翻译成GPA(这里GPA就是真实的物理地址HPA)。

但当CPU运行在SVM Guest模式时,翻译变成了两级:

关键点在这里:第二级翻译(NPT)是Hypervisor完全控制的,Guest操作系统根本看不见也改不了NPT。Guest知道的只是GPA,至于GPA背后到底对应哪块HPA上的物理内存,是Hypervisor说了算。

这就给了我们一个上帝视角:同一个GPA,我们可以让它在"读/写"时指向物理页A(原始代码),在"取指执行"时指向物理页B(我们动过手脚的页)。

CPU怎么区分"读/写"和"取指"?因为NPT的页表项里有NX(No Execute,bit63)位。如果我们把一页设为"可读可写但不可执行"(NX=1),CPU在这页上取指时会触发一个Nested Page Fault(#NPF),并且在错误码里用一个bit告诉我们:"这次违规是因为取指(execute)"。

这就是整个NPT Hook技术的根基。后面所有复杂的机制(影子页、跳板、TLB刷新),都是在这个根基上长出来的工程细节。

在动手之前,先让我们用"方案迭代"的方式推导出正确的设计,这样你理解每个部分时就知道它是为了解决什么问题。

你可能想:我在原始代码页上写jmp到我的函数,然后用NPT把这页设为只读,防止别人覆盖我的jmp。但这没有解决根本问题——任何人读这页都能看到jmp指令,CRC校验立刻发现异常。核心矛盾:写了jmp就会被看到。

好,那我不修改原始页。我准备两个物理页:

平时NPT把GPA映射到页A(读/写/执行都指向A)。当CPU要执行目标函数时,我想办法把NPT切到页B,执行完了再切回A。

问题:怎么知道CPU"要执行目标函数"了?一个办法是把页A的NX位置1,禁止执行。这样取指时触发NPF,在NPF处理中我把NPT切到页B(可执行),让CPU重新取指——就跳到了页B上的jmp。

但这还有两个问题:

问题1的解决思路:让页B(执行视图)上不是jmp,而是INT3(0xCC)。执行到INT3会触发#BP异常,我们拦截#BP,在VMEXIT中直接把Guest RIP改成跳板地址,然后立刻切回页A。这样页B暴露的时间窗口被压缩到"执行一条单字节INT3的瞬间。

等等,这里还有问题:即使不切回A,如果另一个核心同时读这页,它的NPT也是独立的(每核心NPT),可以让它始终指向A。但同一核心上,NPF切到B之后,如果在执行INT3之前有一个读操作进来怎么办?实际上,INT3是单字节指令,从NPT切到B到#BP触发VMEXIT之间,CPU只会执行这一条INT3,不可能发生读操作(因为执行流已经陷入VMEXIT了)。

但还有更严重的问题:如果我们把B页做成含jmp的页,那B页上jmp跳到Hook函数后,Hook函数返回时需要执行被jmp覆盖掉的原始指令——这就是经典的Trampoline(跳板)问题,和传统Inline Hook的跳板是一个道理。

综合以上推演,最终方案包含这几个核心部件:

后面的章节我们逐个展开。

很多人以为Hypervisor必须在系统启动前加载(像VMware、Hyper-V那样)。其实不然。SVM(以及VT-x)允许我们在系统运行时动态启用虚拟化——已经运行的操作系统可以作为Guest被"包裹"进Hypervisor里。这就是所谓的Blue Pill类型Hypervisor。

核心思路:

这是完全可行的,因为VMCB保存了Guest的所有寄存器状态,VMRUN时从VMCB加载这些状态后,Guest就从VMRUN的下一条指令继续执行,跟什么都没发生一样。

在做任何事情之前,先确认CPU支持:

如果第3步失败(SVMDIS=1),说明BIOS禁用了SVM,需要提示用户重启进BIOS打开。

设置EFER.SVME位(EFER的bit12,MSR地址0xC0000080):

这一步是全局开关。设置后CPU进入"SVM已启用"状态,可以执行VMRUN指令了。

VMCB是一块4KB的物理连续内存,按照AMD APM定义的格式组织。分配时必须用MmAllocateContiguousMemory(保证物理连续),然后按字节清0。

VMCB分两部分:

初始化State Save Area的思路:把当前CPU正在运行的状态"冻结"下来作为Guest的初始状态。这意味着你需要:

这样VMRUN之后Guest就从你当前的执行点继续运行——Windows对此完全无感。

Control Area需要设置的关键项:

还需要分配一页物理连续内存作为HSAVE区,物理地址写入MSR_VM_HSAVE_PA(0xC0010117),用于硬件在VMRUN/VMSAVE之间保存Host状态。

VMEXIT发生时CPU要切到Host栈执行我们的处理代码。需要为每个CPU分配一块足够大的栈(如64KB-128KB,非分页内存),以及一个Host VMCB(主要用来保存Host的RSP)。

这些准备好之后,通过一段汇编来进入VM循环(伪代码):

执行完VMRUN之后,Guest就开始运行了。你的系统此时已经在虚拟化环境中运行。先什么都不做(NPT是恒等映射),确认系统能稳定运行——如果连这一步都过不了,后面的Hook也不用谈了。

VMCB中NCr3指向的NPT是我们掌控Guest物理内存视图的根本。如果不设置NpEnable(或者NPT有问题),Guest的GPA就是HPA,跟没有虚拟化一样——这当然不是我们想要的。

启动时NPT的最简实现是恒等映射(GPA == HPA),让Guest看到真实的物理内存,不做任何篡改。为了减少页表开销和TLB miss,使用2MB大页:

这样514页(约2MB)就能映射512GB物理内存,覆盖绝大多数消费级硬件的内存容量。每个PDE的权限位设置为P=1、R/W=1、U/S=1(因为我们需要允许Guest内核和用户都访问)、A=1、D=1、NX=0(允许执行)。

NPT的PTE格式跟x64普通PTE完全一样,这是NPT相比EPT的一个友好之处:

2MB大页对于"整页权限一致"的场景够用了,但当我们需要Hook一个函数时,我们只需要对函数所在的4KB页做手脚,不希望影响同一个2MB内其他4KB页。因此需要大页拆分机制。

拆分的思路:

伪代码如下:

为什么要原子操作?因为可能有多个核心同时在NPF处理中尝试拆分同一个大页——第一个成功的真正做了拆分,其他的检测到PDE已经变了就直接放弃。

页表页、影子页都需要物理页,但VMEXIT运行在高IRQL(关中断),不能直接分配物理内存。解决方案:预分配多个4KB大小的物理页,管理哪些页空闲、哪些已使用。

初始只映射前512GB。如果Guest访问了未映射的GPA(NPF,Present=0),就在NPF处理中动态补建页表:遍历四级PML4→PDPT→PD→PT,遇到中间项不存在就从页池分配,最后PTE填恒等映射。这样不需要启动时映射全部物理内存。

有了NPT之后,我们的第一件武器就是NX位。假设我们想Hook函数F(地址为0xFFFFF800`12345000):

这就是一个最简单的"执行陷阱"。

需要写一个通用函数,修改指定GPA在NPT中的权限,伪代码如下:

注意newHpa参数:这是核心——它允许我们把GPA映射到一个不同的HPA(比如影子页的物理地址)。这就是视图切换的底层原语。

NPF VMEXIT时,ExitInfo1是错误码,结构如下:

我们关心的是:当Present=1ID=1时,就是"在一个存在的页上取指但NX=1"——这正是我们要的执行陷阱。

光有NX陷阱只能让我们知道"CPU要执行这个地址了",但CPU触发NPF后,我们还得让它能继续执行。如果我们直接把NX清0让它执行原页,那执行的就是原始指令——没有Hook效果。

我们需要的是:NPF之后,CPU重新取指时看到的不是原始指令,而是"能跳转到Hook函数"的代码。

方案:不修改原始物理页,而是准备一个影子物理页,内容是原页的拷贝,但在Hook点动了手脚。NPF时把NPT从原始物理页切到这个影子页。

分配一个新物理页(从页池),memcpy原始页的内容过去。这页内容完全干净——读它就等于读原页。NPT在正常状态下始终指向这一页,NX=1(禁止执行)。

等等——为什么读/写不直接指向原始物理页,要多此一举拷贝一份?

确实可以直接指向原始物理页(很多实现就是这么做的)。但用影子页0有一个额外好处:可以在这页上设置与原页不同的权限(比如禁止写),或者让它映射到零页(用于隐藏Hypervisor自身)。对于代码Hook场景,影子页0可以直接等于原始物理页(不分配新页),但框架设计上预留这个能力更灵活。

再分配一个物理页,同样memcpy原始页内容。但在被Hook函数的入口偏移处,写入一个字节0xCC(INT3)nop对齐第一条指令。

为什么只写一个0xCC而不是一条完整的jmp?三个原因:

Hook安装完成后,目标GPA在NPT中的默认映射:

此时Guest读/写目标地址都正常(看到干净代码),但一执行就NPF。

当我们在#BP VMEXIT中把Guest RIP改成跳板地址后,Guest开始执行跳板代码。跳板需要完成:

跳板是一段动态生成的机器码,分配在非分页可执行内存(ExAllocatePool2 with POOL_FLAG_NON_PAGED_EXECUTE)。结构如下:

数据区存放3个地址:

这里有个很微妙的地方:跳板本身不是用CALL指令调用原函数,而是用JMP。当原函数执行完RET时,栈顶是调用者的返回地址——RET正确返回到调用被Hook函数的地方。

这是一个工程上的难点。你需要从原函数入口开始,复制若干字节到跳板的OriginalCode区域,这些字节必须覆盖整数条完整指令(不能在指令中间截断),并且总长度要足够容纳跳板开头的"hook跳转"逻辑。

具体来说:

这就是为什么项目里需要一个x64指令长度解码器(不需要完整反汇编,只需要算出每条指令多长)。

跳板分配在NonPagedExecute池,需要让Guest能执行(否则跳过去会NPF)。但是:

这是整个Hook触发的核心流程。让我们从头追踪一次完整的Hook触发:

NPF处理完后,VMRUN回到Guest。CPU重新从RIP=F取指:

VMRUN回Guest后,RIP指向跳板。跳板开始执行:保存寄存器→调用Hook函数→执行原始序言→跳回原函数+序言偏移→原函数正常执行→ret返回到调用者。

整个Hook触发流程完成。

VMMCALL是Guest主动与Hypervisor通信的指令。跳板最后可以通过vmmcall通知Hypervisor执行复位操作,Hypervisor在VMEXIT_VMMCALL中处理:

你可能注意到:在#BP处理中我们已经把NPT切回了影子页0(NX=1)。但如果因为某种原因(比如多核并发、异常流程)NPT没有正确复位,怎么办?

更安全的做法是创建一个高优先级系统线程,每隔很短时间(如5ms)在每个CPU上执行VMMCALL_RESET_SHADOWS,强制把所有Hook页的NPT复位到影子页0(NX=1)。这个"双保险"确保影子页1暴露的时间窗口极短。

伪代码如下:

做Hook的人最怕的不是Hook失效,而是被发现。NPT Hook的优势在于Hypervisor本身对Guest是不可见的,但前提是你要做好隐藏。

Hypervisor的所有私有数据结构(VMCB、NPT页表、Host栈、影子页、Hook元数据、甚至你自己的代码段)都不应该被Guest访问到。最简单粗暴的方法:把这些地址的NPT PTE指向一个全零页

这样Guest读这些地址读到全0(看起来是未分配内存),写触发NPF(注入#PF),执行触发NPF(注入#GP)。

Guest如果读EFER MSR,会看到EFER.SVME=1(因为SVM确实在运行),这直接暴露了虚拟化。处理方式:

CPUID是最常见的虚拟化检测手段:

在Guest中执行VMRUN/VMSAVE/VMLOAD/VMMCALL等SVM指令时,由于我们已经让Guest看到SVME=0,这些指令应该触发#UD(无效操作码异常)。在VMMCALL拦截中:

反检测最重要的原则不是把某个特定的bit伪造好,而是所有的返回值必须一致。如果你让CPUID返回"SVM不支持",那EFER.SVME必须返回0,VM_CR必须返回锁定状态,VMRUN执行必须触发#UD——只要有一个地方矛盾,检测方就能判断出有Hypervisor在运行。

每个CPU核心需要独立的:VMCB(Guest+Host)、HSAVE、Host栈、NPT页表。NPT的PML4和PDPT层可以共享(因为它们映射的是全局地址空间),但PD和PT层需要独立——因为同一时刻,CPU A可能在执行Hook(NPT指向影子页1),而CPU B在读同一函数(NPT指向影子页0)。

启动SVM时,通过KeSetSystemAffinityThread依次把线程绑定到每个CPU,在该CPU上执行VMRUN。

每次修改NPT PTE后,必须刷新TLB。否则CPU可能使用缓存的旧翻译,导致:

AMD SVM通过VMCB中的TlbControl字段控制TLB刷新:

设置TlbControl后,下次VMRUN时硬件自动完成刷新,不需要执行INVLPGA指令。每个CPU使用不同的ASID(GuestAsid = cpuIndex + 1),这样可以利用ASID标记避免不必要的全局刷新。

VMEXIT发生时,硬件自动禁用中断(相当于执行了CLGI)。在VmExitHandler执行期间,中断是关闭的——这意味着:

如果需要在VMEXIT中做复杂操作,应该把工作通过队列DPC/工作项延后到IRQL降低后执行。

如果NPF不是Hook引起的(比如Guest真的发生了缺页),你需要向Guest注入一个#PF异常,让Guest的缺页处理函数来处理。事件注入通过VMCB.EventInj字段:

VMRUN时硬件会自动把这个异常注入到Guest中,Guest的#PF处理函数正常执行,跟真实发生缺页一模一样。

Windows休眠(S3)时CPU状态会丢失,唤醒后VMCB/NPT等状态无效。需要注册\Callback\PowerState回调:

不处理电源状态的后果:唤醒后导致三重故障。

了解了基本方案后,你需要知道:NPT Hook并非只有一种实现方式,不同方案有不同的性能和隐蔽性。

不修改页内容,而是准备两套NPT:

NPF时根据错误码切换VMCB.nCR3指向不同NPT根,利用ASID标记避免TLB flush。

以下是一个NPT Hook框架的完整模块清单,按照实现顺序排列:

里程碑:系统在Hypervisor下稳定运行,功能跟没虚拟化一样。

里程碑:Hypervisor自身代码和数据对Guest不可见。

里程碑:可以Hook任意内核函数,读原函数看到干净字节,执行流被重定向。

开发Hypervisor级别的代码,蓝屏是常态。以下是实战中总结的调试策略:

不要一次写完所有模块再测试。按阶段1→2→3的顺序,每完成一个阶段就验证:

用两台物理机通过串口/网络/USB做内核调试(WinDbg)。Hypervisor的Bug几乎不可能在单机上调试——因为一旦蓝屏你就什么都看不到了。

设置方法:

VMEXIT中不能直接DbgPrint(太慢且在高IRQL)。使用环形缓冲区日志:

NPT Hook之所以"无痕",不是因为它用了什么魔法,而是因为它把Hook从"修改代码"这个维度搬到了"控制内存映射"这个维度。在传统模型里,代码和它所在的物理页是绑定的——你改了代码字节,任何人读那个地址都能看到。但在虚拟化模型里,"地址"和"物理内容"的绑定关系是Hypervisor可以随时切换的,而且这个切换发生在MMU硬件层面,Guest的任何软件操作都无法绕过。

这种思想——多加一层抽象,在这层抽象里做手脚——其实在计算机科学里无处不在。NPT Hook只是"加一层"思想在安全领域的一个具体应用。

每次Hook触发涉及两次VMEXIT(NPF + BP),每次VMEXIT大约500-2000个CPU周期。对于不频繁调用的函数(进程创建、文件打开、注册表操作),这个开销完全可以忽略。但对于每秒调用数万次的函数(如NtQueryInformationProcess、某些图形函数),累计开销也可能会很显著,但整体影响可以在能接受的范围内。

即使你不打算自己写一个Hypervisor,理解NPT Hook的原理也很有价值:

本文讲解的是技术原理和实现思路。请将所学用于正当的安全研究和系统开发。

# 模块 内容
1 CPUID检测 检测AMD/SVM/NPT/SVMDIS
2 物理内存分配器 物理连续页分配(MmAllocateContiguousMemory)、页池(预分配+位图管理)
3 汇编入口 .asm文件:VM循环、VMRUN进入、VMEXIT保存/恢复寄存器、VMMCALL封装
4 VMCB管理 VMCB分配、Control Area初始化、State Save Area填充(读当前寄存器)
5 Host状态 Host栈分配、Host VMCB、HSAVE、MSRPM/IOPM
6 NPT页表 四级页表构建(2MB大页恒等映射)、大页拆分、地址翻译、PTE修改、懒更新
7 VMEXIT分发 基本的ExitCode switch-case,处理NPF(先全部注入Guest #PF)、MSR直通、SHUTDOWN蓝屏
8 DriverEntry + 启动线程 异步启动、逐核绑定亲和性、StartSVM
9 基础MSR拦截 拦截EFER写(保留SVME位)
10 电源回调 Sleep前Suspend、唤醒后Resume
# 模块 内容
11 零页隐藏 SetNestedPageProtection(Hide=TRUE)映射零页
12 PE段规划 code_seg/data_seg把Hypervisor代码和数据分到专用段,4KB对齐
13 自身隐藏 把VMCB/NPT/Host栈/私有数据段/代码段全部Hide
# 模块 内容
14 Hook管理器 HOOK_INFO/HOOK_FUNC结构、链表管理、AddHook/RemoveHook
15 影子页管理 CreateShadowPage(分配+memcpy+隐藏)、SetGuestShadowPage(切换NPT映射)
16 x64指令长度解码器 Legacy prefix→REX→Opcode→ModRM/SIB→Immediate长度计算
17 跳板生成 汇编跳板模板、AllocateJmpTrampoline(分配+memcpy模板+填3个地址+复制序言)
18 #BP拦截 HandleBP:匹配RIP、改写Guest RIP到跳板、复位影子页
19 NPF Hook触发 HandleNPF中Id=1分支:查找Hook、切到影子页1
20 VMMCALL处理 ResetShadows复位影子页
21 复位线程 高优先级系统线程,周期VMMCALL复位影子页0
22 Hook安装/卸载API InstallHook/UninstallHook完整流程
# 模块 内容
23 CPUID伪造 拦截CPUID、清除Hypervisor位、伪造SVM状态
24 EFER/RDMSR伪造 让Guest读EFER看到SVME=0
25 SVM指令#UD CPL split + SVME=0时VMRUN/VMSAVE等触发#UD
26 RDTSC虚拟化 拦截RDTSC,补偿VMEXIT时间差
27 TLB优化 ASID管理、减少不必要的全局flush
28 VMMCALL认证 防伪造的hypercall调用(cookie机制)
症状 可能原因
VMRUN后立刻蓝屏、三重故障、无响应 VMCB.StateSaveArea填错了(尤其是CR3、段寄存器、EFER.SVME)亦或者是处理特定VMEXIT时未更新Rip
运行几秒后随机蓝屏 NPT映射错误(比如漏了某些物理内存区域的映射)
Hook第一次触发正常,第二次蓝屏 影子页复位失败;跳板OriginalCode指令截断
多核环境一个核正常其他核蓝屏 每CPU结构没独立分配;NPT页表共享了不该共享的层
休眠唤醒后蓝屏 电源回调没正确Suspend/Resume
Hook后系统变卡 VMEXIT太多(检查拦截位,不要拦截不需要的事件);复位线程间隔太短
Guest能读到Hypervisor内存 Hide后忘了Flush TLB;某些页没有加入隐藏列表
GVA ──(Guest CR3 页表)──► GPA ──(NPT,nCR3 指向)──► HPA
1. CPUID leaf 0: EBX/ECX/EDX == "AuthenticAMD"(AMD CPU)
2. CPUID leaf 0x80000001: ECX bit2 == 1(支持SVM)
3. RDMSR(0xC0010114) bit4 == 0(BIOS没有锁定SVM,即SVMDIS位为04. CPUID leaf 0x8000000A: EDX bit0 == 1(支持NPT)
ULONG64 efer = __readmsr(MSR_EFER);  // 0xC0000080
__writemsr(MSR_EFER, efer | EFER_SVME);  // bit12
NpEnable = 1              // 启用NPT
NCr3    = NPT_PML4_PA    // NPT的PML4物理地址(后面构建)
GuestAsid = cpuIndex + 1  // ASID,每CPU唯一,用于TLB标记
TlbControl = 0x01         // 首次进入时Flush整个ASID的TLB

// 拦截位图
InterceptException: bit3 = 1   // 拦截 #BP (INT3)
InterceptMisc1:    拦截MSR读写、IO指令、SHUTDOWN
InterceptMisc2:    拦截VMMCALL、VMRUN

// IOPM和MSRPM的物理地址
IopmBasePa  = IOPM_PA;
MsrpmBasePa = MSRPM_PA;
AsmLaunchVm PROC
    push    rbx
    ; 保存当前原始RSP(用于退出VM后恢复)
    mov     [rcx].SavedRsp, rsp
    
    ; 切换到Host栈
    mov     rsp, [rcx].HostStackTop
    
    ; 压入context指针作为参数
    push    rcx
    
VmLoop:
    vmload  VMCB_GUEST       ; 加载Guest状态
    vmrun                    ; 进入Guest(这里会"阻塞"直到VMEXIT)
    clgi                     ; 关中断
    vmsave  VMCB_GUEST       ; 保存Guest状态
    
    ; 检查是否需要退出VM循环
    test    [rcx].Running, 1
    jz      VmExit
    
    ; 保存所有通用寄存器和XMM寄存器到栈
    push    rax; push rbx; push rcx; push rdx; ...
    sub     rsp, 16*16
    movaps  [rsp+0],  xmm0
    movaps  [rsp+16], xmm1
    ; ... 保存XMM0-15
    
    ; 调用C的VmExitHandler(context, regs)
    mov     rdx, rsp         ; regs指针
    call    VmExitHandler
    
    ; 恢复寄存器
    movaps  xmm0, [rsp+0]
    ; ...
    add     rsp, 16*16
    pop     rdx; pop rcx; pop rbx; pop rax; ...
    
    jmp     VmLoop
    
VmExit:
    ; 恢复原始RSP,返回到调用者
    mov     rsp, [rcx].SavedRsp
    pop     rbx
    ret
AsmLaunchVm ENDP
PML4(1页)
  └── PML4E[0] → PDPT
PDPT(1页)
  └── PDPTE[i] → PD[i]   (i = 0..511)
PD[i](512页,每页512项)
  └── PDE[j] → 2MB物理页 (i*512 + j)*0x200000  (LargePage位=1
bit0:  P (Present)
bit1:  R/W (1=writable)
bit2:  U/S (1=user accessible)
bit3:  PWT (Page-Level Write-Through)
bit4:  PCD (Page-Level Cache Disable)
bit5:  A (Accessed,硬件置位)
bit6:  D (Dirty,硬件置位,仅对叶子PTE有效)
bit7:  LargePage/Pat (PD项为1表示2MB大页)
bit8:  G (Global)
bit12-51 (63 for 4KB): PFN
bit63: NX (No Execute)
BOOL Split2MBLargePage(PPTE pde, ULONG64 largePagePA) {
    PPT pt = AllocateOnePageFromPool();
    
    // 初始化PT:512个PTE,映射到原来2MB内的各4KB页
    for (int k = 0; k < 512; k++) {
        pt[k].Flags = pde->Flags & 0x8000000000000FFF;  // 保留NX等权限位
        pt[k].PageFrameNumber = (largePagePA >> 12) + k;
        pt[k].Present = 1;
        pt[k].Write = 1;
    }
    
    // 原子替换:PDE从大页变为指向PT
    PTE newPde = {0};
    newPde.Present = 1;
    newPde.Write = 1;
    newPde.PageFrameNumber = GetPhysicalAddress(pt) >> 12;
    
    PTE oldPde;
    oldPde.Flags = InterlockedCompareExchange64(
        (volatile LONG64*)pde, newPde.Flags, pde->Flags
    );
    
    if (oldPde.Flags != pde->Flags) {
        // 另一个核心已经拆分了,释放分配的PT
        FreePageToPool(pt);
    }
    return TRUE;
}
VOID SetNptPageProtection(CPU_CONTEXT* cpu, ULONG64 gpa, BOOL present, BOOL writable, BOOL noExecute, ULONG64 newHpa) {
    // 1. 遍历NPT四级,找到对应PTE(需要时Split大页)
    PPTE pml4 = cpu->Npt.Pml4;
    // ... walk ...
    
    // 2. 修改PTE
    PTE newPte = {0};
    newPte.Present = present ? 1 : 0;
    newPte.Write = writable ? 1 : 0;
    newPte.NoExecute = noExecute ? 1 : 0;
    newPte.PageFrameNumber = (newHpa != 0 ? newHpa : gpa) >> 12;
    newPte.Accessed = 1;
    newPte.Dirty = 1;
    
    InterlockedExchange64((volatile LONG64*)pte, newPte.Flags);
    
    // 3. 标记需要TLB刷新
    cpu->PendingTlbFlush = TRUE;
}
bit0 (Present): 0=页不存在    1=页存在但权限冲突
bit1 (Write):   0=读/取指访问  1=写访问
bit2 (User):    0=内核态      1=用户态
bit4 (ID):      0=数据访问    1=指令取指(Instruction Fetch)
PTE.PFN = 影子页0的物理页帧号(或原始物理页帧)
PTE.Present = 1
PTE.R/W = 1(可读可写)
PTE.NX = 1(禁止执行)
偏移 0           24             24 + prolog_size
┌────────────────┬────────────────┬──────────────────────┬──────────┐
│ 数据区(24字节) │  指令保存区     │ 原函数序言副本         │ 返回逻辑  │
│ 38字节指针   │  (push/保存)   │ (被复制的原始指令)     │ vmmcall  │
└────────────────┴────────────────┴──────────────────────┴──────────┘
; === 跳板入口 ===
TrampolineEntry:
    sub  rsp, 0x80 + 0x88      ; 分配栈空间(含x64 shadow space + 寄存器保存)
    mov  [rsp + 0x80], rax     ; 保存RAX
    
    ; 保存参数(x64 calling convention)
    mov  [rsp + 0x88 + 0x00], rcx
    mov  [rsp + 0x88 + 0x08], rdx
    mov  [rsp + 0x88 + 0x10], r8
    mov  [rsp + 0x88 + 0x18], r9
    ; 注意:栈上的第5+个参数还在原来的位置,因为我们没动返回地址
    
    ; 准备调用Hook函数
    lea  rax, [AfterHookCall]
    push rax                   ; HookFunc ret后回到AfterHookCall
    jmp  [data.hookFunc]       ; 调用HookFunc(tail call)

AfterHookCall:
    cmp  eax, STATUS_ACCESS_DENIED
    je   Blocked
    
    ; 放行:恢复寄存器,执行原始指令
    mov  rax, [rsp + 0x80]
    add  rsp, 0x80 + 0x88
    
    sub  rsp, 0x28             ; x64 shadow space (32 bytes) + 8 for alignment
    mov  rcx, [saved_rcx]
    mov  rdx, [saved_rdx]
    mov  r8,  [saved_r8]
    mov  r9,  [saved_r9]
    
    ; === 这里执行复制过来的原函数序言 ===
    ; (原始序言指令被memcpy到这里)
    ; 例如:push rbp; mov rbp,rsp; sub rsp,0x20; ...
    ;
    ; 序言最后一条指令应该跳到原函数+序言长度
    jmp  [data.originalAfterProlog]

Blocked:
    mov  eax, STATUS_ACCESS_DENIED
    add  rsp, 0x80 + 0x88
    
HookComplete:
    push rax                   ; 保存返回值
    mov  rax, VMMCALL_HOOK_DONE
    vmmcall                    ; 通知Hypervisor:本线程Hook执行完毕
    pop  rax
    ret                        ; 返回调用者
Guest线程调用目标函数F(0xFFFFF800`12345000)
    │
    ▼
CPU 取指:GVA = F的地址
    │
    ▼ Guest页表翻译(CR3)
GPA = F所在物理页
    │
    ▼ NPT翻译(nCR3)
PTE检查:NX=1(禁止执行!)
    │
    ▼
触发 #NPF VMEXIT
ExitCode = 0x400
ExitInfo1.Present=1, Write=0, ID=1(取指违例)
ExitInfo2 = 故障GPA
GuestRIP = F的地址
HandleNPF(cpu, vmcb):
    faultGPA = vmcb->ControlArea.ExitInfo2;
    faultRIP = vmcb->StateSaveArea.Rip;
    exitInfo = vmcb->ControlArea.ExitInfo1;
    
    if (!exitInfo.Present) {
        // 真正的缺页:补建NPT或注入Guest #PF
        if (NeedLazyMap(faultGPA)) {
            BuildNptEntry(cpu, faultGPA);  // 懒更新
            return;
        }
        InjectException(vmcb, EXCEPTION_PF, 1, MakePfErrorCode(exitInfo));
        return;
    }
    
    if (exitInfo.ID) {
        // === 取指违例:NX触发,Hook的第一道门 ===
        
        // 查找这个RIP是否对应已注册的Hook
        HOOK* hook = FindHookByRip(faultRIP);
        
        if (hook != NULL) {
            // 把NPT切到影子页1(含INT3,可执行)
            RemapPage(cpu, faultGPA, hook->ShadowPage1PA, 
                      present=TRUE, writable=FALSE, noExecute=FALSE);
            
            // 需要TLB Flush
            vmcb->ControlArea.TlbControl = TLB_FLUSH_ASID;
            return;  // 回到Guest,CPU重新取指
        }
        
        // 不是Hook点,注入#GP
        InjectException(vmcb, EXCEPTION_GP, 1, 0);
        return;
    }
    
    if (exitInfo.Write) {
        // 写违例(比如写了只读页)
        // 根据需要处理,比如Hypervisor自身的保护页
        HandleWriteViolation(cpu, vmcb, faultGPA);
        return;
    }
这次NPT指向影子页1(NX=0,可执行)
影子页1在F入口处是0xCC(INT3)
    │
    ▼
CPU 执行 INT3 → 触发 #BP(Breakpoint Exception)
因为VMCB中设置了InterceptException bit3(拦截#BP)
    │
    ▼
VMEXIT!
ExitCode = 0x43(VMEXIT_EXCP_BP)
GuestRIP = F的入口地址(VMCB.StateSaveArea.Rip保存断点地址)
HandleBP(cpu, vmcb):
    faultRIP = vmcb->StateSaveArea.Rip;
    
    // 查找这个RIP对应的Hook
    HOOK_FUNC* func = FindHookFunc(faultRIP);
    
    if (func != NULL) {
        // 核心:直接把Guest RIP改成跳板地址
        vmcb->StateSaveArea.Rip = func->TrampolineAddr + func->TrampolineExecOffset;
        
        // 关键:在切走之前,把NPT切回影子页0(NX=1),为下次Hook做准备
        ULONG64 gpa = VirtualToPhysical((PVOID)func->OriginalAddr);
        RemapPage(cpu, gpa, func->HookInfo->ShadowPage0PA, 
                  present=TRUE, writable=TRUE, noExecute=TRUE);
        vmcb->ControlArea.TlbControl = TLB_FLUSH_ASID;
        return;
    }
    
    // 不是Hook点的INT3:吞掉或者转发给Guest
    if (g_DebugMode) {
        InjectException(vmcb, EXCEPTION_BP, 1, 0);
    } else {
        vmcb->StateSaveArea.Rip = vmcb->ControlArea.NRip;  // 跳过INT3
    }
HandleVmmcall(cpu, vmcb, regs):
    if (vmcb->StateSaveArea.Cpl != 0) {  // 只有Ring0允许
        InjectException(vmcb, EXCEPTION_GP, 1, 0);
        return;
    }
    
    switch (regs->Rax) {
    case VMMCALL_RESET_SHADOWS:
        ResetAllHooksToShadow0(cpu);
        break;
    }
    
    vmcb->StateSaveArea.Rip = vmcb->ControlArea.NRip;
VOID ResetShadowThread(PVOID ctx) {
    while (!g_Unload) {
        for (ULONG i = 0; i < g_NumCpus; i++) {
            if (!g_HvRunning) break;
            KeSetSystemAffinityThread(MAKELONG(1<<i, 0));
            AsmVmmcallResetShadows();  // vmmcall VMMCALL_RESET_SHADOWS
        }
        // 使用KeDelayExecutionThread休眠5ms
    }
}
VOID Unhook(HOOK_FUNC* func) {
    // 1. 从链表摘除,防止新的NPF匹配到它
    RemoveEntryList(&func->ListEntry);
    
    // 2. 所有CPU上恢复NPT到真实物理页
    for each CPU:
        RemapPage(cpu, gpa, originalPA, present=1, writable=1, noExecute=0);
        FlushTLB(cpu);
    
    // 3. 释放跳板
    ExFreePool(func->TrampolineAddr);
    
    // 4. 释放影子页
    FreeShadowPages(func->HookInfo);
    
    // 5. 释放HOOK结构
    ExFreePool(func);
}
// 分配一个全零页
g_ZeroPage = AllocateZeroedPage();

// 隐藏某块内存
VOID HideMemory(PVOID va, SIZE_T size) {
    for each 4KB page in [va, va+size):
        pa = MmGetPhysicalAddress(va);
        for each CPU:
            RemapPage(cpu, pa, g_ZeroPagePA, 
                      present=1, writable=0, noExecute=1);
}
EVENTINJ inj = {0};
inj.Vector = EXCEPTION_PF;  // 14
inj.Type = 3;               // Exception type
inj.Valid = 1;              // 注入有效
inj.ErrorCode = pfErrorCode; // 和正常#PF一样的错误码
vmcb->ControlArea.EventInj = inj;
默认:NPT→影子页0(NX=1) → NPF取指 → 切影子页1(NX=0) → INT3 → #BP → 跳跳板 → 复位

本文从实现者的角度,讲清楚基于AMD NPT(Nested Page Tables)的内存虚拟化Hook技术"为什么这样设计"以及"应该怎么实现"。重点是思路,具体代码示例在我的 Github仓库 上。


[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

最后于 54分钟前 被漫雾.编辑 ,原因:
收藏
免费 9
打赏
分享
最新回复 (3)
雪    币: 158
活跃值: (5061)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
学习
2小时前
0
雪    币: 1687
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
牛批,学习了
1小时前
0
雪    币: 187
活跃值: (3726)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
4
mark
1小时前
0
游客
登录 | 注册 方可回帖
返回