首页 / 资讯中心 / 文章详情

HyperDbg实战:硬件虚拟化下的ring0内核调试与EPT断点

HyperDbg实战:硬件虚拟化下的ring0内核调试与EPT断点 ★ FEATURED ARTICLE
简介Hyperdbg 是一款面向驱动开发、内核安全研究与系统分析人员的 Ring0 级内核调试器适合需要跟踪系统调用、中断处理、内核内存与寄存器状态的中高级开发者使用。压缩包共 93 个文件约 212KB以 C 源码与头文件为主体辅以汇编、Makefile、构建脚本及说明文档呈现了从虚拟机监控、内存管理到命令解析与调试界面的完整工程结构。目前已有 553 人学习下载说明该主题在底层调试领域具有一定关注度。读者可从中梳理 Ring0 调试器的模块划分研读断点设置、内存查看修改、日志记录与回放等实现思路并借鉴其跨 Windows NT 系列环境的设计取舍为驱动排错、恶意代码分析和系统性能优化建立可参考的代码级样本。1. ring0 内核调试器绕不开硬件虚拟化HyperDbg 解决的是哪一层的问题驱动在客户现场偶发蓝屏dump 里只有一个可疑地址windbg 双机调试要重启、要串口、断点一打整个系统就冻住想抓某个内核函数被谁调用、参数长什么样改一字节int3又碰上代码自校验直接崩。这类场景里一个 ring0 内核调试器最该解决的问题不是能不能断下来而是断下来的时候现场还剩多少。HyperDbg 走的是硬件虚拟化这条路把自己做成一个轻量 hypervisor被调试的 Windows 仍跑在 VMX non-root 里全速执行断点靠 EPT 页表权限触发而不是改指令因此对内核代码零侵入、对自校验友好。它适合写驱动的、做样本行为分析和内核 hook 排查的从业者前提是你能接受调试器本身就是一个 VMM这件事以及由此带来的一整套环境约束。2. 从加载 VMM 到第一条命中运行模型与最小可跑流程2.1 为什么它必须先变成一个 hypervisor传统内核调试器的两条路都有硬伤。软件断点改指令首字节遇到校验、遇到只读页、遇到被 inline hook 过的函数就翻车硬件断点只有四个 DR 寄存器抓不了大范围也抓不了数据流。HyperDbg 换了个位置它在 VT-xAMD 平台是 SVM之上占住 VMX root把 Windows 降级成 guest。这样一来CPU 在执行 guest 代码时如果撞上一条被 hypervisor 标记过的条件就会主动 VM exit把控制权交回调试器调试器记完现场再VMRESUME还给 guest。这套模型决定了它能做哪些别人做不了的事。第一EPT扩展页表可以把某个 4KB 页标成不可执行或不可写guest 一旦踩上去就触发 EPT violation而这个陷阱对 guest 完全不可见页表项在 guest 看来还是原来的值——这就是隐形断点的原理。第二MSR 读写、CPUID、CR 写、IO 端口访问这些事件只有 hypervisor 才有资格拦普通调试器根本挂不上钩。第三代价同样明确VT-x 是独占资源一旦 HyperDbg 的 VMM 加载成功机器上就不能再有第二个 hypervisor 干活嵌套虚拟化、内存完整性、基于虚拟化的安全功能全会跟它抢这块地盘。理解这一点之后后面所有的坑基本都能提前预判加载失败多半是 VT-x 被占断点不命中多半是地址或进程上下文搞错了机器卡死多半是回调里干了重活。2.2 环境准备一份能省掉半天返工的清单别在物理工作机上直接上。这套工具的加载/卸载虽然做了保护但一个配错的 EPT hook 足以让系统在半秒内三键重启虚拟机快照是最便宜的后悔药。准备项要求不这么做会怎样运行环境虚拟机开启嵌套虚拟化VT-x 透传给 guestload vmm直接失败或加载后无响应系统版本与要分析的驱动目标一致别跨大版本结构体偏移对不上读出来的全是脏数据快照加载 VMM 之前拍一张卡死后只能重装调试符号提前准备好对应构建的 PDB路径放本地断点只能打裸地址调用栈全是偏移冲突项关闭内存完整性、关闭其它虚拟化类工具VT-x 被占加载报错或直接卡在加载阶段网络远程调试时两端端口互通防火墙放行连接超时误判成 VMM 没起来符号这件事值得单独说一句。EPT 断点接受裸虚拟地址但你要读懂命中现场就离不开函数名和结构体定义。常见的做法是先把符号路径配好、确认能解析出目标模块的导出函数再去下断点顺序反了你会花大量时间在这个地址到底是哪个函数上。2.3 最小可跑流程五条命令验证通路环境就绪后先别急着下断点用最小命令序列把通信链路跑通。下面这段是标准的起手式# 1. 加载 hypervisor 模块成功后本机就运行在 HyperDbg 的 VMM 之上 load vmm # 2. 查看 VMM 状态与连接状态确认加载成功 status # 3. 本地调试模式CLI 通过 hypercall 与 VMM 通信不需要第二台机器 connect local # 4. 读当前 CPU 的寄存器现场验证调试通道是否真的通了 !dr # 5. 列进程拿到目标进程的 PID后面所有断点都要用它做上下文过滤 !processload vmm是关键一步它做的事情是把驱动形式的 VMM 模块加载进内存、在每个逻辑核上执行 VMXON、配置 VMCS然后把当前系统降级成 guest。这一步失败通常没有任何有意义的错误码现象就是卡住或者几秒后重启。status用来确认 VMM 和调试器会话的真实状态比看 CLI 有没有回显靠谱。connect local之后 CLI 才具备下发命令的能力之前敲什么都是空转。!dr是全篇最有用的一条验证命令它能打印出寄存器说明 VM exit 通路、上下文保存、输出回传三段链路全都通了。!process则验证另一件事——调试器能不能正确遍历内核的进程链表这直接决定了后面按 PID 过滤能不能生效。如果!dr有输出但!process空多半是符号或内核结构偏移不匹配如果!dr也空别往下走了回到 2.1 检查 VT-x 占用情况。2.4 两台机器的远程链路谁 listen、谁 connect本地模式适合单机分析但只要目标系统一卡死你就失去了唯一的观察窗口。真要做长时间跟踪还是得两台虚拟机一台跑被调试系统一台跑调试 CLI中间走网络。# 被调试端加载 VMM 后开监听端口等待调试端接入 load vmm listen 50000 # 调试端连过去注意端口要和上面一致 connect 192.0.2.10:50000 # 连上之后先确认状态再开始下断 statuslisten的端口自己定只要两端一致且防火墙放行即可connect的地址写被调试端的地址示例里用的是文档保留网段实际换成你自己的。这里有个顺序问题普遍做法是先在被调试端load vmm再listen调试端最后connect如果连不上先用status看两端各自认为的状态是什么比反复重试有效得多。另外远程模式下所有命中信息都要走网络回传脚本里如果每次命中都打印大段内存网络和缓冲会先崩这一点在第 4 章的脚本部分会再展开。2.5 下第一个断点之前先做四项通路自检很多人第一次用就在这一步翻车命令都执行了断点也下了就是一次都不命中然后开始怀疑工具。实际经验是先把下面四件事逐条确认能排掉八成不命中第一!dr能出寄存器说明 VM exit 通路正常。第二!process能列出进程并能看到目标 PID说明内核结构解析正常。第三对一个你确定存在的内核地址执行!va2pa能算出物理地址说明地址翻译链路正常。第四敲g能让系统继续跑起来而不是直接死机说明 resume 流程正常。四项全过再去下断点任何一项不过问题都不在断点本身。这套自检顺序看起来啰嗦但它把工具没起来和断点没命中两个完全不同的问题隔离开了。3. 断点体系三件套软件断点、硬件断点、EPT Hook 怎么选3.1 三种断点的代价对比选错断点方式是第二个高频翻车点。三者不是替代关系而是各有明确的适用边界先看这张表方式触发原理是否修改 guest 内存数量限制典型适用软件断点把目标地址首字节替换为int3是无硬限制自己的测试代码、可写可改的非校验区域硬件断点DR0–DR3 调试寄存器否最多 4 个极少量、需要精确到指令的观察点EPT Hook改 EPT 页权限触发 EPT violation否guest 视角无感受页表与内存约束内核函数入口、只读代码段、自校验模块软件断点的问题在于改这个动作本身。内核里有大量代码段是只读的改要动 CR0 的写保护位动静大就算改成功模块的自校验、页哈希、完整性检查也会立刻发现。硬件断点干净但太贵四个槽位在真实分析里往往一个都不够用。EPT Hook 是这套工具的主力它在 EPT 层面把目标页标成不可执行guest 执行到那一页就 VM exit调试器判断 RIP 是否等于目标地址是就停不是就临时恢复权限单步放行一条指令再重新挂上。整个过程 guest 的页表项没变代码字节没变所以对校验透明。需要提醒的是EPT Hook 的粒度是页。一个 4KB 页里如果有多个函数钩住其中一个等于把整页都变成陷阱页所有落在这页上的执行都会触发 VM exit命中率会明显上升、性能损耗也跟着上来。这就是为什么很多命令都带进程过滤和核过滤参数——不是为了好看是为了把命中量压到人能读的程度。3.2!epthook的参数拆解基础语法是!epthook 地址 [进程PID] [核号] [选项]四个位置各有讲究# 对内核某函数入口下 EPT 执行断点 # 参数依次为虚拟地址、PID0 表示所有进程、核号0 表示所有核、选项默认 0 !epthook fffff80512345678 0 0 0 # 只对 PID 为 0x1a4 的进程生效减少无关命中 !epthook fffff80512345678 0x1a4 0 0 # 列出当前所有 EPT hook确认挂上了没有 !epthook地址参数必须是目标进程上下文下的有效虚拟地址写错一位就会挂到一个随机页上然后你会收获一堆莫名其妙的命中。PID 传 0 表示不过滤这在第一次探路时很有用先确认这个地址到底会不会被执行再收窄到具体进程。核号同理传 0 表示所有逻辑核多核系统里如果你只关心某个核上的行为就把它钉死否则打印出来是交错的多核现场读起来极其痛苦。选项参数控制 hook 的实现细节比如用多大的页粒度、是否只对特定地址放行不同构建的可选值会有增删。提示动手前先敲help !epthook看你本地这一版的实际签名。命令名和参数在不同构建之间会调整照抄网上的命令是踩坑最快的方式。还有一种更实用的写法直接把脚本挂上去命中时执行自定义逻辑而不是粗暴地停下来# 命中时打印现场不中断整个系统的执行流 !epthook fffff80512345678 script { printf(hit: rip%llx, rcx%llx, rdx%llx\n, $context-rip, $context-rcx, $context-rdx); }script { ... }是内联脚本块$context指向命中瞬间保存的寄存器上下文。这种写法的价值在于不停机传统的断点一命中整个系统就冻住而在驱动里冻住往往意味着超时、蓝屏或者业务中断脚本模式下 VMM 记完现场立刻恢复执行你可以让它在目标路径上安静地跑几个小时最后只捞一份命中日志。上下文里能取哪些字段、字段名叫什么取决于你手上的构建第一次用先打印一两个确定存在的寄存器比如 rip、rax验证通路别一上来就写一大段。3.3 用!monitor抓内存读写与执行EPT Hook 解决的是谁执行到这里!monitor解决的是谁动了这块内存。后者在分析数据流时几乎是唯一手段某个内核结构体的字段被莫名其妙改了你不需要知道改它的函数在哪只需要盯住那几十个字节。# 监控一段内核数据结构的写入范围为起始地址到结束地址 # 第一个参数是访问类型r 读、w 写、e 执行可以组合 !monitor w fffff80520001000 fffff80520001040 0 0 # 列出当前生效的内存监控项 !monitor # 不再需要时移除避免长期挂着拖慢系统 !monitor c参数里最容易被忽略的是范围。地址区间给大了一个高频访问的内核结构每秒能打出上万条命中日志直接爆掉给小了漏掉真正的那次写。实操经验是先把范围压到字段级比如一个 8 字节的指针确认有命中之后再决定要不要扩大。类型参数用w抓写、r抓读、e抓执行读监控的命中量通常远大于写监控除非你要追的是谁在偷看这个结构否则优先从写开始。最后那个c是清理别小看它监控项是常驻的每命中一次都要 VM exit挂着不撤会让整机肉眼可见地变慢。用完就撤跟用完!hide之后要!unhide是一个道理。3.4 命中之后把现场读干净的几条命令断点命中只是拿到了入场券真正有价值的是命中之后那几十秒。下面这组命令是我每次命中后的固定动作# 1. 完整寄存器现场先确认 RIP 落在哪 !dr # 2. 把关键指针指向的内存 dump 出来长度按结构体大小给 !dump fffff80520001000 0x40 # 3. 当前调用栈回答谁调过来的 !stack # 4. 虚拟地址对应的页表项确认页属性、是否被换出 !pte fffff80512345678 # 5. 虚拟地址与物理地址互转做跨进程比对时用 !va2pa fffff80520001000顺序是有讲究的先看!dr确认停在哪再用!dump把参数结构读出来接着!stack回溯调用方——这三步构成了完整的谁、用什么参数、调了谁。!pte在断点不命中时更有用如果页表项显示这一页根本没映射或者被换出那断点自然不会触发问题不在断点而在地址。!va2pa则是跨进程比对的桥梁同一个物理页在不同进程上下文里的虚拟地址不一样排查两个进程改的是不是同一块内存时绕不开它。还有一条经验命中现场别急着g恢复。先把该读的读完、该记的记下来因为一旦继续执行这块内存可能立刻被下一个写覆盖。真需要连续观察就改用脚本在命中时把关键字段写进缓冲让系统继续跑事后再统一分析——这个思路在第 4 章的脚本部分会具体展开。4. 事件拦截syscall、MSR、CR、CPUID 这些看不见的入口4.1 用!syscall抓系统调用很多内核行为追到根上都指向系统调用某个句柄是谁打开的、某个注册表键是谁读的、某个驱动在用户态请求下做了什么。这类入口用地址断点抓很别扭因为系统调用分发点被所有进程共享命中量巨大直接拦系统调用指令才是正解。# 拦截系统调用前两个参数是 PID 和核号均传 0 表示不过滤 !syscall 0 0 # 只想看某个进程的系统调用把 PID 钉死 !syscall 0x1a4 0 # 取消拦截 !syscall c命中时最该看的是rax——x64 下它就是系统调用号rcx之后是参数。第一次跑建议只挂几秒钟就撤因为全系统的系统调用量是海量的挂久了输出会刷屏VMM 也吃力。真正好用的方式是配合过滤先用大范围跑一遍确认目标进程确实在这条路径上再用条件把命中收敛到具体调用号。取消拦截这一条千万别忘!syscall没撤就去做别的事你会发现后面所有命令的响应都变慢了然后误以为是工具本身性能差。另外系统调用拦截常和 EPT 断点配合先!syscall抓到调用号和参数拿着参数里的句柄或指针再去对内核里消费这个参数的函数下 EPT 断点。两步走比一步到位稳得多。4.2 MSR / CR / CPUID / IO / 异常 的拦截命令这一类事件只有 hypervisor 层能拦是这套工具相对传统调试器的核心差异点。命令族长得都差不多掌握一个就会用一片命令拦截对象典型用途参数要点!msrread/!msrwrite指定 MSR 的读 / 写追谁改了某个控制寄存器模型相关的 MSRMSR 地址 PID 核号!crwrite指定控制寄存器的写抓谁在改 CR0/CR3/CR4排查页表切换异常CR 编号 PID 核号!cpuidCPUID 指令观察虚拟化环境探测、特性位读取PID 核号!ioin/!ioout指定端口范围的 IO 读写追硬件访问、端口级副作用端口号 PID 核号!exception指定异常向量抓被静默吞掉的异常、排查 SEH 类问题向量号 PID 核号!interrupt指定中断向量观察中断处理时序向量号 PID 核号参数结构高度一致事件标识在前PID 和核号在后全部传 0 就是全系统拦截。这个统一性让上手成本很低但也带来同一个陷阱——全量拦截的命中量远超预期。以!cpuid为例一个普通的进程启动过程就会打出成百上千条日志根本读不过来。所以这类命令的正确用法从来不是挂上去等着而是先用最小范围跑一次确认事件确实发生再带着过滤条件重挂。还有个细节值得提MSR 和 CR 的写拦截在系统启动早期极其频繁如果挂得太早或者范围太大系统可能在启动阶段就被拖死。稳妥的做法是等系统完全起来、进入你要观察的业务场景之后再挂观察窗口结束立刻撤掉。异常拦截则要注意区分真异常和正常控制流——有些语言运行时和驱动会把异常当流程控制用!exception会把这些全打出来读的时候得自己甄别。4.3 脚本引擎把全量命中收敛成我要的那一次前面反复出现的问题都是同一个命中太多了。硬件层拦截不可能精准精准必须靠软件层过滤。脚本引擎就是干这个的。// 保存为 filter.ds用 !script filter.ds 加载 // ? { } 是通用事件块命中任何已注册事件都会进来 ? { // 只关心系统调用号为 0x55 的那次调用其余直接返回 if ($context-rax 0x55) { printf(pid%d core%d rax%llx arg1%llx arg2%llx\n, $pid, $core, $context-rax, $context-rcx, $context-rdx); } }这段脚本有三个要点。第一$context是命中瞬间的上下文快照读它不会触发新的内存访问安全。第二$pid和$core是框架注入的元信息比自己去查当前进程靠谱。第三if判断放在最前面因为脚本本身的执行也是在 VM exit 里跑的写得越重系统越慢——一个每命中一次都做字符串拼接的脚本足以把整机响应拖到不可用。加载方式有两种内联写在命令后面3.2 节那种或者写成.ds文件用!script加载。前者适合一次性的小探针后者适合反复迭代的过滤器——改文件、重新!script比每次重敲一长串命令舒服得多。字段名和事件名在不同构建之间会有差异第一版脚本请只打印$pid和$context-rip这两个最稳的字段跑通之后再往上加。实际操作中我习惯分两步收敛第一步用最宽松的条件甚至不加任何if跑十秒看命中频率和关心的字段长什么样第二步拿真实值写死条件重新加载。跳过第一步直接写死条件经常会出现一条都不打印的情况然后你要花更多时间去分辨是条件写错了还是事件根本没触发。4.4 把命中落到磁盘日志与回放长时间跟踪不可能一直盯着屏幕。这套工具提供了日志开关命中输出可以落到文件里事后再用文本工具慢慢筛# 开启日志后续所有输出同时写入文件 !logopen C:\trace\session.log # 跟踪跑一段时间后关闭日志避免文件无限增长 !logclose日志路径要提前建好目录路径不存在时命令会失败但不一定报错你会以为开了其实没开。日志文件在多核高命中场景下增长极快几百 MB 是常态所以通常配合脚本里的条件过滤一起用——先过滤再记录而不是先记录再 grep。回放阶段建议按 PID 和核号分组看混在一起的多核日志读起来基本没有意义。5. 避坑与排查五条用重启换来的经验现象一执行load vmm之后毫无反应几秒后系统重启。原因几乎总是 VT-x 被别的组件占着基于虚拟化的安全功能、内存完整性、其它虚拟化类工具或者虚拟机本身没开嵌套虚拟化。解决顺序是先确认虚拟机设置里 VT-x 已透传再逐项关闭宿主系统上的冲突功能最后重启一次让资源彻底释放。改完不重启是最常见的无效操作。现象二断点下上了命令也回显成功但一次都不命中。分三种情况地址是别的进程上下文下的虚拟地址在目标进程里根本没映射目标页被换出或者属性不允许执行目标函数已经被别的 hook 改过真实执行流根本没过你设的地址。排查顺序是先用!pte看页表项是否有效再用 PID 传 0 做全进程试探最后用!va2pa确认物理页确实存在。三项都正常还不命中就去检查那个函数是不是被 inline hook 跳走了。现象三命中一次之后系统直接卡死或蓝屏。十有八九是回调里做了重活脚本里访问了会触发缺页的地址、打印了大结构体、或者在被 hook 的路径上又触发了同一个 hook 形成递归。解决办法是把回调降到最轻——只记录寄存器和一两个指针把结构体解析挪到命中之后离线做同时用条件过滤把命中频率压下来。虚拟机快照在这种时候就是救命稻草别省这一步。现象四脚本加载成功、没有报错但什么都没打印。先排除最蠢的可能输出没刷新、日志路径写错、脚本文件编码带 BOM 导致解析异常。然后是逻辑问题条件里比较的值是从别处抄来的跟你这次的实际值对不上。做法是先把整个if注释掉只打印$pid确认事件通路是通的再逐条加回条件。现象五函数名全显示成裸地址调用栈只有偏移。符号没加载或者加载了不匹配的版本。不同系统构建的内核结构体偏移不一样符号对不上时读出来的字段会整体错位比不读还危险——你会基于错误的数据得出错误结论。稳妥做法是下断点之前先确认目标模块的函数能正常解析符号路径固定下来不要中途切换。现象六顺带提一句用完监控项和隐藏功能忘了撤。监控项不撤每次内存访问都要 VM exit整机慢到你以为系统坏了隐藏功能不撤后续排查会被自己制造的看不见的进程干扰。养成习惯每开一个观察点就在纸上记一笔收工时逐条清掉。6. 进阶把 EPT 断点、内存监控和脚本串成一条可复现的分析链单个命令解决不了真实问题真正的分析永远是组合拳。举一个典型场景某个内核结构体里的字段在运行一段时间后被改成了非法值你不知道是谁改的也不知道什么时候改的。可行的路径是这样第一步定位结构体地址。在消费这个结构体的内核函数上下 EPT 断点用脚本模式打印参数里的指针跑一次正常业务流程拿到指针值。第二步用!monitor w盯住那个字段所在的一小段范围范围控制在 8 到 16 字节别贪大。第三步命中后立刻!stack回溯同时!dr取寄存器确认写值这三条信息凑齐基本就能锁定写入方。第四步把整个流程写成脚本固定下来下次换一个结构体只改地址和偏移。# 第一步在消费函数上下断脚本模式不中断执行 !epthook fffff80512345678 script { printf(consumer hit, arg%llx\n, $context-rcx); } # 第二步拿到指针后监控其中的目标字段假设偏移 0x18 !monitor w fffff80520001018 fffff80520001020 0 0 # 第三步命中后回溯调用方 !stack # 第四步收工撤掉所有观察点 !monitor c这套流程里最容易出错的是第二步的范围。给大了日志爆掉给小了漏掉目标字段判断依据是结构体定义里那个字段的实际宽度——指针就是 8 字节标志位可能只有 4 字节按实际宽度上下各留一点余量就够。比流程更重要的是验证。任何一条链路上线之前先做一次对照实验自己用内存写命令往那个字段写一个可识别的值看监控项能不能抓到、脚本能不能打出来。抓不到就说明监控没生效别急着去分析业务逻辑。这个习惯听起来笨但它把工具没配对和目标没发生彻底分开了能省掉大量无意义的猜测。另外值得养成的习惯是给每次分析留一份最小复现脚本。把用到的 EPT 断点、监控项、过滤条件、日志路径全写进一个.ds文件加一段命令清单注释清楚这次改了什么地址。内核分析动辄跨越几天换个构建、换个快照之前的环境全没了没有这份清单就得从头再来一遍。从那以后我每次开工前都强制走一遍 2.5 节那四项自检确认寄存器、进程、地址翻译、恢复执行全部正常才允许自己下第一个断点——这一步花三分钟能挡掉后面三小时的自证清白。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站