Introduction to Hypercall

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 寄存器传递,对应的数据格式如下:

Hyper-v Hypercall 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,那么 RDXR8 寄存器可以用于传递输入参数。

如果 Hypervisor 支持 Extended Fast Hypercalls,那么还可以使用 XMM 寄存器来传递输入参数,最多支持 112 字节的数据:XMM0 ~ XMM5 共 16 * 6 = 96 字节,以及 RDXR816 字节。

1.5 Hypercall Outputs

Hypercall 的返回值通过 RAX 寄存器传递,对应的数据格式如下:

Hyper-v 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: kd> u poi(nt!HvcallCodeVa)
fffff804`3f330000 0f01d9 vmmcall
fffff804`3f330003 c3 ret

1: kd> u nt!HvcallInitiateHypercall
nt!HvcallInitiateHypercall:
fffff804`40945080 4883ec28 sub rsp,28h
fffff804`40945084 488b059de22200 mov rax,qword ptr [nt!HvcallCodeVa]
fffff804`4094508b e850f20000 call nt!_guard_retpoline_indirect_rax
fffff804`40945090 4883c428 add rsp,28h
fffff804`40945094 c3 ret

注意这里函数的调式符号为 HvcallInitiateHypercall ,只不过是以 HvlInvokeHypercall 的名义导出的。

0x03. Hypercall 监控

在 WinDbg 中,可以通过对 poi(nt!HvcallCodeVa) 下硬件执行断点来监控 Hypercall 的调用,拆分后的代码如下所示:

ba e1 poi(nt!HvcallCodeVa)

.printf " Hypercall: 0x%X\n", rcx & 0xFFFF
.printf " Fast: 0x%X\n", (rcx >> 16) & 1
.printf "Variable header size: 0x%X\n", (rcx >> 17) & 0x1FF
.printf " Is Nested: 0x%X\n", (rcx >> 26) & 1
.printf " Rep Count: 0x%X\n", (rcx >> 32) & 0xFFF
.printf " Rep Start Index: 0x%X\n", (rcx >> 48) & 0xFFF
k
g

WinDbg 仅支持单行命令,所以实际测试时需要把断点之后的命令写成一行、使用双引号括起来并且原有命令中的特殊字符需要进行转义处理。

使用上面的方法进行监控,WinDbg 会输出大量的日志,这其中不乏一些奇怪的日志:

           Hypercall: 0xB
Fast: 0x0
Variable header size: 0x0
Is Nested: 0x0
Rep Count: 0x0
Rep Start Index: 0x100
# Child-SP RetAddr Call Site
00 fffffd89`ba061828 fffff800`37c89f7c 0xfffff800`35e10000
01 fffffd89`ba061830 fffff800`384b9752 nt!HvlSendSyntheticClusterIpi+0x7c
02 fffffd89`ba061860 fffff800`37abaecb hal!HalRequestIpi+0x532
03 fffffd89`ba061b00 fffff800`37bc5e84 nt!PoIdle+0x45b
04 fffffd89`ba061c60 00000000`00000000 nt!KiIdleLoop+0x44

比如,按照微软官方文档的理解,这里 Rep Count0 ,所以编号为 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 的反应速度就会快很多。具体的操作方法如下:

  1. 写一段汇编指令过滤掉 Fast 类型的 Hypercall
  2. 将汇编指令编译成机器码
  3. 在内核模块的 .text 末尾找到一块可执行的空白区间用于存放机器码
  4. 修复 HOOK 的跳转
    • nt!HvcallCodeVa 处的值修改为上述机器码的起始地址
    • 过滤代码执行完毕后跳转回 poi(nt!HvcallCodeVa) 执行代码

过滤代码如下:

        test    rcx, 0x10000
jnz skip
int 3
skip:
mov rax, 0xfffff8014dfc0000
jmp rax

这里如果遇到非 Fast Hypercall 则通过 int 3 中断,会自动激活 WinDbg;也可以在这里下条件断点进行自动监控。

0x04. 参考文档

  1. Jaanus Kääp 博客 Hyper-V #0x1 - Hypercalls part 1
  2. 微软官方文档 Hypervisor Top-Level Functional Specification
请作者喝杯咖啡☕