nt-load-order Part 1 WinDbg'ing our way into the Windows bootloader
这是关于 WinDbg 基础知识、Windows 驱动程序加载顺序和我的 nt-load-order crate 的两部分博客系列的第一部分:
-
第 1 部分:使用 WinDbg 探索 Windows 引导加载程序 -
第 2 部分:计划于 2025 年 1 月 26 日发布
几乎没有任何理由去逆向工程 Windows 驱动程序的加载顺序。这正是我要做这件事的原因。如果你和我一样疯狂,想用 Rust 编写一个 Windows 引导加载程序,你就不可避免地需要处理这个主题。同样,如果你想了解引导 Windows 时底层发生了什么,这篇文章就是为你准备的。虽然 Mark Russinovich 的 LoadOrder 工具为我们提供了长期的支持,但该工具现在已经过时,不了解四分之一世纪以来 Windows 的变化。我也不知道还有谁最近深入研究过加载顺序,因此我自己承担了这项任务。
在这篇文章中,我将介绍我对 Windows 10 21H2 加载顺序的研究成果,以及用 Rust 实现的逻辑。据我所知,这些见解也适用于最新的 Windows 11 版本。我的 Rust 库附带了一个 Win32 GUI 工具,用于探索本地和目标 Windows 安装的加载顺序。如果这些内容听起来对你有趣,请继续阅读。
它是什么以及为什么重要
Windows 引导加载程序的基本任务之一是在将控制权移交给内核之前,将内核及其初始引导驱动程序加载到内存中。在这个早期阶段,引导加载程序使用固件提供的函数(即 UEFI 的块 I/O 协议)从磁盘读取文件。当内核接管时,加载的引导驱动程序需要足以让内核自行访问磁盘并读取剩余文件,而无需依赖通用的固件提供的函数。
因此,这个引导驱动程序列表并非一成不变。它在很大程度上取决于计算机的硬件和软件配置。第一个加载的驱动程序之一就取决于你的 CPU,它可能是 x86 架构,但可能是 AMD 或 Intel 的。Windows 附带了不同的 CPU 驱动程序,以适应它们特定的微码更新程序和电源管理功能。接下来是总线系统和磁盘控制器的驱动程序,直到我们爬到树的足够高度以访问引导磁盘。引导分区的文件系统还需要一个额外的驱动程序 - 现在专门使用 NTFS,但过去情况有所不同。安全产品也可能决定将其驱动程序添加到此列表中,以便在尽可能早的阶段扫描恶意软件(这种技术称为早期加载反恶意软件或简称 ELAM)。最后,微软对遥测和安全补充的热情又增加了更多驱动程序到这个列表中。
Windows 在 HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServices
注册表项中维护所有引导驱动程序。这不仅仅是一个简单的列表,而是分布在多个键和子键中的多层组织 - 复杂到足以在后续文章中专门讨论。
引导驱动程序是常规的 PE 二进制文件,可以有依赖关系。这意味着 Windows 引导加载程序需要查看每个驱动程序并加载它们的所有依赖项。自 Windows 10 以来,内核二进制文件也可能有 API Set 依赖项,当前的引导驱动程序正在积极使用该功能。因此,现代 Windows 引导加载程序需要检查依赖项是否是 API Set 依赖项,并可能将其转换为操作系统风格的目标模块。风格是另一个最近引入的概念:请记住,Windows 已经超越了客户端和服务器市场,现在也在 Xbox 或 HoloLens 等微软硬件产品上运行。
引导过程中的 API Set 是近年来演变趋势的另一个指标:越来越多的操作系统功能需要引导加载程序支持,而以前这些功能仅与内核和堆栈的上层相关。
我在这里发布的大部分内容都没有得到微软的官方文档记录。Windows 内部原理这本书涵盖了一些方面,独立博主也研究了引导过程的其他一些部分。但是要全面了解现代 Windows 引导过程,如果不启动调试器和你最喜欢的逆向工程工具是不可能的。
即使使用调试器,最终的引导驱动程序加载顺序也不会轻易呈现给我们。Windows 引导加载程序将其嵌入到传递给内核的 LOADER_PARAMETER_BLOCK
结构中。然后内核解析该结构并很快丢弃它。在这个阶段查看结构已经太晚了。
要知道 LOADER_PARAMETER_BLOCK
中真正包含的内容,我们需要在最早的阶段调试中断进入内核。这正是我们现在要做的。
设置调试环境
Windows 带有一个出色的图形内核调试器,称为 WinDbg。它自早期 Windows NT 时代以来不断成熟,最近被重写为具有现代 UI 的 Windows Store 应用程序。虽然它可以调试我们主机计算机上的各种内核模式和用户模式目标(包括本地运行系统的内核),但我们实际上想要使用它在引导早期中断进入内核。这需要一个额外的目标 Windows 机器,该机器连接到我们的主机,我们可以随意启动和停止。虚拟机显然是一个不错的选择。
我选择在 QEMU 虚拟机上安装 Windows 10 21H2,使用 qemu.weilnetz.de 上提供的预编译 QEMU 构建。由于我们在这里调试引导过程,引导加载程序的选择变得非常重要,我决定使用基于 UEFI 的 Windows 引导加载程序。因此,我们的 QEMU VM 需要配备 UEFI 固件。我可能还需要在虚拟机的虚拟硬盘之间传输数据,所以我选择了 Windows 开箱即用支持的唯一虚拟硬盘格式,即 VHD。最后,当模拟 USB 平板电脑等绝对定位设备时,模拟鼠标指针的精度要高得多,所以你也要启用它。
综上所述,我完整的 QEMU 命令如下:
"C:Program Filesqemuqemu-img.exe" create -f vpc win10.vhd 40G
"C:Program Filesqemuqemu-system-x86_64.exe" -nodefaults -m 4096 -smp 4 -vga std -drive if=pflash,format=raw,unit=0,readonly=on,file="C:Program Filesqemushareedk2-x86_64-code.fd" -hda win10.vhd -cdrom win10_installation.iso -usbdevice tablet
使用这些命令安装 Windows 10。最重要的是要有耐心:当 Windows 10 同时作为主机和客户机时,QEMU 的运行速度可能会非常慢。当你最终完成安装后,可以移除 -cdrom
参数,因为我们不再需要安装 ISO 了。
要为调试准备新安装的系统,请以管理员权限打开命令提示符,并使用 BCDEdit 复制默认的引导加载程序条目:
bcdedit /copy {default} /d "Windows 10 Debug"
该命令会显示类似以下的消息:
The entry was successfully copied to {aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}.
在后续命令中使用该 {aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}
标识符来为新创建的引导项设置内核调试:
bcdedit /set {aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee} debug on
bcdedit /set {aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee} dbgsettings serial debugport:1 baudrate:115200
现在已经设置好了通过串行端口进行内核调试,我们需要为 QEMU 虚拟机添加相应的虚拟串行端口。我通过在 qemu-system-x86_64
命令行中添加 -serial tcp::13337,server
来实现这一点。当你现在再次启动 QEMU 时,它会在启动虚拟机之前等待 WinDbg 连接到该 TCP 端口。这给了我们充足的时间在另一端设置 WinDbg。
配置 WinDbg
如果你之前使用过经典的 WinDbg GUI,那就忘掉你所知道的一切吧。来自 Windows Store 的新版 WinDbg 已经完全重写,它带来了全新的用户界面以及 JavaScript 自动化支持。当我开始研究时它还是预览版,但现在已经稳定并可用于生产环境(至少他们是这么说的)。
在我们的情况下,我们需要将其设置为通过网络连接调试串行目标。WinDbg 的"附加到内核"窗口中的"网络"和"串行"选项卡看起来很诱人,但遗憾的是这两个选项都不适合我们的情况:"网络"选项只适用于通过以太网直接连接的目标,但我们的虚拟机实际上有一个串行端口。虽然"串行"选项确实支持物理串行端口以外的设备,但没有用于输入 IP 地址和 TCP 端口的字段。因此,我们需要通过"粘贴连接字符串"选项卡并输入:
com:ipport=13337,port=127.0.0.1
请确保勾选 Break on connection 复选框。这是我们尽可能早地中断进入内核以获取 LOADER_PARAMETER_BLOCK
的关键。
当你点击 确定 后,QEMU 会立即继续启动系统。在引导加载程序中选择新创建的 Windows 10 Debug 条目,WinDbg 将在 KiSystemStartup
的一个子调用中中断进入内核。
转储 LOADER_PARAMETER_BLOCK
调试 Windows 内核可以是一项非常令人感激的任务。尽管 Windows 是闭源的,但微软为大多数操作系统组件提供了可下载的符号文件。更好的是,WinDbg 会自动检测所有已加载模块的确切版本并下载相应的符号文件。而像 Vergilius Project 这样的独立网站已经使符号文件内容可以通过网络轻松访问。
根据你的 WinDbg 设置,所有这些检测和下载过程可能已经在后台完成。我的 WinDbg 一旦中断进入调试器就会获取缺失的内核符号。你可以通过在命令窗口中输入 lm
并检查 nt
行来验证这一点。如果你的 WinDbg 没有显示 nt
的 pdb symbols
,请尝试以下命令来重置符号路径并下载缺失的符号:
.sympath srv*
.reload
现在设置终于完成了,是时候转储著名的 LOADER_PARAMETER_BLOCK
结构了。在引导的这个早期阶段,它仍然存储在全局变量 KeLoaderBlock
中。执行以下命令
dt poi(nt!KeLoaderBlock) nt!_LOADER_PARAMETER_BLOCK
显示了它的所有字段:
输出中的一些可点击链接也邀请你进一步探索子字段。但这种乐趣并没有持续太久:点击 LoadOrderListHead
字段只显示 Flink
和 Blink
子字段,但缺少有关实际元素内容和后续元素的任何信息。我们需要以某种方式了解每个元素的底层结构并遍历这个双向链表。
这就是我们要感谢一些微软开发人员粗心大意的地方。如果你恰好拥有早期 Windows 10 版本 1507 和 1511 的 Windows Driver Kit (WDK),并仔细检查其安装,你会发现一个内容丰富的 C:Program Files (x86)Windows Kits10Include10.0.10586.0umminwin
文件夹,这个文件夹在后续的 WDK 版本中都没有提供。必须假设这些头文件是误发布的。这个文件夹是关于引导加载程序信息的宝库。在我们的例子中,文件 arc.h
特别有趣,因为它定义了一个结构 BLDR_DATA_TABLE_ENTRY
,这个结构被证实是 LoadOrderListHead
元素的类型:
typedefstruct _BLDR_DATA_TABLE_ENTRY {
KLDR_DATA_TABLE_ENTRY KldrEntry;
UNICODE_STRING CertificatePublisher;
UNICODE_STRING CertificateIssuer;
PVOID ImageHash;
PVOID CertificateThumbprint;
ULONG ImageHashAlgorithm;
ULONG ThumbprintHashAlgorithm;
ULONG ImageHashLength;
ULONG CertificateThumbprintLength;
ULONG LoadInformation;
ULONG Flags;
} BLDR_DATA_TABLE_ENTRY, *PBLDR_DATA_TABLE_ENTRY;
在我的研究过程中,Microsoft 悄悄地停止了对 WDK 1507 和 1511 的下载支持。如果你是企业客户,你可能还有机会从 Microsoft 代表那里获取它:毕竟,Windows 10 版本 1507 以"Windows 10 LTSB"的形式在企业版中得到支持直到 2025 年。对于其他所有人来说,Microsoft 的通用源代码转储平台(也就是众所周知的 GitHub)拯救了这一天:有人好心地将 WDK 1507 安装的头文件上传到了 GitHub,地址是 https://github.com/tpn/winsdk-10/tree/master/Include/10.0.10240.0/um/minwin。
现在我们知道了列表元素的类型,如何使用它呢?我们知道 BLDR_DATA_TABLE_ENTRY
的结构,但它并没有随任何 WinDbg 可以轻松使用的 PDB 一起提供。针对这些场景,Microsoft 开发了 SynTypes.js
脚本,它可以从头文件中读取任意结构并使其在 WinDbg 中可用。
要获取 SynTypes.js
,克隆 https://github.com/microsoft/WinDbg-Samples 仓库(例如克隆到 C:WinDbg-Samples
)。然后在 WinDbg 命令窗口中输入以下命令:
.scriptload "C:WinDbg-SamplesSyntheticTypesSynTypes.js
这使得功能强大但不太人性化的 Debugger.Utility.Analysis.SyntheticTypes
命名空间在你的 WinDbg 会话中可用。现在可以通过以下方式读取一个_简单的_头文件
Debugger.Utility.Analysis.SyntheticTypes.ReadHeader("C:ExperimentsMyHeader.h", "nt")
然后这些结构就会作为内核的合成类型可用。你可以通过调用以下命令来转储特定内存地址的结构,例如:
dx Debugger.Utility.Analysis.SyntheticTypes.CreateInstance("MY_STRUCTURE", 0xfffff806`43656300)
让我们回到我们感兴趣的 BLDR_DATA_TABLE_ENTRY
结构。如果你将上述结构完全按原样放入 bldr.h
文件并通过 SynTypes 加载它,你会注意到 ReadHeader
确实成功解析了头文件,但后续无法使用 CreateInstance
来使用它。我遇到的具体错误是:
Unable to get property 'size' of undefined or null reference [at SynTypes (line 102 col 5)]
就像计算机科学中经常出现的情况一样,这个错误消息并不能真正帮助我们找出问题所在,但当你知道解决方案时就会明白其中的道理。回想一下,当我们通过 dt poi(nt!KeLoaderBlock) nt!_LOADER_PARAMETER_BLOCK
转储时,需要为 LOADER_PARAMETER_BLOCK
添加一个前导下划线。在这种情况下,我们只在文件中定义了 BLDR_DATA_TABLE_ENTRY
,而依赖所有其他类型来自已加载的 PDB。但是 PDB 已经不知道从 _LOADER_PARAMETER_BLOCK
到 LOADER_PARAMETER_BLOCK
的 typedef,这同样适用于我们试图在 BLDR_DATA_TABLE_ENTRY
中使用的结构。
因此,我们需要为 BLDR_DATA_TABLE_ENTRY
中的三个结构字段添加前导下划线:
typedefstruct _BLDR_DATA_TABLE_ENTRY {
_KLDR_DATA_TABLE_ENTRY KldrEntry;
_UNICODE_STRING CertificatePublisher;
_UNICODE_STRING CertificateIssuer;
PVOID ImageHash;
PVOID CertificateThumbprint;
ULONG ImageHashAlgorithm;
ULONG ThumbprintHashAlgorithm;
ULONG ImageHashLength;
ULONG CertificateThumbprintLength;
ULONG LoadInformation;
ULONG Flags;
} BLDR_DATA_TABLE_ENTRY, *PBLDR_DATA_TABLE_ENTRY;
你可以相应地更新 bldr.h
中的结构,但在同一文件上再次调用 ReadHeader
不会重新加载它。你唯一的选择是在重新加载 bldr.h
之前先 .scriptunload
然后 .scriptload
整个 SynTypes.js
脚本。
现在我们终于在 WinDbg 中有了一个可用的 BLDR_DATA_TABLE_ENTRY
类型,是时候转储所有 LoadOrderListHead
元素了。WinDbg 为此提供了 !list
命令,它会遍历链表的链接并为每个元素执行一个命令。结合 SynTypes.js
的函数,我们最终得到了这个庞大的 WinDbg 命令:
dx @$instance = "BLDR_DATA_TABLE_ENTRY"
!list -t _LIST_ENTRY.Flink -x "r @$t0 = @$extret; dx -r2 Debugger.Utility.Analysis.SyntheticTypes.CreateInstance(@$instance, Debugger.State.PseudoRegisters.Temporaries.t0)" 0xfffff806`43656300
这些命令需要一些解释。!list
命令在遍历链表时非常方便。它需要一个类型参数 -t
,设置为 LIST_ENTRY
结构的 Flink
字段以正向遍历,还需要一个额外的参数 -x
,其中包含要为每个元素执行的命令字符串。
但是 !list
来自 WinDbg 的早期,有多个限制会让我们的工作变得困难。例如,它不支持在 -x
参数内使用额外的引号,这就排除了将 "BLDR_DATA_TABLE_ENTRY"
传递给 CreateInstance
调用的可能。我能想到的唯一解决方法是将其声明为变量,然后传递该变量。
实际上存在两个变量世界:一个是通过 r
命令读写的带有伪寄存器 (如 @$t0
) 的"传统"世界。另一个是通过 dx
命令访问的现代"调试器对象模型"。这两个世界并不总是兼容的。
一个例子是 !list
本身,它定义了一个伪寄存器 @$extret
来传递当前元素的地址。然而,这个伪寄存器无法通过调试器对象模型的 Debugger.State.PseudoRegisters
访问。此外,!list
要求你在 -x
参数中至少使用一次 @$extret
,否则它会被附加到命令字符串的末尾 (通常会导致语法错误)。
经过多次尝试,我终于找到了解决这些限制的方法:在每次迭代时将已知的用户定义伪寄存器 @t0
设置为 @$extret
的值,然后在 dx
调用中通过 Debugger.State.PseudoRegisters.Temporaries.t0
访问该寄存器。
起初我以为只有 !list
命令特别有问题,而 WinDbg 的其他部分都没问题。但在阅读了 Thomas Weller 关于 WinDbg 脚本问题的详细 StackOverflow 回答后,我得出结论:这些限制在 WinDbg 世界中很常见,我们将来可能会看到更多类似的变通方法。
为了完整性,我必须说这一切之所以能工作,是因为 KldrEntry.InLoadOrderLinks
是 BLDR_DATA_TABLE_ENTRY
的第一个字段,并且包含了我们想要遍历的链接。如果不是这种情况,我们就需要一个额外的 CONTAINING_RECORD
命令来计算列表元素的偏移量。我将在下一节中展示这一点。
一旦你运行上述命令,我们就会得到所有 LoadOrderListHead
元素的格式良好的转储:
当 SynTypes.js
不起作用时
在这个项目中,我遇到了 SynTypes.js
的问题,于是我向 Yarden Shafir 请教,她写了一系列出色的 WinDbg 博客文章(向她致敬)。她将我介绍给了微软的 WinDbg 开发者 Will Messmer,他最终复现了我的问题。原来我使用的 WinDbg 预览版存在一个 bug,导致 SynTypes.js
和其他命令在启动早期无法工作。这个问题最终在 WinDbg 的正式版本中得到修复,但我花了几个月才等到它。
在此期间,我需要一个临时解决方案,我的方案是简单地使用 PDB 中包含的 KLDR_DATA_TABLE_ENTRY
来转储 LoadOrderListHead
。虽然会错过 BLDR_DATA_TABLE_ENTRY
中添加的证书、哈希和标志,但仍然可以很好地概览所有数据表条目。我使用的命令是:
!list -t _LIST_ENTRY.Flink -x "dt nt!_KLDR_DATA_TABLE_ENTRY" 0xfffff806`43656300
这个命令完全使用了经典的 WinDbg 功能,没有使用调试器对象模型。幸运的是,这样我就不需要使用上面描述的那些丑陋的变通方法了。
为了完整性,我承诺提供一个使用 CONTAINING_RECORD
的示例,即使 InLoadOrderLinks
不是结构的第一个字段,这个示例也能正常工作。示例如下:
!list -t _LIST_ENTRY.Flink -x "dt nt!_KLDR_DATA_TABLE_ENTRY @@(#CONTAINING_RECORD(@$extret, nt!_KLDR_DATA_TABLE_ENTRY, InLoadOrderLinks))" 0xfffff806`43656300
由于 InLoadOrderLinks
是第一个字段,这行命令与较短的命令产生完全相同的结果。
引导调试 - 我们需要深入探索
现在我们已经掌握了确定最终加载顺序的所有方法。但是这个加载顺序究竟是如何在引导加载程序中构建的呢?
要弄清楚这一点,我们需要更深入地调试实际的引导加载程序,而不仅仅是内核。而且我们很幸运:虽然 WinDbg 最初是为调试 Windows 内核而创建的,但引导加载程序也带有 WinDbg 存根,同样可以进行调试。
首先需要在引导菜单中添加另一个条目,让我们称之为 Windows 10 BootDebug:
bcdedit /copy {default} /d "Windows 10 BootDebug"
该命令会再次输出一个 GUID。随后,你可以通过以下命令为此条目启用引导加载程序调试:
bcdedit /set {aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee} bootdebug on
bcdedit /set {aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee} dbgsettings serial debugport:1 baudrate:115200
当你现在重启 Windows 虚拟机并连接 WinDbg 时,它会在早期调用的 DebugService2
函数中断点。微软符号服务器很贴心地再次为该二进制文件提供了 PDB,为我们的 WinDbg 会话提供了所有公共甚至私有函数的名称。太好了!
现在让我们找出我们实际想要设置断点的位置。这就是你最喜欢的反编译器派上用场的时候了。自从 Ghidra 公开发布以来,我一直是它的忠实粉丝,并在后续对 winload.efi
的整个分析中使用它。但如果你更喜欢 Binary Ninja、IDA、Radare2 等工具,它们也同样可以完成这项工作。事实上,我当时使用的 Ghidra 版本在从 PDB 导入符号时遇到了问题,所以我不得不绕了个路:我先将 winload.efi
导入到 IDA 中,使用 Ghidra 自带的 xml_exporter.py
插件将 IDA 的分析结果导出为 XML,最后再将该 XML 数据导入到 Ghidra 中。现在你可能不需要重复这些步骤,但了解这个技巧总是好的。
我们现在有超过 5000 个命名函数。我们该从哪里开始呢?这就是我在 ReactOS 的同事们细致工作发挥作用的时候了。二十多年来,ReactOS 项目的成员们在逆向工程世界上最复杂的操作系统(Windows)并创建开源替代品方面做出了巨大贡献。对他们来说,仅仅创建一个功能等效的系统是不够的,他们会利用所有可用的公开信息来保持内部结构尽可能接近原始系统。这一点在内核和引导加载程序等相邻组件上尤其明显。尽管 ReactOS 传统上关注现已过时的 Windows Server 2003,但引导加载程序的许多部分仍然相似,为我们提供了从何处开始寻找的线索。
一个很好的起点是 CmpDoSort
,它在 ReactOS 内核和 Windows 引导加载程序中都以相同的名称存在。正如其名称和函数级注释所示,这个私有函数用于根据排序标准对驱动程序进行排序。通过跳转到 CmpDoSort
的调用者及其父调用者,我们可以快速建立所有相关函数的调用树:
OslpLoadAllModules
-> OslLoadImage // ntoskrnl.exe
-> OslLoadImage // hal.dll
-> OslLoadApiSetSchema
-> OslKdInitialize
-> OslLoadImage // mcupdate.dll
-> OslGetBootDrivers
-> OslHiveFindDrivers
-> CmpFindDrivers
-> CmpFindPendingDrivers
-> CmpAddDependentDrivers
-> CmpSortDriverList
-> CmpDoSort
-> OslpFilterDriverListOnGroup // for EarlyLaunchListHead
-> OslpFilterDriverListOnServices // for CoreDriverListHead
-> OslpFilterDriverListOnServices // for TpmCoreDriverListHead
-> OslFilterCoreExtensions
-> OslLoadDrivers // CoreDriverListHead
-> OslLoadDrivers // TpmCoreDriverListHead
-> OslLoadDrivers // EarlyLaunchListHead
-> OslLoadDrivers // CoreExtensionDriverListHead
-> OslFilterCoreExtensions
-> OslLoadDrivers // BootDriverListHead
太棒了!这为我们提供了足够多的断点设置位置,让我们能够在引导加载程序构建加载顺序时检查中间状态。
这些函数中的一些还将 LOADER_PARAMETER_BLOCK
作为第一个参数。多亏了 arc.h
和公共 PDB,我们知道 LOADER_PARAMETER_BLOCK
的完整结构,并可以将其添加到 Ghidra 中。有了函数和字段名称,我们现在可以像阅读原始源代码一样阅读引导加载程序的许多部分。
在 2025 年,没有自定义 JavaScript 的项目是不完整的。你可能不会相信,这也适用于我们的引导加载程序研究:尽管我们刚刚学会了如何转储内存中的结构,甚至添加合成结构并解决 WinDbg 的不足,但在引导加载程序调试期间,所有这些知识都是无用的。首先,内核还没有加载,这就是为什么所有 nt
PDB 符号都不可用。更糟糕的是,SynTypes.js
脚本在这个早期阶段根本不起作用。
幸运的是,现代 WinDbg 带有一个简单的编辑器来编写我们自己的 JavaScript - 这在引导加载程序调试期间实际上是可以工作的。你需要自己编写样板代码,但一旦完成,解析任何结构都相当简单。让我们试试看。
一些辅助函数
所有有趣的事情都从切换到 WinDbg 的 Scripting 标签开始,点击 New Script 并选择 JavaScript → Imperative Script。这会为我们提供一个模板,其中只包含一个 initializeScript 和一个空的 invokeScript 函数。让我们通过扩展 invokeScript 函数来在 WinDbg 中实现第一个"Hello world":
host.diagnostics.debugLog('Hello worldn');
这个冗长的调用很快就会变得笨拙,所以我创建了一个辅助函数:
functionlog(msg) {
host.diagnostics.debugLog(msg);
host.diagnostics.debugLog('n');
}
我们接下来会多次用到它。
让我们现在开始输出一些动态内容,比如内存地址中的值。在 WinDbg 的 JavaScript 方言中,读取一个 32 位值是这样的:
let address = 0x12345;
let value = host.memory.readMemoryValues(address, 1, 4)[0];
log('Value: ' + value);
看起来很简单,对吧?等等,当你想在实际场景中使用它时就不那么简单了。我很快就意识到 WinDbg 的 JavaScript 方言本身并不支持 64 位数学运算。如果你用 64 位内存地址替换 0x12345
,我的示例就无法工作了。
不过 WinDbg 提供了多种解决方案。其中之一是 host.parseInt64
函数,它可以从字符串创建一个 64 位整数值:
let address = host.parseInt64('0xfffff80012345f90');
然而大多数情况下,你并不想读取一个静态地址。我通常会在要读取的地址存储在寄存器中时中断到调试器。这可以通过以下方式实现:
let regs = host.currentThread.Registers.User;
let address = regs.rcx;
现在如果你需要给寄存器值添加偏移量,可以通过 .add
函数来实现,例如 let address = regs.rcx.add(0x10)
。原生 JavaScript 数学运算会再次搞乱 64 位值。
我们将多次读取 16 位、32 位和 64 位整数值,所以添加一些辅助函数是有意义的。不过它们都是相对简单的单行代码:
functionread_u16(address) {
return host.memory.readMemoryValues(address, 1, 2)[0];
}
functionread_u32(address) {
return host.memory.readMemoryValues(address, 1, 4)[0];
}
functionread_u64(address) {
return host.memory.readMemoryValues(address, 1, 8)[0];
}
我们接下来要检查的几个结构体中也包含了 UNICODE_STRING
类型。上面提到的 BLDR_DATA_TABLE
就是一个例子,而且我们还会遇到更多。由于字符串通常是在调试器中最有趣的字段,所以我们还需要一个函数来读取 UNICODE_STRING
。让我们回顾一下 UNICODE_STRING
的结构:
typedefstruct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, *PUNICODE_STRING;
与传统的 C 字符串不同,UNICODE_STRING
不一定以 NUL 字符结尾。UNICODE_STRING
的实际长度存储在第一个字段中。MaximumLength
字段仅在修改字符串时才有意义,不过我们不会这样做。
让我们将这些知识转化为一个 WinDbg JavaScript 辅助函数:
functionread_unicode_string(address) {
let length = read_u16(address);
let buffer_address = read_u64(address.add(8));
let buffer = host.memory.readMemoryValues(buffer_address, length / 2, 2);
returnString.fromCharCode(...buffer);
}
仔细观察,你可能会注意到一个奇怪的地方:为什么我们从偏移量 8 读取缓冲区地址,而 Length
和 MaximumLength
每个字段只有 2 字节?这就是你需要考虑我们正在调试的架构的地方:x64 架构的 ABI 要求每个结构字段都按其大小的倍数对齐。由于 PWSTR
是指向宽字符串缓冲区的指针,而指针长度为 8 字节,这个字段最终位于偏移量 8,在 MaximumLength
字段后添加了 4 字节的填充。
原文始发于微信公众号(securitainment):使用 WinDbg 探索 Windows 引导加载程序的 nt-load-order 第 1 部分
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论