首页
社区
课程
招聘
Hypervisor From Scratch – 第 8 部分:如何使用管理程序施展魔法
2024-3-31 16:49 3149

Hypervisor From Scratch – 第 8 部分:如何使用管理程序施展魔法

2024-3-31 16:49
3149

目录

八、如何使用管理程序施展魔法

8.1.介绍

嗨,大家好,

欢迎来到 Hypervisor From Scratch 的第八部分。 如果你到达这里,那么你可能已经读完了第七部分,就我个人而言,我认为第七部分是最难理解的部分,所以脱帽致敬,你做得很好。

第八部分将是令人兴奋的部分,因为我们将看到许多使用虚拟机管理程序解决逆向工程相关问题的现实和实际示例。 例如,我们将看到隐藏的钩子如何在虚拟机管理程序存在的情况下工作,或者如何创建系统调用钩子,并且我们最终能够将消息从 vmx root 传输到操作系统(vmx non-root),然后进入用户模式因此它为我们提供了有关系统如何工作的宝贵信息。

除了一些与操作系统相关的概念之外,我们还将看到一些与 CPU 相关的主题,例如 VPID 以及一些有关 Meltdown 和 Spectre 补丁如何工作的一般信息。

事件注入、异常位图以及添加对虚拟化 Hyper-V 计算机的支持都是将要讨论的其他内容。

在开始之前,我要特别感谢我的朋友 Petr Benes 对 Hypervisor From Scratch 所做的贡献,当然,如果没有他的帮助,Hypervisor From Scratch 就不可能存在;还要感谢 Liran Alon 在修复 VPID 问题方面提供的巨大帮助,还要感谢 Gerhart 他对 Hyper-V 内部结构的深入了解使得 Hypervisor From Scratch 可用于 Hyper-V。

8.2.概述

这部分分为 八个 主要部分:

  1. 如何将中断(事件)注入guest和异常位图
  2. 使用 EPT 实现隐藏挂钩
  3. 系统调用钩子
  4. 使用 VPID 使 EPT 缓存失效
  5. 演示自定义 VMX root-mode兼容消息跟踪机制并将 WPP 跟踪添加到我们的虚拟机管理程序
  6. 我们将添加对 Hyper-V 的支持
  7. 修复一些以前的设计注意事项
  8. 讨论(在本节中,我们讨论本部分各个主题的不同问题和方法)

8.3.事件注入

虚拟机管理程序的基本部分之一是能够注入事件(事件是中断、异常、NMI 和 SMI),就像它们正常到达一样,并且能够监视接收到的中断和异常。

这给了我们强大的管理guest操作系统的能力和独特的构建应用程序的能力,例如,如果您正在开发反作弊应用程序,您可以轻松禁用 断点陷阱 中断,并且它完全禁用Windbg或任何其他调试器,因为您是第一个收到有关断点通知的调试器,因此您可以决定中止断点或将其交给调试器。

这只是一个简单的例子,攻击者需要找到解决方法。 您还可以使用事件注入进行逆向工程,例如,直接将断点注入到使用不同反调试技术的应用程序中以隐藏其代码。

我们还可以实现虚拟机管理程序的一些重要功能,例如基于事件注入的隐藏挂钩。

在深入了解事件注入之前,我们需要了解一些基本的处理器概念和 Intel 使用的术语。 其中大部分源自 这篇文章 这个答案

Intel x86 定义了两个重叠的类别: 向量事件中断异常 )和 异常类故障陷阱中止 )。

8.4.矢量事件

向量事件( 中断异常 )会导致处理器在保存大部分处理器状态(足够以后可以从该点继续执行)后跳转到中断处理程序。

异常和中断有一个 ID,称为向量,它确定处理器跳转到哪个中断处理程序。 中断处理程序在中断描述符表 (IDT) 中进行描述。

8.4.1.中断

中断 在程序执行期间随机发生,以响应来自硬件的信号。 系统硬件使用中断来处理处理器外部的事件,例如服务外围设备的请求。 软件还可以通过执行 INT n 指令来产生中断。

8.4.2.异常

当处理器在执行指令时检测到错误情况(例如被零除)时,就会发生异常。 处理器识别各种错误情况,包括保护违规、页面错误和内部机器故障。

8.5.异常分类

异常 分为 错误陷阱中止, 具体取决于它们报告的方式以及导致异常的指令是否可以在不丢失程序或任务连续性的情况下重新启动。

总之: 陷阱 会增加指令指针 (RIP), 故障 不会增加,并且 会中止 “explode”。

我们将从故障分类开始。 您可能听说过所谓的页面错误(如果您是过去的人,则可能听说过分段错误)

故障只是一种可以纠正的异常类型,并允许处理器执行某些故障处理程序来纠正违规操作,而无需终止整个操作。 当发生故障时,系统状态将恢复到故障操作发生之前的较早状态,并调用故障处理程序。 执行完故障处理程序后,处理器返回到故障指令并再次执行它。 最后一句话很重要,因为这意味着它会重做指令执行以确保在后续操作中使用正确的结果。 这与陷阱的处理方式不同。

陷阱是在执行陷阱指令后立即传递的异常。 在我们的虚拟机管理程序中,我们捕获各种指令,这意味着在执行指令(例如 rdtscrdtscp )后,将向处理器报告陷阱异常。 一旦报告了陷阱异常,控制权就会传递给陷阱处理程序,该处理程序将执行一些操作。 在执行陷阱处理程序之后,处理器返回到陷阱指令之后的指令。

然而,中止是发生的异常,并且并不总是产生错误的位置。 中止通常用于报告硬件错误或其他情况。 你不会经常看到这些,如果你看到了……那么,你就做错了。 重要的是要知道所有异常都是在指令边界上报告的——不包括中止。 指令边界非常简单:如果将字节 0F 31 48 C1 E2 20 转换为指令,

1
2
rdtsc
shl rdx, 20h

那么指令边界将在字节 3148 之间。这是因为 0F 31 的指令操作码 是rdtsc 。 这样,两条指令就被边界分开。

8.5.1.事件注入字段

事件注入是通过使用 VMCS 的中断信息字段来完成的。

VM-entry时将中断信息写入VMCS的VM-entry字段; 加载所有guest上下文(包括 MSR 和寄存器)后,它使用此字段中指定的向量通过中断描述符表 (IDT) 传递异常。

配置事件注入的第一个字段是VM-entry interruption-information field(32位)或VMCS中的VM_ENTRY_INTR_INFO,该字段提供有关要注入的事件的详细信息

下图显示了每个位的详细信息。
图片描述

  • 向量 ) (位 7:0)决定使用 IDT 中的哪个条目或注入哪个其他事件,或者换句话说,它定义要在 IDT 中注入的中断的索引,例如以下命令 (!idt 在windbg中显示了IDT索引。 (请注意,索引是左侧的数字)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
lkd> !idt
 
Dumping IDT: fffff8012c05b000
 
00: fffff80126551100 nt!KiDivideErrorFaultShadow
01: fffff80126551180 nt!KiDebugTrapOrFaultShadow    Stack = 0xFFFFF8012C05F9D0
02: fffff80126551200 nt!KiNmiInterruptShadow    Stack = 0xFFFFF8012C05F7D0
03: fffff80126551280 nt!KiBreakpointTrapShadow
04: fffff80126551300 nt!KiOverflowTrapShadow
05: fffff80126551380 nt!KiBoundFaultShadow
06: fffff80126551400 nt!KiInvalidOpcodeFaultShadow
07: fffff80126551480 nt!KiNpxNotAvailableFaultShadow
08: fffff80126551500 nt!KiDoubleFaultAbortShadow    Stack = 0xFFFFF8012C05F3D0
09: fffff80126551580 nt!KiNpxSegmentOverrunAbortShadow
0a: fffff80126551600 nt!KiInvalidTssFaultShadow
0b: fffff80126551680 nt!KiSegmentNotPresentFaultShadow
0c: fffff80126551700 nt!KiStackFaultShadow
0d: fffff80126551780 nt!KiGeneralProtectionFaultShadow
0e: fffff80126551800 nt!KiPageFaultShadow
10: fffff80126551880 nt!KiFloatingErrorFaultShadow
11: fffff80126551900 nt!KiAlignmentFaultShadow
12: fffff80126551980 nt!KiMcheckAbortShadow Stack = 0xFFFFF8012C05F5D0
13: fffff80126551a80 nt!KiXmmExceptionShadow
14: fffff80126551b00 nt!KiVirtualizationExceptionShadow
15: fffff80126551b80 nt!KiControlProtectionFaultShadow
1f: fffff80126551c00 nt!KiApcInterruptShadow
20: fffff80126551c80 nt!KiSwInterruptShadow
29: fffff80126551d00 nt!KiRaiseSecurityCheckFailureShadow
2c: fffff80126551d80 nt!KiRaiseAssertionShadow
2d: fffff80126551e00 nt!KiDebugServiceTrapShadow
2f: fffff80126551f00 nt!KiDpcInterruptShadow
30: fffff80126551f80 nt!KiHvInterruptShadow
31: fffff80126552000 nt!KiVmbusInterrupt0Shadow
32: fffff80126552080 nt!KiVmbusInterrupt1Shadow
33: fffff80126552100 nt!KiVmbusInterrupt2Shadow
34: fffff80126552180 nt!KiVmbusInterrupt3Shadow
...

中断 类型 (位 10:8)决定如何执行注入的详细信息。

一般来说,VMM 应该对除以下异常之外的所有异常使用类型硬件异常:

  • 断点异常 (#BP):VMM 应使用软件异常类型。
  • 溢出异常 (#OF):VMM 应使用使用类型软件异常。
  • 由 INT1 生成的那些调试异常 (#DB)(VMM 应使用使用类型特权软件异常)。

对于异常,传递错误代码位(位 11)确定传递是否将错误代码推送到guest堆栈上。 (我们稍后会讨论错误代码)

最后一位是,当且仅当有效位(位 31)为 1 时,VM 入口才会注入事件。该字段中的有效位在每次 VM-exit时都会被清除,这意味着当您想要注入事件时,请设置该位注入中断,处理器将在下一次 VM-exit时自动清除它。

控制事件注入的第二个字段是 VM-entry exception error code

VMCS 中的 VM-entry exception error code(32 位)或 VM_ENTRY_EXCEPTION_ERROR_CODE :当且仅当有效位(位 31)和传送错误代码位(位 11)都在 VM-entry interruption-information字段中设置时,才使用该字段。

控制事件注入的第三个字段是 VM-entry instruction length

VM-entry instruction length(32位)VMCS中的VM_ENTRY_INSTRUCTION_LEN :对于类型为软件中断、软件异常或特权软件异常的事件的注入,该字段用于确定压入堆栈的RIP的值。

总而言之,VMCS 中的这些内容控制着事件注入过程: VM_ENTRY_INTR_INFOVM_ENTRY_EXCEPTION_ERROR_CODEVM_ENTRY_INSTRUCTION_LEN

8.5.2.向量事件注入

如果VM-entry interruption-information中的有效位为1,则 VM entry 会在加载guest状态的所有组件(包括MSR)之后以及建立 VM-execution control fields后导致事件被传递(或挂起)。

中断 类型 (如上所述)可以是以下值之一。

1
2
3
4
5
6
7
8
9
10
11
enum _INTERRUPT_TYPE
{
    INTERRUPT_TYPE_EXTERNAL_INTERRUPT = 0,
    INTERRUPT_TYPE_RESERVED = 1,
    INTERRUPT_TYPE_NMI = 2,
    INTERRUPT_TYPE_HARDWARE_EXCEPTION = 3,
    INTERRUPT_TYPE_SOFTWARE_INTERRUPT = 4,
    INTERRUPT_TYPE_PRIVILEGED_SOFTWARE_INTERRUPT = 5,
    INTERRUPT_TYPE_SOFTWARE_EXCEPTION = 6,
    INTERRUPT_TYPE_OTHER_EVENT = 7
};

现在是时候设置 向量 位了。 以下枚举是 IDT 中索引的表示形式。 (查看 上面!idt 命令的索引)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
typedef enum _EXCEPTION_VECTORS
{
    EXCEPTION_VECTOR_DIVIDE_ERROR,
    EXCEPTION_VECTOR_DEBUG_BREAKPOINT,
    EXCEPTION_VECTOR_NMI,
    EXCEPTION_VECTOR_BREAKPOINT,
    EXCEPTION_VECTOR_OVERFLOW,
    EXCEPTION_VECTOR_BOUND_RANGE_EXCEEDED,
    EXCEPTION_VECTOR_UNDEFINED_OPCODE,
    EXCEPTION_VECTOR_NO_MATH_COPROCESSOR,
    EXCEPTION_VECTOR_DOUBLE_FAULT,
    EXCEPTION_VECTOR_RESERVED0,
    EXCEPTION_VECTOR_INVALID_TASK_SEGMENT_SELECTOR,
    EXCEPTION_VECTOR_SEGMENT_NOT_PRESENT,
    EXCEPTION_VECTOR_STACK_SEGMENT_FAULT,
    EXCEPTION_VECTOR_GENERAL_PROTECTION_FAULT,
    EXCEPTION_VECTOR_PAGE_FAULT,
    EXCEPTION_VECTOR_RESERVED1,
    EXCEPTION_VECTOR_MATH_FAULT,
    EXCEPTION_VECTOR_ALIGNMENT_CHECK,
    EXCEPTION_VECTOR_MACHINE_CHECK,
    EXCEPTION_VECTOR_SIMD_FLOATING_POINT_NUMERIC_ERROR,
    EXCEPTION_VECTOR_VIRTUAL_EXCEPTION,
    EXCEPTION_VECTOR_RESERVED2,
    EXCEPTION_VECTOR_RESERVED3,
    EXCEPTION_VECTOR_RESERVED4,
    EXCEPTION_VECTOR_RESERVED5,
    EXCEPTION_VECTOR_RESERVED6,
    EXCEPTION_VECTOR_RESERVED7,
    EXCEPTION_VECTOR_RESERVED8,
    EXCEPTION_VECTOR_RESERVED9,
    EXCEPTION_VECTOR_RESERVED10,
    EXCEPTION_VECTOR_RESERVED11,
    EXCEPTION_VECTOR_RESERVED12
};

一般来说,事件的传递就像它是正常生成的一样,并且使用该字段中的向量来选择IDT中的描述符来传递事件。 由于事件注入发生在从客户状态区域加载 IDTR (IDT 寄存器)之后,因此这是客户 IDT,或者换句话说,事件被传递到 GUEST_IDTR_BASEGUEST_IDTR_LIMIT

将上面的描述放到实现中,我们有以下功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Injects interruption to a guest
VOID EventInjectInterruption(INTERRUPT_TYPE InterruptionType, EXCEPTION_VECTORS Vector, BOOLEAN DeliverErrorCode, ULONG32 ErrorCode)
{
    INTERRUPT_INFO Inject = { 0 };
    Inject.Valid = TRUE;
    Inject.InterruptType = InterruptionType;
    Inject.Vector = Vector;
    Inject.DeliverCode = DeliverErrorCode;
    __vmx_vmwrite(VM_ENTRY_INTR_INFO, Inject.Flags);
 
    if (DeliverErrorCode) {
        __vmx_vmwrite(VM_ENTRY_EXCEPTION_ERROR_CODE, ErrorCode);
    }
}

作为示例,我们想要将 #BP (断点)注入到guest中,我们可以使用以下代码:

1
2
3
4
5
6
7
8
/* Inject #BP to the guest (Event Injection) */
VOID EventInjectBreakpoint()
{
EventInjectInterruption(INTERRUPT_TYPE_SOFTWARE_EXCEPTION, EXCEPTION_VECTOR_BREAKPOINT, FALSE, 0);
UINT32 ExitInstrLength;
__vmx_vmread(VM_EXIT_INSTRUCTION_LEN, &ExitInstrLength);
__vmx_vmwrite(VM_ENTRY_INSTRUCTION_LEN, ExitInstrLength);
}

或者,如果我们想注入 #GP(0) 或错误代码为 0 的一般保护故障,那么我们使用以下代码:

1
2
3
4
5
6
7
8
/* Inject #GP to the guest (Event Injection) */
VOID EventInjectGeneralProtection()
{
    EventInjectInterruption(INTERRUPT_TYPE_HARDWARE_EXCEPTION, EXCEPTION_VECTOR_GENERAL_PROTECTION_FAULT, TRUE, 0);
    UINT32 ExitInstrLength;
    __vmx_vmread(VM_EXIT_INSTRUCTION_LEN, &ExitInstrLength);
    __vmx_vmwrite(VM_ENTRY_INSTRUCTION_LEN, ExitInstrLength);
}

您可以为其他类型的中断和异常编写函数。 您唯一应该考虑的是 InterruptionType,除了上面讨论的 #DP、#BP、#OF 之外,它始终是硬件异常。

8.5.3.异常错误代码

您可能会注意到,我们在 VMCS 中使用了 VM_ENTRY_EXCEPTION_ERROR_CODE 和interruption-information字段的第 11 位 ,对于某些异常,我们禁用了它们,而对于其他一些异常,我们将它们设置为特定值,那么错误代码是什么?

某些异常会将 32 位“错误代码”推送到堆栈顶部,这提供了有关错误的附加信息。 在将控制权返回给当前正在运行的程序之前,必须从堆栈中提取该值。 (即,在调用IRET从中断返回之前)。

事实上,错误代码必须从堆栈中提取,这使得事件注入变得更加复杂,因为我们必须确定 Windows 是否尝试从堆栈中提取错误代码,因为如果我们将某些内容放入堆栈中,则会出现错误Windows 不希望稍后拉出它,或者我们没有推送任何东西,但 Windows 认为堆栈中有一些东西需要拉出。

下表显示了其中一些异常以及是否存在 错误代码 ,该表源自 英特尔 SDM,第 1 卷,第 6 章表 6-1. 异常和中断 )。

姓名 向量编号 类型 助记符 错误代码?
除零错误 0 (0x0) 过错 #的
调试 1(0x1) 故障/陷阱 #D B
不可屏蔽中断 2(0x2) 打断 -
断点 3(0x3) 陷阱 #BP
溢出 4(0x4) 陷阱 #的
超出限制范围 5(0x5) 过错 #BR
无效操作码 6(0x6) 过错 #出去
设备不可用 7(0x7) 过错 #NM
双故障 8 (0x8) 中止 #DF 是(零)
协处理器段溢出 9(0x9) 过错 -
无效的 TSS 10(0xA) 过错 #TS 是的
段不存在 11(0xB) 过错 #例如 是的
堆栈段故障 12(0xC) 过错 #SS 是的
一般性保护错误 13(0xD) 过错 #GP 是的
页面错误 14(0xE) 过错 #PF 是的
预订的 15(0xF) - -
x87 浮点异常 16(0x10) 过错 #MF
对准检查 17(0x11) 过错 #AC 是的
机器检查 18(0x12) 中止 #MC
SIMD 浮点异常 19(0x13) 过错 #XM/#XF
虚拟化异常 20(0x14) 过错 #VE
预订的 21-29(0x15-0x1D) - -
安全异常 30(0x1E) - #SX 是的
预订的 31(0x1F) - -
三重故障 - - -
FPU 错误中断 中断请求13 打断 #FERR

现在我们学习了如何创建新事件,是时候看看如何监视系统中断了。

8.6.异常位图

如果您还记得 MSR 位图,我们为每个 MSR 都有一个掩码,用于显示该 MSR 上的读取或写入是否应导致 VM-exit。

异常的监控使用相同的方法,这意味着一个简单的掩码对其进行管理。 此掩码在 VMCS 中为 EXCEPTION_BITMAP

异常位图是一个 32 位字段,其中每个异常包含一位。 当异常发生时,其向量用于选择该字段中的一位。 如果该位为 1,则异常会导致 VM-exit。 如果该位为 0,则异常通过 IDT 正常传递。

现在由您决定是否要将异常注入回guest或更改状态或任何您想要执行的操作。

例如,如果设置 EXCEPTION_BITMAP 的第 3 位 则每当某处(用户模式和内核模式)出现断点时,就会出现带有 EXIT_REASON_EXCEPTION_NMI (退出原因 == 0)的 vm-exit。

1
2
// Set exception bitmap to hook division by zero (bit 1 of EXCEPTION_BITMAP)
__vmx_vmwrite(EXCEPTION_BITMAP, 0x8); // breakpoint 3nd bit

现在我们可以更改程序的状态,然后恢复guest,记住恢复guest不会导致异常传递给guest,如果我们希望guest正常处理事件,我们必须手动注入事件。 例如,我们可以使用前面提到的“EventInjectBreakpoint”函数将异常注入回客户端。

最后一个问题是我们如何找到发生的异常的索引,你知道我们可能会为多个异常设置异常位图,所以我们必须知道这个 vm-exit 发生的确切原因,或者更清楚地知道是什么异常导致了这个 vm-出口。

以下 VMCS 字段向我们报告该事件,

  • VM_EXIT_INTR_INFO
  • VM_EXIT_INTR_ERROR_CODE

下表显示了如何使用 VM_EXIT_INTR_INFO
图片描述
其结构如下:

1
2
3
4
5
6
7
8
9
10
11
typedef union _VMEXIT_INTERRUPT_INFO {
    struct {
        UINT32 Vector : 8;
        UINT32 InterruptionType : 3;
        UINT32 ErrorCodeValid : 1;
        UINT32 NmiUnblocking : 1;
        UINT32 Reserved : 18;
        UINT32 Valid : 1;
    };
    UINT32 Flags;
}VMEXIT_INTERRUPT_INFO, * PVMEXIT_INTERRUPT_INFO;

我们可以使用 vmread 指令读取详细信息,例如,以下命令显示我们如何检测断点(0xcc)是否发生。

1
2
3
4
5
6
7
// read the exit reason
__vmx_vmread(VM_EXIT_INTR_INFO, &InterruptExit);
 
if (InterruptExit.InterruptionType == INTERRUPT_TYPE_SOFTWARE_EXCEPTION && InterruptExit.Vector == EXCEPTION_VECTOR_BREAKPOINT)
{
// Do whatever , e.g re-inject the breakpoint
}

如果我们想重新注入带有错误代码的异常(参见上表),那么可以使用 VMCS中的VM_EXIT_INTR_ERROR_CODE 读取错误代码。 之后,将错误代码写入 VM_ENTRY_EXCEPTION_ERROR_CODE 的deliver-error-code ,并启用VM_ENTRY_INTR_INFO ,以确保重新注入没有任何缺陷。

另外,请记住,页面错误的处理方式有所不同,您可以阅读 Intel SDM 以获取更多信息。

可是等等! 您是否注意到异常位图在 VMCS 中只是一个 32 位字段,而在 IDT 中我们有多达 256 个中断?!

如果您对这个问题感到好奇,可以在 讨论 部分阅读其答案。

8.7.Monitor Trap Flag(MTF)

Monitor Trap Flag或 MTF 是一项功能,其工作方式与 r/eflags 中的陷阱标志完全相同,只是它对GUEST不可见。。

每当您在 CPU_BASED_VM_EXEC_CONTROL 上设置此标志时,在 VMRESUME 之后,处理器会执行一条指令,然后发生 vm-exit。

我们必须清除此标志,否则每条指令都会导致 vm-exit。

以下函数负责设置和取消设置 MTF。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Set the monitor trap flag */
VOID HvSetMonitorTrapFlag(BOOLEAN Set)
{
    ULONG CpuBasedVmExecControls = 0;
 
    // Read the previous flag
    __vmx_vmread(CPU_BASED_VM_EXEC_CONTROL, &CpuBasedVmExecControls);
 
    if (Set) {
        CpuBasedVmExecControls |= CPU_BASED_MONITOR_TRAP_FLAG;
    }
    else {
        CpuBasedVmExecControls &= ~CPU_BASED_MONITOR_TRAP_FLAG;
    }
 
    // Set the new value
    __vmx_vmwrite(CPU_BASED_VM_EXEC_CONTROL, CpuBasedVmExecControls);
}

设置 MTF 会导致 vm-exit 并带有退出原因 (EXIT_REASON_MONITOR_TRAP_FLAG),我们在 vm-exit 处理程序中取消设置 MTF。

MTF 对于实现隐藏挂钩至关重要,稍后将在隐藏挂钩部分中详细介绍 MtfEptHookRestorePoint。 。

这是 MTF vm-exit 处理程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
case EXIT_REASON_MONITOR_TRAP_FLAG:
{
    /* Monitor Trap Flag */
    if (GuestState[CurrentProcessorIndex].MtfEptHookRestorePoint)
    {
        // Restore the previous state
        EptHandleMonitorTrapFlag(GuestState[CurrentProcessorIndex].MtfEptHookRestorePoint);
        // Set it to NULL
        GuestState[CurrentProcessorIndex].MtfEptHookRestorePoint = NULL;
    }
    else
    {
        LogError("Why MTF occured ?!");
    }
 
    // Redo the instruction
    GuestState[CurrentProcessorIndex].IncrementRip = FALSE;
 
    // We don't need MTF anymore
    HvSetMonitorTrapFlag(FALSE);
 
    break;
}

8.8.隐藏挂钩

(无任何限制地模拟硬件调试寄存器)

您使用过硬件调试器寄存器吗?!

调试寄存器允许研究人员和程序员有选择地启用与一组四个调试地址相关的各种调试条件(读、写、执行),而无需对程序指令进行任何更改。

如您所知,我们最多可以为这些硬件寄存器设置 4 个位置,这是这些寄存器的最大限制。

那么如果我们有一个结构体(比如说 _EPROCESS)并且我们想查看 Windows 中的哪些函数在该结构体中读取或写入呢?

当前的调试寄存器不可能实现这一点,但我们使用 EPT 来拯救!

8.8.1.读/写和执行的隐藏挂钩场景

我们有两种隐藏钩子策略,一种用于 读/写 ,一种用于 执行

对于读/写,

我们在与该地址对应的条目中取消设置读取或写入或两者(根据用户想要的方式)。

这意味着在读取或写入之前会发生 vm-exit,并且 EPT 违规将通知我们。 在 EPT 违规处理程序中,我们记录尝试读取或写入的地址,然后在 EPT 表中找到该条目并设置读取和写入(意味着允许对该页进行任何读取或写入),并设置 MTF 标志。

VMM恢复,执行一条指令,或者换句话说,执行读或写,然后发生MTF vm-exit。 在 MTF vm-exit 处理程序中,我们再次取消设置读写访问权限,以便将来对该页面的任何访问都将导致 EPT 违规。

请注意,上述所有场景都发生在一个核心上。 每个核心都有一个单独的 TLB 和单独的监视器陷阱标志。

对于执行,

对于执行,我们使用英特尔处理器中称为“仅执行”的功能。

仅执行意味着我们可以在禁用读取和写入访问的同时启用执行访问的页面。 。

如果用户想要执行挂钩,那么我们在EPT表中找到该条目并取消设置读写访问权限并设置执行访问权限。 然后我们从原始页面(Page A)创建一个副本到其他地方(Page B),并用绝对跳转到钩子函数修改复制的页面(Page B)

现在,每次任何指令尝试执行我们的函数时,都会执行绝对跳转,并调用我们的钩子函数。 每次任何指令尝试读取或写入该位置时,当我们取消对该页面的读写访问权限时,就会发生 EPT 违规,因此我们可以交换原始页面(页面 A)并设置监视器陷阱标志以恢复挂钩执行一条指令后。

这不是很容易吗? 不懂的话再看一遍。

你也可以考虑不同的方法; 例如, DdiMon 从该页面创建一个副本,并通过替换其中的一个字节 (0xcc) 断点来修改挂钩位置。 现在它拦截每个断点(使用异常位图)并交换原始页面。 这种方法实现起来更简单,也更可靠,但它会导致每个钩子的 vm-exit,因此速度较慢,但 EPT Hooks 的第一个方法永远不会导致 vm-exit 执行。

Read 和 Write hooks 的 VM-exits 是不可避免的。

这部分的执行钩子源自 Gbps hv

让我们深入研究实施。

8.8.2.实施隐藏挂钩

对于挂钩函数,首先,我们将页面拆分为 4KB 条目,如上一部分 所述 。 然后找到该条目并阅读该条目。 我们想要保存挂钩页面的详细信息,以便稍后使用。 对于读/写钩子,我们取消设置读或写或两者,而对于执行钩子,我们取消设置读/写访问权限并设置执行访问权限,并将页面内容复制到新页面中,并将条目的物理地址与第二页的物理地址交换地址(假页面的物理地址)。

然后我们构建一个蹦床(稍后解释),最后决定如何根据 vmx-state(vmx-root 或 vmx non-root)使 TLB 失效,最后将钩子详细信息添加到 HookedPagesList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/* This function returns false in VMX Non-Root Mode if the VM is already initialized
   This function have to be called through a VMCALL in VMX Root Mode */
BOOLEAN EptPerformPageHook(PVOID TargetAddress, PVOID HookFunction, PVOID* OrigFunction, BOOLEAN UnsetRead, BOOLEAN UnsetWrite, BOOLEAN UnsetExecute) {
 
    EPT_PML1_ENTRY ChangedEntry;
    INVEPT_DESCRIPTOR Descriptor;
    SIZE_T PhysicalAddress;
    PVOID VirtualTarget;
    PVOID TargetBuffer;
    PEPT_PML1_ENTRY TargetPage;
    PEPT_HOOKED_PAGE_DETAIL HookedPage;
    ULONG LogicalCoreIndex;
 
    // Check whether we are in VMX Root Mode or Not
    LogicalCoreIndex = KeGetCurrentProcessorIndex();
 
    if (GuestState[LogicalCoreIndex].IsOnVmxRootMode && !GuestState[LogicalCoreIndex].HasLaunched)
    {
        return FALSE;
    }
 
    /* Translate the page from a physical address to virtual so we can read its memory.
     * This function will return NULL if the physical address was not already mapped in
     * virtual memory.
     */
    VirtualTarget = PAGE_ALIGN(TargetAddress);
 
    PhysicalAddress = (SIZE_T)VirtualAddressToPhysicalAddress(VirtualTarget);
 
    if (!PhysicalAddress)
    {
        LogError("Target address could not be mapped to physical memory");
        return FALSE;
    }
 
    // Set target buffer, request buffer from pool manager , we also need to allocate new page to replace the current page ASAP
    TargetBuffer = PoolManagerRequestPool(SPLIT_2MB_PAGING_TO_4KB_PAGE, TRUE, sizeof(VMM_EPT_DYNAMIC_SPLIT));
 
    if (!TargetBuffer)
    {
        LogError("There is no pre-allocated buffer available");
        return FALSE;
    }
 
    if (!EptSplitLargePage(EptState->EptPageTable, TargetBuffer, PhysicalAddress, LogicalCoreIndex))
    {
        LogError("Could not split page for the address : 0x%llx", PhysicalAddress);
        return FALSE;
    }
 
    // Pointer to the page entry in the page table.
    TargetPage = EptGetPml1Entry(EptState->EptPageTable, PhysicalAddress);
 
    // Ensure the target is valid.
    if (!TargetPage)
    {
        LogError("Failed to get PML1 entry of the target address");
        return FALSE;
    }
 
    // Save the original permissions of the page
    ChangedEntry = *TargetPage;
 
    /* Execution is treated differently */
 
    if (UnsetRead)
        ChangedEntry.ReadAccess = 0;
    else
        ChangedEntry.ReadAccess = 1;
 
    if (UnsetWrite)
        ChangedEntry.WriteAccess = 0;
    else
        ChangedEntry.WriteAccess = 1;
 
 
    /* Save the detail of hooked page to keep track of it */
    HookedPage = PoolManagerRequestPool(TRACKING_HOOKED_PAGES, TRUE, sizeof(EPT_HOOKED_PAGE_DETAIL));
 
    if (!HookedPage)
    {
        LogError("There is no pre-allocated pool for saving hooked page details");
        return FALSE;
    }
 
    // Save the virtual address
    HookedPage->VirtualAddress = TargetAddress;
 
    // Save the physical address
    HookedPage->PhysicalBaseAddress = PhysicalAddress;
 
    // Fake page content physical address
    HookedPage->PhysicalBaseAddressOfFakePageContents = (SIZE_T)VirtualAddressToPhysicalAddress(&HookedPage->FakePageContents[0]) / PAGE_SIZE;
 
    // Save the entry address
    HookedPage->EntryAddress = TargetPage;
 
    // Save the orginal entry
    HookedPage->OriginalEntry = *TargetPage;
 
 
    // If it's Execution hook then we have to set extra fields
    if (UnsetExecute)
    {
        // Show that entry has hidden hooks for execution
        HookedPage->IsExecutionHook = TRUE;
 
        // In execution hook, we have to make sure to unset read, write because
        // an EPT violation should occur for these cases and we can swap the original page
        ChangedEntry.ReadAccess = 0;
        ChangedEntry.WriteAccess = 0;
        ChangedEntry.ExecuteAccess = 1;
 
        // Also set the current pfn to fake page
        ChangedEntry.PageFrameNumber = HookedPage->PhysicalBaseAddressOfFakePageContents;
 
        // Copy the content to the fake page
        RtlCopyBytes(&HookedPage->FakePageContents, VirtualTarget, PAGE_SIZE);
 
        // Create Hook
        if (!EptHookInstructionMemory(HookedPage, TargetAddress, HookFunction, OrigFunction))
        {
            LogError("Could not build the hook.");
            return FALSE;
        }
    }
 
    // Save the modified entry
    HookedPage->ChangedEntry = ChangedEntry;
 
    // Add it to the list
    InsertHeadList(&EptState->HookedPagesList, &(HookedPage->PageHookList));
 
    /***********************************************************/
    // if not launched, there is no need to modify it on a safe environment
    if (!GuestState[LogicalCoreIndex].HasLaunched)
    {
        // Apply the hook to EPT
        TargetPage->Flags = ChangedEntry.Flags;
    }
    else
    {
        // Apply the hook to EPT
        EptSetPML1AndInvalidateTLB(TargetPage, ChangedEntry, INVEPT_SINGLE_CONTEXT);
    }
 
    return TRUE;
}

现在我们需要一个函数来创建另一个页面,并使用跳转另一个页面(页面 B)的绝对跳转(蹦床)来修补原始页面(页面 A)。

在( Page B )中,我们将跳转到挂钩函数,该函数也会复制修补到( Page B )的字节并保存原始函数,以便调用者返回到(Page B)上的原始页面。

这是一个简单的内联钩子,我们使用 LDE ( LDE64x64 ) 作为绕行函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
BOOLEAN EptHookInstructionMemory(PEPT_HOOKED_PAGE_DETAIL Hook, PVOID TargetFunction, PVOID HookFunction, PVOID* OrigFunction)
{
    SIZE_T SizeOfHookedInstructions;
    SIZE_T OffsetIntoPage;
 
    OffsetIntoPage = ADDRMASK_EPT_PML1_OFFSET((SIZE_T)TargetFunction);
    LogInfo("OffsetIntoPage: 0x%llx", OffsetIntoPage);
 
    if ((OffsetIntoPage + 13) > PAGE_SIZE - 1)
    {
        LogError("Function extends past a page boundary. We just don't have the technology to solve this.....");
        return FALSE;
    }
 
    /* Determine the number of instructions necessary to overwrite using Length Disassembler Engine */
    for (SizeOfHookedInstructions = 0;
        SizeOfHookedInstructions < 13;
        SizeOfHookedInstructions += LDE(TargetFunction, 64))
    {
        // Get the full size of instructions necessary to copy
    }
 
    LogInfo("Number of bytes of instruction mem: %d", SizeOfHookedInstructions);
 
    /* Build a trampoline */
 
    /* Allocate some executable memory for the trampoline */
    Hook->Trampoline = PoolManagerRequestPool(EXEC_TRAMPOLINE, TRUE, MAX_EXEC_TRAMPOLINE_SIZE);
 
    if (!Hook->Trampoline)
    {
        LogError("Could not allocate trampoline function buffer.");
        return FALSE;
    }
 
    /* Copy the trampoline instructions in. */
    RtlCopyMemory(Hook->Trampoline, TargetFunction, SizeOfHookedInstructions);
 
    /* Add the absolute jump back to the original function. */
    EptHookWriteAbsoluteJump(&Hook->Trampoline[SizeOfHookedInstructions], (SIZE_T)TargetFunction + SizeOfHookedInstructions);
 
    LogInfo("Trampoline: 0x%llx", Hook->Trampoline);
    LogInfo("HookFunction: 0x%llx", HookFunction);
 
    /* Let the hook function call the original function */
    *OrigFunction = Hook->Trampoline;
 
    /* Write the absolute jump to our shadow page memory to jump to our hook. */
    EptHookWriteAbsoluteJump(&Hook->FakePageContents[OffsetIntoPage], (SIZE_T)HookFunction);
 
    return TRUE;
}

为了创建一个简单的绝对跳转,我们使用以下函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Write an absolute x64 jump to an arbitrary address to a buffer. */
VOID EptHookWriteAbsoluteJump(PCHAR TargetBuffer, SIZE_T TargetAddress)
{
    /* mov r15, Target */
    TargetBuffer[0] = 0x49;
    TargetBuffer[1] = 0xBB;
 
    /* Target */
    *((PSIZE_T)&TargetBuffer[2]) = TargetAddress;
 
    /* push r15 */
    TargetBuffer[10] = 0x41;
    TargetBuffer[11] = 0x53;
 
    /* ret */
    TargetBuffer[12] = 0xC3;
}

在 EPT 违规的情况下,首先,我们找到导致此 vm-exit 的物理地址的详细信息。 然后我们调用 EptHandleHookedPage 创建一个有关详细信息的日志,然后我们设置一个 MTF 在执行一条指令后恢复到挂钩状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* Check if this exit is due to a violation caused by a currently hooked page. Returns FALSE
 * if the violation was not due to a page hook.
 *
 * If the memory access attempt was RW and the page was marked executable, the page is swapped with
 * the original page.
 *
 * If the memory access attempt was execute and the page was marked not executable, the page is swapped with
 * the hooked page.
 */
BOOLEAN EptHandlePageHookExit(VMX_EXIT_QUALIFICATION_EPT_VIOLATION ViolationQualification, UINT64 GuestPhysicalAddr)
{
    BOOLEAN IsHandled = FALSE;
    PLIST_ENTRY TempList = 0;
 
    TempList = &EptState->HookedPagesList;
    while (&EptState->HookedPagesList != TempList->Flink)
    {
        TempList = TempList->Flink;
        PEPT_HOOKED_PAGE_DETAIL HookedEntry = CONTAINING_RECORD(TempList, EPT_HOOKED_PAGE_DETAIL, PageHookList);
        if (HookedEntry->PhysicalBaseAddress == PAGE_ALIGN(GuestPhysicalAddr))
        {
            /* We found an address that match the details */
 
            /*
               Returning true means that the caller should return to the ept state to the previous state when this instruction is executed
               by setting the Monitor Trap Flag. Return false means that nothing special for the caller to do
            */
            if (EptHandleHookedPage(HookedEntry, ViolationQualification, GuestPhysicalAddr))
            {
                // Next we have to save the current hooked entry to restore on the next instruction's vm-exit
                GuestState[KeGetCurrentProcessorNumber()].MtfEptHookRestorePoint = HookedEntry;
 
                // We have to set Monitor trap flag and give it the HookedEntry to work with
                HvSetMonitorTrapFlag(TRUE);
 
 
            }
 
            // Indicate that we handled the ept violation
            IsHandled = TRUE;
 
            // Get out of the loop
            break;
        }
    }
    // Redo the instruction
    GuestState[KeGetCurrentProcessorNumber()].IncrementRip = FALSE;
    return IsHandled;
 
}

每次发生 EPT 违规时,我们都会检查是否是因为 读访问写访问执行访问 违规并记录 GUEST_RIP ,然后恢复初始标志(允许所有读、写和执行)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
BOOLEAN EptHandleHookedPage(EPT_HOOKED_PAGE_DETAIL* HookedEntryDetails, VMX_EXIT_QUALIFICATION_EPT_VIOLATION ViolationQualification, SIZE_T PhysicalAddress) {
 
    ULONG64 GuestRip;
    ULONG64 ExactAccessedAddress;
    ULONG64 AlignedVirtualAddress;
    ULONG64 AlignedPhysicalAddress;
 
 
    // Get alignment
    AlignedVirtualAddress = PAGE_ALIGN(HookedEntryDetails->VirtualAddress);
    AlignedPhysicalAddress = PAGE_ALIGN(PhysicalAddress);
 
    // Let's read the exact address that was accesses
    ExactAccessedAddress = AlignedVirtualAddress + PhysicalAddress - AlignedPhysicalAddress;
 
    // Reading guest's RIP
    __vmx_vmread(GUEST_RIP, &GuestRip);
 
    if (!ViolationQualification.EptExecutable && ViolationQualification.ExecuteAccess)
    {
        LogInfo("Guest RIP : 0x%llx tries to execute the page at : 0x%llx", GuestRip, ExactAccessedAddress);
 
    }
    else if (!ViolationQualification.EptWriteable && ViolationQualification.WriteAccess)
    {
        LogInfo("Guest RIP : 0x%llx tries to write on the page at :0x%llx", GuestRip, ExactAccessedAddress);
    }
    else if (!ViolationQualification.EptReadable && ViolationQualification.ReadAccess)
    {
        LogInfo("Guest RIP : 0x%llx tries to read the page at :0x%llx", GuestRip, ExactAccessedAddress);
    }
    else
    {
        // there was an unexpected ept violation
        return FALSE;
    }
 
    EptSetPML1AndInvalidateTLB(HookedEntryDetails->EntryAddress, HookedEntryDetails->OriginalEntry, INVEPT_SINGLE_CONTEXT);
 
    // Means that restore the Entry to the previous state after current instruction executed in the guest
    return TRUE;
}

就是这样! 我们有一个有效的隐藏挂钩。

8.8.3.从页面中删除挂钩

从页面中删除钩子对我们来说至关重要,原因有二: 首先,有时我们需要禁用钩子,其次,当我们想要关闭虚拟机管理程序时,我们必须删除所有钩子。 否则,我们可能会遇到奇怪的行为。

删除钩子很简单,因为我们保存了详细信息,包括 PageHookList 中的原始条目; 我们 必须找到此列表中的条目并向所有处理器广播以更新其 TLB 并删除该条目。

下面的函数就是为了这个目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* Remove single hook from the hooked pages list and invalidate TLB */
BOOLEAN HvPerformPageUnHookSinglePage(UINT64 VirtualAddress) {
    PLIST_ENTRY TempList = 0;
    SIZE_T PhysicalAddress;
 
    PhysicalAddress = PAGE_ALIGN(VirtualAddressToPhysicalAddress(VirtualAddress));
 
    // Should be called from vmx non-root
    if (GuestState[KeGetCurrentProcessorNumber()].IsOnVmxRootMode)
    {
        return FALSE;
    }
 
    TempList = &EptState->HookedPagesList;
    while (&EptState->HookedPagesList != TempList->Flink)
    {
        TempList = TempList->Flink;
        PEPT_HOOKED_PAGE_DETAIL HookedEntry = CONTAINING_RECORD(TempList, EPT_HOOKED_PAGE_DETAIL, PageHookList);
 
        if (HookedEntry->PhysicalBaseAddress == PhysicalAddress)
        {
            // Remove it in all the cores
            KeGenericCallDpc(HvDpcBroadcastRemoveHookAndInvalidateSingleEntry, HookedEntry->PhysicalBaseAddress);
 
            // remove the entry from the list
            RemoveEntryList(HookedEntry->PageHookList.Flink);
 
            return TRUE;
        }
    }
    // Nothing found , probably the list is not found
    return FALSE;
}

在 vmx-root 中,我们还搜索特定的钩子并使用 EptSetPML1AndInvalidateTLB 将该条目返回到初始状态,该状态先前保存在 OriginalEntry 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* Remove and Invalidate Hook in TLB */
// Caution : This function won't remove entries from LIST_ENTRY, just invalidate the paging, use HvPerformPageUnHookSinglePage instead
BOOLEAN EptPageUnHookSinglePage(SIZE_T PhysicalAddress) {
    PLIST_ENTRY TempList = 0;
 
    // Should be called from vmx-root, for calling from vmx non-root use the corresponding VMCALL
    if (!GuestState[KeGetCurrentProcessorNumber()].IsOnVmxRootMode)
    {
        return FALSE;
    }
 
    TempList = &EptState->HookedPagesList;
    while (&EptState->HookedPagesList != TempList->Flink)
    {
        TempList = TempList->Flink;
        PEPT_HOOKED_PAGE_DETAIL HookedEntry = CONTAINING_RECORD(TempList, EPT_HOOKED_PAGE_DETAIL, PageHookList);
        if (HookedEntry->PhysicalBaseAddress == PAGE_ALIGN(PhysicalAddress))
        {
            // Undo the hook on the EPT table
            EptSetPML1AndInvalidateTLB(HookedEntry->EntryAddress, HookedEntry->OriginalEntry, INVEPT_SINGLE_CONTEXT);
            return TRUE;
        }
    }
    // Nothing found , probably the list is not found
    return FALSE;
}

如果我们想取消所有页面的钩子,那么我们使用另一个VMCALL,不需要迭代这里的列表,因为所有的钩子都必须被删除。 只需通过所有核心广播即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Remove all hooks from the hooked pages list and invalidate TLB */
// Should be called from Vmx Non-root
VOID HvPerformPageUnHookAllPages() {
 
    // Should be called from vmx non-root
    if (GuestState[KeGetCurrentProcessorNumber()].IsOnVmxRootMode)
    {
        return;
    }
 
    // Remove it in all the cores
    KeGenericCallDpc(HvDpcBroadcastRemoveHookAndInvalidateAllEntries, 0x0);
 
    // No need to remove the list as it will automatically remove by the pool uninitializer
}

在 vmx-root 中,我们只需迭代列表并将它们恢复到初始状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* Remove and Invalidate Hook in TLB */
// Caution : This function won't remove entries from LIST_ENTRY, just invalidate the paging, use HvPerformPageUnHookAllPages instead
VOID EptPageUnHookAllPages() {
    PLIST_ENTRY TempList = 0;
 
    // Should be called from vmx-root, for calling from vmx non-root use the corresponding VMCALL
    if (!GuestState[KeGetCurrentProcessorNumber()].IsOnVmxRootMode)
    {
        return FALSE;
    }
 
    TempList = &EptState->HookedPagesList;
    while (&EptState->HookedPagesList != TempList->Flink)
    {
        TempList = TempList->Flink;
        PEPT_HOOKED_PAGE_DETAIL HookedEntry = CONTAINING_RECORD(TempList, EPT_HOOKED_PAGE_DETAIL, PageHookList);
 
        // Undo the hook on the EPT table
        EptSetPML1AndInvalidateTLB(HookedEntry->EntryAddress, HookedEntry->OriginalEntry, INVEPT_SINGLE_CONTEXT);
    }
}

8.8.4.修改EPT条目时的重要注意事项

我在多核系统上测试驱动程序时遇到的一件有趣的事情是 EPT 条目应该在一条指令中修改。

例如,如果您一点一点地更改 EPT 条目的访问位,那么您可能会收到一个错误(EPT 错误配置),即一个访问位发生更改,并且在下一个访问位应用之前,另一个核心尝试访问页表,有时会出现这样的错误:导致 EPT 配置错误,有时您可能无法获得所需的行为。

例如下面修改EPT条目的方法就是错误的!

1
2
3
HookedEntryDetails->EntryAddress->ExecuteAccess = 1;
HookedEntryDetails->EntryAddress->WriteAccess = 1;
HookedEntryDetails->EntryAddress->ReadAccess = 1;

但下面的代码是正确的。 (立即应用一条指令中的更改)。

1
2
// Apply the hook to EPT
TargetPage->Flags = OriginalEntry.Flags;

这就是为什么我们有以下函数来获取一个自旋锁,以确保只有一个条目被修改一次,然后使该核心的 TLB 无效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*  This function set the specific PML1 entry in a spinlock protected area then invalidate the TLB ,
    this function should be called from vmx root-mode
*/
VOID EptSetPML1AndInvalidateTLB(PEPT_PML1_ENTRY EntryAddress, EPT_PML1_ENTRY EntryValue, INVEPT_TYPE InvalidationType)
{
    // acquire the lock
    SpinlockLock(&Pml1ModificationAndInvalidationLock);
    // set the value
    EntryAddress->Flags = EntryValue.Flags;
 
    // invalidate the cache
    if (InvalidationType == INVEPT_SINGLE_CONTEXT)
    {
        InveptSingleContext(EptState->EptPointer.Flags);
    }
    else
    {
        InveptAllContexts();
    }
    // release the lock
    SpinlockUnlock(&Pml1ModificationAndInvalidationLock);
}

上述函数解决了同时修改EPT表的问题,因为我们对所有核心都有一个EPT表。

8.9.系统调用挂钩

当涉及到虚拟机管理程序时,我们有不同的选项来挂钩系统调用。 这些方法中的每一种都有其自身的优点和缺点。

让我们回顾一下可用于挂钩系统调用的一些方法。

第一种方法是挂钩 MSR 0xc0000082 (LSTAR)。 该 MSR 是用于调度系统调用的内核入口。 每次在用户模式下执行像Syscall这样的指令时,处理器都会自动切换到内核模式并运行该MSR中存储的地址。 在 Windows 中,KiSystemCall64 的地址存储在该 MSR 中。

这意味着每次应用程序需要调用系统调用时,它都会执行系统调用,现在该函数负责查找 SSDT 中的条目并进行调用。 简而言之,SSDT是Windows中的一个表,它存储基于系统调用号的Windows函数的指针。 所有SSDT条目和LSTAR MSR都在PatchGuard的控制之下。

这给我们带来了三种可能性!

首先,我们可以更改 MSR LSTAR 以指向我们的自定义函数,并使其与 PatchGuard 兼容,我们可以设置 MSR 位图,如果任何内核例程想要读取此 MSR,则会发生 vm-exit,以便我们可以更改结果。 我们可以显示 KiSystemCall64,而不是显示我们的自定义处理程序 并且 PatchGuard 永远不会知道这是一个假 MSR。

挂钩 MSR LSTAR 很复杂,Meltdown 的更新使其变得更加复杂。 在Meltdown后系统中, LSTAR 指向 KiSystemCall64Shadow, 其中涉及更改CR3并执行 KPTI相关 指令和Meltdown缓解。 不是一个好主意, 挂钩LSTAR 因为我们在 Meltdown 前和 Meltdown 后缓解方面遇到困难,而且此 MSR 中的系统状态发生变化,因此我们无法挂钩内核中的任何内容,因为内核未映射到 CR3 上。

Hyperbone 使用这种方法(即使在撰写本文时尚未针对熔毁后系统进行更新)。

第二个选项是查找 SSDT 表并更改其条目以指向我们的自定义函数,每次 PatchGuard 尝试审核这些条目时,我们都可以向其显示未修补的列表。 我们唯一应该记住的是找到 KiSystemCall64 尝试读取该位置的位置并将该位置保存在某个地方,这样我们就可以知道尝试读取的函数是否是我们的其他函数(可能还有 PatchGuard)的系统调用调度程序。

实现这种方法并不是超级快,因为我们需要取消设置 SSDT 条目的 EPT Read,并且每次发生读取时都会发生 vm-exit,因此我们为每个系统调用都有一个 vm-exit,从而使我们的计算机变慢!

第三个选项是在 SSDT 条目中查找函数,并在我们需要挂钩的函数上放置一个隐藏的挂钩。 这样,我们就可以捕获自定义的函数列表,因为我认为挂钩所有系统调用是愚蠢的!

我们在这部分实现第三个选项。

另一种可能的方法是通过扩展功能启用寄存器 (EFER) 进行系统调用挂钩,如此处所述。 该方法基于禁用 EFER MSR 的 Syscall Enable(或 SCE 位); 因此,每次执行系统调用时,处理器都会生成#UD异常,我们可以通过使用异常位图(如上所述)来拦截#UD来处理这些系统调用。

同样,这不是一个好主意,因为它会导致每个系统调用的 vm-exit; 因此,它的速度相当慢,但可用于实验目的。

此外,它们可能是其他选择。 如果您认识的话,请立即对此帖子发表评论并进行描述!

8.9.1.寻找内核库

要找到 SSDT,我们需要找到 nt!KeServiceDescriptorTablent!KeServiceDescriptorTableShadow ,这些表在 x86 系统中导出,但在 x64 系统中不导出。 这使得事情变得更加复杂,因为查找这些表的例程可能会在未来版本的 Windows 中发生变化; 因此,我们的 Syscall hooker 在未来的版本中可能会出现问题。

首先,我们需要找到 ntoskrnl的基地址, 它是图像的大小,这是通过使用 ZwQuerySystemInformation 来完成的,首先,我们通过使用MmGetSystemRoutineAddress找到这个函数

然后我们分配一块内存以从 Windows 获取详细信息并找到基址和模块大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* Get the kernel base and Image size */
PVOID SyscallHookGetKernelBase(PULONG pImageSize)
{
    NTSTATUS status;
    ZWQUERYSYSTEMINFORMATION ZwQSI = 0;
    UNICODE_STRING routineName;
    PVOID pModuleBase = NULL;
    PSYSTEM_MODULE_INFORMATION pSystemInfoBuffer = NULL;
    ULONG SystemInfoBufferSize = 0;
 
 
    RtlInitUnicodeString(&routineName, L"ZwQuerySystemInformation");
    ZwQSI = (ZWQUERYSYSTEMINFORMATION)MmGetSystemRoutineAddress(&routineName);
    if (!ZwQSI)
        return NULL;
 
 
    status = ZwQSI(SystemModuleInformation,
        &SystemInfoBufferSize,
        0,
        &SystemInfoBufferSize);
 
    if (!SystemInfoBufferSize)
    {
        LogError("ZwQuerySystemInformation (1) failed");
        return NULL;
    }
 
    pSystemInfoBuffer = (PSYSTEM_MODULE_INFORMATION)ExAllocatePool(NonPagedPool, SystemInfoBufferSize * 2);
 
    if (!pSystemInfoBuffer)
    {
        LogError("ExAllocatePool failed");
        return NULL;
    }
 
    memset(pSystemInfoBuffer, 0, SystemInfoBufferSize * 2);
 
    status = ZwQSI(SystemModuleInformation,
        pSystemInfoBuffer,
        SystemInfoBufferSize * 2,
        &SystemInfoBufferSize);
 
    if (NT_SUCCESS(status))
    {
        pModuleBase = pSystemInfoBuffer->Module[0].ImageBase;
        if (pImageSize)
            *pImageSize = pSystemInfoBuffer->Module[0].ImageSize;
    }
    else {
        LogError("ZwQuerySystemInformation (2) failed");
        return NULL;
    }
 
    ExFreePool(pSystemInfoBuffer);
    return pModuleBase;
}

更新 2 :您还可以使用 RtlPcToFileHeader 代替上述方法:

1
RtlPcToFileHeader(&RtlPcToFileHeader, &NtoskrnlBase);

8.9.2.查找SSDT和影子SSDT表

现在我们有了基地址 ntoskrnl 我们可以搜索这个模式来找到 nt!KeServiceDescriptorTableShadow

1
const unsigned char KiSystemServiceStartPattern[] = { 0x8B, 0xF8, 0xC1, 0xEF, 0x07, 0x83, 0xE7, 0x20, 0x25, 0xFF, 0x0F, 0x00, 0x00 };

nt!KeServiceDescriptorTableShadow 包含 nt!KiServiceTablewin32k!W32pServiceTable, 这是 NT Syscalls 和 Win32K Syscalls 的 Syscall 函数的 SSDT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/* Find SSDT address of Nt fucntions and W32Table */
BOOLEAN SyscallHookFindSsdt(PUINT64 NtTable, PUINT64 Win32kTable)
{
    ULONG kernelSize = 0;
    ULONG_PTR kernelBase;
    const unsigned char KiSystemServiceStartPattern[] = { 0x8B, 0xF8, 0xC1, 0xEF, 0x07, 0x83, 0xE7, 0x20, 0x25, 0xFF, 0x0F, 0x00, 0x00 };
    const ULONG signatureSize = sizeof(KiSystemServiceStartPattern);
    BOOLEAN found = FALSE;
    LONG relativeOffset = 0;
    ULONG_PTR addressAfterPattern;
    ULONG_PTR address;
    SSDTStruct* shadow;
    PVOID ntTable;
    PVOID win32kTable;
 
    //x64 code
    kernelBase = (ULONG_PTR)SyscallHookGetKernelBase(&kernelSize);
 
    if (kernelBase == 0 || kernelSize == 0)
        return FALSE;
 
    // Find KiSystemServiceStart
 
    ULONG KiSSSOffset;
    for (KiSSSOffset = 0; KiSSSOffset < kernelSize - signatureSize; KiSSSOffset++)
    {
        if (RtlCompareMemory(((unsigned char*)kernelBase + KiSSSOffset), KiSystemServiceStartPattern, signatureSize) == signatureSize)
        {
            found = TRUE;
            break;
        }
    }
 
    if (!found)
        return FALSE;
 
    addressAfterPattern = kernelBase + KiSSSOffset + signatureSize;
    address = addressAfterPattern + 7; // Skip lea r10,[nt!KeServiceDescriptorTable]
    // lea r11, KeServiceDescriptorTableShadow
    if ((*(unsigned char*)address == 0x4c) &&
        (*(unsigned char*)(address + 1) == 0x8d) &&
        (*(unsigned char*)(address + 2) == 0x1d))
    {
        relativeOffset = *(LONG*)(address + 3);
    }
 
    if (relativeOffset == 0)
        return FALSE;
 
    shadow = (SSDTStruct*)(address + relativeOffset + 7);
 
    ntTable = (PVOID)shadow;
    win32kTable = (PVOID)((ULONG_PTR)shadow + 0x20);    // Offset showed in Windbg
 
    *NtTable = ntTable;
    *Win32kTable = win32kTable;
 
    return TRUE;
}

请注意, nt!KeServiceDescriptorTable 仅包含 nt!KiServiceTable , 并且不提供 win32k!W32pServiceTable

8.9.3.通过系统调用号获取例程地址

找到 NT Syscall Table 和 Win32k Syscall Table 后,现在需要将 Syscall Numbers 转换为其相应的地址。

以下公式将 API 编号转换为函数地址。

1
((SSDT->pServiceTable[ApiNumber] >> 4) + SSDTbase);

请记住,NT 系统调用从 0x0 开始,但 Win32k 系统调用从 0x1000 开始,因此当我们根据表的开头计算索引时,我们应该减去 0x1000 的 Win32k 系统调用。

总而言之,我们有以下功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* Find entry from SSDT table of Nt fucntions and W32Table syscalls */
PVOID SyscallHookGetFunctionAddress(INT32 ApiNumber, BOOLEAN GetFromWin32k)
{
    SSDTStruct* SSDT;
    BOOLEAN Result;
    ULONG_PTR SSDTbase;
    ULONG ReadOffset;
    UINT64 NtTable, Win32kTable;
 
    // Read the address og SSDT
    Result = SyscallHookFindSsdt(&NtTable, &Win32kTable);
 
    if (!Result)
    {
        LogError("SSDT not found");
        return 0;
    }
 
    if (!GetFromWin32k)
    {
        SSDT = NtTable;
    }
    else
    {
        // Win32k APIs start from 0x1000
        ApiNumber = ApiNumber - 0x1000;
        SSDT = Win32kTable;
    }
 
    SSDTbase = (ULONG_PTR)SSDT->pServiceTable;
 
    if (!SSDTbase)
    {
        LogError("ServiceTable not found");
        return 0;
    }
    return (PVOID)((SSDT->pServiceTable[ApiNumber] >> 4) + SSDTbase);
 
}

现在我们已经有了所需例程的地址,现在是时候在该函数上放置一个隐藏的钩子了,我们还需要它们的函数原型,以便我们可以适当地读取它们的参数。

系统调用挂钩示例将在稍后(如何测试?)部分中进行演示。 。

8.10.虚拟处理器ID(VPID)和TLB

在英特尔,它对 VPID 的解释很模糊,所以我找到了一个很好的 链接 ,解释得更加简单; 因此,最好阅读下面的详细信息,而不是从 SDM 开始。

转换后备缓冲区 (TLB) 是用于虚拟地址到物理地址转换的高速内存页缓存。 它遵循本地原则,避免对最近使用的页面进行耗时的查找。

主机映射与guest不一致,反之亦然。 每个客户机都有自己的地址空间,映射表不能在另一个客户机(或主机)中重复使用。 因此,第一代 VM(例如 Intel Core 2 (VMX))会在每个 VM 进入(恢复)和 VM-exit时刷新 TLB。 但刷新 TLB 却是一个大问题,它是现代 CPU 中最关键的组件之一。

英特尔工程师开始思考这一点。 Intel Nehalem TLB 条目已通过引入虚拟处理器 ID 进行更改。 因此每个 TLB 条目都标有此 ID。 CPU 不指定 VPID,由虚拟机管理程序分配它们,而主机 VPID 为 0。从 Intel Nehalem 开始,不得刷新 TLB。 当进程尝试访问实际 VPID 与 TLB 条目 VPID 不匹配的映射时,会发生标准 TLB 未命中。 一些英特尔数据显示,与采用英特尔酷睿 2 的 Meron 相比,虚拟机往返转换的延迟性能提升了 40%。

假设您有两个或更多虚拟机:

  • 如果启用 VPID,则不必担心 VM1 意外获取 VM2 的缓存内存(甚至虚拟机管理程序本身)
  • 如果您不启用 VPID,CPU 会将 VPID=0 分配给所有操作(VMX 根和 VMX non-root),并在每次转换时刷新 TLB

逻辑处理器可以使用 16 位 VPID 来标记某些缓存信息。

在以下情况下,VPID 为 0000H:

  • 外部 VMX 操作。 (例如系统管理模式(SMM))。
  • VMX root operation
  • VMX non-root operation when the “enable VPID” VM-execution control is 0

8.11.INVVPID - Invalidate Translations Based on VPID

为了支持 VPID,我们必须将 CPU_BASED_CTL2_ENABLE_VPID 添加到Secondary Processor-Based VM-Execution Controls。

下一步是使用 VMWRITE 指令为 VMCS 的 VIRTUAL_PROCESSOR_ID 字段设置 16 位值。 该值用作该核心上当前 VMCS 的索引,因此我们当前 VMCS 的 VPID 为 1。

另外,如上所述,0 具有特殊含义,不应使用。

1
2
3
4
5
6
// Set up VPID
 
/* For all processors, we will use a VPID = 1. This allows the processor to separate caching
   of EPT structures away from the regular OS page translation tables in the TLB.   */
 
__vmx_vmwrite(VIRTUAL_PROCESSOR_ID, 1);

INVVPID(指令)根据 **virtual processor identifier ** (VPID)使转换后备缓冲区(TLB)和分页结构高速缓存中的映射无效。

对于 INVVPID,处理器当前支持 4 种类型,这些类型在 IA32_VMX_EPT_VPID_CAP MSR 中报告。

这些类型的枚举是:

1
2
3
4
5
6
7
typedef enum _INVVPID_ENUM
{
    INDIVIDUAL_ADDRESS = 0x00000000,
    SINGLE_CONTEXT = 0x00000001,
    ALL_CONTEXT = 0x00000002,
    SINGLE_CONTEXT_RETAINING_GLOBALS = 0x00000003
}INVVPID_ENUM, *PINVVPID_ENUM;

稍后我将详细描述这些类型。

对于 INVVPID 的实现,我们使用这样的汇编函数(它 执行invvpid ): 从 RCX 和 RDX for x64 快速调用约定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
AsmInvept PROC PUBLIC
 
    invept  rcx, oword ptr [rdx]
    jz @jz
    jc @jc
    xor     rax, rax
    ret
 
    @jz:
    mov     rax, VMX_ERROR_CODE_FAILED_WITH_STATUS
    ret
 
    @jc:
    mov     rax, VMX_ERROR_CODE_FAILED
    ret
 
AsmInvept ENDP

然后,用于调用此汇编函数的通用函数:

1
2
3
4
5
6
7
8
9
10
inline Invvpid(INVVPID_ENUM Type, INVVPID_DESCRIPTOR* Descriptor)
{
    if (!Descriptor)
    {
        static INVVPID_DESCRIPTOR ZeroDescriptor = { 0 };
        Descriptor = &ZeroDescriptor;
    }
 
    return AsmInvvpid(Type, Descriptor);
}

对于 INVVPID,有一个定义如下的描述符。
图片描述
该结构定义如下:

1
2
3
4
5
6
7
typedef struct _INVVPID_DESCRIPTOR
{
    UINT64 VPID : 16;
    UINT64 RESERVED : 48;
    UINT64 LINEAR_ADDRESS;
 
} INVVPID_DESCRIPTOR, *PINVVPID_DESCRIPTOR;

INVVPID 的类型定义如下:

  • 单独地址无效: 如果 INVVPID 类型为 0,逻辑处理器将使线性地址和 INVVPID 描述符中指定的 VPID 的映射无效。 在某些情况下,它也可能使其他线性地址(或其他 VPID)的映射无效。
1
2
3
4
5
inline InvvpidIndividualAddress(UINT16 Vpid, UINT64 LinearAddress)
{
    INVVPID_DESCRIPTOR Descriptor = { Vpid, 0, LinearAddress };
    return Invvpid(INDIVIDUAL_ADDRESS, &Descriptor);
}
  • 单上下文无效: 如果 INVVPID 类型为 1,则逻辑处理器将使所有用 INVVPID 描述符中指定的 VPID 标记的映射无效。 在某些情况下,它也可能使其他 VPID 的映射无效。
1
2
3
4
5
inline InvvpidSingleContext(UINT16 Vpid)
{
    INVVPID_DESCRIPTOR Descriptor = { Vpid, 0, 0 };
    return Invvpid(SINGLE_CONTEXT, &Descriptor);
}
  • 所有上下文无效: 如果 INVVPID 类型为 2,则逻辑处理器将使标记有除 VPID 0000H 之外的所有 VPID 的所有映射无效。 在某些情况下,VPID 0000H 的转换也可能无效。
1
2
3
4
inline InvvpidAllContexts()
{
    return Invvpid(ALL_CONTEXT, NULL);
}
  • 单上下文无效,保留全局转换: 如果 INVVPID 类型为 3,则逻辑处理器将使所有用 INVVPID 描述符中指定的 VPID 标记的映射无效,全局转换除外。 在某些情况下,它也可能使全局转换(以及与其他 VPID 的映射)失效。 有关global translations 的信息,请参阅《 IA-32 英特尔架构软件开发人员手册》第 3A 卷第 4 章中的“Caching Translation Information”部分。
1
2
3
4
5
inline InvvpidSingleContextRetainingGlobals(UINT16 Vpid)
{
    INVVPID_DESCRIPTOR Descriptor = { Vpid, 0, 0 };
    return Invvpid(SINGLE_CONTEXT_RETAINING_GLOBALS, &Descriptor);
}

您可能会想到如何在虚拟机管理程序中使用 VPID。 我们可以用它来代替 INVEPT,但一般来说,它对我们来说没有任何特殊用途。 我在讨论部分对此进行了更多描述。 顺便说一句,VPID 将用于实现特殊功能,因为它比 INVEPT 更灵活,而且当我们有多个 VMCS (EPTP) 时也是如此。 (你能想一下其中的一些吗?)。

8.11.1.使用VPID的重要注意事项

使用 VPID 时您应该了解一些重要事项。

启用 VPID 会产生不刷新 VMEntry/VMExit 上的 TLB 的副作用。 如果需要,您应该手动刷新guest TLB 条目(通过使用 INVEPT/INVVPID)。 禁用 VPID 时,这些问题可能会被隐藏。

当 VPID 被禁用时,VMEntry 会刷新整个 TLB。 因此,在执行应使 TLB 条目无效的操作(例如,修改 EPT 条目)时,管理程序不需要显式地使guest填充的 TLB 条目无效。 当启用 VPID 时,应使用 INVEPT/INVVPID。

发现此类问题确实是您遇到的问题的一个简单方法是在每个 VMEntry 之前执行 INVEPT 全局上下文以刷新整个 TLB,同时仍然保持 VPID 启用。 如果现在可以工作,您应该检查哪里缺少 INVEPT 执行。

根据我的经验,如果你只是启用 VPID 而没有任何额外的假设,所有进程都会开始一一崩溃,最终内核崩溃,这是因为我们没有使 TLB 无效。

为了解决每个进程崩溃的问题,我们必须在 Mov 到 Cr3 的情况下使 TLB 无效,因此每当 vm-exit 发生且 Reason == EXIT_REASON_CR_ACCESS (28) 时,如果它是 Mov 到 Cr3 我们必须使 TLB 无效(INVEPT 或 INVVPID [有关更多详细信息,请参阅 更新 1 ])。

所以我们这样编辑代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
case TYPE_MOV_TO_CR:
{
    switch (CrExitQualification->Fields.ControlRegister)
    {
    case 0:
        __vmx_vmwrite(GUEST_CR0, *RegPtr);
        __vmx_vmwrite(CR0_READ_SHADOW, *RegPtr);
        break;
    case 3:
        __vmx_vmwrite(GUEST_CR3, (*RegPtr & ~(1ULL << 63)));
        // InveptSingleContext(EptState->EptPointer.Flags); (changed, look for "Update 1" at the 8th part for more detail)
        InvvpidSingleContext(VPID_TAG);
        break;
    case 4:
        __vmx_vmwrite(GUEST_CR4, *RegPtr);
        __vmx_vmwrite(CR4_READ_SHADOW, *RegPtr);
 
        break;
    default:
        LogWarning("Unsupported register %d in handling control registers access", CrExitQualification->Fields.ControlRegister);
        break;
    }
}

另请注意,由于我们对所有核心都有一个 EPTP,因此足以使单上下文无效,否则我们必须使所有上下文无效。

更新 1: 正如 Satoshi Tanda 提到的,

CR3 处理程序应使用 INVVPID 而不是 INVEPT,因为 INVEPT 无效的内容超出了所需的范围。 我们想要使 GVA -> HPA(组合映射)的缓存无效,两条指令都执行此操作。 这就是 INVEPT 也起作用的原因,但 INVEPT 还会使 GPA -> HPA(客户物理映射)的缓存失效,这些缓存不受客户 CR3 更改的影响,并且可以保留而不会失效。

一般准则是,当需要 TLB 刷新emulation时,INVVPID;当 EPT 条目更改时,INVEPT。 您可以在以下位置找到有关这些指令和缓存类型的更多信息:

  • 28.3.1 可能被缓存的信息
  • 28.3.3.3 INVVPID 指令的使用指南。

因此我们使用 InvvpidSingleContext 代替 InveptSingleContext。

老实说,我们对处理 Cr3 vm-exits 存在一些误解,尽管上面的代码工作正常,但通常它会带来一些性能损失。 我将在“ 修复以前的设计问题 ”部分中解释这些性能问题。

您可能还会问为什么我们避免写入 CR3 的第 63 位。

1
__vmx_vmwrite(GUEST_CR3, (*RegPtr & ~(1ULL << 63)));

CR3 的位 63 是一个新位,是 PCID 功能的一部分。 它允许操作系统更改 CR3 值,而无需使除标有全局位之外的所有 TLB 条目(用相同 EP4TA 和 VPID 标记)无效。

EP4TA 是 EPTP 的第 51:12 位的值。

例如,Windows KVA Shadowing 和 Linux KPTI 在 CR3 mov 上向该位发出信号,在用户和内核转换时在用户空间 PCID 和内核空间 PCID 之间更改 PCID。

我们不应该在 mov reg、cr3 模拟上写入 CR3 的第 63 位,因为处理器不会写入,并且尝试写入这将导致现代 Win10 上的崩溃。

8.11.2.INVVPID与IVNPCID

INVPCID 与虚拟机管理程序并不真正相关,但在这种情况下,如果您想知道,INVPCID 会使基于进程上下文标识符 (PCID) 的转换后备缓冲区 (TLB) 和分页结构缓存中的映射无效。

所以它就像 INVVPID,不同之处在于它不是特定于虚拟机管理程序的。 它还具有其特定的上下文(当前为 3 个),您可以 在此处 阅读更多内容,但通常请记住,为了减少开销,Intel 的 Westmere 架构和相关指令 INVPCID(使 PCID 无效)引入了一项称为进程上下文 ID (PCID) 的功能)与哈斯韦尔。 启用 PCID 后,TLB 的使用和刷新方式会发生变化。 首先,TLB 使用拥有该条目的进程的 PCID 来标记每个条目。 这允许来自同一虚拟地址的两个不同映射存储在 TLB 中,只要它们具有不同的 PCID。 其次,启用 PCID 后,从一组页表切换到另一组页表不会再刷新 TLB。 由于每个进程只能使用具有正确 PCID 的 TLB 条目,因此无需每次都刷新 TLB。

此行为用于 Meltdown 缓解,以避免清除支持 PCID 的处理器的整个 TLB。

8.12.设计VMX-root 模式兼容的消息跟踪

毫无疑问,设计虚拟机管理程序最困难的部分之一是将消息从 VMX root-mode发送到 VMX non-root模式。 这是因为您有很多限制,例如无法访问非分页缓冲区,当然,大多数 NT 函数不(任何 IRQL)兼容,因为它们可能访问驻留在分页池中的缓冲区。

事情到这里就结束了,还有很多其他的限制需要处理。

本节的灵感来自于 Pavel Yosifovich 所著的《Windows 内核编程》一书中的第 6 章:内核机制(高 IRQL 同步),如果您想开始内核编程,这本书真是一本很棒的书。

8.12.1概念

本节介绍一些在开始之前您应该了解的操作系统概念。

8.12.2.什么是自旋锁

自旋锁是内存中的一个位,提供原子测试和修改操作。 当一个CPU尝试获取一个自旋锁,并且它当前不空闲时,CPU会继续在该自旋锁上旋转,忙于等待另一个CPU释放它,这意味着它会不断检查,直到另一个获取它的线程首先释放它。

8.12.3.测试和设置

您可能在大学里读过有关测试和设置的内容。 不过,如果您没有这样做,在计算机科学中,测试和设置指令是用于将 1(设置)写入内存位置并将其旧值作为单个原子返回的指令(即不可中断)手术。 如果多个进程可以访问相同的内存位置,并且如果一个进程当前正在执行测试和设置,则在第一个进程的测试和设置完成之前,没有其他进程可以开始另一个测试和设置。

8.12.4.安全是什么意思

“安全”在虚拟机管理程序中被大量使用。 我们所说的“安全”是指始终有效且不会导致系统崩溃或系统停止的东西。 这是因为在 vmx root 模式下管理代码非常棘手。 毕竟,中断被屏蔽(禁用),或者将缓冲区从 vmx root 模式传输到 vmx non-root 模式需要额外的努力,我们应该谨慎并避免执行某些 API 以确保安全。

8.12.5.什么是DPC

( 延迟过程调用 DPC ) 是一种 Windows 机制,允许高优先级任务(例如中断处理程序)推迟所需但优先级较低的任务以供以后执行。 这允许设备驱动程序和其他低级事件使用者快速执行其处理的高优先级部分,并安排非关键的附加处理以较低优先级执行。

DPC 由 DPC 对象实现,当设备驱动程序或某些其他内核模式程序发出 DPC 请求时,内核会创建并初始化这些 DPC 对象。 然后,DPC 请求被添加到 DPC 队列的末尾。 每个处理器都有一个单独的 DPC 队列。 DPC 具有三个优先级:低、中和高。 默认情况下,所有 DPC 都设置为中等优先级。 当 Windows 下降到 Dispatch/DPC 级别的 IRQL 时,它会检查 DPC 队列中是否有任何挂起的 DPC,并执行它们,直到队列为空或发生具有更高 IRQL 的其他中断。

这是 MSDN 对 DPC 的描述:

由于 ISR 必须尽快执行,因此驱动程序通常必须推迟中断服务的完成,直到 ISR 返回之后。 因此,系统提供了对延迟过程调用 (DPC) 的支持,它可以从 ISR 排队,并在稍后的时间以比 ISR 更低的 IRQL 执行。

有两篇关于 DPC 的帖子 这里 这里 ,您可以阅读它们以获取更多信息。

8.12.6.挑战

例如,Vmx-root 模式不是 HIGH_IRQL 中断(在讨论 部分 讨论),但由于它禁用所有中断,我们可以认为它是 HIGH_IRQL 状态。 问题是必须的同步函数被设计为在低于 DISPATCH_LEVEL 的 IRQL 上工作。

为什么会出现问题呢? 想象一下您有一个单核处理器,并且您的函数需要一个自旋锁(假设它只是一个需要访问的缓冲区)。 该函数将 IRQL 提升至 DISPATCH_LEVEL 。 现在,Windows 调度程序无法中断该函数,直到它释放自旋锁并将 IRQL 降低到 PASSIVE_LEVELAPC_LEVEL 。 函数执行过程中,发生vm-exit; 因此,我们现在处于 vmx root 模式。 这是因为,正如我告诉过你的,vm-exit 的发生就像是一个 HIGH_IRQL 中断。

现在,如果我们想在 vmx root 模式下访问该缓冲区该怎么办? 可能会出现两种情况。

  • 我们等待先前由 vmx 非 root 模式下的线程获取的自旋锁,并且我们必须永远等待。 发生死锁。
  • 我们在不查看锁的情况下进入函数(同时有另一个线程进入该函数。),因此会导致缓冲区损坏和数据无效。

另一个限制是在 Windows 设计中,无法在 IRQL DISPATCH_LEVEL 或更高级别将线程置于等待状态。 这是因为在 Windows 中,当您获取自旋锁时,它会将 IRQL 提高到 2 – DISPATCH_LEVEL(如果尚未达到),获取自旋锁,执行工作,最后释放自旋锁并降低 IRQL。

如果您查看像 KeAcquireSpinLockKeReleaseSpinLock 这样的函数,它们会在参数中获得 IRQL。 首先, KeAcquireSpinLock 将当前 IRQL 保存到用户提供的参数中,然后将 IRQL 提升到 DISPATCH_LEVEL 并设置一个位。 当该函数完成共享数据的工作时,它会调用 KeReleaseSpinLock 并传递旧的 IRQL 参数,以便该函数取消设置该位并恢复旧的 IRQL(降低 IRQL)。

Windows有4种自旋锁,

  1. KeAcquireSpinLock – KeReleaseSpinLock :可以在 IRQL <= DISPATCH_LEVEL 处调用该对。
  2. KeAcquireSpinLockAtDpcLevel – KeReleaseSpinLockFromDpcLevel :这对只能在 IRQL = DISPATCH_LEVEL 下调用,如果您已经在 IRQL 2 中,它会更加优化,因为它不会保存旧的 IRQL,并且它是专门为在 DPC 例程上工作而设计的。
  3. KeAcquireInterruptSpinLock – KeReleaseInterruptSpinLock:基于硬件使用此对,例如在中断服务例程 (ISR) 中或由具有中断源的驱动程序使用。
  4. ExInterlockedXxx :该函数将 IRQL 提升到 HIGH_LEVEL 并执行其任务,它不需要释放函数,因为没有人在 HIGH_IRQL 上中断我们。

但不幸的是,当涉及到 vmx root 模式时,事情会变得更加复杂。 我们在 VMX root-mode下没有 IRQL。 这是操作系统的事情,所以我们不能使用上述任何功能,如果我们想在多核之间使用消息跟踪机制,事情会变得更糟!

由于这些原因,我们必须设计自定义自旋锁。

8.12.7.设计自旋锁

在多核系统中设计自旋锁本质上需要硬件支持原子操作,这意味着硬件(大多数情况下是处理器)应该保证操作仅由逻辑(超线程)核心执行并且是不可中断的。

有一篇文章 这里 描述了具有不同优化的不同类型的自旋锁,它也在 这里 实现。

处理器中这种机制的设计超出了本文的范围。 我们只需使用 Windows 提供的一个名为“ _interlockedbittestandset ”的内部函数。

这使得我们的实现超级简单。 我们只需要使用以下函数,处理器就有责任处理所有事情。

更新2: 我们也应该在参数中使用 volatile 关键字,否则就像un-volatiling的。

1
2
3
4
inline BOOLEAN SpinlockTryLock(volatile LONG* Lock)
{
    return (!(*Lock) && !_interlockedbittestandset(Lock, 0));
}

现在我们需要旋转! 如果上述函数不成功,那么我们必须不断检查CPU以查看另一个处理器何时释放锁。

更新2: 我们也应该在参数中使用 volatile 关键字,否则就像非挥发性的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void SpinlockLock(volatile LONG* Lock)
{
    unsigned wait = 1;
 
    while (!SpinlockTryLock(Lock))
    {
        for (unsigned i = 0; i < wait; ++i)
        {
            _mm_pause();
        }
 
        // Don't call "pause" too many times. If the wait becomes too big,
        // clamp it to the max_wait.
 
        if (wait * 2 > max_wait)
        {
            wait = max_wait;
        }
        else
        {
            wait = wait * 2;
        }
    }
}

如果您想知道 _mm_pause() 是什么,那么它相当于 x86 中的 PAUSE 指令。

暂停指令通常用在测试自旋锁的循环中,当其他线程拥有自旋锁时,以缓解紧循环。

PAUSE 通知 CPU 这是一个自旋锁等待循环,因此可以优化内存和缓存访问。 另请参阅 x86 中的暂停指令, 了解有关在离开自旋循环时避免内存顺序错误推测的更多详细信息。 PAUSE 可能会使 CPU 停止一段时间以节省电量。 较旧的 CPU 将其解码为 REP NOP,因此您不必检查它是否受支持。 较旧的 CPU 将尽可能快地不执行任何操作 (NOP)。

对于释放锁,没有什么特别要做的,因此只需取消设置它而无需关心任何其他处理器,因为没有其他处理器想要取消设置它。

更新2: 我们也应该在参数中使用 volatile 关键字,否则就像un-volatiling的。

1
2
3
4
void SpinlockUnlock(volatile LONG* Lock)
{
    *Lock = 0;
}

最后一步是使用 volatile 变量作为锁。

1
2
// Vmx-root lock for logging
volatile LONG VmxRootLoggingLock;

volatile ”关键字告诉编译器,变量的值可能随时更改,而编译器在附近找到的代码不会采取任何操作。 这带来的影响是相当严重的。 如果您对理解“volatile** 有疑问,这里有很多例子 。

8.12.8.消息跟踪器设计

为了解决上述死锁的挑战,我创建了两个消息池来保存消息。 第一个池设计用作 VMX non-root消息(缓冲区)的存储,第二个池用于存储 vmx-root 消息。

我们有以下结构来描述这两个池的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Core-specific buffers
typedef struct _LOG_BUFFER_INFORMATION {
 
    UINT64 BufferStartAddress;                      // Start address of the buffer
    UINT64 BufferEndAddress;                        // End address of the buffer
 
    UINT64 BufferForMultipleNonImmediateMessage;    // Start address of the buffer for accumulating non-immadiate messages
    UINT32 CurrentLengthOfNonImmBuffer;             // the current size of the buffer for accumulating non-immadiate messages
 
 
    KSPIN_LOCK BufferLock;                          // SpinLock to protect access to the queue
    KSPIN_LOCK BufferLockForNonImmMessage;          // SpinLock to protect access to the queue of non-imm messages
 
    UINT32 CurrentIndexToSend;                      // Current buffer index to send to user-mode
    UINT32 CurrentIndexToWrite;                     // Current buffer index to write new messages
 
} LOG_BUFFER_INFORMATION, * PLOG_BUFFER_INFORMATION;

一般来说,我们将保存缓冲区,如下所示,消息的每个块都带有描述该块的 BUFFER_HEADER。

缓冲区的其他信息(例如 要写入的当前索引要发送的当前索引 )保存在上述结构中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
A core buffer is like this , it's divided into MaximumPacketsCapacity chucks,
each chunk has PacketChunkSize + sizeof(BUFFER_HEADER) size
 
             __________________________
            |      BUFFER_HEADER      |
            |_________________________|
            |                         |
            |           BODY          |
            |         (Buffer)        |
            | size = PacketChunkSize  |
            |                         |
            |_________________________|
            |      BUFFER_HEADER      |
            |_________________________|
            |                         |
            |           BODY          |
            |         (Buffer)        |
            | size = PacketChunkSize  |
            |                         |
            |_________________________|
            |                         |
            |                         |
            |                         |
            |                         |
            |           .             |
            |           .             |
            |           .             |
            |                         |
            |                         |
            |                         |
            |                         |
            |_________________________|
            |      BUFFER_HEADER      |
            |_________________________|
            |                         |
            |           BODY          |
            |         (Buffer)        |
            | size = PacketChunkSize  |
            |                         |
            |_________________________|

BUFFER_HEADER 的定义如下:

1
2
3
4
5
6
// Message buffer structure
typedef struct _BUFFER_HEADER {
    UINT32 OpeationNumber;  // Operation ID to user-mode
    UINT32 BufferLength;    // The actual length
    BOOLEAN Valid;          // Determine whether the buffer was valid to send or not
} BUFFER_HEADER, * PBUFFER_HEADER;

我们保存块的已用长度和一个确定我们之前是否发送过的位。

操作编号是数字,它将被发送到用户模式以显示来自内核的缓冲区的类型。 换句话说,它是一个指示缓冲区的意图(和结构)的数字,因此用户模式应用程序将知道如何处理该缓冲区。

当前定义了以下操作编号:

1
2
3
4
5
// Message area >= 0x4
#define OPERATION_LOG_INFO_MESSAGE                          0x1
#define OPERATION_LOG_WARNING_MESSAGE                       0x2
#define OPERATION_LOG_ERROR_MESSAGE                         0x3
#define OPERATION_LOG_NON_IMMEDIATE_MESSAGE                 0x4

它们每一个都显示了不同类型的消息,最后一个显示了在这个缓冲区中累积了一堆缓冲区。 此消息跟踪旨在将任何类型的缓冲区从 vmx root 和操作系统发送到用户模式,因此它不仅限于发送消息,我们可以发送具有自定义结构和不同操作编号的缓冲区。

关于我们的消息跟踪的最后一件事是,它可以配置以下常量,您可以更改它们以便为您的专用使用提供更好的性能。

1
2
3
4
5
// Default buffer size
#define MaximumPacketsCapacity 1000 // number of packets
#define PacketChunkSize     1000 // NOTE : REMEMBER TO CHANGE IT IN USER-MODE APP TOO
#define UsermodeBufferSize  sizeof(UINT32) + PacketChunkSize + 1 /* Becausee of Opeation code at the start of the buffer + 1 for null-termminating */
#define LogBufferSize MaximumPacketsCapacity * (PacketChunkSize + sizeof(BUFFER_HEADER))

您可以配置缓冲区中的最大块数以及每个块的大小等内容。 在某些情况下,如果没有线程来消耗(读取)这些块并且池已满,则需要设置上述变量; 它取代了以前的未读缓冲区。 因此,如果您不能频繁使用池,那么最好为 MaximumPacketsCapacity 指定一个更高的数字,这样您就不会丢失任何内容。

8.12.9.初始化阶段

在初始化阶段,我们为上述结构分配空间(2次,一次为VMX non-root,一次为vmx-root),然后分配缓冲区作为保存消息的存储。

我们必须将它们全部归零,并使用 KeInitializeSpinLock 来初始化自旋锁。 我们仅对 vmx 非 root 使用此自旋锁,此函数可确保锁的值未设置。 我们对自定义自旋锁 (VmxRootLoggingLock) 执行相同的操作,只是取消设置它。

你可能会问,“BufferLockForNonImmMessage”是什么,它是另一个锁,将使用它作为优化(见下文)。

总而言之,我们有以下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* Initialize the buffer relating to log message tracing */
BOOLEAN LogInitialize() {
 
 
    // Initialize buffers for trace message and data messages (wee have two buffers one for vmx root and one for vmx non-root)
    MessageBufferInformation = ExAllocatePoolWithTag(NonPagedPool, sizeof(LOG_BUFFER_INFORMATION) * 2, POOLTAG);
 
    if (!MessageBufferInformation)
    {
        return FALSE; //STATUS_INSUFFICIENT_RESOURCES
    }
 
    // Zeroing the memory
    RtlZeroMemory(MessageBufferInformation, sizeof(LOG_BUFFER_INFORMATION) * 2);
 
    // Initialize the lock for Vmx-root mode (HIGH_IRQL Spinlock)
    VmxRootLoggingLock = 0;
 
    // Allocate buffer for messages and initialize the core buffer information
    for (int i = 0; i < 2; i++)
    {
 
        // initialize the lock
        // Actually, only the 0th buffer use this spinlock but let initialize it for both but the second buffer spinlock is useless
        // as we use our custom spinlock.
        KeInitializeSpinLock(&MessageBufferInformation[i].BufferLock);
        KeInitializeSpinLock(&MessageBufferInformation[i].BufferLockForNonImmMessage);
 
        // allocate the buffer
        MessageBufferInformation[i].BufferStartAddress = ExAllocatePoolWithTag(NonPagedPool, LogBufferSize, POOLTAG);
        MessageBufferInformation[i].BufferForMultipleNonImmediateMessage = ExAllocatePoolWithTag(NonPagedPool, PacketChunkSize, POOLTAG);
 
        if (!MessageBufferInformation[i].BufferStartAddress)
        {
            return FALSE; // STATUS_INSUFFICIENT_RESOURCES
        }
 
        // Zeroing the buffer
        RtlZeroMemory(MessageBufferInformation[i].BufferStartAddress, LogBufferSize);
 
        // Set the end address
        MessageBufferInformation[i].BufferEndAddress = (UINT64)MessageBufferInformation[i].BufferStartAddress + LogBufferSize;
    }
}

8.12.10.发送阶段(保存缓冲区并将其添加到池中)

一般来说,在常规的 Windows 例程中,我们的 IRQL 不应超过调度级别。 不存在我们的日志管理器需要在更高的IRQL中使用的情况,所以我们不关心它们; 因此,我们在这里有两种不同的方法。 首先,我们在 vmx 非 root 中使用 KeAcquireSpinLock 获取锁(自旋锁),因为这是 Windows 优化的获取锁的方式,而对于 vmx-root 模式,我们使用之前设计的自旋锁获取锁。

正如我上面告诉你的,我们想要解决这个问题,即当我们获取锁时可能会发生 vmx-exit,因此不可能使用相同的自旋锁,因为可能会发生死锁。

现在我们必须看看我们是从 vmx 非 root 还是 vmx root 进行操作,根据这个条件,我们选择我们的锁以及我们想要将消息放入其中的缓冲区的索引。

我不会解释每个步骤,因为它很简单,它只是管理缓冲区并将数据从一个缓冲区复制到另一个缓冲区,而且代码注释很好,因此您可以阅读代码,相反,我解释消息跟踪的棘手部分。

为新消息缓冲区创建标头后,我们将复制字节并更改有关缓冲区索引的信息。 这里的最后一步是查看是否有任何线程正在等待接收我们的消息。

如果没有线程在等待我们的消息,那么这里就没有什么可做的,但是如果有一个线程处于 IRP Pending 状态(我稍后会解释),那么我们使用 KeInsertQueueDpc 以便将其添加到我们的 DPC 队列中 随后由 Windows 在 IRQL == DISPATCH_LEVEL 中执行。

这意味着我们的回调函数稍后将由Windows执行,当然,Windows在VMX non-root中执行我们的函数,所以它是安全的。 稍后我将描述这个回调以及我们如何创建 DPC。

最后,我们必须释放锁,以便其他线程可以进入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/* Save buffer to the pool */
BOOLEAN LogSendBuffer(UINT32 OperationCode, PVOID Buffer, UINT32 BufferLength)
{
    KIRQL OldIRQL;
    UINT32 Index;
    BOOLEAN IsVmxRoot;
 
    if (BufferLength > PacketChunkSize - 1 || BufferLength == 0)
    {
        // We can't save this huge buffer
        return FALSE;
    }
 
    // Check that if we're in vmx root-mode
    IsVmxRoot = GuestState[KeGetCurrentProcessorNumber()].IsOnVmxRootMode;
 
    // Check if we're in Vmx-root, if it is then we use our customized HIGH_IRQL Spinlock, if not we use the windows spinlock
    if (IsVmxRoot)
    {
        // Set the index
        Index = 1;
        SpinlockLock(&VmxRootLoggingLock);
    }
    else
    {
        // Set the index
        Index = 0;
        // Acquire the lock
        KeAcquireSpinLock(&MessageBufferInformation[Index].BufferLock, &OldIRQL);
    }
 
    // check if the buffer is filled to it's maximum index or not
    if (MessageBufferInformation[Index].CurrentIndexToWrite > MaximumPacketsCapacity - 1)
    {
        // start from the begining
        MessageBufferInformation[Index].CurrentIndexToWrite = 0;
    }
 
    // Compute the start of the buffer header
    BUFFER_HEADER* Header = (BUFFER_HEADER*)((UINT64)MessageBufferInformation[Index].BufferStartAddress + (MessageBufferInformation[Index].CurrentIndexToWrite * (PacketChunkSize + sizeof(BUFFER_HEADER))));
 
    // Set the header
    Header->OpeationNumber = OperationCode;
    Header->BufferLength = BufferLength;
    Header->Valid = TRUE;
 
    /* Now it's time to fill the buffer */
 
    // compute the saving index
    PVOID SavingBuffer = ((UINT64)MessageBufferInformation[Index].BufferStartAddress + (MessageBufferInformation[Index].CurrentIndexToWrite * (PacketChunkSize + sizeof(BUFFER_HEADER))) + sizeof(BUFFER_HEADER));
 
    // Copy the buffer
    RtlCopyBytes(SavingBuffer, Buffer, BufferLength);
 
    // Increment the next index to write
    MessageBufferInformation[Index].CurrentIndexToWrite = MessageBufferInformation[Index].CurrentIndexToWrite + 1;
 
    // check if there is any thread in IRP Pending state, so we can complete their request
    if (GlobalNotifyRecord != NULL)
    {
        /* there is some threads that needs to be completed */
        // set the target pool
        GlobalNotifyRecord->CheckVmxRootMessagePool = IsVmxRoot;
        // Insert dpc to queue
        KeInsertQueueDpc(&GlobalNotifyRecord->Dpc, GlobalNotifyRecord, NULL);
 
        // set notify routine to null
        GlobalNotifyRecord = NULL;
    }
 
    // Check if we're in Vmx-root, if it is then we use our customized HIGH_IRQL Spinlock, if not we use the windows spinlock
    if (IsVmxRoot)
    {
        SpinlockUnlock(&VmxRootLoggingLock);
    }
    else
    {
        // Release the lock
        KeReleaseSpinLock(&MessageBufferInformation[Index].BufferLock, OldIRQL);
    }
}

8.12.11.读取阶段(读取缓冲区并将其发送到用户模式)

是时候读取之前填充的缓冲区了! 事实上,我们在前面的函数“ LogSendBuffer ”中添加了一个DPC,这表明“ LogReadBuffer ”是在VMX non-root模式下执行的,因此我们可以自由地使用大多数API(不是全部)。

理论上,我们这里有一个问题,如果我们想从 VMX root-mode池中读取缓冲区,那么当我们获取 VMX root-mode锁时,可能会导致死锁,并且可能会发生 vm-exit。 因此,我们永远在 vmx root 模式下旋转该锁,但实际上这里不存在死锁。 你能猜出为什么吗?

这是因为我们的 LogReadBuffer 在 DISPATCH_LEVEL 中执行,因此 Windows 调度程序不会中断我们,并且我们的函数执行时没有任何中断,而且事实上我们在这里没有做任何花哨的事情。 我的意思是,我们没有执行任何导致代码中 vm-exit 的操作(例如 CPUID),因此实际上这里没有什么会导致死锁,但我们应该记住,我们不允许运行导致死锁的代码 vmx-退出。

我们根据之前的信息计算标头地址,并将有效位设置为零,以便表明该缓冲区之前已被使用过。

然后我们将缓冲区复制到参数中指定的缓冲区,并将操作编号放在目标缓冲区的顶部,以便将来的函数知道该缓冲区的用途。 我们还可以使用 DbgPrint 向内核调试器显示消息。 在 DISPATCH_LEVEL(vmx 非 root 模式)下使用 DbgPrint 是安全的。 我们可能需要多次使用 DbgPrint,因为该函数默认最大为 512 字节。 尽管您可以更改限制数量,但我们假设选择了默认大小。

最后,我们必须重置一些有关缓冲区的信息,清除缓冲区消息(没有必要将缓冲区清零,但为了使调试过程更容易,我更喜欢将缓冲区清零),并释放锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/* return of this function shows whether the read was successfull or not (e.g FALSE shows there's no new buffer available.)*/
BOOLEAN LogReadBuffer(BOOLEAN IsVmxRoot, PVOID BufferToSaveMessage, UINT32* ReturnedLength) {
 
    KIRQL OldIRQL;
    UINT32 Index;
 
    // Check if we're in Vmx-root, if it is then we use our customized HIGH_IRQL Spinlock, if not we use the windows spinlock
    if (IsVmxRoot)
    {
        // Set the index
        Index = 1;
 
        // Acquire the lock
        SpinlockLock(&VmxRootLoggingLock);
    }
    else
    {
        // Set the index
        Index = 0;
 
        // Acquire the lock
        KeAcquireSpinLock(&MessageBufferInformation[Index].BufferLock, &OldIRQL);
    }
 
    // Compute the current buffer to read
    BUFFER_HEADER* Header = (BUFFER_HEADER*)((UINT64)MessageBufferInformation[Index].BufferStartAddress + (MessageBufferInformation[Index].CurrentIndexToSend * (PacketChunkSize + sizeof(BUFFER_HEADER))));
 
    if (!Header->Valid)
    {
        // there is nothing to send
        return FALSE;
    }
 
    /* If we reached here, means that there is sth to send  */
    // First copy the header
    RtlCopyBytes(BufferToSaveMessage, &Header->OpeationNumber, sizeof(UINT32));
 
 
    // Second, save the buffer contents
    PVOID SendingBuffer = ((UINT64)MessageBufferInformation[Index].BufferStartAddress + (MessageBufferInformation[Index].CurrentIndexToSend * (PacketChunkSize + sizeof(BUFFER_HEADER))) + sizeof(BUFFER_HEADER));
    PVOID SavingAddress = ((UINT64)BufferToSaveMessage + sizeof(UINT32)); // Because we want to pass the header of usermode header
    RtlCopyBytes(SavingAddress, SendingBuffer, Header->BufferLength);
 
 
#if ShowMessagesOnDebugger
 
    // Means that show just messages
    if (Header->OpeationNumber <= OPERATION_LOG_NON_IMMEDIATE_MESSAGE)
    {
        /* We're in Dpc level here so it's safe to use DbgPrint*/
        // DbgPrint limitation is 512 Byte
        if (Header->BufferLength > DbgPrintLimitation)
        {
            for (size_t i = 0; i <= Header->BufferLength / DbgPrintLimitation; i++)
            {
                if (i != 0)
                {
                    DbgPrint("%s", (char*)((UINT64)SendingBuffer + (DbgPrintLimitation * i) - 2));
                }
                else
                {
                    DbgPrint("%s", (char*)((UINT64)SendingBuffer + (DbgPrintLimitation * i)));
                }
            }
        }
        else
        {
            DbgPrint("%s", (char*)SendingBuffer);
        }
 
    }
#endif
 
    // Finally, set the current index to invalid as we sent it
    Header->Valid = FALSE;
 
    // Set the length to show as the ReturnedByted in usermode ioctl funtion + size of header
    *ReturnedLength = Header->BufferLength + sizeof(UINT32);
 
 
    // Last step is to clear the current buffer (we can't do it once when CurrentIndexToSend is zero because
    // there might be multiple messages on the start of the queue that didn't read yet)
    // we don't free the header
    RtlZeroMemory(SendingBuffer, Header->BufferLength);
 
    // Check to see whether we passed the index or not
    if (MessageBufferInformation[Index].CurrentIndexToSend > MaximumPacketsCapacity - 2)
    {
        MessageBufferInformation[Index].CurrentIndexToSend = 0;
    }
    else
    {
        // Increment the next index to read
        MessageBufferInformation[Index].CurrentIndexToSend = MessageBufferInformation[Index].CurrentIndexToSend + 1;
    }
 
    // Check if we're in Vmx-root, if it is then we use our customized HIGH_IRQL Spinlock, if not we use the windows spinlock
    if (IsVmxRoot)
    {
        SpinlockUnlock(&VmxRootLoggingLock);
    }
    else
    {
        // Release the lock
        KeReleaseSpinLock(&MessageBufferInformation[Index].BufferLock, OldIRQL);
    }
}

8.12.12.检查新消息

检查新消息很简单; 我们只需要根据之前的信息检查当前的消息索引,看看它的头是否有效。 如果它有效,则表明我们有一条新消息,但如果它无效,则某些函数读取了之前的消息,并且没有新消息。

为了检查新消息,我们甚至不需要获取锁,因为基本上我们不写入任何内容,并且在我们的情况下读取不需要锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* return of this function shows whether the read was successfull or not (e.g FALSE shows there's no new buffer available.)*/
BOOLEAN LogCheckForNewMessage(BOOLEAN IsVmxRoot) {
 
    KIRQL OldIRQL;
    UINT32 Index;
 
    if (IsVmxRoot)
    {
        Index = 1;
    }
    else
    {
        Index = 0;
    }
    // Compute the current buffer to read
    BUFFER_HEADER* Header = (BUFFER_HEADER*)((UINT64)MessageBufferInformation[Index].BufferStartAddress + (MessageBufferInformation[Index].CurrentIndexToSend * (PacketChunkSize + sizeof(BUFFER_HEADER))));
 
    if (!Header->Valid)
    {
        // there is nothing to send
        return FALSE;
    }
 
    /* If we reached here, means that there is sth to send  */
    return TRUE;
}

8.12.13.向池发送消息

之前,我们了解了如何保存(发送)缓冲区并读取它们。 每条消息都是一个字符串缓冲区,因此最后,我们必须使用“ LogSendBuffer ”来发送缓冲区,但我们需要考虑额外的工作来发送格式良好的消息。

va_start 和 va_end 用于支持一个函数的多个参数,例如 DbgPrint 或 printf。

您可以使用 KeQuerySystemTime、ExSystemTimeToLocalTime 和 RtlTimeToTimeFields 的组合来获取当前系统时间(请参阅示例),然后将它们与 sprintf_s 放在一起。

有一个特殊的原因 我们使用类似 sprintf 的函数而不是RtlString* 函数 ; 原因在 讨论 部分进行了描述。 下一步是使用 strnlen_s 计算长度。

最后,我们在这里进行了重要的优化; 从逻辑上讲,我们创建两种消息,一种称为“立即消息”,我们将直接将其发送到池中,另一种类型是“非立即消息”,我们将消息收集在另一个缓冲区中,并将新消息附加到该缓冲区中,直到消息到达为止容量已满(我们不应该超过 PacketChunkSize 限制)。

使用这种方式,我们不会单独将每条消息发送到用户模式,而是将一个缓冲区中的多条消息发送到用户模式。 我们将获得明显的绩效提升。 例如,使用 PacketChunkSize == 1000 字节 的配置,我们在缓冲区上发送 6 条消息(这是平均值,基本上取决于每个消息的大小),因为您可能知道 CPU 必须做很多事情才能将其状态从内核模式更改为用户模式-mode 以及创建新的 IRP 数据包是一项繁重的任务。

您还可以更改配置,例如增加 PacketChunkSize ,以便更多消息保存在临时缓冲区中,但通常,它会延迟您看到消息的时间。

另外,我们处理缓冲区,因此我们需要另一个自旋锁。

把它们放在一起我们有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// Send string messages and tracing for logging and monitoring
BOOLEAN LogSendMessageToQueue(UINT32 OperationCode, BOOLEAN IsImmediateMessage, BOOLEAN ShowCurrentSystemTime, const char* Fmt, ...)
{
    BOOLEAN Result;
    va_list ArgList;
    size_t WrittenSize;
    UINT32 Index;
    KIRQL OldIRQL;
    BOOLEAN IsVmxRootMode;
    int SprintfResult;
    char LogMessage[PacketChunkSize];
    char TempMessage[PacketChunkSize];
    char TimeBuffer[20] = { 0 };
 
    // Set Vmx State
    IsVmxRootMode = GuestState[KeGetCurrentProcessorNumber()].IsOnVmxRootMode;
 
    if (ShowCurrentSystemTime)
    {
        // It's actually not necessary to use -1 but because user-mode code might assume a null-terminated buffer so
        // it's better to use - 1
        va_start(ArgList, Fmt);
        // We won't use this because we can't use in any IRQL
        /*Status = RtlStringCchVPrintfA(TempMessage, PacketChunkSize - 1, Fmt, ArgList);*/
        SprintfResult = vsprintf_s(TempMessage, PacketChunkSize - 1, Fmt, ArgList);
        va_end(ArgList);
 
        // Check if the buffer passed the limit
        if (SprintfResult == -1)
        {
            // Probably the buffer is large that we can't store it
            return FALSE;
        }
 
        // Fill the above with timer
        TIME_FIELDS TimeFields;
        LARGE_INTEGER SystemTime, LocalTime;
        KeQuerySystemTime(&SystemTime);
        ExSystemTimeToLocalTime(&SystemTime, &LocalTime);
        RtlTimeToTimeFields(&LocalTime, &TimeFields);
 
        // We won't use this because we can't use in any IRQL
        /*Status = RtlStringCchPrintfA(TimeBuffer, RTL_NUMBER_OF(TimeBuffer),
            "%02hd:%02hd:%02hd.%03hd", TimeFields.Hour,
            TimeFields.Minute, TimeFields.Second,
            TimeFields.Milliseconds);
 
        // Append time with previous message
        Status = RtlStringCchPrintfA(LogMessage, PacketChunkSize - 1, "(%s)\t %s", TimeBuffer, TempMessage);*/
 
        // this function probably run without error, so there is no need to check the return value
        sprintf_s(TimeBuffer, RTL_NUMBER_OF(TimeBuffer), "%02hd:%02hd:%02hd.%03hd", TimeFields.Hour,
            TimeFields.Minute, TimeFields.Second,
            TimeFields.Milliseconds);
 
        // Append time with previous message
        SprintfResult = sprintf_s(LogMessage, PacketChunkSize - 1, "(%s - core : %d - vmx-root? %s)\t %s", TimeBuffer, KeGetCurrentProcessorNumberEx(0), IsVmxRootMode ? "yes" : "no", TempMessage);
 
        // Check if the buffer passed the limit
        if (SprintfResult == -1)
        {
            // Probably the buffer is large that we can't store it
            return FALSE;
        }
 
 
    }
    else
    {
        // It's actually not necessary to use -1 but because user-mode code might assume a null-terminated buffer so
        // it's better to use - 1
        va_start(ArgList, Fmt);
        // We won't use this because we can't use in any IRQL
        /* Status = RtlStringCchVPrintfA(LogMessage, PacketChunkSize - 1, Fmt, ArgList); */
        SprintfResult = vsprintf_s(LogMessage, PacketChunkSize - 1, Fmt, ArgList);
        va_end(ArgList);
 
        // Check if the buffer passed the limit
        if (SprintfResult == -1)
        {
            // Probably the buffer is large that we can't store it
            return FALSE;
        }
 
    }
    // Use std function because they can be run in any IRQL
    // RtlStringCchLengthA(LogMessage, PacketChunkSize - 1, &WrittenSize);
    WrittenSize = strnlen_s(LogMessage, PacketChunkSize - 1);
 
    if (LogMessage[0] == '\0') {
 
        // nothing to write
        DbgBreakPoint();
        return FALSE;
    }
 
    if (IsImmediateMessage)
    {
        return LogSendBuffer(OperationCode, LogMessage, WrittenSize);
    }
    else
    {
        // Check if we're in Vmx-root, if it is then we use our customized HIGH_IRQL Spinlock, if not we use the windows spinlock
        if (IsVmxRootMode)
        {
            // Set the index
            Index = 1;
            SpinlockLock(&VmxRootLoggingLockForNonImmBuffers);
        }
        else
        {
            // Set the index
            Index = 0;
            // Acquire the lock
            KeAcquireSpinLock(&MessageBufferInformation[Index].BufferLockForNonImmMessage, &OldIRQL);
        }
        //Set the result to True
        Result = TRUE;
 
        // If log message WrittenSize is above the buffer then we have to send the previous buffer
        if ((MessageBufferInformation[Index].CurrentLengthOfNonImmBuffer + WrittenSize) > PacketChunkSize - 1 && MessageBufferInformation[Index].CurrentLengthOfNonImmBuffer != 0)
        {
 
            // Send the previous buffer (non-immediate message)
            Result = LogSendBuffer(OPERATION_LOG_NON_IMMEDIATE_MESSAGE,
                MessageBufferInformation[Index].BufferForMultipleNonImmediateMessage,
                MessageBufferInformation[Index].CurrentLengthOfNonImmBuffer);
 
            // Free the immediate buffer
            MessageBufferInformation[Index].CurrentLengthOfNonImmBuffer = 0;
            RtlZeroMemory(MessageBufferInformation[Index].BufferForMultipleNonImmediateMessage, PacketChunkSize);
        }
 
        // We have to save the message
        RtlCopyBytes(MessageBufferInformation[Index].BufferForMultipleNonImmediateMessage +
            MessageBufferInformation[Index].CurrentLengthOfNonImmBuffer, LogMessage, WrittenSize);
 
        // add the length
        MessageBufferInformation[Index].CurrentLengthOfNonImmBuffer += WrittenSize;
 
 
        // Check if we're in Vmx-root, if it is then we use our customized HIGH_IRQL Spinlock, if not we use the windows spinlock
        if (IsVmxRootMode)
        {
            SpinlockUnlock(&VmxRootLoggingLockForNonImmBuffers);
        }
        else
        {
            // Release the lock
            KeReleaseSpinLock(&MessageBufferInformation[Index].BufferLockForNonImmMessage, OldIRQL);
        }
 
        return Result;
    }
}

8.12.14.在用户模式下接收缓冲区和消息

从用户模式接收缓冲区是通过使用 IOCTL 完成的。 首先,我们在用户模式应用程序中创建另一个线程。 该线程负责将内核模式缓冲区带到用户模式,然后根据操作号进行操作。

1
2
3
4
HANDLE Thread = CreateThread(NULL, 0, ThreadFunc, Handle, 0, NULL);
if (Thread) {
    printf("[*] Thread Created successfully !!!");
}

该线程执行以下函数。 我们使用 IRP Pending 将数据从内核模式传输到用户模式。 IRP Pending 主要用于传输数据包。 例如,您向内核发送一个IRP数据包,内核将这个数据包标记为Pending。 每当用户模式缓冲区可用于发送到用户模式时,内核就会完成 IRP 请求,IOCTL 函数返回到用户模式并继续执行。

这有点像当你使用 Wait for 一个对象时。 我们还可以在 Windows 中使用 事件 ,只要缓冲区可用,就会触发事件,但 IRP Pending 更好,因为它设计用于向用户模式发送消息。

我们要做的就是为内核模式代码分配一个缓冲区,并使用 DeviceIoControl 来请求数据包。 当收到来自内核的数据包时,我们处理该数据包并通过操作号进行切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
void ReadIrpBasedBuffer(HANDLE  Device) {
 
    BOOL    Status;
    ULONG   ReturnedLength;
    REGISTER_EVENT RegisterEvent;
    UINT32 OperationCode;
 
    printf(" =============================== Kernel-Mode Logs (Driver) ===============================\n");
    RegisterEvent.hEvent = NULL;
    RegisterEvent.Type = IRP_BASED;
    char OutputBuffer[UsermodeBufferSize + 100] = { 0 };
 
    try
    {
 
        while (TRUE) {
 
            ZeroMemory(OutputBuffer, UsermodeBufferSize);
 
            Sleep(200);                         // we're not trying to eat all of the CPU ;)
 
            Status = DeviceIoControl(
                Device,                         // Handle to device
                IOCTL_REGISTER_EVENT,           // IO Control code
                &RegisterEvent,                 // Input Buffer to driver.
                SIZEOF_REGISTER_EVENT * 2,      // Length of input buffer in bytes. (x 2 is bcuz as the driver is x64 and has 64 bit values)
                OutputBuffer,                   // Output Buffer from driver.
                sizeof(OutputBuffer),           // Length of output buffer in bytes.
                &ReturnedLength,                // Bytes placed in buffer.
                NULL                            // synchronous call
            );
 
            if (!Status) {
                printf("Ioctl failed with code %d\n", GetLastError());
                break;
            }
            printf("\n========================= Kernel Mode (Buffer) =========================\n");
 
            OperationCode = 0;
            memcpy(&OperationCode, OutputBuffer, sizeof(UINT32));
 
            printf("Returned Length : 0x%x \n", ReturnedLength);
            printf("Operation Code : 0x%x \n", OperationCode);
 
            switch (OperationCode)
            {
            case OPERATION_LOG_NON_IMMEDIATE_MESSAGE:
                printf("A buffer of messages (OPERATION_LOG_NON_IMMEDIATE_MESSAGE) :\n");
                printf("%s", OutputBuffer + sizeof(UINT32));
                break;
            case OPERATION_LOG_INFO_MESSAGE:
                printf("Information log (OPERATION_LOG_INFO_MESSAGE) :\n");
                printf("%s", OutputBuffer + sizeof(UINT32));
                break;
            case OPERATION_LOG_ERROR_MESSAGE:
                printf("Error log (OPERATION_LOG_ERROR_MESSAGE) :\n");
                printf("%s", OutputBuffer + sizeof(UINT32));
                break;
            case OPERATION_LOG_WARNING_MESSAGE:
                printf("Warning log (OPERATION_LOG_WARNING_MESSAGE) :\n");
                printf("%s", OutputBuffer + sizeof(UINT32));
                break;
 
            default:
                break;
            }
 
 
            printf("\n========================================================================\n");
        }
    }
    catch (const std::exception&)
    {
        printf("\n Exception !\n");
    }
}

8.12.15.IOCTL 和管理用户模式请求

当IOCTL到达内核端时, 主要函数中的DrvDispatchIoControl 被调用。 该函数返回一个指向调用者在指定IRP中的I/O堆栈位置的指针。

从 IRP 堆栈中,我们可以读取 IOCTL 代码和缓冲区地址,这次我们执行必要的检查并将参数传递给 LogRegisterIrpBasedNotification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* Driver IOCTL Dispatcher*/
NTSTATUS DrvDispatchIoControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    PIO_STACK_LOCATION  IrpStack;
    PREGISTER_EVENT RegisterEvent;
    NTSTATUS    Status;
 
    IrpStack = IoGetCurrentIrpStackLocation(Irp);
 
    switch (IrpStack->Parameters.DeviceIoControl.IoControlCode)
    {
    case IOCTL_REGISTER_EVENT:
 
        // First validate the parameters.
        if (IrpStack->Parameters.DeviceIoControl.InputBufferLength < SIZEOF_REGISTER_EVENT || Irp->AssociatedIrp.SystemBuffer == NULL) {
            Status = STATUS_INVALID_PARAMETER;
            DbgBreakPoint();
            break;
        }
         
        RegisterEvent = (PREGISTER_EVENT)Irp->AssociatedIrp.SystemBuffer;
 
        switch (RegisterEvent->Type) {
        case IRP_BASED:
            Status = LogRegisterIrpBasedNotification(DeviceObject, Irp);
            break;
        case EVENT_BASED:
            Status = LogRegisterEventBasedNotification(DeviceObject, Irp);
            break;
        default:
            ASSERTMSG("\tUnknow notification type from user-mode\n", FALSE);
            Status = STATUS_INVALID_PARAMETER;
            break;
        }
        break;
 
    default:
        ASSERT(FALSE);  // should never hit this
        Status = STATUS_NOT_IMPLEMENTED;
        break;
    }
 
    if (Status != STATUS_PENDING) {
        Irp->IoStatus.Status = Status;
        Irp->IoStatus.Information = 0;
        IoCompleteRequest(Irp, IO_NO_INCREMENT);
    }
 
    return Status;
}

来检查是否有任何其他线程处于挂起状态, 要注册 IRP 通知,首先,我们通过检查GlobalNotifyRecord 如果有任何线程完成 IRP 并返回到用户模式,因为在我们的设计中,我们忽略请求缓冲区的多个线程,这意味着只有一个线程可以读取内核模式缓冲区。

其次,我们初始化一个描述状态的自定义结构。 下面的结构体负责保存Type、DPC Object和目标缓冲区。

1
2
3
4
5
6
7
8
9
typedef struct _NOTIFY_RECORD {
    NOTIFY_TYPE     Type;
    union {
        PKEVENT     Event;
        PIRP        PendingIrp;
    } Message;
    KDPC            Dpc;
    BOOLEAN         CheckVmxRootMessagePool; // Set so that notify callback can understand where to check (Vmx root or Vmx non-root)
} NOTIFY_RECORD, * PNOTIFY_RECORD;

为了填充这个结构,我们通过调用 KeInitializeDpc 初始化一个 DPC 对象,该函数获取稍后应该调用的函数回调 (LogNotifyUsermodeCallback) 以及该函数的参数 (NotifyRecord)。

我们首先检查 VMX non-root池,看看是否有任何新可用的内容。 否则,我们检查 vmx-root 模式缓冲区。 此优先级是因为 VMX non-root缓冲区更重要。 毕竟,我们大部分时间都处于 VMX Root 模式,因此我们可能会看到来自 vmx-root 的数千条消息,而来自 vmx 非 root 的消息则较少。 如果我们首先检查 vmx root 消息缓冲区,那么我们可能会丢失来自 vmx 非 root 的一些消息,或者永远找不到时间来处理它们。

如果有任何新消息可用,那么我们直接将 DPC 添加到队列 ( KeInsertQueueDpc )。

如果没有任何新消息可用,那么我们只需保存通知记录以供将来使用,并且我们使用 IoMarkIrpPending 将 IRP 标记为挂起状态并返回 STATUS_PENDING

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/* Register a new IRP Pending thread which listens for new buffers */
NTSTATUS LogRegisterIrpBasedNotification(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    PNOTIFY_RECORD NotifyRecord;
    PIO_STACK_LOCATION IrpStack;
    KIRQL   OOldIrql;
    PREGISTER_EVENT RegisterEvent;
 
    // check if current core has another thread with pending IRP, if no then put the current thread to pending
    // otherwise return and complete thread with STATUS_SUCCESS as there is another thread waiting for message
 
    if (GlobalNotifyRecord == NULL)
    {
        IrpStack = IoGetCurrentIrpStackLocation(Irp);
        RegisterEvent = (PREGISTER_EVENT)Irp->AssociatedIrp.SystemBuffer;
 
        // Allocate a record and save all the event context.
        NotifyRecord = ExAllocatePoolWithQuotaTag(NonPagedPool, sizeof(NOTIFY_RECORD), POOLTAG);
 
        if (NULL == NotifyRecord) {
            return  STATUS_INSUFFICIENT_RESOURCES;
        }
 
        NotifyRecord->Type = IRP_BASED;
        NotifyRecord->Message.PendingIrp = Irp;
 
        KeInitializeDpc(&NotifyRecord->Dpc, // Dpc
            LogNotifyUsermodeCallback,     // DeferredRoutine
            NotifyRecord        // DeferredContext
        );
 
        IoMarkIrpPending(Irp);
 
        // check for new message (for both Vmx-root mode or Vmx non root-mode)
        if (LogCheckForNewMessage(FALSE))
        {
            // check vmx root
            NotifyRecord->CheckVmxRootMessagePool = FALSE;
 
            // Insert dpc to queue
            KeInsertQueueDpc(&NotifyRecord->Dpc, NotifyRecord, NULL);
        }
        else if (LogCheckForNewMessage(TRUE))
        {
            // check vmx non-root
            NotifyRecord->CheckVmxRootMessagePool = TRUE;
 
            // Insert dpc to queue
            KeInsertQueueDpc(&NotifyRecord->Dpc, NotifyRecord, NULL);
        }
        else
        {
            // Set the notify routine to the global structure
            GlobalNotifyRecord = NotifyRecord;
        }
 
        // We will return pending as we have marked the IRP pending.
        return STATUS_PENDING;
    }
    else
    {
        return STATUS_SUCCESS;
    }
}

8.12.16.用户态通知回调

正如您在上面的代码中看到的,我们在两个函数( LogRegisterIrpBasedNotification 和 LogSendBuffer )中将 DPC 添加到队列中。 这样,我们就不会错过任何内容,并且所有内容都会在生成消息时进行处理。 例如,如果有任何线程在等待消息,则 LogSendBuffer 会通知它有新消息,如果没有任何线程在等待消息,则 LogSendBuffer 无法执行任何操作,只要有新线程到达内核然后它检查新消息。 再想一想。 很美丽。

现在是时候从内核池读取数据包并将它们发送到用户模式了。

调用LogNotifyUsermodeCallback 时,我们确定处于 DISPATCH_LEVEL 和 vmx 非 root 模式。

在此函数中,我们检查发送到内核的参数是否有效。 这是因为用户模式提供了它们。 例如,我们检查IRP堆栈的 参数。 设备 Io 控制。 输入缓冲区长度参数。 设备 Io 控制。 OutputBufferLength 确保它们不为空或检查 SystemBuffer 是否 为空。

然后我们用用户模式缓冲区调用 LogReadBuffer ,因此该函数将填充用户模式缓冲区并在合适的位置添加操作编号。 另外, Irp->IoStatus.Information 向用户模式提供缓冲区长度。

这里的最后一步是完成IRP,因此I/O管理器将结果发送到用户模式,并且线程可以继续其正常生命。

我们在所有进程中访问用户模式缓冲区的原因(因为 DPC 可能在随机用户模式进程上运行)以及为什么我们使用 DPC 而不使用 APC 之类的其他东西,将在讨论部分 讨论

下面的代码演示了我们上面讨论的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/* Complete the IRP in IRP Pending state and fill the usermode buffers with pool data */
VOID LogNotifyUsermodeCallback(PKDPC Dpc, PVOID DeferredContext, PVOID SystemArgument1, PVOID SystemArgument2)
{
 
    PNOTIFY_RECORD NotifyRecord;
    PIRP Irp;
    UINT32 Length;
 
    UNREFERENCED_PARAMETER(Dpc);
    UNREFERENCED_PARAMETER(SystemArgument1);
    UNREFERENCED_PARAMETER(SystemArgument2);
 
    NotifyRecord = DeferredContext;
 
    ASSERT(NotifyRecord != NULL); // can't be NULL
    _Analysis_assume_(NotifyRecord != NULL);
 
    switch (NotifyRecord->Type)
    {
 
    case IRP_BASED:
        Irp = NotifyRecord->Message.PendingIrp;
 
        if (Irp != NULL) {
 
            PCHAR OutBuff; // pointer to output buffer
            ULONG InBuffLength; // Input buffer length
            ULONG OutBuffLength; // Output buffer length
            PIO_STACK_LOCATION IrpSp;
 
            // Make suree that concurrent calls to notify function never occurs
            if (!(Irp->CurrentLocation <= Irp->StackCount + 1))
            {
                DbgBreakPoint();
                return;
            }
 
            IrpSp = IoGetCurrentIrpStackLocation(Irp);
            InBuffLength = IrpSp->Parameters.DeviceIoControl.InputBufferLength;
            OutBuffLength = IrpSp->Parameters.DeviceIoControl.OutputBufferLength;
 
            if (!InBuffLength || !OutBuffLength)
            {
                Irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
                IoCompleteRequest(Irp, IO_NO_INCREMENT);
                break;
            }
 
            // Check again that SystemBuffer is not null
            if (!Irp->AssociatedIrp.SystemBuffer)
            {
                // Buffer is invalid
                return;
            }
 
            OutBuff = Irp->AssociatedIrp.SystemBuffer;
            Length = 0;
 
            // Read Buffer might be empty (nothing to send)
            if (!LogReadBuffer(NotifyRecord->CheckVmxRootMessagePool, OutBuff, &Length))
            {
                // we have to return here as there is nothing to send here
                return;
            }
 
            Irp->IoStatus.Information = Length;
 
 
            Irp->IoStatus.Status = STATUS_SUCCESS;
            IoCompleteRequest(Irp, IO_NO_INCREMENT);
        }
        break;
 
    case EVENT_BASED:
 
        // Signal the Event created in user-mode.
        KeSetEvent(NotifyRecord->Message.Event, 0, FALSE);
 
        // Dereference the object as we are done with it.
        ObDereferenceObject(NotifyRecord->Message.Event);
 
        break;
 
    default:
        ASSERT(FALSE);
        break;
    }
 
    if (NotifyRecord != NULL) {
        ExFreePoolWithTag(NotifyRecord, POOLTAG);
    }
}

8.12.17.未初始化阶段

没什么特别的,我们只是取消分配之前分配的缓冲区。 请记住,我们应该在驱动程序的第一个函数中初始化消息跟踪器,以便我们可以使用它,当然,当我们不再有任何消息时,请在最后取消初始化它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Uninitialize the buffer relating to log message tracing */
VOID LogUnInitialize()
{
 
    // de-allocate buffer for messages and initialize the core buffer information (for vmx-root core)
    for (int i = 0; i < 2; i++)
    {
        // Free each buffers
        ExFreePoolWithTag(MessageBufferInformation[i].BufferStartAddress, POOLTAG);
        ExFreePoolWithTag(MessageBufferInformation[i].BufferForMultipleNonImmediateMessage, POOLTAG);
    }
 
    // de-allocate buffers for trace message and data messages
    ExFreePoolWithTag(MessageBufferInformation, POOLTAG);
}

8.13.WPP追踪

WPP 跟踪是 Windows 提供的另一种机制,可用于跟踪来自 vmx 非 root 和 vmx root 模式以及任何 IRQL 的消息。 它主要用于在开发过程中调试代码,并且能够发布可供应用程序在结构化 ETW 事件中使用的事件。

使用 WPP 软件跟踪记录消息与使用 Windows 事件记录服务类似。 驱动程序将消息 ID 和未格式化的二进制数据记录在日志文件中。 随后,后处理器将日志文件中的信息转换为人类可读的形式。

为了使用 WPP Tracing,首先,我们应该通过将 UseWPPTracing 设置为 TRUE 来配置驱动程序以使用 WPP Tracing 作为消息跟踪。 默认情况下它是 FALSE

1
2
// Use WPP Tracing instead of all logging functions
#define UseWPPTracing       TRUE

然后,我们转到项目的 属性 并将 “运行 Wpp 跟踪” 设置为 “是” ,并通过将“ 生成跟踪消息的函数” 设置为 “HypervisorTraceLevelMessage (LEVEL,FLAGS,MSG,…)” 来添加用于发送消息的自定义函数。
图片描述
然后我们需要使用 Visual Studio 的 “工具”->“创建 GUID” 为我们的驱动程序生成一个唯一的 GUID ,并生成一个并将其设置为以下格式。

1
2
3
4
5
6
7
8
#define WPP_CONTROL_GUIDS                                              \
    WPP_DEFINE_CONTROL_GUID(                                           \
        HypervisorFromScratchLogger, (2AE39766,AE4B,46AB,AFC4,002DB8109721), \
        WPP_DEFINE_BIT(HVFS_LOG)             /* bit  0 = 0x00000001 */ \
        WPP_DEFINE_BIT(HVFS_LOG_INFO)        /* bit  1 = 0x00000002 */ \
        WPP_DEFINE_BIT(HVFS_LOG_WARNING)     /* bit  2 = 0x00000004 */ \
        WPP_DEFINE_BIT(HVFS_LOG_ERROR)       /* bit  3 = 0x00000008 */ \
        )   

WPP_DEFINE_BIT 为我们的消息创建一些特定事件,这些事件可以在将来用于屏蔽特定事件。

完成上述所有代码后,我们通过在代码的第一行添加以下代码来初始化 WPP Tracing,例如 DriverEntry

1
2
// Initialize WPP Tracing
WPP_INIT_TRACING(DriverObject, RegistryPath);

最后,我们使用以下代码对 驱动程序卸载 函数进行清理并将 WPP 跟踪设置为关闭。

1
2
// Stop the tracing
WPP_CLEANUP(DriverObject);

为了使事情变得简单,我将以下代码添加到之前的消息跟踪代码中,这意味着我们不会将缓冲区发送到自定义消息跟踪缓冲区,而是将其发送到 WPP 跟踪缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
if (OperationCode == OPERATION_LOG_INFO_MESSAGE)
{
    HypervisorTraceLevelMessage(
        TRACE_LEVEL_INFORMATION,  // ETW Level defined in evntrace.h
        HVFS_LOG_INFO,
        "%s",// Flag defined in WPP_CONTROL_GUIDS
        LogMessage);
}
else if (OperationCode == OPERATION_LOG_WARNING_MESSAGE)
{
    HypervisorTraceLevelMessage(
        TRACE_LEVEL_WARNING,  // ETW Level defined in evntrace.h
        HVFS_LOG_WARNING,
        "%s",// Flag defined in WPP_CONTROL_GUIDS
        LogMessage);
}
else if (OperationCode == OPERATION_LOG_ERROR_MESSAGE)
{
    HypervisorTraceLevelMessage(
        TRACE_LEVEL_ERROR,  // ETW Level defined in evntrace.h
        HVFS_LOG_ERROR,
        "%s",// Flag defined in WPP_CONTROL_GUIDS
        LogMessage);
}
else
{
    HypervisorTraceLevelMessage(
        TRACE_LEVEL_NONE,  // ETW Level defined in evntrace.h
        HVFS_LOG,
        "%s",// Flag defined in WPP_CONTROL_GUIDS
        LogMessage);
}

另外,我们还需要 .tmh 文件。 这些文件由 WPP 框架自动生成,其中包含跟踪消息所需的代码。 TMH 文件名应该与 C 文件相同,例如,如果我们在“Driver.c”中添加跟踪消息,那么我们应该包含“ Driver.tmh ”。 我们在两个文件中使用了 WPP Tracing API,第一个是 Driver.cLogging.c, 因此我们必须包含 Driver.tmhLogging.tmh ,并且只要我们将所有内容收集到一个文件中,就不需要在其他项目文件中使用这些文件。

WPP 追踪已完成! 为了在用户模式下查看消息,我们必须使用另一个应用程序,例如traceview。

就个人而言,我更喜欢使用自定义消息跟踪,因为 WPP Tracing 需要其他一些应用程序来解析 .pdb 文件 或其他文件来显示消息,并且我没有找到任何在不使用其他应用程序的情况下解析应用程序中消息的好示例应用程序。

您可以稍后在 Let's Test it 中看到 WPP Tracing 的结果! 部分。

8.14.支持 Hyper-V

正如我在前面的部分中告诉您的,测试和构建 Hyper-V 虚拟机管理程序需要额外考虑,并添加更多代码行来支持 Hyper-V 嵌套虚拟化。

在撰写本部分时,Hyper-V 和 VMware Workstation 彼此不兼容,这意味着如果您运行 Hyper-V,则无法运行 VMware,并且会出现类似这样的消息。

VMware Workstation 和 Hyper-V 不兼容。 在运行 VMware Workstation 之前从系统中删除 Hyper-V 角色。

对于VMware也是如此,如果运行VMware就无法运行Hyper-V,需要执行命令然后重新启动计算机才能使用另一个VMM。

为了使用 Hyper-V,您应该运行以下命令(以管理员身份),然后重新启动计算机。

1
bcdedit /set hypervisorlaunchtype auto

如果您想运行VMware,您可以运行以下命令(以管理员身份)并重新启动计算机。

1
bcdedit /set hypervisorlaunchtype off

8.14.1.启用嵌套虚拟化

第 1 部分 中,有一节介绍如何启用 VMware 的嵌套虚拟化并测试您的驱动程序。 对于 Hyper-V,我们有一个完全相同的场景,首先关闭目标虚拟机,然后通过在 Powershell 上运行以下命令为目标虚拟机启用嵌套虚拟化:

请注意,不要输入 PutYourVmNameHere,而是输入要为其启用嵌套虚拟化的虚拟机的名称。

1
Set-VMProcessor -VMName PutYourVmNameHere -ExposeVirtualizationExtensions $true

如果您需要禁用它,您可以运行:

1
Set-VMProcessor -VMName PutYourVmNameHere -ExposeVirtualizationExtensions $false

现在您需要将 Hyper-V 计算机连接到 Windbg 调试器。 有很多方法可以做到这一点。 您可以阅读 此处 此处 (我更喜欢使用 kdnet.exe )。

现在我们有了测试环境,是时候修改我们的虚拟机管理程序以便支持 Hyper-V 了。

8.14.2.Hyper-V 在嵌套虚拟化中的可见行为

Hyper-V 对我们的虚拟机管理程序有一些可见的行为,这意味着您应该管理其中一些与我们相关的行为,并将其中一些交给 Hyper-V 作为顶级虚拟机管理程序来管理它们,您感到困惑吗? 让我再解释一次。

在嵌套虚拟化环境中,您不会直接获取 vm-exits 和所有其他虚拟机管理程序事件,而是由顶级虚拟机管理程序获取 vm-exit(在我们的示例中,Hyper-V 是顶级)。 顶级虚拟机管理程序调用较低级别虚拟机管理程序的 vm-exit 处理程序(在本例中,我们的虚拟机管理程序是低级虚拟机管理程序。)现在较低级别虚拟机管理程序管理 vm-exit(例如,它向传送给 guest 虚拟机)在 vm-exit 完成后,它会执行 VMRESUME,但该指令不会直接转到 guest vmx 非 root。 相反,它会转到顶级虚拟机管理程序的 vm-exit 处理程序,现在由顶级虚拟机管理程序执行任务(在我们的示例中,将事件插入guest)。

因此,即使我们的虚拟机管理程序不是第一个获取事件的虚拟机管理程序,但我们的虚拟机管理程序是第一个管理它们的。

另一方面,Windows内核与Hyper-V高度集成,这意味着它使用大量的Hypercalls(Vmcalls)和MSR来与Hyper-V联系,如果Windows内核没有从Hyper-V获得有效响应然后它崩溃或停止。

作为第一个管理 vm-exit 的虚拟机管理程序,我们必须检查 vm-exit 详细信息,看看 vm-exit 是否与我们所指的 Hyper-V 相关。 换句话说,这是一个通用的虚拟机退出,或者是因为Windows想要与Hyper-V对话。

好吧,让我们看看我们应该管理什么,不应该管理什么。

8.14.3.Hyper-V 管理程序顶级功能规范 (TLFS)

Hyper-V 虚拟机管理程序顶级功能规范 (TLFS) 描述了虚拟机管理程序对其他操作系统组件的外部可见行为。 该规范旨在对客户操作系统开发人员有用。

关于Hyper-V的TLFS的文档 如果你想研究Hyper-V,你必须阅读这里 ,但我们只是想支持Hyper-V。 因此,有一个文档( 实现 Microsoft Hypervisor 接口的要求 )描述了我们为了支持 Hyper-V 应该做的事情。 当然,我们不会实现所有这些以使我们的虚拟机管理程序能够在 Hyper-V 上运行。

8.14.4.超出 MSR 范围

第 6 部分 之间的 MSR 索引 (RCX) 中,我描述了 MSR 位图,如果您还记得 MSR 位图支持0x000000000x00001FFF0xC00000000xC0001FFF 。 Windows 使用从 0x400000000x400000F0 的 其他 MSR来请求某些内容或向 vmx-root 报告某些内容。

您可能会问为什么他们不使用 VMCALL。 当然,他们可以使用 VMCALL,但大多数虚拟机管理程序都会这样做。 它更便宜并且早于 VMCALL,而且该系列是专门为虚拟机管理程序使用而设计的。

它更便宜的原因与关于为什么使用 int 2e 而不是 sysenter 作为通过 vmcall 发送数据并允许它从环 0 或环 3 并决定事情( rdmsr 不需要环检查)和发送数据 的成本的讨论相同back 比简单的 MSR 接口更强大,并且也可以与遗留编译器和系统一起使用。

找到这些 MSR 的定义 您可以在此处

总而言之,我修改了之前的 MSR 处理程序(MSR Read - RDMSR 和 MSR Write - WRMSR 以支持 0x400000000x400000F0 之间的 MSR )。 我们所要做的就是在 vmx-root 模式下执行 RDMSR 或 WRMSR。

您可能会问,使用硬件无效的 MSR 运行 WRMSR 或 RDMSR 可以吗?

答案是不! 但我们执行它的原因是因为我们处于嵌套虚拟化环境中,并且它不是真正的 vmx-root,物理上我们处于 vmx 非 root 模式(如果这是有意义的)。

换句话说,VMware或Hyper-V或任何嵌套虚拟化环境在VMX non-root中调用我们的vm-exit处理程序并假装它处于vmx-root模式,因此执行WRMSR或RDMSR会导致真正的vm-exit到Hyper-V ,这就是他们处理实际 vm-exit 的方式。

例如 RDMSR 处理如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* Handles in the cases when RDMSR causes a Vmexit*/
VOID HvHandleMsrRead(PGUEST_REGS GuestRegs)
{
 
    MSR msr = { 0 };
 
 
    // RDMSR. The RDMSR instruction causes a VM exit if any of the following are true:
    //
    // The "use MSR bitmaps" VM-execution control is 0.
    // The value of ECX is not in the ranges 00000000H - 00001FFFH and C0000000H - C0001FFFH
    // The value of ECX is in the range 00000000H - 00001FFFH and bit n in read bitmap for low MSRs is 1,
    //   where n is the value of ECX.
    // The value of ECX is in the range C0000000H - C0001FFFH and bit n in read bitmap for high MSRs is 1,
    //   where n is the value of ECX & 00001FFFH.
 
    /*
       Execute WRMSR or RDMSR on behalf of the guest. Important that this
       can cause bug check when the guest tries to access unimplemented MSR
       even within the SEH block* because the below WRMSR or RDMSR raises
       #GP and are not protected by the SEH block (or cannot be protected
       either as this code run outside the thread stack region Windows
       requires to proceed SEH). Hypervisors typically handle this by noop-ing
       WRMSR and returning zero for RDMSR with non-architecturally defined
       MSRs. Alternatively, one can probe which MSRs should cause #GP prior
       to installation of a hypervisor and the hypervisor can emulate the
       results.
       */
 
       // Check for sanity of MSR if they're valid or they're for reserved range for WRMSR and RDMSR
    if ((GuestRegs->rcx <= 0x00001FFF) || ((0xC0000000 <= GuestRegs->rcx) && (GuestRegs->rcx <= 0xC0001FFF))
        || (GuestRegs->rcx >= RESERVED_MSR_RANGE_LOW && (GuestRegs->rcx <= RESERVED_MSR_RANGE_HI)))
    {
        msr.Content = __readmsr(GuestRegs->rcx);
    }
 
    GuestRegs->rax = msr.Low;
    GuestRegs->rdx = msr.High;
}

相同的检查也适用于 WRMSR。

8.14.5.Hyper-V 超级调用 (VMCALL)

VMCALL 与 RDMSR 和 WRMSR 完全相同,尽管在 vmx-root 模式下运行 VMCALL 具有已知行为(调用 SMM 监视器)。 尽管如此,在我们的例子中,在嵌套虚拟化环境中,它会导致虚拟机退出到 Hyper-V,以便 Hyper-V 可以管理超级调用。

Hyper-V 的 VMCALL(超级调用)具有以下约定。
图片描述
由于我们想要使用我们的虚拟机管理程序 VMCALL,因此解决此问题的一个快速而肮脏的解决方案是以某种方式向 vm-exit 处理程序显示我们的虚拟机管理程序例程应该管理此 VMCALL; 因此,我们将一些随机的十六进制值放入 r10、r11、r12(因为这些寄存器未在 fastcall 调用约定中使用,您也可以选择其他寄存器),因此我们可以在 vm-exit 处理程序上检查这些寄存器,以确保这VMCALL 与我们的虚拟机管理程序相关。

由于 Windows x64 fastcall 调用约定,某些寄存器不应更改,因此我们保存它们以便稍后恢复。

通常,寄存器 RAX、RCX、RDX、R8、R9、R10、R11 被视为 易失性 保存 (调用者保存),寄存器 RBX、RBP、RDI、RSI、RSP、R12、R13、R14 和 R15 被视为非易失性(被调用者 - 已保存)。

1
2
3
4
5
6
7
8
9
10
11
12
13
; We change r10 to HVFS Hex ASCII and r11 to VMCALL Hex ASCII and r12 to NOHYPERV Hex ASCII so we can make sure that the calling Vmcall comes
; from our hypervisor and we're resposible for managing it, otherwise it has to be managed by Hyper-V
push    r10
push    r11
push    r12
mov     r10, 48564653H          ; [HVFS]
mov     r11, 564d43414c4cH      ; [VMCALL]
mov     r12, 4e4f485950455256H   ; [NOHYPERV]
vmcall                          ; VmxVmcallHandler(UINT64 VmcallNumber, UINT64 OptionalParam1, UINT64 OptionalParam2, UINT64 OptionalParam3)
pop     r12
pop     r11
pop     r10
ret                             ; Return type is NTSTATUS and it's on RAX from the previous function, no need to change anything

对于 Hyper-V VMCALL,我们需要调整 RCX、RDX、R8,如上图所示。

1
2
3
4
5
AsmHypervVmcall PROC
    vmcall                       ; __fastcall Vmcall(rcx = HypercallInputValue, rdx = InputParamGPA, r8 = OutputParamGPA)
    ret
 
AsmHypervVmcall ENDP

最后,在 vm-exit 处理程序中,我们检查 VMCALL 以查看随机值是否存储在寄存器中。 如果它在这些寄存器上,那么我们调用虚拟机管理程序 VMCALL 处理程序。 否则,我们让 Hyper-V 对其 VMCALL 执行任何操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
case EXIT_REASON_VMCALL:
{
    // Check if it's our routines that request the VMCALL our it relates to Hyper-V
    if (GuestRegs->r10 == 0x48564653 && GuestRegs->r11 == 0x564d43414c4c && GuestRegs->r12 == 0x4e4f485950455256)
    {
        // Then we have to manage it as it relates to us
        GuestRegs->rax = VmxVmcallHandler(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8, GuestRegs->r9);
    }
    else
    {
        // Otherwise let the top-level hypervisor to manage it
        GuestRegs->rax = AsmHypervVmcall(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8);
    }
    break;
}

8.14.6.Hyper-V 接口 CPUID 离开

支持 Hyper-V 的最后一步是管理 CPUID 叶子,以下是我们必须管理的一些 CPUID 叶子。

请注意,根据我 [提到的 ](https://github.com/Microsoft/Virtualization-Documentation/raw/master/tlfs/Requirements for Implementing the Microsoft Hypervisor Interface.pdf)文档,我们必须返回非 “Hv#1” 值。 这表明我们的虚拟机管理程序不符合 Microsoft 虚拟机管理程序接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
else if (RegistersState->rax == CPUID_HV_VENDOR_AND_MAX_FUNCTIONS)
{
 
    // Return a maximum supported hypervisor CPUID leaf range and a vendor
    // ID signature as required by the spec.
 
    cpu_info[0] = HYPERV_CPUID_INTERFACE;
    cpu_info[1] = 'rFvH'// "[H]yper[v]isor [Fr]o[m] [Scratch] = HvFrmScratch"
    cpu_info[2] = 'rcSm';
    cpu_info[3] = 'hcta';
}
else if (RegistersState->rax == HYPERV_CPUID_INTERFACE)
{
    // Return our interface identifier
    //cpu_info[0] = 'HVFS'; // [H]yper[V]isor [F]rom [S]cratch
 
    // Return non Hv#1 value. This indicate that our hypervisor does NOT
    // conform to the Microsoft hypervisor interface.
 
    cpu_info[0] = '0#vH'// Hv#0
    cpu_info[1] = cpu_info[2] = cpu_info[3] = 0;
 
}

顺便说一句,无需对 CPUID 叶子进行上述修改即可工作,但最好基于 TLFS 对其进行管理。

我在 Hyper-V 开发过程中注意到的另一件事是,我们有 vm-exits,因为guest执行 HLT(停止)指令,当然,我们不想停止处理器,所以在 EXIT_REASON_HLT 的情况下我们只是忽略它。

完成的! 从现在开始,您也可以在 Hyper-V 上测试您的虚拟机管理程序:)

8.15.修复以前的设计问题

在这一部分中,我们希望改进我们的虚拟机管理程序并修复前几部分中有关问题和误解的一些问题。

8.15.1.修复预分配缓冲区的问题

我们之前的缓冲区预分配有两个问题,

  • 它不允许我们从 VMX Root 模式挂钩页面,这意味着每个池分配都应该从 vmx 非 root 模式开始。
  • 在分配过程中,我们没有获取自旋锁,因此处理器可能会中断我们。 下次我们想要继续执行时,就不会进行分配,因为我们为每个核心分配池。

了解决这些问题,我们需要设计一个全局池管理器。 您可以在“PoolManager.c”和“PoolManager.h”中看到池管理器代码。 我不会描述它是如何工作的,因为如果你看到源代码就很清楚了,但我将解释这个池管理器的功能以及如何使用它的功能。

在此池管理器中,我们将使用全局预分配缓冲区,并准备好十个预分配缓冲区,而不是分配核心到核心特定的预分配缓冲区,每次使用其中一个缓冲区时,我们都会向池管理器添加一个请求以替换另一个缓冲区 尽快池,这样我们就永远不会用完预分配的池。

当然,如果十个请求到达池管理器,我们可能会用完预分配的池,但我们不需要这样的请求,当然,在它们之间,池管理器有机会重新分配新池。

这里的功能解释:

1
BOOLEAN PoolManagerInitialize();

初始化池管理器并预分配一些池。

1
VOID PoolManagerUninitialize();

取消分配所有已分配的池

1
BOOLEAN PoolManagerCheckAndPerformAllocation();

上面的函数尝试查看新的池请求是否可用,如果可用,则分配它。 它应该在 PASSIVE_LEVEL (vmx 非 root 模式)中调用,因为我们需要分页分配,而且检查它的最佳位置是在 IOCTL 处理程序上,因为我们经常调用它,并且它是 PASSIVE_LEVEL 并且安全。

1
BOOLEAN PoolManagerRequestAllocation(SIZE_T Size, UINT32 Count, POOL_ALLOCATION_INTENTION Intention);

如果我们请求分配一个新的池,我们可以调用这个函数。 它将请求存储在内存中的某个位置,以便在安全时进行分配( IRQL == PASSIVE_LEVEL )。

POOL_ALLOCATION_INTENTION 是一个枚举,描述了我们为什么需要这个池。 使用它是因为我们可能需要不同大小的用于其他目的的池,因此我们使用池管理器没有任何问题。

1
UINT64 PoolManagerRequestPool(POOL_ALLOCATION_INTENTION Intention, BOOLEAN RequestNewPool, UINT32 Size);

在 vmx-root 模式下,如果我们立即调用它需要一个安全池地址,如果我们将 RequestNewPool 设置为 TRUE,它也会请求一个新池; 因此,下次安全时,将分配池。

另外,您可以查看代码以获取其他解释。

8.15.2.避免拦截对 CR3 的访问

从第 5 部分到这一部分,我们的误解之一是我们拦截 CR3 访问,因为我们在Cpu Based VM Exec Controls上设置了CR3 load-exiting 和 CR3 store-exiting。

一般来说,当您在 EPT 下运行 CR3 时,拦截guest对 CR3 的访问是很不常见的。 这种行为主要是在实现影子 MMU 时完成的(因为 CPU 中缺乏 EPT 支持),因此不拦截 CR3 访问是任何启用 EPT 运行的虚拟机管理程序的标准行为。

拦截 CR3 访问始终是可配置的,我们必须清除 中的CPU_BASED_CR3_STORE_EXITINGCPU_BASED_CR3_LOAD_EXITINGCPU_BASED_INVLPG_EXITING VMCS 的 CPU_BASED_VM_EXEC_CONTROL 位。

但是等等,为什么我们要清除它们,我们从来没有设置过它们!

如前面部分所述,某些 VMX 控件是保留的,必须设置为特定值(0 或 1),该值由处理器确定。 这就是为什么我们使用函数“HvAdjustControls”并向它们传递代表这些设置的 MSR(MSR_IA32_VMX_PROCBASED_CTLS、MSR_IA32_VMX_PINBASED_CTLS、MSR_IA32_VMX_EXIT_CTLS、MSR_IA32_VMX_ENTRY_CTLS)。

实际上,VMCS 控件有 3 种类型的设置。

  • 始终灵活。 这些从来没有被保留过。
  • 默认0。 这些是(或已经)保留的,默认设置为 0。
  • 默认1。 它们被(或已经)保留,默认设置为 1。

在较新的处理器上,如果位 55 (IA32_VMX_BASIC) 被读取为 1,则任何默认的 VMX 控制可能会被清除为 0。该位还报告对 VMX 功能 MSR A32_VMX_TRUE_PINBASED_CTLS、IA32_VMX_TRUE_PROCBASED_CTLS、IA32_VMX_TRUE_EXIT_CTLS 和 IA32_VMX_TRUE_ENTRY_ 的支持CTLS。

因此,我们必须检查我们的CPU是否支持该位,如果支持,那么我们必须使用新的 A32_VMX_TRUE_PINBASED_CTLSIA32_VMX_TRUE_PROCBASED_CTLSIA32_VMX_TRUE_EXIT_CTLSIA32_VMX_TRUE_ENTRY_CTLS 而不是 MSR_IA32_VMX_PROCBASED_CTLSMSR_IA32_VMX _PINBASED_CTLS 、 MSR_IA32_VMX_EXIT_CTLSMSR_IA32_VMX_ENTRY_CTLS

请注意, MSR_IA32_VMX_PROCBASED_CTLS2 没有其他版本。

为此,我们首先阅读 MSR_IA32_VMX_BASIC

1
2
3
4
IA32_VMX_BASIC_MSR VmxBasicMsr = { 0 };
 
// Reading IA32_VMX_BASIC_MSR
VmxBasicMsr.All = __readmsr(MSR_IA32_VMX_BASIC);

然后我们检查MSR_IA32_VMX_BASIC的第55位是否被设置。 如果已设置,则我们对 HvAdjustControls 使用不同的 MSR。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CpuBasedVmExecControls = HvAdjustControls(CPU_BASED_ACTIVATE_MSR_BITMAP | CPU_BASED_ACTIVATE_SECONDARY_CONTROLS,
    VmxBasicMsr.Fields.VmxCapabilityHint ? MSR_IA32_VMX_TRUE_PROCBASED_CTLS : MSR_IA32_VMX_PROCBASED_CTLS);
 
__vmx_vmwrite(CPU_BASED_VM_EXEC_CONTROL, CpuBasedVmExecControls);
 
LogInfo("Cpu Based VM Exec Controls (Based on %s) : 0x%x",
    VmxBasicMsr.Fields.VmxCapabilityHint ? "MSR_IA32_VMX_TRUE_PROCBASED_CTLS" : "MSR_IA32_VMX_PROCBASED_CTLS", CpuBasedVmExecControls);
 
SecondaryProcBasedVmExecControls = HvAdjustControls(CPU_BASED_CTL2_RDTSCP |
    CPU_BASED_CTL2_ENABLE_EPT | CPU_BASED_CTL2_ENABLE_INVPCID |
    CPU_BASED_CTL2_ENABLE_XSAVE_XRSTORS  | CPU_BASED_CTL2_ENABLE_VPID, MSR_IA32_VMX_PROCBASED_CTLS2);
 
__vmx_vmwrite(SECONDARY_VM_EXEC_CONTROL, SecondaryProcBasedVmExecControls);
LogInfo("Secondary Proc Based VM Exec Controls (MSR_IA32_VMX_PROCBASED_CTLS2) : 0x%x", SecondaryProcBasedVmExecControls);
 
__vmx_vmwrite(PIN_BASED_VM_EXEC_CONTROL, HvAdjustControls(0,
    VmxBasicMsr.Fields.VmxCapabilityHint ? MSR_IA32_VMX_TRUE_PINBASED_CTLS : MSR_IA32_VMX_PINBASED_CTLS));
 
__vmx_vmwrite(VM_EXIT_CONTROLS, HvAdjustControls(VM_EXIT_IA32E_MODE,
    VmxBasicMsr.Fields.VmxCapabilityHint ? MSR_IA32_VMX_TRUE_EXIT_CTLS : MSR_IA32_VMX_EXIT_CTLS));
 
__vmx_vmwrite(VM_ENTRY_CONTROLS, HvAdjustControls(VM_ENTRY_IA32E_MODE,
    VmxBasicMsr.Fields.VmxCapabilityHint ? MSR_IA32_VMX_TRUE_ENTRY_CTLS : MSR_IA32_VMX_ENTRY_CTLS));

这样,我们可以通过禁用不必要的 vm-exits 来获得更好的性能,因为 Windows 中每个进程都有无数的 CR3 更改,并且 Meldown 补丁会带来两次 cr3 更改。 我们不再需要拦截他们。

8.15.3.恢复 IDTR、GDTR、GS Base 和 FS Base

前面部分中我们没有的事情之一是,当我们想要关闭虚拟机管理程序时,我们没有恢复 IDTR、GDTR、GS Base 和 FS Base。 当您执行 vmxoff 时,我们应该重置 GDTR/IDTR,否则 PatchGuard 会检测到它们已被修改。

为了恢复它们,在每个核心中执行 vmxoff 之前,会调用以下函数,它会处理应恢复的所有内容以避免 PatchGuard 错误。

它从 VMCS 读取 GUEST_GS_BASE 和 GUEST_FS_BASE,并使用 WRMSR 进行写入以恢复它们,还使用 lgdt 和 litt 指令恢复 GUEST_GDTR_BASE、GUEST_GDTR_LIMIT 和 GUEST_IDTR_BASE、GUEST_IDTR_LIMIT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
VOID HvRestoreRegisters()
{
    ULONG64 FsBase;
    ULONG64 GsBase;
    ULONG64 GdtrBase;
    ULONG64 GdtrLimit;
    ULONG64 IdtrBase;
    ULONG64 IdtrLimit;
 
    // Restore FS Base
    __vmx_vmread(GUEST_FS_BASE, &FsBase);
    __writemsr(MSR_FS_BASE, FsBase);
 
    // Restore Gs Base
    __vmx_vmread(GUEST_GS_BASE, &GsBase);
    __writemsr(MSR_GS_BASE, GsBase);
 
    // Restore GDTR
    __vmx_vmread(GUEST_GDTR_BASE, &GdtrBase);
    __vmx_vmread(GUEST_GDTR_LIMIT, &GdtrLimit);
 
    AsmReloadGdtr(GdtrBase, GdtrLimit);
 
    // Restore IDTR
    __vmx_vmread(GUEST_IDTR_BASE, &IdtrBase);
    __vmx_vmread(GUEST_IDTR_LIMIT, &IdtrLimit);
 
    AsmReloadIdtr(IdtrBase, IdtrLimit);
}

这是恢复IDTR和GDTR的汇编部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
;------------------------------------------------------------------------
 
; AsmReloadGdtr (PVOID GdtBase (rcx), ULONG GdtLimit (rdx) );
 
AsmReloadGdtr PROC
    push    rcx
    shl     rdx, 48
    push    rdx
    lgdt    fword ptr [rsp+6]   ; do not try to modify stack selector with this ;)
    pop     rax
    pop     rax
    ret
AsmReloadGdtr ENDP
 
;------------------------------------------------------------------------
 
; AsmReloadIdtr (PVOID IdtBase (rcx), ULONG IdtLimit (rdx) );
 
AsmReloadIdtr PROC
    push    rcx
    shl     rdx, 48
    push    rdx
    lidt    fword ptr [rsp+6]
    pop     rax
    pop     rax
    ret
AsmReloadIdtr ENDP
 
;------------------------------------------------------------------------

另外,最好在每个核心上分别执行 vmxoff 后取消设置 cr4 的 vmx-enable 位。

1
2
// Now that VMX is OFF, we have to unset vmx-enable bit on cr4
__writecr4(__readcr4() & (~X86_CR4_VMXE));

8.16.让我们测试一下!

我们的虚拟机管理程序的代码在裸机(物理机)、VMware 的嵌套虚拟化和 Hyper-V 的嵌套虚拟化上进行了测试。

8.16.1.查看 WPP 跟踪消息

要测试 WPP 跟踪,您需要一个用于解析消息的应用程序,我使用 TraceView。

TraceView 位于 *tools< Platform* Windows 驱动程序工具包 (WDK) 的 *> 子目录中,其中 < Platform* > 表示正在运行跟踪会话的平台,例如 x86、x64 或 arm64。

还有其他用于此目的的 GUI 和命令行应用程序,您可以 在此处 查看其中一些应用程序的列表。

首先,打开traceview(以管理员身份运行),转到 File-> Create New Log Session ,然后使用 Visual Studio 生成的 .pdb 文件。 PDB 文件包含调试信息,对于 WPP 跟踪,它们包含 GUID 和消息格式。
图片描述
选择提供商后,单击“下一步”。
图片描述
在这里您可以配置您想要查看的消息类型,例如您只想查看错误消息。

默认配置是查看所有消息。
图片描述
最后,您将看到以下结果。
图片描述

8.16.2.如何测试?

现在是时候看看我们在这部分做了什么!

*注意:默认情况下,以下测试均未激活,您必须取消注释特定行才能在虚拟机管理程序中查看结果!*

8.16.3.事件注入和异常位图演示

为了测试事件注入和异常位图,我们有一个场景,我们想要监视在用户模式应用程序中触发的每个调试断点。

为此,我使用 Immunity Debugger 调试了一个应用程序,并在多个地址上放置了断点。 我们想要拦截任何应用程序的每个断点。

首先,取消 Vmx.c 中以下行的注释。

1
2
// Set exception bitmap to hook division by zero (bit 1 of EXCEPTION_BITMAP)
 __vmx_vmwrite(EXCEPTION_BITMAP, 0x8); // breakpoint 3nd bit

这将导致每次使用异常位图执行断点异常时出现 vm-exit。

以下代码负责处理异常位图的 vm-exits。 我们通过 VMCS 的 VM_EXIT_INTR_INFO 检查导致 vm-exit 的中断/异常是什么。 如果它是软件异常并且其向量是断点,那么我们确定执行 (int 3 或 0xcc) 是导致此 vm 退出的原因。

现在,我们创建一个日志,显示 GUEST_RIP 中发生的断点,然后将断点重新注入到来宾(事件注入)。 我们必须将其重新注入回来宾,因为在此 vm-exit 后事件被取消,您可以检查它,只需删除 EventInjectBreakpoint(),您的用户模式调试器将不再工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
case EXIT_REASON_EXCEPTION_NMI:
{
    /*
 
    Exception or non-maskable interrupt (NMI). Either:
        1: Guest software caused an exception and the bit in the exception bitmap associated with exception’s vector was set to 1
        2: An NMI was delivered to the logical processor and the “NMI exiting” VM-execution control was 1.
 
    VM_EXIT_INTR_INFO shows the exit infromation about event that occured and causes this exit
    Don't forget to read VM_EXIT_INTR_ERROR_CODE in the case of re-injectiong event
 
    */
 
    // read the exit reason
    __vmx_vmread(VM_EXIT_INTR_INFO, &InterruptExit);
 
    if (InterruptExit.InterruptionType == INTERRUPT_TYPE_SOFTWARE_EXCEPTION && InterruptExit.Vector == EXCEPTION_VECTOR_BREAKPOINT)
    {
 
        ULONG64 GuestRip;
        // Reading guest's RIP
        __vmx_vmread(GUEST_RIP, &GuestRip);
 
        // Send the user
        LogInfo("Breakpoint Hit (Process Id : 0x%x) at : %llx ", PsGetCurrentProcessId(), GuestRip);
 
        GuestState[CurrentProcessorIndex].IncrementRip = FALSE;
 
        // re-inject #BP back to the guest
        EventInjectBreakpoint();
 
    }
    else
    {
        LogError("Not expected event occured");
    }
    break;
}

要查看 gif 格式的结果,请单击下面的链接。
查看 .gif 格式的示例 (event-inject-and-exception-bitmap.gif)
图片描述

8.16.4.隐藏挂钩演示

隐藏钩子分为两部分,第一部分是读/写的隐藏钩子(就像模拟硬件调试寄存器,没有任何限制),第二部分是执行的隐藏钩子,相当于不可见的内联钩子。

为了激活隐藏钩子测试,请从 Driver.c 取消注释 HiddenHooksTest() 。

请注意,您可以同时使用隐藏挂钩进行读/写、执行或系统调用挂钩,没有限制。

1
2
3
4
//////////// test ////////////
HiddenHooksTest();
// SyscallHookTest();
//////////////////////////////

8.16.5.读/写挂钩或硬件调试寄存器模拟

为了测试读取和写入,请取消注释第一行,现在,如果从任何位置对当前线程的 _ETHREAD 结构 ( KeGetCurrentThread() ) 进行任何读/写,您都会收到通知。

1
2
3
4
5
6
7
8
9
10
11
12
/* Make examples for testing hidden hooks */
VOID HiddenHooksTest()
{
    // Hook Test
        EptPageHook(KeGetCurrentThread(), NULL, NULL, TRUE, TRUE, FALSE);
    //  EptPageHook(ExAllocatePoolWithTag, ExAllocatePoolWithTagHook, (PVOID*)&ExAllocatePoolWithTagOrig, FALSE, FALSE, TRUE);
 
    // Unhook Tests
    //HvPerformPageUnHookSinglePage(ExAllocatePoolWithTag);
    //HvPerformPageUnHookAllPages();
     
}

要查看 gif 格式的结果,请单击下面的链接。

查看 .gif 格式的示例 (hidden-hook-example-read-write.gif)

图片描述
另外,您可以在 Windbg 中看到结果!
图片描述

8.16.6.隐藏执行钩子

隐藏挂钩的第二种情况是内联挂钩 ExAllocatePoolWithTag 函数。

这是通过取消注释以下行来完成的。

1
2
3
4
5
6
7
8
9
10
11
12
/* Make examples for testing hidden hooks */
VOID HiddenHooksTest()
{
    // Hook Test
    //  EptPageHook(KeGetCurrentThread(), NULL, NULL, TRUE, TRUE, FALSE);
        EptPageHook(ExAllocatePoolWithTag, ExAllocatePoolWithTagHook, (PVOID*)&ExAllocatePoolWithTagOrig, FALSE, FALSE, TRUE);
 
    // Unhook Tests
    //HvPerformPageUnHookSinglePage(ExAllocatePoolWithTag);
    //HvPerformPageUnHookAllPages();
     
}

还有一个记录每个 ExAllocatePoolWithTag 的简单函数。

1
2
3
4
5
6
7
8
9
10
/* Hook function that HooksExAllocatePoolWithTag */
PVOID ExAllocatePoolWithTagHook(
    POOL_TYPE   PoolType,
    SIZE_T      NumberOfBytes,
    ULONG       Tag
)
{
    LogInfo("ExAllocatePoolWithTag Called with : Tag = 0x%x , Number Of Bytes = %d , Pool Type = %d ", Tag, NumberOfBytes, PoolType);
    return ExAllocatePoolWithTagOrig(PoolType, NumberOfBytes, Tag);
}

钩子已应用! 您还可以尝试使用( u nt!ExAllocatePoolWithTag )并查看那里没有内联挂钩,因此它 完全隐藏 ,当然与 PatchGuard 兼容!

要查看 gif 格式的结果,请单击下面的链接。

[查看 .gif 格式的示例 (hidden-hook-example-exec.gif)](

图片描述

8.16.7.系统调用挂钩演示

我们测试系统调用挂钩的场景首先取消 Driver.c 中以下行的注释。

1
2
3
4
//////////// test ////////////
// HiddenHooksTest();
SyscallHookTest();
//////////////////////////////

以下函数首先搜索 API 编号 0x55(在 Windows 10 1909 上,0x55 代表 NtCreateFile ,这并非适用于所有版本的 Windows,您必须 找到NtCreateFile 根据您的 Windows 版本 的正确 API 编号,系统的完整列表 - Nt 表的索书号在 这里 ,Win32k 表的索书号在 这里 )。

找到 NtCreateFile 的地址(系统调用编号 0x55)后,我们在此地址上设置一个隐藏的钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Make examples for testing hidden hooks */
VOID SyscallHookTest() {
 
    // Note that this syscall number is only valid for Windows 10 1909, you have to find the syscall number of NtCreateFile based on
    // Your Windows version, please visit https://j00ru.vexillium.org/syscalls/nt/64/ for finding NtCreateFile's Syscall number for your Windows.
     
    INT32 ApiNumberOfNtCreateFile = 0x0055;
    PVOID ApiLocationFromSSDTOfNtCreateFile = SyscallHookGetFunctionAddress(ApiNumberOfNtCreateFile, FALSE);
 
    if (!ApiLocationFromSSDTOfNtCreateFile)
    {
        LogError("Error in finding base address.");
        return FALSE;
    }
 
    if (EptPageHook(ApiLocationFromSSDTOfNtCreateFile, NtCreateFileHook, (PVOID*)&NtCreateFileOrig, FALSE, FALSE, TRUE))
    {
        LogInfo("Hook appkied to address of API Number : 0x%x at %llx\n", ApiNumberOfNtCreateFile, ApiLocationFromSSDTOfNtCreateFile);
    }
}

为了处理内联挂钩,使用以下函数根据文件名创建日志并最终调用原始 NtCreateFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* Hook function that hooks NtCreateFile */
NTSTATUS NtCreateFileHook(
    PHANDLE            FileHandle,
    ACCESS_MASK        DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    PIO_STATUS_BLOCK   IoStatusBlock,
    PLARGE_INTEGER     AllocationSize,
    ULONG              FileAttributes,
    ULONG              ShareAccess,
    ULONG              CreateDisposition,
    ULONG              CreateOptions,
    PVOID              EaBuffer,
    ULONG              EaLength
)
{
    HANDLE kFileHandle;
    NTSTATUS ConvertStatus;
    UNICODE_STRING kObjectName;
    ANSI_STRING FileNameA;
 
    kObjectName.Buffer = NULL;
 
    __try
    {
 
        ProbeForRead(FileHandle, sizeof(HANDLE), 1);
        ProbeForRead(ObjectAttributes, sizeof(OBJECT_ATTRIBUTES), 1);
        ProbeForRead(ObjectAttributes->ObjectName, sizeof(UNICODE_STRING), 1);
        ProbeForRead(ObjectAttributes->ObjectName->Buffer, ObjectAttributes->ObjectName->Length, 1);
 
        kFileHandle = *FileHandle;
        kObjectName.Length = ObjectAttributes->ObjectName->Length;
        kObjectName.MaximumLength = ObjectAttributes->ObjectName->MaximumLength;
        kObjectName.Buffer = ExAllocatePoolWithTag(NonPagedPool, kObjectName.MaximumLength, 0xA);
        RtlCopyUnicodeString(&kObjectName, ObjectAttributes->ObjectName);
 
        ConvertStatus = RtlUnicodeStringToAnsiString(&FileNameA, ObjectAttributes->ObjectName, TRUE);
        LogInfo("NtCreateFile called for : %s", FileNameA.Buffer);
 
    }
    __except (EXCEPTION_EXECUTE_HANDLER)
    {
    }
 
    if (kObjectName.Buffer)
    {
        ExFreePoolWithTag(kObjectName.Buffer, 0xA);
    }
 
 
    return NtCreateFileOrig(FileHandle, DesiredAccess, ObjectAttributes, IoStatusBlock, AllocationSize, FileAttributes,
        ShareAccess, CreateDisposition, CreateOptions, EaBuffer, EaLength);
}

要查看 gif 格式的结果,请单击下面的链接。

查看 .gif 格式的示例 (syscall-hook-example-1.gif)

图片描述

另外,您可以在 Windbg 中看到结果!
图片描述

8.17.讨论

是时候看看这部分的问题和讨论了,讨论的通常是关于开发 hypervisor 的问题和经验。 感谢 Petr 准备好这一部分。

1. VMX-root 模式下的IRQL是什么? 您是否尝试过在 VMX root-mode下使用 KeGetCurrentIrql() 并查看结果? 它返回图中的以下结果,不同的IRQL。
图片描述
- IRQL 只不过是 Cr8 寄存器,当 VM-exit时 Cr8 寄存器不会改变,因此,您的 KeGetCurrentIrql() 返回 VM-exit发生之前的 IRQL。

- 在 VM-root 模式下,“没有 IRQL”,因为 VMX 不知道 IRQL 等术语(这是 Microsoft 的东西),但实际上,HIGH_IRQL 是最接近 VMX-root 模式下的状态,因为中断被禁用

- 实际上,在 VMM 上下文中运行时,IRQL 要求并没有多大意义。 例如,即使您进入 PASSIVE_LEVEL ,从技术上讲,您出于所有意图和目的都处于 HIGH_LEVEL ,因为中断被禁用。

- 您可以 使用KeGetEffectiveIrql() 在VMX-root 模式下 ,它总是返回HIGH_LEVEL(该函数检查EFLAGS中的IF(中断标志)位是否已设置,如果没有,则返回HIGH_LEVEL,如果是,则返回相同的值如 KeGetCurrentIrql() ,当 VM-exit时,EFLAGS.IF 被清除,但 IF 只影响硬件中断,仍然会发生异常。

- 如果您在理解 VMM 中的 IRQL 方面仍然存在问题,那么 Alex 在 Hyperplatform 中回答了一些有趣的问题: https://github.com/tandasat/HyperPlatform/issues/3#issuecomment-231804839 试图解释为什么 vmx root -mode 就像 HIGH_IRQL 。 我尝试给他们添加一些解释。

2. 在 VMM 模式中,操作系统进行上下文切换是否安全?

- 当然不是。 所以你至少处于 DISPATCH_LEVEL IRQL 运行 ***(因为 Windows 安排所有线程以低于DISPATCH_LEVEL 的*** )。

3. 在 VMM 模式下“等待”对象安全吗?

- 当然不是,您将被上下文切换到另一个线程/空闲线程,该线程现在将作为 VMM 主机运行。 (意味着您等待某些对象,并且当另一个 vm-exit 发生时,您不再位于前一个线程中。)

4. 在 VMM 模式中接收 DPC 是否安全/可以?

- 再说一次,当然不是。 您至少处于 DISPATCH_LEVEL 的另一个原因。

5. 即使您愿意,您能收到 DPC 吗?

- 没有。 接收 DPC 需要中断,而 r/eflags 中的 IF 已关闭,因此本地 APIC 永远不会传送它。

6. 您会收到任何设备中断吗?

- 不,因为 EFLAGS.IF 已关闭。

7. 您想在VMM模式中途被中断吗?

- 也没有。 所以你至少在 MAX_DIRQL

8. 你会收到时钟中断吗?

- 不(这也是为什么你有时会遇到 CLOCK WATCHDOG BSOD 的原因)……所以你至少处于 CLOCK_LEVEL

9. 您会收到 IPI 吗?

- 不,因为 IF 已关闭,所以本地 APIC 永远不会发送它们。 您可能也不想在 VMM 主机内部运行 IPI…所以您至少处于 IPI_LEVEL 。 从技术上讲,因为您没有在处理 IPI,而是完全禁用了中断,所以您处于 IPI_LEVEL + 1,也称为 HIGH_LEVEL

10. 为什么 ExAllocatePoolWithTag 在 Vmx root 模式下不起作用?

- 换句话说,如果您调用 ExAllocatePoolWithTag ,并且这是 PAGED POOL,您可能会很不幸,这将需要分页,这需要阻塞您的线程,现在,其他一些线程将在 VMM 主机模式下运行...当然,你可能会很幸运,控制权会回到你身边,但这太疯狂了……如果你请求非分页池,它会“看起来起作用”……然后在一种情况下,将需要 TLB 刷新,这会发送IPI...无法交付...因此它会挂起。 ETC。

11. 我可以在 VMX root 模式下使用 Insert DPC 吗? 我使用 KeInsertQueueDpc (因为根据 MSDN,这个函数可以在任何级别调用)。

- 是和不是。 当您保证不会出现虚拟机退出冲突(这会以某种方式导致递归/死锁)时,这是可以的,但这很大程度上取决于用例。

- 出于演示目的,我不介意在“真实/生产”环境中使用 KeInsertQueueDpc ,我可能会从虚拟机管理程序注入 NMI,并在 NMI 处理程序中对 DPC 进行排队。

- 这是一个间接的方法,因此会稍微慢一些,但我认为这是一种通常更安全的方法......(我用这种方式)但是,我必须注意它不是万无一失的,因为我已经遇到了递归 NMI 注入和死锁也在 NMI 处理程序中。

- 正如我所说,没有灵丹妙药,当您尝试与底层操作系统通信时,总会有一些黑暗的角落。

12. 不允许使用 RtlStringCchLengthA 和 RtlStringCchLengthA 等函数,因为根据 MSDN,它的 IRQL 是 PASSIVE_LEVEL,所以我们不能在 VMX-Root 模式下使用它们? 我们应该做什么呢?

- 我们可以使用 sprintf (以及类似 sprintf 的函数) C std 库中的 。 使用起来很安全,因为它不分配任何内存。 AFAIK RtlString* 函数位于 PAGE 部分,因此它们可以被调出,如果您在调出它们时从 VMX-root 模式调用它们……。 你知道会发生什么;)

13. 我正在阅读有关 VPID (INVVPID) 的内容,这似乎对于 hvpp 和 hyperplatform 等管理程序以及我们的管理程序不可用? 我对吗? 我的意思是,在虚拟化已经运行的系统的虚拟机管理程序中是否有任何特殊情况,首选 INVVPID 而不是 INVEPT?

- 你是对的,invvpid 在我们的情况下通常是无用的。 可能有益的唯一情况 我能想到invvpid 是模拟“ invlpg ”指令,请参见 此处

- 简单地说, invept 将使所有 EPT 映射无效。 使用 invvpid ,您可以使 特定 guest(即底层操作系统)中的 地址无效。 我认为您知道缓存通常是如何工作的,但无论如何我都会尝试解释一下:使用 invept ,您会丢失guest的所有缓存,因此需要时间再次填充该缓存( INVEPT 后每次第一次内存访问都会很慢) 。

- 使用 invvpid ,缓存被保留,但唯一的单个地址无效,因此仅加载该地址会很慢,我真的想不出任何其他需要它的实际示例,除了 invlpg模拟。 上面提到的

14. 如果我们在 vmx root 中访问会导致 EPT 违规的地址,会发生什么情况?

这就像问“如果我们禁用分页并访问将导致页面错误的地址,会发生什么” EPT 是针对guest的,vmx-root 本质上是主机。 当您位于 vmx root 中时,不会发生 EPT 转换。 只能定期寻呼。 因此,您访问的地址是否会导致 EPT 违规并不重要,重要的是该地址在 vmx-root 的常规 CR3 页表中是否有效。

15. 如果我们想在 IDT 索引 > 32 的异常/中断上导致 vm-exit,该怎么办? 异常位图只是 VMCS 中的一个 32 位字段!

x86架构中只有32个例外。 其余为外部中断,由基于引脚的控制“ 外部中断退出 ”拦截。 这意味着您无法选择特殊中断来导致 vm-exit,但您可以配置基于引脚的控制以在每个中断的情况下导致 vm-exit。

16. 如果多个CPU同时尝试获取同一个自旋锁,哪个CPU首先获取自旋锁?

- 通常情况下,没有顺序 - 电子速度最快的 CPU 获胜:)。 内核确实提供了一种替代方案,称为排队自旋锁,它以 FIFO 为基础为 CPU 提供服务。 这些仅适用于 IRQL DISPATCH_LEVEL。 相关的API是KeAcquireInStackQueuedSpinLock和KeReleaseInStackQueuedSpinLock。 查看 WDK 文档以获取更多详细信息。

17. 我们使用DPC来传输消息,并且由于我们可能作为DPC的一部分在任意用户态进程中执行,那么为什么我们的消息跟踪工作没有问题?

- 它有效是因为我们 使用了METHOD_BUFFERED 在 IOCTL 中 。 通常,您必须在驱动程序条目中指定您需要一个缓冲方法。

1
2
// Establish user-buffer access method.
DeviceObject->Flags |= DO_BUFFERED_IO;

- 但在 IOCTL 的情况下,您已经在 IOCTL 代码中指定了此标志,如果您不熟悉 METHOD_BUFFERED ,这是 Windows 为您提供系统范围地址的一种方式,该地址在任何进程(内核模式)中都有效为什么我们可以从任意进程填充缓冲区并在 任意进程的Irp->AssociatedIrp.SystemBuffer 中填充地址。

- 使用 METHOD_BUFFERED 当然速度较慢,但它解决了此类问题,并且通常更安全。

18. 为什么我们在消息追踪中不使用APC而使用DPC?

- 在我们的例子中,我们可以使用 APC 代替 DPC,但是使用 DPC 给我们带来了更好的优先级,因为回调会 在DISPATCH_LEVEL 尽快 中执行。 APC 是特定于线程的,这意味着每当线程运行时,我们就有机会执行回调,而 DPC 是特定于处理器的,因此我们可以中断任何随机进程,因此速度更快。

- 另一个原因是 APC 是未记录的内核对象,而 DPC 是有记录的,因此这就是程序员更喜欢使用 DPC 的原因。

8.18.结论

我们到了这一部分的结尾,在这一部分中,我们看到了一些可以通过虚拟化已经运行的系统来实现的重要内容,例如隐藏钩子、系统调用钩子、事件注入、异常位图和我们的自定义 VMX Root 兼容消息跟踪,通过现在您应该能够在多种研究中使用您的虚拟机管理程序驱动程序并解决您的逆向工程问题。

在下一部分中,我们将研究一些高级虚拟化主题,例如 APIC 虚拟化以及许多其他内容,以构建稳定且有用的虚拟机管理程序。

希望你们喜欢,我们下一部分见。

8.19.参考

[1] 虚拟处理器 ID 和 TLB - ( http://www.jauu.net/2011/11/13/virtual-processor-ids-and-tlb/ )
[2] INVVPID — 基于 VPID 的无效翻译 - ( https://www.felixcloutier.com/x86/invvpid )
[3] INVPCID — 无效进程上下文标识符 - ( https://www.felixcloutier.com/x86/invpcid )
[4] 以下是 Spectre 和 Meltdown 补丁如何以及为什么会损害性能 - ( https://arstechnica.com/gadgets/2018/01/heres-how-and-why-the-spectre-and-meltdown-patches -会损害性能/
[5] vmxoff 路径真的安全/正确吗? -( https://github.com/tandasat/HyperPlatform/issues/3
[6] 第 5 天:VM-Exit 处理程序、事件注入、上下文修改和 CPUID 模拟 - ( https://revers.engineering/day-5-vmexits-interrupts-cpuid-emulation/ )
[7] 测试和设置 - ( https://en.wikipedia.org/wiki/Test-and-set )
[8] _interlockedbittestandset 内在函数 - ( https://docs.microsoft.com/en-us/cpp/intrinsics/interlockedbittestandset-intrinsic-functions?view=vs-2019 )
[9] 自旋锁和读写锁 - ( https://locklessinc.com/articles/locks/ )
[10] 暂停 - 旋转循环提示 - ( https://c9x.me/x86/html/file_module_x86_id_232.html )
[11] x86 中“PAUSE”指令的用途是什么? -( https://stackoverflow.com/questions/12894078/what-is-the- Purpose-of-the-pause-instruction- in-x86)
[12] x86暂停指令在spinlock中如何工作 以及 可以在其他场景中使用吗? -( https://stackoverflow.com/questions/4725676/how-does-x86-pause-instruction-work-in-spinlock-and-can-it-be-used-in-other-sc
[13] volatile 关键字简介 - ( https://www.embedded.com/introduction-to-the-volatile-keyword/ )
[14] 延迟过程调用 - ( https://en.wikipedia.org/wiki/Deferred_Procedure_Call )
[15] 逆向 DPC:KeInsertQueueDpc - ( https://repnz.github.io/posts/practical-reverse-engineering/reversing-dpc-keinsertqueuedpc/ )
[16] 转储 DPC 队列:HIGH_LEVEL IRQL 中的冒险 - ( https://repnz.github.io/posts/practical-reverse-engineering/dumping-dpc-queues/ )
[17] 第 3C 卷 – 第 31 章 –(31.5.1 确定 VMX 功能的算法) – ( https://software.intel.com/en-us/articles/intel-sdm )
[18] 第 3D 卷 – 附录 A.2 –(保留控件和默认设置) – ( https://software.intel.com/en-us/articles/intel-sdm )
[19] 将 WPP 跟踪添加到内核模式(Windows 驱动程序) – ( http://kernelpool.blogspot.com/2018/05/add-wpp-tracing-to-kernel-mode-windows.html )
[20] WPP 软件跟踪 – ( https://docs.microsoft.com/en-us/windows-hardware/drivers/devtest/wpp-software-tracing )
[21] TraceView – ( https://docs.microsoft.com/en-us/windows-hardware/drivers/devtest/traceview )
[22] 陷阱和中断有什么区别? –( https://stackoverflow.com/questions/3149175/what-is-the-difference- Between-trap-and-interrupt )
[23] 如何在命令行中禁用 Hyper-V? –( https://stackoverflow.com/questions/30496116/how-to-disable-hyper-v-in-command-line
[24] 在具有嵌套虚拟化的虚拟机中运行 Hyper-V – ( https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/user-guide/nested-virtualization )
[25] 虚拟机管理程序顶级功能规范 – ( https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/reference/tlfs )
[26] 实现 Microsoft Hypervisor 接口的要求 – ( https://github.com/Microsoft/Virtualization-Documentation/raw/master/tlfs/Requirements%20for%20Implementing%20the%20Microsoft%20Hypervisor%20Interface.pdf )
[27] 简单 Svm Hook 规范 – ( https://github.com/tandasat/SimpleSvmHook )
[28] x86 调用约定 – ( https://en.wikipedia.org/wiki/X86_calling_conventions )
[29] 异常 – ( https://wiki.osdev.org/Exceptions )
[30] Nt 系统调用表 – ( https://j00ru.vexillium.org/syscalls/nt/64/ )
[31] Win32k 系统调用表 – ( https://j00ru.vexillium.org/syscalls/win32k/64/ )
[32] KVA Shadow:缓解 Windows 上的 Meltdown – ( https://msrc-blog.microsoft.com/2018/03/23/kva-shadow-mitigating-meltdown-on-windows/ )
[33] HyperBone - 带钩子的简约 VT-X 虚拟机管理程序 – ( https://github.com/DarthTon/HyperBone )
[34] 通过扩展功能启用寄存器 (EFER) 进行系统调用挂钩 – ( https://revers.engineering/syscall-hooking-via-extended-feature-enable-register-efer/ )
[35] xdbg64 的 TitanHide – ( https://github.com/dotfornet/TitanHide/ )
[36] 系统服务描述符表 - SSDT – ( https://ired.team/miscellaneous-reversing-forensics/windows-kernel/glimpse-into-ssdt-in-windows-x64-kernel )
[37] DdiMon – ( https://github.com/tandasat/DdiMon )
[38] Gbhv - 简单的 x64 虚拟机管理程序框架 – ( https://github.com/Gbps/gbhv )
[39] 挂钩 SSDT(影子) – ( https://m0uk4.gitbook.io/notebooks/mouka/windowsinternal/ssdt-hook )
[40] DetourXS – ( https://github.com/DominicTobias/detourxs )
[41] 陷阱和中断有什么区别? –( https://stackoverflow.com/questions/3149175/what-is-the-difference- Between-trap-and-interrupt )


[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

最后于 4天前 被zhang_derek编辑 ,原因:
收藏
点赞3
打赏
分享
最新回复 (2)
雪    币: 5062
活跃值: (5272)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
jelasin 3 2024-3-31 17:35
2
0
太牛了,哥
雪    币: 865
活跃值: (1438)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
tian_chen 2024-4-1 09:01
3
0
大佬可以出AMD不...
游客
登录 | 注册 方可回帖
返回