前言
最近在研究 CE 下层的 DBVM(Dark Byte Virtual Machine)。
搞内核对抗的兄弟都知道,现在的反作弊(AC)对驱动的监控简直到了“丧心病狂”的地步——扫驱动列表、校验签名、监控设备对象(DeviceObject)、甚至 Hook IRP_MJ_DEVICE_CONTROL。传统的“写个驱动然后在 R0 搞事”的路子,风险越来越高。
既然我们已经玩到了虚拟化层(Ring -1),手里握着 Hypervisor,为什么还要在 Ring 0 这一层受气?
本文重点关注 DBVM 在 AMD 平台 上的实现,特别是:
- 虚拟化层如何接管系统(大管家)。
- 如何处理缺页实现 NPT Hook(手术刀)。
- (重点)如何利用 CPU 特性实现 Ring 3 直通 Ring -1 的无驱动通信(黑魔法)。
地图:核心源码结构
拿到源码,第一眼看目录结构可能会晕。对于 AMD 平台的 NPT Hook,其实只有几个文件是“核心中的核心”,其他的基本是辅助或者 Intel 相关的。
请死死盯住以下四个文件:
vmm/vmeventhandler_amd.c:【大管家】。AMD SVM 的 #VMEXIT 事件分发中心,所有的异常入口。
vmm/nphandler.c:【手术刀】。处理 NPT(Nested Page Tables)缺页异常的核心,隐形 Hook 就在这里做。
vmm/vmcall.c & vmcallstructs.h:【传声筒】。定义了 R-1 如何接收外部指令的协议。
- **
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: break; case VMEXIT_NPF: handleNestedPagingFault(currentcpuinfo, ...); break;
case VMEXIT_VMMCALL: handleVMCall(currentcpuinfo, vmregisters); currentcpuinfo->vmcb->RIP += 3; break; } ````
**关键点:** 如果你只关心 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
|
实战细节:
- 寄存器约定:DBVM 并不像常规函数那样用 RCX 传参,而是指定 RAX 存放数据指针,RDX 和 RCX 存放 Magic Number。
- 密码校验:如果在 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; DWORD level2pass; DWORD command; };
struct VMCALL_WATCH_INPUT { DWORD structsize; DWORD level2pass; DWORD command; UINT64 PhysicalAddress; UINT64 UserModeLoop; UINT64 KernelModeLoop; DWORD Size; DWORD Options; DWORD MaxEntryCount; INT32 ID; };
struct VMCALL_RETRIEVE_INPUT { uint32_t structsize; uint32_t level2pass; uint32_t command; uint32_t ID; uint64_t resultsBuffer; uint32_t bufferSize; uint32_t copied; };
#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); m_isAMD = (cpuInfo[1] == 0x68747541); }
void DBVMWatcher::ExecuteVMCALL(void* input) { 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) { UINT64 phyAddr = 0; if (!ProcessHandler::getInstance().GetPhysicalAddress(address, phyAddr)) return false;
if (options & EPTO_LOG_ALL) { phyAddr &= ~0xFFFULL; size = 4096; }
VMCALL_WATCH_INPUT input = { 0 }; input.structsize = sizeof(input); input.level2pass = DBVM_PASSWORD2; input.command = (type == 0) ? VMCALL_WATCH_WRITES : VMCALL_WATCH_READS; input.PhysicalAddress = phyAddr;
ExecuteVMCALL(&input);
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;
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(); input.bufferSize = (DWORD)bufSize;
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; }
|
避坑指南
虽然“无驱动通信”很爽,但在魔改源码时有几个坑必须注意:
多核并发炸裂:
vmmcall 是在当前核心处理的。如果你的 R3 程序开了多线程在不同核心上狂发指令,VMM 里的全局链表(比如 Hook 列表)必须加自旋锁(Spinlock),否则分分钟 BSOD。
看门狗(Watchdog):
不要在 vmcall.c 的处理函数里做太耗时的操作(比如暴力搜索全内存)。VMM 运行时,Guest OS 是被冻结的。如果你卡了 5 秒以上,Windows 的 CLOCK_WATCHDOG_TIMEOUT 蓝屏就会教做人。
Intel 区别:
本文针对 AMD。Intel 的 VMCALL 同样会触发 VMEXIT,但部分 Hypervisor 实现可能会在 VMX 层面注入 #UD 给 Ring 3。不过 DBVM 的源码逻辑在 Intel 上通常也是开放的,原理类似。
总结
通过分析 DBVM 源码,我们发现:
- vmeventhandler_amd.c 是入口,不检查 CPL 是最大的利用点。
- nphandler.c 是核心,利用 NPT 缺页实现隐形 Hook。
- vmcall.c 提供了通信协议,配合 CPU 特性,我们完全可以丢掉 Ring 0 驱动,实现 Ring 3 -> Ring -1 的降维打击。
这才是虚拟化对抗的精髓所在:跳出操作系统的规则,制定自己的规则。