前言

最近在研究 CE 下层的 DBVM(Dark Byte Virtual Machine)。

搞内核对抗的兄弟都知道,现在的反作弊(AC)对驱动的监控简直到了“丧心病狂”的地步——扫驱动列表、校验签名、监控设备对象(DeviceObject)、甚至 Hook IRP_MJ_DEVICE_CONTROL。传统的“写个驱动然后在 R0 搞事”的路子,风险越来越高。

既然我们已经玩到了虚拟化层(Ring -1),手里握着 Hypervisor,为什么还要在 Ring 0 这一层受气?

本文重点关注 DBVM 在 AMD 平台 上的实现,特别是:

  1. 虚拟化层如何接管系统(大管家)。
  2. 如何处理缺页实现 NPT Hook(手术刀)。
  3. (重点)如何利用 CPU 特性实现 Ring 3 直通 Ring -1 的无驱动通信(黑魔法)。

地图:核心源码结构

拿到源码,第一眼看目录结构可能会晕。对于 AMD 平台的 NPT Hook,其实只有几个文件是“核心中的核心”,其他的基本是辅助或者 Intel 相关的。

请死死盯住以下四个文件:

  1. vmm/vmeventhandler_amd.c【大管家】。AMD SVM 的 #VMEXIT 事件分发中心,所有的异常入口。
  2. vmm/nphandler.c【手术刀】。处理 NPT(Nested Page Tables)缺页异常的核心,隐形 Hook 就在这里做。
  3. vmm/vmcall.c & vmcallstructs.h【传声筒】。定义了 R-1 如何接收外部指令的协议。
  4. **vmm/main.c**:初始化入口,了解一下 VMM 启动流程即可。

下面我们逐个击破。


入口:vmeventhandler_amd.c 的分发逻辑

在 AMD 体系下,当 CPU 从 Guest(你的 Windows)退回到 Host(DBVM)时,会产生一个 #VMEXIT

vmeventhandler_amd.c 中,核心函数通常是一个巨大的 switch-case 结构(源码中可能被封装在 handleVMEvent_AMD 类似名字的函数里)。这是 VMM 的心脏跳动逻辑:

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
// 伪代码逻辑还原
switch (exitcode)
{
case VMEXIT_MSR:
// 拦截 MSR 读写,想做 Syscall Hook (MSR 0xC0000082) 可以在这里搞事
break;

case VMEXIT_NPF:
// 【重点】Nested Page Fault (NPT 缺页)
// 当 Guest 访问了 NPT 表中权限不足的物理页(例如:把页面设为不可写/不可执行)时触发。
// 这是实现“内存隐身”和“无痕 Hook”的必经之路。
handleNestedPagingFault(currentcpuinfo, ...);
break;

case VMEXIT_VMMCALL:
// 【重点】处理 Guest 发来的指令
// 这就是我们和 VMM 通信的接口
handleVMCall(currentcpuinfo, vmregisters);

// 处理完必须跳过指令,否则死循环
currentcpuinfo->vmcb->RIP += 3;
break;

// ... 其他异常处理 (CPUID, IOIO, etc.)
}
````

**关键点:**
如果你只关心 Hook,你不需要关心太多其他的,死死盯住 `VMEXIT_NPF`。当你在 NPT 表里把某个物理页的权限改成“只读”或“不可执行”,CPU 访问它时就会炸出这个异常,控制权瞬间反转到 Hypervisor 手中。

-----

# 手术刀:nphandler.c 里的“偷天换日”

打开 `nphandler.c`,最重要的函数是 `handleNestedPagingFault`。这里是 DBVM 实现“影子内存”(Cloaking)的地方。

### 1\. 核心逻辑

看代码逻辑,它大概干了这几件事:

1. **获取故障地址**:
从 VMCB(AMD 的虚拟机控制块)里读取 `EXITINFO2`,这是 Guest 试图访问的物理地址(GPA)。

2. **检查监视列表**:
代码里有一行 `ept_handleWatchEvent(...)`。
*注意*:虽然名字带 `ept`(Intel 术语),但作者在 AMD 代码里也混用了这个名字。这行代码会检查当前访问的地址是不是我们“下断点”或者“Hook”的地址。

3. **影子页面切换 (Cloaking/Shadow Walker)**:
如果触发缺页的是我们隐藏的代码页,DBVM 需要在这里做一个“偷天换日”的操作(MTF, Monitor Trap Flag):

* **读操作** -\> 临时修改 NPT,使其指向**原始页面**(看起来没被修改,过签名校验)。
* **执行操作** -\> 临时修改 NPT,使其指向**Hook 页面**(执行我们的 Shellcode)。

### 2\. AMD 的痛点

AMD 的 NPT 和 Intel EPT 有个历史遗留区别:AMD NPT **不支持“Execute Only”(仅执行)** 权限。
这意味着你不能简单地设置一个页面为“可执行但不可读”。
因此,DBVM 在 AMD 上必须采用 **缺页 -\> 切换映射 -\> 单步执行 -\> 恢复映射** 的复杂状态机。

代码片段里的 `NPMode1CloakRestoreCallback` 就是干恢复工作的。它会修改 NPT 表项(`data->npentry`),重新把 P(Present)、RW(Read/Write)、US(User/Supervisor)位填好,确保下一次访问还能触发异常,维持 Hook 状态。

-----

# 实战:构建通信链路

这部分是本文的精华。我们将从底层的汇编实现,到中间的协议定义,再到上层的 C++ 封装,完整还原这条通信链路。

## 1. 汇编:暗号与握手

DBVM 为了防止误触,设计了一套**密码校验机制**。

**VMCALLWrapper.asm:**

```asm
.code

; DBVM 访问密码 (硬编码的 Magic Number)
; 这两个值必须与 DBVM 内部的校验值匹配,否则 VMM 会直接忽略调用
DBVM_PASS1 EQU 0db23d0c1h
DBVM_PASS3 EQU 08118302bh

; =================================================================
; UINT64 dbvm_vmcall_amd(void* input)
; 遵循 x64 调用约定,参数 input 在 RCX 中
; =================================================================
dbvm_vmcall_amd PROC
push rbx

; 【关键协议】
; RAX = 参数结构体指针 (User Mode Virtual Address)
; RDX = 密码 1
; RCX = 密码 3

mov rax, rcx ; 将输入参数 input (rcx) 转移到 rax
mov rdx, DBVM_PASS1 ; 装载密码 1
mov rcx, DBVM_PASS3 ; 装载密码 3

; 触发 AMD VMMCALL (Opcode: 0F 01 D9)
; CPU 瞬间陷入 Ring -1
BYTE 00Fh, 001h, 0D9h

; VMM 处理完后,会修改 Guest 的 RAX 作为返回值
; 所以函数返回时 RAX 就是结果

pop rbx
ret
dbvm_vmcall_amd ENDP

实战细节:

  1. 寄存器约定:DBVM 并不像常规函数那样用 RCX 传参,而是指定 RAX 存放数据指针,RDX 和 RCX 存放 Magic Number。
  2. 密码校验:如果在 Ring 3 随便执行 vmmcall 而没有填充正确的 RDX/RCX,VMM 会认为这是无效调用或者 Guest OS 自己的正常操作,直接忽略。

2. 协议层:定义 VMCALL 结构体

解决了握手问题,接下来要定义传输的数据包。

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
#pragma pack(push, 1)

// 基础命令头
struct VMCALL_BASIC {
DWORD structsize; // 结构体大小,用于 VMM 校验
DWORD level2pass; // 二级密码 (DBVM_PASSWORD2 = 0x921d708a)
DWORD command; // 命令号
};

// 开启内存监视 (NPT Hook) 的参数结构
// 对应 Command ID: VMCALL_WATCH_WRITES (41) / VMCALL_WATCH_READS (42)
struct VMCALL_WATCH_INPUT {
DWORD structsize;
DWORD level2pass;
DWORD command;
UINT64 PhysicalAddress; // 目标物理地址 (PA)
UINT64 UserModeLoop; // 用户态死循环地址 (用于拦截后暂存线程)
UINT64 KernelModeLoop; // 内核态死循环地址
DWORD Size; // 监控范围 (4096)
DWORD Options; // 选项 (如 EPTO_LOG_ALL)
DWORD MaxEntryCount; // 缓冲区最大条目数
INT32 ID; // [Out] 返回的 WatchID
};

// 获取日志数据的参数结构
// 对应 Command ID: VMCALL_WATCH_RETRIEVELOG (43)
struct VMCALL_RETRIEVE_INPUT {
uint32_t structsize;
uint32_t level2pass;
uint32_t command;
uint32_t ID; // WatchID
uint64_t resultsBuffer; // Ring 3 接收缓冲区的地址 (VA)
uint32_t bufferSize;
uint32_t copied; // [Out] 实际拷贝的字节数
};

#pragma pack(pop)

实战细节:

  • UserModeLoop:这是一个极其精妙的设计。当 NPT 触发 Hook 时,DBVM 无法长时间挂起 Guest(会导致看门狗蓝屏)。DBVM 的做法是将当前 CPU 核心的 RIP 指向0xCC指令,让线程在 Ring 3 “空转”,直到我们处理完毕再恢复。

3. 应用层:DBVMWatcher 的封装

初始化与环境检测:

1
2
3
4
5
6
7
8
9
10
11
12
DBVMWatcher::DBVMWatcher() : m_watchID(-1) {
int cpuInfo[4] = { -1 };
__cpuid(cpuInfo, 0);
// 判断 Vendor ID 是否为 "AuthenticAMD"
m_isAMD = (cpuInfo[1] == 0x68747541);
}

void DBVMWatcher::ExecuteVMCALL(void* input) {
// 根据 CPU 类型调用对应的汇编 Wrapper
if (m_isAMD) dbvm_vmcall_amd(input);
else dbvm_vmcall_intel(input);
}

开启监控 (Start):

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
bool DBVMWatcher::Start(DWORD pid, UINT64 address, DWORD size, int type, DWORD options) {
// 1. 获取目标物理地址 (PA)
// 虽然 VMM 可以帮我们转译 VA,但在开启 NPT Hook 时,
// 我们通常需要明确指定要 Hook 哪个物理页。
UINT64 phyAddr = 0;
if (!ProcessHandler::getInstance().GetPhysicalAddress(address, phyAddr)) return false;

// 2. 对齐页边界 (4KB)
if (options & EPTO_LOG_ALL) {
phyAddr &= ~0xFFFULL;
size = 4096;
}

// 3. 构造命令包
VMCALL_WATCH_INPUT input = { 0 };
input.structsize = sizeof(input);
input.level2pass = DBVM_PASSWORD2; // 0x921d708a
input.command = (type == 0) ? VMCALL_WATCH_WRITES : VMCALL_WATCH_READS;
input.PhysicalAddress = phyAddr;
// ... 设置 UserModeLoop 等参数 ...

// 4. 发射!
ExecuteVMCALL(&input);

// 5. 获取 WatchID
m_watchID = input.ID;
return (m_watchID != -1);
}

高效数据回传 (Poll): 传统的内核通信常用事件(Event)通知,但 R3 直通 R-1 没有这种机制。DBVM 采用主动轮询模式。R3 程序定期发送 RETRIEVE_LOG 命令,VMM 将缓冲区里的 Hook 记录直接 memcpy 到 R3 的内存中。

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
std::vector<PageEventBasic> DBVMWatcher::Poll() {
std::vector<PageEventBasic> results;
if (m_watchID == -1) return results;

// 准备一块 R3 内存作为缓冲区
const int MAX_FETCH = 100;
size_t bufSize = sizeof(PageEventListDescriptor) + MAX_FETCH * sizeof(PageEventBasic);
std::vector<uint8_t> buffer(bufSize, 0);

VMCALL_RETRIEVE_INPUT input = { 0 };
input.command = VMCALL_WATCH_RETRIEVELOG;
input.ID = m_watchID;
input.resultsBuffer = (UINT64)buffer.data(); // 直接传 vector 的数据指针 (VA)
input.bufferSize = (DWORD)bufSize;

// VMM 会利用当前 CR3,将数据写入 buffer.data() 指向的内存
ExecuteVMCALL(&input);

// 解析返回数据
if (input.copied > 0) {
auto* header = (PageEventListDescriptor*)buffer.data();
auto* entries = (PageEventBasic*)(buffer.data() + sizeof(PageEventListDescriptor));
for (DWORD i = 0; i < header->numberOfEntries; i++) {
results.push_back(entries[i]);
}
}
return results;
}

避坑指南

虽然“无驱动通信”很爽,但在魔改源码时有几个坑必须注意:

  1. 多核并发炸裂
    vmmcall 是在当前核心处理的。如果你的 R3 程序开了多线程在不同核心上狂发指令,VMM 里的全局链表(比如 Hook 列表)必须加自旋锁(Spinlock),否则分分钟 BSOD。

  2. 看门狗(Watchdog)
    不要在 vmcall.c 的处理函数里做太耗时的操作(比如暴力搜索全内存)。VMM 运行时,Guest OS 是被冻结的。如果你卡了 5 秒以上,Windows 的 CLOCK_WATCHDOG_TIMEOUT 蓝屏就会教做人。

  3. Intel 区别
    本文针对 AMD。Intel 的 VMCALL 同样会触发 VMEXIT,但部分 Hypervisor 实现可能会在 VMX 层面注入 #UD 给 Ring 3。不过 DBVM 的源码逻辑在 Intel 上通常也是开放的,原理类似。


总结

通过分析 DBVM 源码,我们发现:

  1. vmeventhandler_amd.c 是入口,不检查 CPL 是最大的利用点。
  2. nphandler.c 是核心,利用 NPT 缺页实现隐形 Hook。
  3. vmcall.c 提供了通信协议,配合 CPU 特性,我们完全可以丢掉 Ring 0 驱动,实现 Ring 3 -> Ring -1 的降维打击。

这才是虚拟化对抗的精髓所在:跳出操作系统的规则,制定自己的规则。