Skip to main content

Command Palette

Search for a command to run...

记一次Introcore 内核级 Bug 调试记录:从 PUSH 指令到页表模拟崩溃

Updated
•3 min read•View as Markdown

在虚拟机内省(VMI)的底层开发中,稳定性往往比功能更难攻克。最近在对 Windows 7 进行监控实验时,Bitdefender 的 HVMI (Introcore) 引擎频繁触发致命崩溃。错误指向了页表写入模拟逻辑。

通过对 hvmid 进程进行深度的 GDB 挂载调试,我还原了一个关于指令建模、地址不匹配以及异常处理机制的完整 Bug 链路。


1. 现象:内省引擎的“自杀”

在监控环境下,Introcore 抛出两类致命日志并导致守护进程崩溃:

[ERROR] Access at 79c04255 spills in the next entry, size 8, instruction 'PUSH'
[ERROR] IntHookPtwEmulateWrite failed: 0xe1000508
[ERROR] First operand is not memory for PT instruction, type is 1!
[DEBUGGER] IntEnterDebugger called from introcore/src/guests/hooks/mem/hook_ptwh.c:115
Introspection engine fatal error, shutting down

初步线索:

  • 错误码 0xe1000508 即 INT_STATUS_NOT_SUPPORTED。

  • 崩溃点位于页表写入模拟器(PT Emulator),触发指令是 PUSH。

  • 现象:模拟器认为当前指令不是“合法的页表写”,但 Introcore 将此不匹配视为 Fatal 错误并关闭了引擎。


2. GDB 调试:剥茧抽丝定位根因

第一步:定位指令模拟失败现场

在 decoder.c:2764 断点捕获现场:

(gdb) break introcore/src/decoder.c:2764
(gdb) continue
# 命中断点
(gdb) p gVcpu->Instruction.Mnemonic         --> "MOV" (或 PUSH)
(gdb) p gVcpu->Instruction.Operands[0].Type  --> 1 (ND_OP_REG)

结论: IntDecEmulatePTWrite 硬性要求 Operand[0] 必须是 ND_OP_MEM(显式内存写)。现场指令由于操作数是寄存器,必然返回 NOT_SUPPORTED。

第二步:追溯调用链

通过 bt (backtrace) 确认为何普通的写入会进入 PT 预处理:

#0 IntDecEmulatePTWrite
#1 IntHookPtwEmulateWrite (hook_ptwh.c:91)
#2 IntHandleMemAccess (callbacks.c:487)
#3 IntHandleEptViolation (...)

这证明:还没进入任何具体业务回调,引擎就在 IntHandleMemAccess 中尝试将这次写当成“页表写”去模拟。

第三步:还原 PTM 结构,发现“误伤”

回到 IntHandleMemAccess 查看命中的 Hook:

(gdb) up 2
(gdb) p/x pHook->Header.Flags       --> 0x8000000 (HOOK_FLG_PAGING_STRUCTURE)
(gdb) p pHook->Callback             --> IntHookPtmWriteCallback

这是一个 PTM (Page Table Monitoring) 全局 Hook。我进一步检查了 PTM 的内部条目表:

# 检查物理地址偏移
(gdb) p/x PhysicalAddress           --> 0x187f68 
(gdb) set $pt = (HOOK_PTM_TABLE*)pHook->Header.Context
(gdb) set $off = (0x187f68 & 0xfff) >> 3
(gdb) p $off                        --> 493
# 验证该 Entry 是否有回调挂载
(gdb) p $pt->Entries[493].Flink == &$pt->Entries[493]
--> $1 = true (链表为空!)

真相大白: 写入发生在偏移 493,但该条目上没有任何监控回调。PTM 原本规定空 Entry 应直接忽略,但由于 callbacks.c 的预处理逻辑无差别介入,导致了崩溃。


3. 指令解码的深水区:PUSH 与隐式写入

即使解决了过滤问题,PUSH 指令本身在解码器中的建模也是一个巨大的坑。

建模不匹配

在 HVMI 的解码器里,PUSH 被建模为两个操作数:

  • Operand[0]: 源(寄存器、立即数或内存)。

  • Operand[1]: 目的内存(即堆栈 [RSP])。

模拟器默认从 Operand[0] 取值并认为它是内存目标。这导致了两个问题:

  1. 类型报错:若 PUSH %rax,Operand[0] 是寄存器,直接触发上述 type is 1 报错。

  2. 符号扩展问题:在 x64 下,PUSH imm32 会向栈写入 8 字节。如果只按 Operand[0](4字节)处理,模拟出的值高位会丢失。

地址不匹配与 PtEmuBuffer 异常

在调试中发现,引擎在处理流程中地址不统一:

compiler_depend.ts : 443 [ERROR] IntHookPtwProcessWrite failed at 79c04000: 0xe1000501

根因是 gVcpu->PtEmuBuffer.Valid 为空。由于 PT 预处理阶段使用的地址与后续回调使用的地址(有时是页基址,有时是 ExitGpa)不一致,导致缓冲区填充失败。Introcore 的逻辑非常强硬:如果缓冲区无效,直接 Fatal 退出。


4. 极限挑战:跨页溢出与 MOVDQU

当 Guest OS 执行 16 字节写入(如 MOVDQU)或写入刚好跨越两个 PTE 边界(Spills)时,模拟器会彻底失效:

  • 报错:Unsupported access size: 16

  • 报错:spills in the next entry, size 8

在 callbacks.c 的原始代码中,对于这类 NOT_SUPPORTED 错误,引擎没有选择降级,而是直接调用 IntBugCheck(),这种“过度防御”导致了宿主机守护进程的频繁殉爆。


5. 调试总结:根因

通过这一连串的 GDB 现场分析,问题的根因可以总结为以下三点:

  1. 预处理逻辑过于强硬:无论 Entry 是否被监控,只要命中监控页就强制模拟;且对模拟失败(如不支持的指令)的处理是致命的,而非降级。

  2. 指令建模不全:对 PUSH 等隐式内存写指令的操作数处理逻辑缺失。

  3. 地址一致性缺失:在处理链条(范围判断 -> PT 预处理 -> 回调入参)中没有统一使用真实写入地址(ExitGpa),导致缓冲区状态异常。


6. 官方回应:揭开“僵尸页表”的真相

在定位到技术细节后,我与 HVMI 项目的维护者(包括原架构师)进行了深入沟通。官方的回复虽然确认了项目已归档(不再维护),但为这一系列怪异现象提供了决定性的架构级解释:

"HVMI may not see that a page-table has been freed and later on reallocated as a different kind of page (stack page, free page, etc.). HVMI will still think it's a page-table... HVMI would only see random writes using random instructions (such as PUSH or MOVDQU)..."

这段回复揭示了问题的根本原因(Root Cause)——状态脱节(State Desynchronization)。

“僵尸页表” (The Zombie Page Table) 现象

  1. 信号丢失:由于 HVMI 的初始化时机(如在 Guest 运行后介入,但实际测试仍然错误)或高并发下的 EPT 事件丢失,HVMI 漏掉了操作系统“释放页表”的信号。

  2. 认知偏差:HVMI 数据库中,物理页 0x187000 仍被标记为页表,因此 EPT Hook 依然存在。

  3. 地址重用:操作系统实际上已经回收了该页,并将其重新分配给内核栈(Stack)使用。

  4. 冲突爆发:当 OS 在栈上执行正常的 PUSH 操作时,HVMI 拦截了写入,却用页表的逻辑去校验它。

结论: 报错 Unsupported instruction: PUSH 其实是正确的。因为在一个正常的系统中,绝对不会有人用 PUSH 去修改真正的页表。HVMI 崩溃是因为它发现自己陷入了“不可知状态”。

遗留问题

在尝试放开部分监控后,虽然崩溃停止了,但使用 sudo xl vncviewer win7 交互时会导致主机卡死。这可能意味着:虽然引擎不再崩溃,但由于跳过了某些 PT 写入的模拟,导致 Guest OS 陷入了指令重试死循环,或者在高频的异常风暴中占满了宿主机资源。

底层安全的开发就像在冰面上行走,每一次对“位置”指令的妥协,都可能在另一个维度引发风暴。

More from this blog

记一次基于 Linux 5.15.0-139 内核源码中ebpf辅助函数解惑学习

在 eBPF 开发中,辅助函数(Helper Functions)是连接沙箱代码与内核原生的唯一桥梁, 辅助函数既不是动态加载的插件,也不是脆弱的符号引用。它们是在内核启动那一刻,由引导代码根据链接脚本的‘施工图’,强行焊接在内存只读区域的物理基石。 本文起源于我在开发ebpf程序时思考辅助函数到底是什么?它和内核提供的其他函数有什么区别? 。 本文记录了我是如何通过源码分析、逆向思考和底层调试,一步步打通 eBPF 辅助函数逻辑的全过程。所有的代码均出自于5.15.0-139内核源码 第一...

Feb 6, 20265 min read10

记一次从零设计 Vmi 代码注入引擎:劫持、执行、清理

0.摘要 在虚拟化安全的研究中,如何在尽量不修改 Guest 内核的前提下执行自定义代码,一直是一个有趣且充满挑战的话题。本文尝试分享一套基于 Hypervisor 的完整代码注入思路,涵盖了从劫持 Guest 执行流、基于 EPT 的透明注入、双向通信协议到生命周期管理的全过程。 仓库位置:[mini-int3-injector].(https://github.com/ania0-art/mini-int3-injector). 1. 引言:一点背景与思考 1.1 传统方案遇到的一些挑战 ...

Jan 25, 20264 min read36

once2twice

9 posts

something about hook and rdma