简介:这份 ZIP 资料围绕 Ring0 级内核调试器 Hyperdbg 展开,面向具备操作系统与驱动开发基础、希望进入内核调试与系统分析领域的学习者和开发者。它针对内核级事件跟踪、断点设置、内存与寄存器查看等调试需求,提供一套可供研读和编译的源码材料,帮助读者理解 Ring0 调试器如何在最高特权层与操作系统内核交互。压缩包共 93 个文件,体积约 212KB,以 C 源码和头文件为主体,辅以汇编、makefile、构建脚本及说明文档,整体呈现轻量但模块划分明确的学习型代码库。已有 553 人浏览学习,说明该主题在系统底层技术圈具有一定关注度。读者可从中获得调试器核心模块的组织方式、内核交互相关实现思路、构建入口与配套说明,便于对照操作系统原理梳理 Ring0 调试的关键机制,也适合作为研究内核调试工具架构、理解底层调试流程的参考材料。
1. 现场那个蓝屏,让我把调试器换了个思路
客户现场那台机器上,一个自研驱动在特定 ioctl 下发后偶发蓝屏,dump 里只留下一个我怀疑但没法证实的调用栈。想复现就得双机内核调试,可现场没有第二台机器,没有串口线,管理员也不让反复重启。我最后是在本机把 HyperDbg 跑起来解决的。它是一个基于硬件虚拟化(Intel VT-x + EPT)的 ring0 内核调试器,把自己插在操作系统之下,目标内核每条被挂钩的指令先被它的 VMM 接管,断点、内存写监控、MSR 访问都在同一台机器上完成。做驱动开发、内核态行为分析、逆向验证的人,尤其是手上只有一个环境又不想反复重启的,这个工具值得花半天时间摸清楚。
2. 虚拟化层才是内核调试的底座:三层结构与命令边界
2.1 为什么 ring0 调试绕不开虚拟化这一层
内核态代码跑在 ring0,用户态那套调试手段连门都摸不到。常规做法无非三条:双机内核调试、本地只读内核调试、软件 hook。双机调试能力最全,但要一根串口线或者一条专用网络通道,加上另一台机器,断下来之后整个系统冻结,调试节奏是「断—看—继续」;本地内核调试只能看不能断,很多结构体还得靠猜;软件 hook 改 SSDT 或者往函数头写 inline hook,改的是目标内存,系统自带的内核代码完整性校验、驱动自校验、CRC 比对,随便撞上一个就翻车。
HyperDbg 走的是第四条路:把当前系统整个降级成 guest,自己跑在 VMX root 模式。CPU 每次执行到需要拦截的点,硬件触发一次 VM-exit,控制权先落到 VMM 手里,VMM 判断是继续还是交给调试器。关键在于,EPT hook 并不改写目标函数的第一条指令,它改的是扩展页表里的映射关系——把那一页映射到另一份影子页,影子页里放一条会触发 VM-exit 的指令。目标内核从自己的视角看,代码段内容一个字节都没变,内存完整性校验、CRC 自校验都读不出异常。这就是它跟传统 hook 工具最本质的差别,也是为什么它能长时间挂在一个高频函数上而不被目标系统发现。
代价同样明确:它必须独占 VT-x。只要系统里已经有别的组件在用虚拟化(基于虚拟化的安全功能、内存完整性保护、内置的虚拟化平台、任何一款虚拟机软件),VMM 就加载不上去,这是绝大多数人第一次使用时唯一会遇到的障碍,后面第 5 章会细说。另外它版本迭代很快,命令语法在不同 release 之间会调整,我每次换版本都先help一遍目标命令,这个习惯救过我好几次。
2.2 VMM、内核模块与 CLI 三层分工
这套工具拆成三层,各自管一摊,顺序不能乱:
用户态的hyperdbg-cli是命令行前端,负责解析你敲的命令、跑脚本引擎、做符号解析和反汇编。它本身不碰内核,所有动作都通过设备对象往下发。
中间是内核调试模块(一个驱动),负责创建设备对象、收发 IOCTL、做内存读写、枚举进程线程、管理断点列表。它跑在目标系统的内核态里,是 VMM 和用户态之间的桥。
最底下是 VMM 模块,跑在 VMX root 模式,管 VMCS、管 EPT 表、管事件分发。透明模式、EPTHook、MSR 拦截这些都在这一层实现。
加载顺序必须在命令里体现出来:先load vmm,再load。内核模块起来的时候要和 VMM 建立一条通信通道(共享内存加 VMCALL 接口),VMM 不在,模块加载会直接失败,报的错还比较含糊,看起来像是驱动加载失败。我见过有人把这两条命令顺序调过来,然后花一晚上查签名问题。
2.3 断点命令与事件命令,什么时候用哪个
这套工具的命令分成两大类:断点类和事件类。断点类走传统路子,bp往目标地址写 int3,改内存,适合临时看一眼;事件类走硬件虚拟化,不改可见内存,适合长时间挂。我按实际使用频率整理了一张对照表:
| 命令 | 拦截机制 | 是否改写目标内存 | 典型用途 | 需要注意 |
|---|---|---|---|---|
bp | 写 int3 | 是 | 临时断点、单步跟进 | 会被内存校验发现,别长期挂 |
!epthook | EPT 执行拦截 | 否 | 函数入口长期挂钩 | 地址落在大页上会失败,需换!epthook2 |
!epthook2 | EPT 大页拆分后拦截 | 否 | 同上,处理 2MB 页 | 拆页有开销,别在热路径滥用 |
!monitor | EPT 读写执行监控 | 否 | 查「谁改了这个结构体」 | 监控范围越小越好,整页监控会卡 |
!syscall | 系统调用进出拦截 | 否 | 抓某进程的调用序列 | 全系统挂会显著拖慢 |
!msrread/!msrwrite | MSR 访问拦截 | 否 | 驱动初始化阶段的 MSR 操作 | 编号要写准,写错会静默不触发 |
!crwrite | 控制寄存器写入拦截 | 否 | 查谁改了 CR0/CR3/CR4 | 系统启动阶段触发极频繁 |
!cpuid | CPUID 指令拦截 | 否 | 反虚拟机检测分析、特性探测 | 高频,务必加 pid 或核过滤 |
!exception | 异常分发拦截 | 否 | 断点异常、缺页异常的来源分析 | 容易和高频异常撞车 |
跟双机调试比,优势是单机、能脚本过滤、能只挂某一个进程;劣势是它必须独占虚拟化、蓝屏风险和版本稳定性都比成熟的内核调试器高。我的用法是:先在双机调试里把问题范围缩到一个函数,再用 HyperDbg 在本机做长时间观察,两边不冲突的时候这么配效率最高。
3. 从零把 VMM 挂上去:前置检查、模块加载与 status 自检
3.1 上机前的五项检查,少一项都白忙
这一步我吃过亏,所以现在固定按清单走。硬件层要确认 CPU 支持 VT-x 且固件里已经打开;系统层要确认没有别的组件占着虚拟化;驱动层要能加载未签名驱动;系统版本要是 x64;最后确认你手上这个发布包对应当前系统 build。
# 1. 看固件虚拟化和系统虚拟化状态,重点看是否出现「已检测到虚拟机监控程序」 systeminfo | findstr /i "Hyper-V 虚拟化 virtualization hypervisor" # 2. 关掉系统自带的 hypervisor 启动项(内置虚拟化平台、WSL2 都依赖它) bcdedit /set hypervisorlaunchtype off # 3. 打开测试签名,方便加载未签名的调试驱动 bcdedit /set testsigning on # 4. 重启之后确认这两项真的生效了 bcdedit /enum {current}第 1 条命令的输出是判断依据:如果显示「已检测到虚拟机监控程序,将不显示 Hyper-V 所需的功能」,说明系统里已经有一层 hypervisor 在跑,VMM 一定加载不上。第 2 条把启动项关掉,重启后那层东西才不会起来。第 3 条打开测试签名,右下角会出现水印,这是正常的,调试完记得关。第 4 条不是可选项,我见过bcdedit /set敲完没重启就直接测,然后怀疑发布包有问题的情况。
还有一项在设置界面里:系统设置 → 隐私和安全性 → 设备安全性 → 内核隔离 → 内存完整性,要关掉。这个开关依赖虚拟化,打开状态下 VT-x 被它占着,VMM 加载会失败,而且失败信息不会告诉你是它占的。
3.2 发布包的目录结构和每个文件的作用
解压之后先别急着双击,把目录扫一遍,知道自己在加载什么:
| 路径 / 文件 | 作用 | 备注 |
|---|---|---|
hyperdbg-cli.exe | 用户态命令行前端 | 必须以管理员身份启动 |
| VMM 二进制 | 虚拟化层,跑在 VMX root | 和 CLI 同目录,由load vmm加载 |
hyperkd.sys | 内核调试模块 | 由load加载,需要能加载未签名驱动 |
| 脚本目录 | 安装、卸载、驱动签名相关脚本 | 常见是 PowerShell 或批处理,按包内实际文件名执行 |
| 示例脚本目录 | 一批.dsh事件脚本示例 | 改一改就能当模板用 |
| 符号缓存目录 | 系统模块的 pdb 缓存 | 第一次解析符号会联网下载,比较慢 |
发布包里带安装脚本,常见做法是管理员权限跑一遍安装脚本,它负责把驱动注册成服务、把文件放到固定目录、处理签名。我一般不用脚本一步到底,而是先看一眼脚本里做了什么,再决定手动做还是让它做——驱动类工具装上去容易,卸不干净会一直占着虚拟化,后面所有调试都做不了。
3.3 加载 VMM 与首次 status 校验
下面这段是每次上机的固定动作,顺序照抄就行:
# 以管理员身份启动命令行前端,进入 hyperdbg > 提示符 hyperdbg-cli.exe # 第一步:加载虚拟化层。失败说明 VT-x 被占用,先回去做 3.1 的检查 load vmm # 第二步:加载内核调试模块。这一条必须在 VMM 之后,否则通信通道建不起来 load # 第三步:连接本地这台机器 connect local # 第四步:看状态,确认 VMM、模块、连接三层都正常 statusstatus的输出是判断环境是否可用的唯一依据,它会把 VMM 加载状态、内核模块状态、当前连接的处理器数量分别列出来。三层里任何一层是未加载,后面的命令全都不会生效,而且很多命令失败时不报错,只是安静地什么都不做——这是最坑的地方,你会以为钩子挂上了,其实一直空转。
如果load vmm失败,先在管理员命令行里查驱动服务状态,再看系统事件日志里有没有驱动加载失败记录。如果load失败而load vmm成功,八成是签名问题:确认测试签名已生效、驱动文件的签名链完整、系统没有开启强制完整性校验。
3.4 把符号对上,后面所有命令才有意义
没有符号,你只能对着裸地址看汇编。符号这一步不做,dt、x、u这些命令的价值会掉一大半。
# 配置符号路径,指向本地缓存目录;首次会联网下载,耐心等 sympath add SRV*C:\symbols*<符号服务器地址> # 重新加载符号表 sympath reload # 列出当前进程里已加载的模块,确认目标驱动在列表里 lm # 查符号,确认解析成功,能打印出地址就说明符号通了 x nt!NtCreateFile # 反汇编前 0x10 字节,看看函数开头长什么样 u nt!NtCreateFile l10sympath add里的路径写法是「缓存目录 + 符号源」两段式,第一次跑会下载一批 pdb,慢是正常的,别以为卡死了。x命令用来验证符号是否真的解析成功——如果它返回的是「symbol not found」,别急着怀疑工具,先看系统 build 号和符号版本是否对得上,系统打过补丁之后旧 pdb 会全部失效。
4. EPT hook 与寄存器事件:把断点下到内核执行路径上
4.1 epthook 的三个参数怎么定
!epthook是这套工具里用得最多的命令,参数看着简单,实际每个都有讲究:
# 在目标函数入口挂 EPT 执行钩子 # 参数依次为:地址(可用符号) 进程 ID 核心号 事件脚本 # pid 写 0 表示对所有进程生效,core 写 0 表示对所有核心生效 !epthook nt!NtCreateFile 0 0 { printf("hit at %llx, pid=%x\n", $ip, $pid); }第一个参数是地址,可以直接写符号,工具会自己解析成虚拟地址。这里有个前提:符号必须已经解析成功,否则它会把这个字符串当成无效地址,命令静默失败。
第二个参数是进程 ID,写 0 表示全系统生效。这个参数是我认为最该重视的一个——内核函数是全系统共用的,全系统挂钩意味着每一次调用都触发一次 VM-exit,系统调用密集的场景下性能会断崖式下跌。我一般的做法是先用process命令列出目标进程,拿到 pid 之后再挂钩,只挂关心的那个进程。
第三个参数是核心号,写 0 表示所有核。多核机器上如果只想观察某个核上的行为(比如某个中断处理路径),把这个值写死能显著降低开销。
第四个参数是事件脚本,可以内联写一对花括号,也可以写一个.dsh脚本文件的路径。脚本在 VM-exit 上下文里执行,能力有限,别在里面做重操作。
命令执行成功会返回一个事件 ID,这个 ID 后面用来关事件,记下来。
4.2 用 monitor 抓「谁改了这个结构体」
驱动排查里最常见的问题是「这个字段莫名其妙被改了,但不知道是谁改的」。传统做法是到处下断点,HyperDbg 里用!monitor一次搞定:
# 监控某个内核对象前 0x40 字节的写操作 # 参数:监控类型(r/w/x/rw) 起始地址 长度 进程 ID 核心号 脚本 !monitor w <目标结构体地址> 40 0 0 { printf("write from %llx, value check at %llx\n", $ip, $context); } # 监控范围收窄到一个字段,命中率更高、开销更小 !monitor w <字段地址> 8 <目标pid> 0 { printf("field written by %llx\n", $ip); }监控类型选w只抓写,选rw读写都抓。长度别贪大,监控一整页会让系统明显卡顿,因为页内每一次访问都要触发 VM-exit。$ip是触发访问的指令地址,$context是访问的目标地址,脚本里把这两个打出来,基本就能锁定是哪个函数动的手。
4.3 寄存器类事件:抓驱动初始化阶段的寄存器操作
很多驱动会在初始化时改控制寄存器、读 MSR,这些动作在正常调试里很难观察,用事件命令就直观了:
# 拦截对 IA32_APIC_BASE 的写操作 !msrwrite 0x1b 0 0 { printf("IA32_APIC_BASE written, pid=%x\n", $pid); } # 拦截 CR0 的写入,打印写入后的值 !crwrite 0 0 0 { printf("CR0 write, new value = %llx\n", $context); } # 拦截 CPUID 指令,用于分析驱动里的特性探测逻辑 !cpuid 0 0 { printf("CPUID leaf=%x\n", $context); }!crwrite的第一个参数是控制寄存器编号,0 是 CR0、3 是 CR3、4 是 CR4。这个事件在系统启动阶段触发极其频繁,挂上去之后系统可能直接卡死,所以务必配合 pid 或者核心号过滤,挂完立刻做你要观察的动作,观察完马上关。
!cpuid在分析反虚拟机检测逻辑时很有用,因为很多检测手段就是读 CPUID 的某几个 leaf 看返回值。挂上之后能看到驱动程序到底查了哪些 leaf,再对照着改 VMM 的返回值,这是透明模式调参的主要依据。
4.4 事件的生命周期:挂上、查看、关掉
事件挂上不关,是导致系统越来越卡的头号原因,我踩过不止一次:
# 列出当前所有已注册的事件,看 ID、类型、地址、命中次数 events # 关掉指定事件,参数是事件 ID close <event_id> # 脚本文件形式的写法,适合超过几行的逻辑 !epthook nt!NtWriteFile 0 0 C:\scripts\watch.dshevents的输出里有一列是命中次数,这个数字非常有用:如果挂上去几分钟还是 0,说明钩子压根没生效(地址不对、大页问题、进程 id 不匹配),别在那儿等;如果数字涨得飞快,说明你挂在了高频路径上,赶紧关掉重新收窄范围。
.dsh脚本文件里可以写条件判断,这是控制开销的关键:
{ if ($pid == 0x1a2b) { printf("target process hit, ip=%llx\n", $ip); } }脚本在 VM-exit 上下文执行,里面别做复杂运算、别循环、别调用外部逻辑。我一般只做三件事:判断 pid、打印关键寄存器、把结果写进日志。多一行动作就多一分卡死风险。
5. 五类翻车现场:加载失败、钩子不触发、符号错位与蓝屏
5.1 load vmm 失败,status 显示未加载
现象:敲完load vmm没有任何输出,status里虚拟化层显示未加载,后面所有事件命令都静默失败。
原因:VT-x 被别的组件占用。最常见的三个占用者是系统自带虚拟化平台(内置虚拟化安全功能、WSL2、沙盒都依赖它)、内存完整性保护、以及任何在跑的虚拟机软件。这三者只要有一个在,VMM 就挂不上。
解决:bcdedit /set hypervisorlaunchtype off关启动项,设置里关掉内存完整性,退出所有虚拟机软件,然后完整重启(不是关机再开,某些系统上快速启动会跳过启动项)。重启后先用systeminfo确认「已检测到虚拟机监控程序」这行消失了,再试load vmm。
5.2 load 失败,驱动加载报权限或签名错误
现象:load vmm成功,load报错,提示驱动加载失败或者拒绝访问,事件日志里能看到驱动加载失败记录。
原因:内核调试模块未签名或者签名链不完整,系统拒绝加载。测试签名没打开、或者打开了但没重启,都会这样。
解决:确认bcdedit /enum {current}里testsigning是Yes,没有就设上并重启。如果系统开了强制完整性校验,还要在设置里把内核隔离那一项关掉。装完调试环境之后别顺手做系统更新,某些更新会把测试签名策略重置,第二天回来发现加载不上,会很懵。
5.3 epthook 挂上了但命中次数一直是 0
现象:命令执行成功,返回了事件 ID,events里也能看到,但命中次数一直是 0,目标函数明明在被调用。
原因有四个:地址落在大页上,普通的 EPT hook 处理不了 2MB 页;pid 参数写的是别的进程;符号解析出的地址是旧的(系统打过补丁但符号缓存没更新);目标函数在挂钩之前已经被调用过,你观察的时机不对。
解决:先换!epthook2试,它会处理大页拆分;再把 pid 改成 0 验证是不是过滤条件的问题;然后用u命令反汇编那个地址,确认函数开头是不是预期的那条指令。我遇到过符号缓存陈旧导致地址偏移几十字节的情况,反汇编一看就露馅了,sympath reload之后就正常了。
5.4 dt 和 x 解析不出结构体
现象:x nt!NtCreateFile返回 symbol not found,dt nt!_EPROCESS <地址>打印一堆问号。
原因:符号路径没配、pdb 版本和当前系统 build 不匹配、或者首次下载没完成就超时了。
解决:sympath看一眼当前配置,路径写对了再sympath reload。系统打过大版本补丁之后必须重新拉符号,旧的 pdb 全部作废。如果网络环境不方便联网下载,就把符号缓存目录整个拷过来,用本地路径方式配置。符号这件事没有后悔药,配不对后面全是裸地址。
5.5 挂完钩子系统卡顿甚至蓝屏
现象:事件挂上之后系统明显变慢,鼠标都拖不动,严重时直接蓝屏。
原因:挂在了高频路径上(缺页异常、系统调用总入口、时钟中断相关函数),每个核每秒触发几十万次 VM-exit;或者脚本里做了重操作;或者事件挂完忘了关,一直累积。
解决:立刻events看命中次数,涨得快的全部close。重新挂的时候把 pid 和核心号都收窄,脚本里只留一行 printf。真正需要长时间观察的场景,用日志方式落盘而不是每命中一次就打印,输出到控制台本身的开销比你想的大得多。蓝屏之后先看重启前的最后一次操作,八成就是刚挂的那个事件。
6. 把事件流变成可分析的数据:脚本过滤与日志落地
观察类调试真正的价值不在「看到一次命中」,而在「把几千次命中变成一条能读的序列」。我现在的固定做法是:用!syscall挂在目标进程上,脚本里只做过滤和落盘,事件结束后把日志拉出来用脚本分析。
# 只抓目标进程的系统调用,脚本里做 pid 过滤 !syscall <目标pid> 0 C:\scripts\syscall_watch.dsh # 打开输出日志,把事件命中记录落盘,避免控制台打印拖慢系统 logopen C:\logs\syscall_trace.txt # 观察结束后先关事件,再关日志,顺序不能反 events close <event_id> logclose脚本文件里只留判断和输出,不做任何计算:
{ if ($pid == <目标pid>) { printf("%llx %llx\n", $ip, $context); } }日志拿到手之后,用一段 Python 把指令地址聚合成调用序列,比肉眼翻控制台高效得多:
import collections # 日志每行是 "命中指令地址 目标地址",按指令地址聚合看调用频次 counter = collections.Counter() with open(r"C:\logs\syscall_trace.txt", encoding="utf-8", errors="ignore") as f: for line in f: parts = line.split() if len(parts) >= 2: counter[parts[0]] += 1 # 打印出现次数最多的 20 个地址,再拿回调试器里用 u 命令反汇编 for addr, cnt in counter.most_common(20): print(f"{addr} {cnt}")这段脚本做两件事:按指令地址聚合,把「几万条流水」压成「几十个热点」;输出结果带着地址回到调试器里u一下,就能知道热点落在哪些函数上。参数上没什么可调的,唯一要注意的是日志编码——控制台输出到文件时编码不一定统一,加errors="ignore"免得中途抛异常。
透明模式是另一个值得单独验的点。它挂在 VMM 层,目的是让目标系统从自己的视角看不出下面还有一层东西。验证方法不复杂:挂上透明模式之后,从目标内核里读 CR3、读 IDT 基址、执行 CPUID 查 hypervisor 相关的 leaf,看返回值是否和裸机一致。我一般会写一个小的探测驱动,在透明模式开关前后各跑一次,把两次结果 diff 一遍,差异项就是还需要调的地方。这里没有通用答案,不同系统版本暴露的信息不一样,只能一处一处对。
最后说个血泪经验:这套工具的能力边界和它的风险是同一件事——它跑在目标系统之下,一旦事件脚本里出了岔子,蓝屏是直接蓝,没有中间态。我现在的习惯是,每次挂事件之前先把events清一遍,挂完立刻确认命中次数在涨,观察窗口控制在几分钟内,结束就close加logclose。这套动作我强制走一遍,不省。希望帮到你。
本文还有配套的精品资源,点击获取