简介:Hyperdbg 是一套面向 ring0 内核级调试的完整源码资料,适合驱动程序开发者、操作系统学习者与安全研究人员用来理解内核调试器的实现思路,应对系统调用跟踪、中断处理分析、内核内存异常排查等场景。压缩包共 93 个文件,约 212KB,以 38 个 .h 头文件和 35 个 .c 源文件为主体,辅以汇编、构建脚本、Makefile、说明文档及图标等资源,整体轻量但模块划分明确。已有 553 人学习下载,可见其在同类小众内核调试主题中具有一定关注度。源码围绕 VMX 虚拟化、MMU 内存管理、事件与断点处理、符号检索、反汇编以及图形界面等部分展开,读者可据此研究软硬件断点设置、内核内存查看与修改、寄存器状态监控、调试日志记录与回放等机制的落地方式,也能借鉴其工程组织与构建流程,为自行开发调试工具或深入分析系统底层行为提供参考。
1. 蓝屏之后只剩一个 dump:ring0 内核调试器 HyperDbg 到底解决什么问题
HyperDbg 是一个开源的 ring0 内核调试器(kernel debugger),但它跟大多数人印象里的内核调试不是一回事:不需要第二台机器、不需要串口线、不需要 KDNET 网络握手,调试器和被调试系统跑在同一台物理机上。它靠硬件虚拟化把当前系统变成虚拟机监控器下的一层 guest,用 EPT 翻转页权限来接管执行流,因此挂钩内核函数时不需要往代码段里写 0xCC,也就绕开了 PatchGuard 那一套自校验。适合谁?写驱动的人、做内核态对抗分析的人、以及那些被"改一个寄存器就蓝屏、重启后现场只剩一个 dump"折磨过、想在本机直接把断点打下去的人。它的门槛不在命令,而在机器上那块 VT-x 能不能被它独占。
2. 架构选型:为什么它用 VT-x/EPT 而不是软件断点
2.1 三条内核调试路线的取舍
内核态调试这件事,业内能走的路其实只有三条,每条路的代价完全不同。
| 路线 | 依赖条件 | 断点实现方式 | 典型限制 |
|---|---|---|---|
| 双机调试(成熟调试器 + 串口/网线) | 第二台物理机或虚拟机宿主 | 写 0xCC、硬件断点 | 环境搭建麻烦,调试期间目标机基本停摆 |
| 纯软件断点(改指令首字节) | 无额外依赖 | 覆盖为 0xCC 再回填 | 只读段改不了、被自校验发现、触发内核完整性检查 |
| 虚拟化调试(HyperDbg 这一类) | VT-x / AMD-V 独占 | EPT 页权限翻转 | 与 Hyper-V、WSL2、内存完整性抢资源 |
第一条路最稳,问题是它把"复现"变成了两件设备的事:你在 A 机上敲命令,B 机上跑现场,任何一次环境漂移都要重新对齐。第二条路最轻,但只要目标函数在只读页里,或者代码段有自己的 CRC 校验,改字节这一步就直接翻车。
HyperDbg 选的是第三条。它在系统启动后把自己加载成一个轻量 VMM,然后不再改动任何客户机代码字节,只改 EPT 里那一条页表项的权限位——执行位一清,目标页一被执行就触发 EPT violation,CPU 从 guest 退出到 VMM,控制权就落到了调试器手里。指令内存看起来一个字节都没变,rdmsr 读出来的 MSR、内存里的代码段、CRC 校验全是对的,这是它在对抗场景里最实际的价值。
代价也很明确:VT-x 只有一个,谁先占谁用。这台机器上只要有别的组件在用它,HyperDbg 就加载不起来。
2.2 VMM 层与调试器模块:两层各自负责什么
它的运行结构是两层,加载的时候也是两条命令,很多人第一次用会卡在这里。
底层是 VMM 模块,负责真正的脏活:检测 CPU 虚拟化能力、建立 VMCS/EPT 表、接管 VM exit、维护一张"哪些地址被挂了钩子"的表。这一层加载成功后,整台机器已经跑在虚拟化环境下了,但这时候你还什么都干不了,因为没有东西去消费它抛上来的事件。
上层是调试器模块,负责命令解析、脚本解释、事件注册与生命周期管理,以及把 VMM 抛上来的退出原因翻译成"第几次命中、命中在哪个地址、当前进程线程是谁"。脚本引擎也在这层,!epthook、!monitor、!syscall这些命令背后都是一套统一的"事件 + 脚本"模型。
两层分离的好处是:VMM 只做最小必要的事,逻辑越少越不容易在 VM exit 上下文里出问题;调试器模块可以随时卸载重载,不用动已经跑起来的虚拟化层。血泪经验是——出问题时先分清是"VMM 没起来"还是"事件没挂上",这两类故障的排查路径完全不一样。
2.3 一次 EPT Hook 从挂上到命中的完整路径
把这条路径在脑子里跑通一遍,后面所有参数和报错都能对上号。
第一步,命令里给的是一个虚拟地址,调试器先把它翻译成物理地址,再顺着 EPT 的层级找到对应的页表项,把原始权限位记下来存档。
第二步,清掉那一项的执行位(或读写位),然后发一次 EPT TLB 失效,保证 CPU 不再用旧的缓存翻译。
第三步,目标指令被取指,CPU 发现 EPT 里这一页不可执行,触发 EPT violation,VM exit 到 VMM。
第四步,VMM 查钩子表,确认这个地址在监控范围内,把控制权交给调试器模块,执行用户写的那段脚本。
第五步,脚本跑完之后不能就这么返回——页权限还是关着的,直接返回会无限重复触发。常见做法是临时恢复该页权限,同时打开单步 trap(或利用监视 trap flag 的机制),让 guest 只执行一条指令,再重新把权限关掉。所以一次 hook 命中实际上包含两次以上的 VM exit,这也是为什么挂钩热路径函数会把系统拖慢。
第六步,脚本里的print结果输出到调试器控制台,事件计数加一。
理解了第五步,就能明白两个常见现象:为什么在!epthook的脚本里写复杂逻辑会明显掉帧,以及为什么某些指令(比如 rep 前缀的长指令)在单步恢复时会表现得不太自然。
3. 环境落地:把 HyperDbg 跑起来的最小闭环
3.1 前置条件:VT-x 独占、测试签名与内存完整性
这一段是纯排雷,跳过它后面全是蓝屏。
先确认硬件虚拟化在 BIOS 里是开着的,然后处理软件层的抢占。Windows 上最常见的三个抢占者是:Hyper-V 平台本身(包括 WSL2 背后的虚拟机平台)、基于虚拟化的代码完整性保护、以及某些安全软件的驱动。三者的共同点都是先一步拿走了 VT-x。
# 让宿主系统不要把 VT-x 占走,交给 HyperDbg 的 VMM 使用 bcdedit /set hypervisorlaunchtype off # 自己编译出来的驱动没有正式签名,必须打开测试签名模式 bcdedit /set testsigning on # 关掉内存完整性(HVCI),它同样建立在虚拟化之上 reg add "HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity" /v Enabled /t REG_DWORD /d 0 /f # 以上都需要重启才生效 shutdown /r /t 0这三条命令改的都是启动期状态,必须重启。重启后有两个验证动作:一是用系统信息工具看"已检测到虚拟机监控程序"这一项是否还是"是",如果仍然是,说明还有东西没关干净;二是确认测试签名模式已经生效,否则驱动加载会直接返回签名错误。
提示:打开测试签名后,Secure Boot 在某些配置下会阻止加载,需要在固件设置里先关掉。这一步属于环境准备,做完之后建议单独打一个系统还原点。
3.2 编译与加载:load vmm 与 load dm 的两步闭环
源码树里同时给了 VMM 驱动工程和调试器可执行工程,构建脚本和解决方案都在,按仓库说明装好编译器工具链与驱动开发包即可。版本对齐很关键:驱动开发包的版本和编译器版本不匹配时,编出来的驱动会在加载阶段报一个完全不相干的错误码,浪费半天。
编完之后,产物是两样东西:VMM 驱动和调试器主程序。把主程序用管理员权限启动,进入它自己的命令行控制台。
# 第一步:把机器虚拟化,加载 VMM 层 load vmm # 第二步:加载调试器模块,脚本引擎和事件管理在这一层 load dm # 验证:能看到进程列表说明上层通路已经打通 !process # 验证:事件列表此时应该是空的 eventsload vmm成功时会打印出虚拟化初始化的信息,包括检测到的特性支持情况。如果这一步报错,99% 是 3.1 里的抢占问题没解决,别去翻驱动代码。
load dm失败则更多是路径或版本问题——调试器主程序和驱动之间的通信接口对不上,通常出现在"驱动是旧版本编的、主程序是新版本"这种混搭场景。
两步都成功之后,这台机器已经处于被监控状态,但还没有任何事件在跑,性能开销接近零。这一点比双机调试舒服:它是懒加载的,挂多少钩子付多少代价。
3.3 最小验证集:断点、单步、内存与反汇编
在挂 EPT 钩子之前,先用常规断点确认整条链路是通的。命令名基本对齐大家熟悉的调试器习惯,bp下断、bl列表、g继续、p单步、u反汇编、dq看内存。
# 在一个内核函数上下一号断点,地址换成你环境里的真实地址 bp fffff801`a1b2c3d4 # 确认断点已经登记 bl # 放行,等待命中 g # 命中后先看指令,确认落点是不是你以为的那个函数 u fffff801`a1b2c3d4 # 再看栈上的参数,x64 下前四个参数在 rcx/rdx/r8/r9 dq @rsp # 单步一条,观察寄存器变化 p这里的重点是地址必须用运行时真实地址。内核模块有基址随机化,静态反汇编里看到的地址直接抄过来,断点会挂在一个完全不相关的页上,而且不会报错——这是新手最常见的"断点不命中"。
u出来的指令要和你的预期比对:如果第一个字节是e9之类,说明地址落在了跳转桩上;如果整段反汇编对不上任何已知函数,说明地址算错了。
确认断点、单步、内存读取这三件事都能正常工作,才算具备了挂 EPT 钩子的资格。这一步不通过就往后面走,后面所有失败都无法归因。
4. 事件与脚本引擎:把「看一眼」变成「自动抓」
4.1 内置事件清单与选型
它把所有可拦截的东西抽象成统一的"事件",不同命令只是不同拦截点的语法糖。
| 命令 | 拦截点 | 典型用途 | 相对开销 |
|---|---|---|---|
!epthook | EPT 页级执行/读写 | 挂钩内核函数、监控调用 | 中,每次命中两次以上 VM exit |
!epthook2 | 更细粒度的指令级跟踪 | 需要避开常见检测、粒度更小 | 高 |
!monitor | 一段内存的读/写/执行 | 抓谁改了某个结构体字段 | 中 |
!syscall | 系统调用进出 | 过滤调用、抓参数 | 低到中 |
!cpuid | cpuid 指令 | 分析虚拟化探测行为 | 低 |
!msrread/!msrwrite | rdmsr / wrmsr | 抓驱动改 MSR | 低 |
!ioin/!ioout | 端口读写指令 | 追踪设备访问 | 低 |
!exception | 异常分发 | 抓缺页、通用保护 | 中 |
!dr | 调试寄存器访问 | 反调试对抗分析 | 低 |
选型的判断标准只有一条:你要观察的是"某个地址被执行了"还是"某块内存被改了"。前者用钩子,后者用监控块。用钩子去观察数据变化,会漏掉非执行路径的写入;用监控块去观察函数调用,会把同一页上所有无关访问全部引进来,日志直接爆炸。
4.2 !epthook 与 !monitor:脚本参数逐项拆开
先看执行钩子。语法是地址加一段脚本块,脚本在每次命中时执行。
# 在目标内核函数上挂执行钩子,命中时打印当前进程和第一个参数 !epthook fffff801`a1b2c3d4 { printf("hit pid=%x tid=%x arg1=%llx\n", $pid, $tid, $rcx); }三个部分要分开理解。地址必须是运行时虚拟地址,和bp的要求一样。花括号里是脚本体,在 VM exit 上下文里执行,所以只能做纯内存读取和输出,不能调用会睡眠或触发缺页的操作。$pid、$tid、$rcx是伪寄存器,由调试器在命中时刻填充,不需要自己从结构体里扒。
再看内存监控。它多了一个长度和权限掩码。
# 监控某结构体里一个 8 字节字段的写入,把写者打出来 !monitor fffff801`b2c3d4e0 8 w { printf("writer pid=%x tid=%x rip=%llx val=%llx\n", $pid, $tid, $rip, poi(fffff801`b2c3d4e0)); }8是监控长度,粒度是字节,但实际生效的页级保护意味着同一页上任何访问都可能把你唤醒,脚本里必须自己再判一次地址。w是权限掩码,可选读、写、执行及其组合。poi()是解引用,把地址里的值取出来——不套poi打出来的是地址本身,这个坑几乎每个人都会踩一次。
脚本执行完,事件会继续挂着,直到你显式清理。
# 列出所有活跃事件,拿到 ID events # 按 ID 精确清掉,不带参数是全清 flush 14.3 伪寄存器、表达式与 poi 解引用
脚本引擎里有两类东西要分清:伪寄存器和普通变量。伪寄存器以$开头,由调试器在命中瞬间填值,$pid、$tid、$proc、$thread、$rip、$rcx这些都属于这一类,是最省事的取值方式。
普通表达式支持算术、位运算和比较,可以在脚本里做过滤,也可以直接在控制台用?求值。
// 只在指定进程里才输出,避免全局钩子把日志刷爆 if ($pid == 0x1a4) { printf("target pid hit rip=%llx\n", $rip); } // 读一个指针再解引用,注意 x64 下先确认它是有效地址 if (poi($rsp) != 0) { printf("arg0=%llx\n", poi(poi($rsp))); }if过滤是控制日志量的第一手段。把过滤条件放在脚本最前面,比事后 grep 日志高效得多,因为不满足条件的命中连格式化字符串的开销都省了。
解引用要格外小心:在 VM exit 上下文里,如果poi()的地址是无效页,行为不一定像用户态那样干脆地报个错。我一般会在脚本里加一个地址范围判断,只在确认属于内核空间时才去读。
注意:伪寄存器的具体名称和各命令支持的参数,会随版本有小幅差异,命令名和参数以你手上这版
help输出为准,别照抄别人的截图。
4.4 透明模式:把调试痕迹藏到什么程度
透明模式通过!hide指定进程,让调试器对这个进程隐藏自身的存在:调试端口、调试寄存器、以及一部分可被枚举到的痕迹会被处理掉。分析带反调试的样本时,不打开这个模式,样本一上来就能发现自己在被调。
# 对指定进程启用透明模式,参数是进程 ID !hide 0x1a4 # 分析结束后恢复 !unhide 0x1a4但要清楚它的边界。第一,它藏的是"状态类"痕迹,藏不住"时序类"痕迹——EPT violation 带来的额外开销、cpuid 和 rdtsc 的微小差异,这些物理层面的事实改不掉。第二,透明模式和常规断点是互相干扰的:你一旦在目标进程里下普通断点,被隐藏的状态就会被自己破坏。第三,它作用范围是进程级的,内核全局的钩子不受影响。
所以正确的用法是:静态分析定好点位,透明模式下去挂 EPT 钩子观察行为,全程不碰普通断点。想要边跑边停下看现场,就得接受暴露。
5. 避坑与排查:六个把人劝退的地方
5.1 加载阶段:蓝屏与加载失败
现象:敲完load vmm,机器直接蓝屏或者卡死重启。
原因是 VT-x 已经被别的组件占着,VMM 初始化时拿不到根模式。检查顺序是:Hyper-V 平台有没有关、内存完整性有没有关、有没有安全软件的驱动在跑。三条命令都在 3.1 里,改完必须重启,改完不重启等于没改。还有一种更隐蔽的情况——BIOS 里的虚拟化选项是开的,但"受信任执行"相关的开关把它锁住了,这种情况下系统信息工具会给出误导性的显示。
现象:重启之后加载仍然失败,报的是签名错误。
原因是测试签名没生效,或者 Secure Boot 把它挡住了。解决分两步:先确认测试签名模式的状态,再去固件里关掉 Secure Boot。自己编译的驱动一定要走这条路,别想着绕过。
现象:加载成功,但机器整体变卡,鼠标都开始飘。
原因是残留了之前没清理的钩子。events看一眼,如果列表里有一堆不认识的事件 ID,flush全清。日常习惯是每次分析结束前先events确认一遍,别指望重启解决问题——重启当然能清,但你会丢掉现场。
5.2 调试阶段:断点不命中与脚本静默
现象:!epthook挂上去返回成功,目标函数明显在跑,脚本一次都没打印。
先怀疑地址。内核模块有基址随机化,静态地址必然错。用进程/模块枚举拿运行时基址,再把偏移加上去。确认方法是命中前先用dq把那个地址的头几个字节读出来,和你反汇编里看到的对一遍——对不上就是地址错了。还有一种情况是目标页已经被别的钩子占用,两个钩子叠在同一页上,后挂的那个不生效。
现象:脚本语法没错,但输出里全是乱码或者值明显不对。
大概率是poi()忘了套,把地址当值打出来了;或者解引用的层级不对,poi的次数少了一层。x64 下指针是 8 字节,取结构体里第 N 个字段要按实际偏移算,不能凭感觉加。
现象:挂上钩子后目标程序行为变了,或者干脆崩了。
看脚本里有没有做重量级操作。VM exit 上下文里不适合做复杂计算和跨页访问,脚本应该只做"读几个值 + 判断 + 打印"。另外检查是不是挂了热路径函数——一个每秒被调几十万次的函数上挂 EPT 钩子,系统会被拖到不可用。这种情况换成条件更精确的地址,或者改用!monitor只在数据被写时才醒。
现象:机器随机卡死,日志停在半截。
这是钩子风暴的典型表现:脚本里的printf触发了新的内存访问,又被自己的监控块捕获,形成递归。解决方式是在脚本里对当前线程或当前指令指针做一次排除判断,命中时先设一个标记位直接返回。写日志这件事在虚拟机监控层是要付代价的,不能像用户态那样随手打。
现象:重启之后所有事件都没了,之前的配置白做。
事件是运行时状态,不落盘,这是设计如此。解决办法是把所有注册命令写成一个脚本文件,每次启动后按顺序重放一遍。这也是我现在的固定动作——分析过程中的每一条命令都同步记进那个文件,下次复现直接跑。
6. 把一次偶发蓝屏变成可回放的证据链
偶发问题最烦的地方在于"再来一次"这个词。我的做法是把整个观察过程固化成一段可以重复执行的注册脚本,而不是每次凭记忆敲命令。
# 复现脚本:先清干净,再按顺序挂事件,最后确认 flush !hide 0x1a4 !monitor fffff801`b2c3d4e0 8 w { if ($pid == 0x1a4) { printf("WRITE pid=%x tid=%x rip=%llx old=%llx\n", $pid, $tid, $rip, poi(fffff801`b2c3d4e0)); } } !epthook fffff801`a1b2c3d4 { if ($pid == 0x1a4) { printf("CALL pid=%x tid=%x arg1=%llx\n", $pid, $tid, $rcx); } } events这段脚本解决的是三件事。第一,顺序固定:先清残留再挂新事件,避免上一轮的钩子污染这一轮的观测。第二,过滤前置:$pid判断放在最外面,只有目标进程的命中才会走到格式化输出,日志量能压下来一到两个数量级。第三,输出带rip:光有进程线程信息不够定位,加上触发点的指令地址,才能和反汇编对上,才能判断是"我预期的那个调用路径"还是"另一条完全没想到的路径"。
验证这套配置是否可信,别直接上真实样本。先找一个自己写的、明确会写那个字段、明确会调那个函数的测试驱动,跑一遍,看日志条数和预期是否一致。条数对得上,再换真实场景。这个"先在自己人身上试一遍"的习惯,能把大量假阴性挡在外面——最怕的不是没日志,是日志有但漏了关键的那一次。
几个具体技巧。events返回的 ID 要记下来,flush的时候按 ID 精确清理,全清会把别的分析任务挂的事件一起干掉。挂在热路径上的监控块要给一个明确的结束条件,或者干脆用时间窗口控制,跑够样本量就flush。日志输出建议控制在每次命中一行,多行输出会把控制台变成瓶颈,反而改变时序。
还有一点是我踩过最深的:不要在脚本里"顺便"读太多东西。每多一次内存解引用,就多一次可能触发异常的机会,而在 VM exit 上下文里排查异常,代价比用户态高一个数量级。
从那以后我每次挂事件之前,都强制先用?或dq把地址和头几个字节核一遍,确认落点无误再写脚本,宁可多花两分钟,也不要在蓝屏之后靠猜。希望帮到你。
本文还有配套的精品资源,点击获取