Hyper-V Hypercall 相关基础知识介绍。
0x01. Hypercall 介绍
Hypercall 用于从虚拟机到 Hypervisor 的状态切换,就像 System Call 用于从用户态到内核态的状态切换一样。
1.1 Hypercall Classes
Hypercall 可以分为两种不同的类型:简单类型(Simple)和重复类型(Repeat / Rep)。
- Simple Hypercall 拥有固定大小的输入和输出参数,执行一个单一的操作
- Repeat Hypercall 可以看成是由一系列 Simple Hypercall 组成的
在发起 Repeat Hypercall 时,调用方需要指明输入输出参数的组数(rep count),以及将要被处理的输入输出参数的索引数(rep start index);Hypervisor 将按照顺序处理对应的数据。
1.2 Hypercall Continuation
Hypervisor 会限制 Hypercall 的执行时间在 50μs
以内,超过该时间限制的 Hypercall 依赖于一种叫做 Hypercall Continuation 的机制来完成,该机制对调用方而言基本是透明的。
对于无法在 50μs
的时间限制内完成的 Hypercall,当控制权从 Hypervisor 返回到虚拟机之后,对应的 RIP 寄存器的值并不会改变;当对应的线程再次获得执行机会时,原有的 Hypercall 会被继续执行。显然,在 Hypercall 完成执行的过程中需要维护一个状态,类似 Repeat Hypercall 的执行一样。
1.3 Hypercall Atomicity and Ordering
一般来说,Hypercall 的执行是原子的:Simple Hypercall 就是单个的原子操作,Repeat Hypercall 则是一系列的原子操作;对于无法一次执行完毕的 Hypercall(即超过 50μs
时间限制的 Hypercall),则由多个原子操作所组成。
1.4 Hypercall Inputs
对于任意的 Hypercall,必然至少有一个输入参数,因为肯定需要指定一个编号。在 x64 环境下,该参数通过 RCX
寄存器传递,对应的数据格式如下:
对应的说明如下:
字段 | 宽度 | 含义 |
---|---|---|
Call code | 16 bits | Hypercall 的编号 |
Fast | 1 bit | 0 表示基于内存的调用约定,1 表示基于寄存器的调用约定 |
Variable header size | 9 bits | Variable Headr 的大小 |
RsvdZ | 5 bits | 必须是 0 |
Is Nested | 1 bit | 0 表示由 Guest Hypervisor 处理,1 表示由 L0 Hypervisor 处理 |
Rep Count | 12 bits | Repeat Hypercall 的重复次数(Simple Hypercall 必须是 0) |
RsvdZ | 4 bits | 必须是 0 |
Rep Start Index | 12 bits | Repeat Hypercall 的索引值(Simple Hypercall 必须是 0) |
RsvdZ | 4 bits | 必须是 0 |
如果 Fast 的值为 0,那么 RDX
寄存器可以用于传递输入参数的 GPA(Guest Physical Address),R8
寄存器可以用于传递输出参数的 GPA。
如果 Fast 的值为 1,那么 RDX
和 R8
寄存器可以用于传递输入参数。
如果 Hypervisor 支持 Extended Fast Hypercalls,那么还可以使用 XMM 寄存器来传递输入参数,最多支持 112
字节的数据:XMM0 ~ XMM5 共 16 * 6 = 96
字节,以及 RDX
和 R8
共 16
字节。
1.5 Hypercall Outputs
Hypercall 的返回值通过 RAX
寄存器传递,对应的数据格式如下:
对应的说明如下:
字段 | 宽度 | 含义 |
---|---|---|
Result | 16 bits | HV_STATUS code |
Rsvd | 16 bits | 保留字段 |
Reps completed | 12 bits | 已经成功执行的 Repeat 数 |
Rsvd | 20 bits | 保留字段 |
注意这里的 Reps completed
是针对整个 Repeat Hypercall 而言的,即整个 Repeat Hypercall 已经成功执行的 Repeat 数。
同样,输出参数也支持使用 XMM 寄存器传递。
0x02. Hypercall 调用
Windows 内核模块导出了一个函数 HvlInvokeHypercall
可以用于发起 Hypercall,该函数是对 vmcall
/ vmmcall
指令的一个包装:
1 | 1: kd> u poi(nt!HvcallCodeVa) |
注意这里函数的调式符号为 HvcallInitiateHypercall
,只不过是以 HvlInvokeHypercall
的名义导出的。
0x03. Hypercall 监控
在 WinDbg 中,可以通过对 poi(nt!HvcallCodeVa)
下硬件执行断点来监控 Hypercall 的调用,拆分后的代码如下所示:
1 | ba e1 poi(nt!HvcallCodeVa) |
WinDbg 仅支持单行命令,所以实际测试时需要把断点之后的命令写成一行、使用双引号括起来并且原有命令中的特殊字符需要进行转义处理。
使用上面的方法进行监控,WinDbg 会输出大量的日志,这其中不乏一些奇怪的日志:
1 | Hypercall: 0xB |
比如,按照微软官方文档的理解,这里 Rep Count
为 0
,所以编号为 0xB
的 Hypercall 应该是一个 Simple Hypercall,而 Simple Hypercall 的 Rep Start Index
也应该是 0
,但这里却为 0x100
。
当然,这种方法最主要的问题是没有对 Hypercall 进行过滤,这就会导致 WinDbg 需要频繁地处理断点,既耗费资源,操作也不是很方便。那么在 WinDbg 的条件断点中再加一个过滤条件可不可以呢?当然是可以的!但是在 WinDbg 进行过滤的时候,其实断点已经命中并且由 WinDbg 接管了,所以反应速度还是很慢。
Jaanus Kääp 通过在 WinDbg 中对内核模块进行 Patch,即对地址 poi(nt!HvcallCodeVa)
进行 HOOK,直接过滤掉不感兴趣的 Hypercall,这样 WinDbg 的反应速度就会快很多。具体的操作方法如下:
- 写一段汇编指令过滤掉 Fast 类型的 Hypercall
- 将汇编指令编译成机器码
- 在内核模块的
.text
末尾找到一块可执行的空白区间用于存放机器码 - 修复 HOOK 的跳转
- 把
nt!HvcallCodeVa
处的值修改为上述机器码的起始地址 - 过滤代码执行完毕后跳转回
poi(nt!HvcallCodeVa)
执行代码
- 把
过滤代码如下:
1 | test rcx, 0x10000 |
这里如果遇到非 Fast Hypercall 则通过 int 3
中断,会自动激活 WinDbg;也可以在这里下条件断点进行自动监控。
0x04. 参考文档
- Jaanus Kääp 博客 Hyper-V #0x1 - Hypercalls part 1
- 微软官方文档 Hypervisor Top-Level Functional Specification