本文从实现者的角度,讲清楚基于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=1且ID=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位为0 )
4. CPUID leaf 0x8000000A : EDX bit0 == 1 (支持NPT)
ULONG64 efer = __readmsr(MSR_EFER);
__writemsr(MSR_EFER, efer | EFER_SVME);
NpEnable = 1 // 启用NPT
NCr3 = NPT_PML4_PA // NPT的PML4物理地址(后面构建)
GuestAsid = cpuIndex + 1 // ASID,每CPU唯一,用于TLB标记
TlbControl = 0x01 // 首次进入时Flush整个ASID的TLB
// 拦截位图
InterceptException: bit3 = 1 // 拦截
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();
for (int k = 0 ; k < 512 ; k++) {
pt[k].Flags = pde->Flags & 0x8000000000000FFF ;
pt[k].PageFrameNumber = (largePagePA >> 12 ) + k;
pt[k].Present = 1 ;
pt[k].Write = 1 ;
}
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) {
FreePageToPool(pt);
}
return TRUE;
}
VOID SetNptPageProtection (CPU_CONTEXT* cpu, ULONG64 gpa, BOOL present, BOOL writable, BOOL noExecute, ULONG64 newHpa) {
PPTE pml4 = cpu->Npt.Pml4;
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);
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 字节) │ 指令保存区 │ 原函数序言副本 │ 返回逻辑 │
│ 3 个8 字节指针 │ (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 (禁止执行!)
│
▼
触发
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) {
if (NeedLazyMap(faultGPA)) {
BuildNptEntry(cpu, faultGPA);
return ;
}
InjectException(vmcb, EXCEPTION_PF, 1 , MakePfErrorCode(exitInfo));
return ;
}
if (exitInfo.ID) {
HOOK* hook = FindHookByRip(faultRIP);
if (hook != NULL ) {
RemapPage(cpu, faultGPA, hook->ShadowPage1PA,
present=TRUE, writable=FALSE, noExecute=FALSE);
vmcb->ControlArea.TlbControl = TLB_FLUSH_ASID;
return ;
}
InjectException(vmcb, EXCEPTION_GP, 1 , 0 );
return ;
}
if (exitInfo.Write) {
HandleWriteViolation(cpu, vmcb, faultGPA);
return ;
}
这次NPT指向影子页1 (NX=0 ,可执行)
影子页1 在F入口处是0xCC (INT3)
│
▼
CPU 执行 INT3 → 触发
因为VMCB中设置了InterceptException bit3(拦截
│
▼
VMEXIT!
ExitCode = 0x43 (VMEXIT_EXCP_BP)
GuestRIP = F的入口地址(VMCB.StateSaveArea.Rip保存断点地址)
HandleBP(cpu, vmcb):
faultRIP = vmcb->StateSaveArea.Rip;
HOOK_FUNC* func = FindHookFunc(faultRIP);
if (func != NULL ) {
vmcb->StateSaveArea.Rip = func->TrampolineAddr + func->TrampolineExecOffset;
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 ;
}
if (g_DebugMode) {
InjectException(vmcb, EXCEPTION_BP, 1 , 0 );
} else {
vmcb->StateSaveArea.Rip = vmcb->ControlArea.NRip;
}
HandleVmmcall(cpu, vmcb, regs):
if (vmcb->StateSaveArea.Cpl != 0 ) {
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();
}
}
}
VOID Unhook (HOOK_FUNC* func) {
RemoveEntryList(&func->ListEntry);
for each CPU:
RemapPage(cpu, gpa, originalPA, present=1 , writable=1 , noExecute=0 );
FlushTLB(cpu);
ExFreePool(func->TrampolineAddr);
FreeShadowPages(func->HookInfo);
ExFreePool(func);
}
g_ZeroPage = AllocateZeroedPage();
VOID HideMemory (PVOID va, SIZE_T size) {
for each 4 KB 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;
inj.Type = 3 ;
inj.Valid = 1 ;
inj.ErrorCode = pfErrorCode;
vmcb->ControlArea.EventInj = inj;
默认:NPT→影子页0 (NX=1 ) → NPF取指 → 切影子页1 (NX=0 ) → INT3 →
本文从实现者的角度,讲清楚基于AMD NPT(Nested Page Tables)的内存虚拟化Hook技术"为什么这样设计"以及"应该怎么实现"。重点是思路,具体代码示例在我的 Github仓库 上。
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!
最后于 2小时前
被漫雾.编辑
,原因: