nt-load-order Part 2 More than you ever wanted to know - ColinFinck.de
这是一个由两部分组成的博客系列的第二部分,主要介绍 WinDbg 基础知识、Windows 驱动程序加载顺序以及我的 nt-load-order 项目:
欢迎来到我关于 Windows 驱动程序加载顺序系列博客的第二部分。很高兴看到你能坚持到这里。
在上一篇文章的准备工作之后,我们现在终于具备了分析加载顺序和开发兼容排序算法所需的所有工具。
硬编码模块
让我们从简单的部分开始。从上一篇文章中显示的调用树可以得出结论,加载顺序中的前 3-4 个模块是硬编码的。一切都始于内核二进制文件ntoskrnl.exe
,然后是 HAL hal.dll
(在最新的 Windows 中只是一个存根,但仍然会加载)。如果在启动期间启用了内核调试,则会加载相应的 KD 传输驱动程序。在我的情况下,这是用于串行内核调试驱动程序的kdcom.dll
。
到目前为止,这与我从开源 ReactOS 引导加载程序("FreeLdr")中已知的内容完全匹配。然而,Windows 是一个不同的品种,它加载了第四个硬编码驱动程序,即 CPU 微码更新程序mcupdate.dll
。这个驱动程序根据检测到的 CPU 而不同,这就是为什么在我的机器的文件系统中实际上叫做mcupdate_AuthenticAMD.dll
。尽管如此,在KLDR_DATA_TABLE_ENTRY
结构中设置的BaseDllName
始终是mcupdate.dll
。
这 3-4 个模块在转储LoadOrderListHead
时总是最先看到的。它们在被添加后就不会再改变位置。
现在让我们来看看实际的驱动程序。
加载引导驱动程序
查看调用树,我们现在已经到达了调用OslGetBootDrivers
的部分。从子调用可以想象,这里的情况更加动态:引导加载程序在OslHiveFindDrivers
中检查注册表以查找要加载的驱动程序,在CmpAddDependentDrivers
中添加这些驱动程序的依赖项,最后在CmpSortDriverList
中对列表进行排序。
乍看之下,所有这些看起来都与之前研究过的内容以及 ReactOS 引导加载程序中使用的内容相似。另一方面,ReactOS 在引导加载程序中没有 API Sets 支持,所以在 Windows 上的情况又会有细微的不同。
注册表之旅
收集动态引导驱动程序从检查HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlServiceGroupOrder
注册表键中的List
值开始。这是一个多行字符串值(REG_MULTI_SZ
),包含了要加载的驱动程序组的名称。
这些组中的每一个在HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlGroupOrderList
注册表键中都有一个二进制值(REG_BINARY
)。这基本上是一个带长度前缀的数组。前 4 个字节表示一个小端整数,表示后面跟随的项目数量。后面的项目也是 4 字节小端整数,称为_标签_。这里重要的是标签的值和顺序。但我们稍后会详细讨论这一点。这种格式几十年来都没有改变,所以现在你可以找到几个独立的实现(例如在 ReactOS 引导加载程序中)。
最后,每个引导驱动程序在HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServices
中都有一个子键。但这不仅限于引导驱动程序:按需加载的驱动程序、传统驱动程序和 NT 服务也都在这个注册表键中占有一席之地。
要编写引导加载程序,我们需要枚举所有子键并检查每个子键的Start
值。这个REG_DWORD
值表示服务是否应该启动以及由谁启动。所有可能的配置都在Microsoft 文档中有记载,但我们的引导加载程序只对零值感兴趣,这表示引导驱动程序。
最近的 Windows 版本在上面又添加了另一层间接性:驱动程序的注册表子键可能包含一个StartOverride
子键,其中为每个硬件配置文件都有一个额外的值。
硬件配置文件在 Windows NT 早期时代非常流行,当时笔记本电脑有大量 I/O 设备但中断线太少,无法同时使用所有设备。一些硬件设备由于其他原因也拒绝同时工作。硬件配置文件提供了一种简单的方法,可以在启动时只激活受影响设备中的一个。
当前的 Windows 版本在底层仍然围绕硬件配置文件构建,但我很久没有看到它们被使用了。无论如何,StartOverride
子键是这个时期的遗留物,我在 Windows 10 和 11 中仍然看到它被使用。一个例子是"3ware"驱动程序,从其Start
值来看像是一个引导驱动程序,但通过StartOverride
被转换为按需加载的驱动程序。
StartOverride
是我在最近 Windows 版本的引导加载程序中发现的细微差别之一。我所知道的开源实现都没有考虑这个键。
Start
和StartOverride
并不是引导加载程序唯一感兴趣的值。驱动程序的每个注册表子键还可能包含Group
和Tag
值。这用于将驱动程序关联到一个组,并引用加载顺序中的位置(相对于组内)。
引导驱动程序排序
现在我们已经了解了所有组和引导驱动程序,我们终于可以对它们进行排序并确定那个著名的驱动程序加载顺序。
从宏观角度来看,引导驱动程序根据ServiceGroupOrder
键中的List
值按组排序。在这些组内,考虑GroupOrderList
键中给出的标签顺序,每个驱动程序占据其引用标签的位置。
当然,微软并没有让它如此简单。如果我们真的想要获得与 Windows 引导加载程序完全相同的加载顺序,我们需要考虑所有边界情况:
-
如果驱动程序有
Group
但没有Tag
值会发生什么?
事实证明,Windows 引导加载程序会为这样的驱动程序分配一个0xffff_fffe
的索引,将它们放在组内加载顺序的最后。 -
如果驱动程序引用了一个组,但该组在
GroupOrderList
中没有条目会发生什么?
令人惊讶的是,这种情况在 Windows 10 和 11 的默认安装中就会发生:多个驱动程序引用了一个不存在的"Core"组。在这种情况下,Windows 引导加载程序简单地将Tag
值视为索引。在这一点上,知道索引是基于 1 的也很重要。 -
如果两个驱动程序具有相同的组和标签会发生什么?
有几个这样的情况,Windows 引导加载程序设法为它们维持一个确定性的顺序。然而,这种确定性顺序是 Windows 引导加载程序内部双向链表及其移动操作的副产品。我将在下面详细介绍这些细节。我的第一次尝试是使用基于在向量中交换元素而不是在列表中移动它们的排序算法。但这与原始顺序相比会产生略微不同的顺序。
这种复杂性引发了一个问题:为什么 Windows 选择通过标签的额外间接性来实现这一点。为什么不像ServiceGroupOrder
键中的List
值那样,只维护一个组内的驱动程序字符串列表呢?
我没有发明这个系统,所以我只能推测。但答案可能在于性能和面向未来的设计 - 就像早期 Windows NT 的设计中经常出现的那样:使用数字标签,向组中添加驱动程序就像编辑一个整数数组一样简单。在中间插入字符串要复杂得多。这对于组列表来说可能被认为是可以接受的,因为这个列表很少改变。但 Windows NT 开发人员显然预期驱动程序会更经常地添加到组中。
此外,你甚至可以通过交换它们的标签来交换两个驱动程序的位置。GroupOrderList
中的数组根本不需要为此而改变。
更多边界情况
显然,对于新一代 Windows 开发人员来说,这些还不够。除了这个复杂但可管理的组和标签系统之外,他们还在引导加载程序中硬编码了一些组。如果驱动程序引用这个组,它会优先于所有其他组,而不考虑ServiceGroupOrder
键中的任何条目。
一个突出的组是"Early-Launch"组,包含早期启动反恶意软件(ELAM)驱动程序。顾名思义,这些驱动程序应该在最早可能的阶段加载,以便在加载任何第三方驱动程序之前开始监控系统。Windows 引导加载程序还硬编码了另外两个组,即"Core Platform Extensions"和"Core Security Extensions"。
这就结束了硬编码组的章节吗?当然不是!
除了组名之外,最近的 Windows 引导加载程序还在列表中硬编码了单个驱动程序。这些驱动程序再次优先于其他所有内容。
我已经确定了两个这样的列表,称为OslCoreDriverServices
和OslTpmCoreDriverServices
。它们的名称确实表明它们有充分的理由首先加载。然而,我真的很想知道为什么这些驱动程序需要在引导加载程序中添加另一个硬编码列表,而不是通过组和标签来管理。
双向链表的重要性
从上一篇文章中我们已经知道,Windows 引导加载程序使用双向链表将最终的驱动程序列表作为LoadOrderListHead
传递给 Windows 内核。虽然由于访问链表时固有的 CPU 缓存未命中,不建议在用户模式下的新应用程序中使用链表,但它们在操作系统内核中一直很普遍,并且继续在那里发挥重要作用。
因此,Windows 引导加载程序的CmpDoSort
函数使用完全为双向链表构建的排序算法并不令人惊讶。让我惊讶的是,任何其他算法都会产生略微不同的顺序。你问这是怎么发生的?让我们通过查看 Windows 引导加载程序如何对驱动程序进行排序来了解:
-
CmpDoSort
被调用时带有一个包含从注册表的Services
键加载的引导驱动程序的列表。注册表按字母顺序存储它们并按字母顺序输出它们。但 Windows 引导加载程序遍历每个条目并将其推送到链表的_前面_。结果是一个包含所有项目的引导驱动程序列表,按相反的字母顺序排列。 -
反向字母顺序的列表从前到后按标签排序,使用插入排序。
-
结果列表只有在按组排序时才会从_后向前_:我们从
ServiceGroupOrder
中的最后一个组开始,从最后一个到第一个驱动程序遍历列表中的所有驱动程序。所有属于该组的驱动程序都被推送到列表的前面。继续反向迭代所有驱动程序,直到我们再次到达我们移动的第一个驱动程序。然后对倒数第二个组、倒数第三个组等重复整个过程。这里不要让向后迭代迷惑你:通过两次向后迭代并将元素推送到列表的前面,结果列表实际上是按升序排序的。 -
对结果引导驱动程序列表进行过滤:来自硬编码组和服务列表的驱动程序被移除并放入它们专用的列表中。然后首先为专用列表调用
OslLoadDrivers
,然后添加引导驱动程序列表中剩余的驱动程序。这就是LoadOrderListHead
获得其最终顺序的方式。
从反向字母顺序列表开始,然后向前迭代,后来又向后迭代,所有这些都在移动元素的同时,列表中没有一个驱动程序不受影响。但它们在每次启动时仍然有一个确定性的顺序。每个驱动程序都有其列表位置的原因。
我试图用不同的排序算法复制相同的顺序,但这比预期的要困难。如果你对细节感兴趣,请查看本文末尾的什么不可行?章节。我最终放弃了,而是花费精力更好地理解原始算法并提出一个兼容的重新实现。
处理依赖关系
只有在将所有引导驱动程序排序到列表中之后,引导加载程序才开始查看驱动程序二进制文件。将它们加载到内存中是第一步,但通常还不够:我们还需要检查所有引导驱动程序的 PE 头以查找导入的依赖项。这些也需要加载并添加到LoadOrderListHead
中。
为了保持事情的趣味性,微软开发人员再次使导入的结果顺序变得特殊。你可能认为它可以简化为一个简单的公式,比如"依赖项在其依赖者之前",但实际情况并非如此。让我们看看Wdf01000
驱动程序及其依赖项在LoadOrderListHead
中是如何排列的:
|
|
---|---|
|
|
|
|
|
|
|
|
看看SleepStudyHelper.sys
,它的导入WppRecorder.sys
在它之前加载。但为什么同样的规则不适用于Wdf01000.sys
?为什么它在LoadOrderListHead
中的所有导入之前加载?
对 Windows 引导加载程序内部LdrpLoadImage
函数的分析揭示了背后的原因。相关部分可以大致写成伪代码:
function LoadImage(Image, IsImport):
AddToEndOfLoadOrderListHead(Image)
for Import in Image.Imports:
LoadImage(Import, TRUE)
if(IsImport):
RemoveEntryFromLoadOrderListHead(Image)
AddToEndOfLoadOrderListHead(Image)
作为引导驱动程序的Wdf01000.sys
会通过调用LoadImage("Wdf01000.sys", FALSE)
来加载。该算法首先将给定的引导驱动程序插入到LoadOrderListHead
的末尾,之后不再移动它。然后,它通过递归调用自身来处理所有导入,并指示这些是导入项。导入项也会被插入到列表末尾。导入项的导入也以相同方式处理。但是,每当一个导入项的所有导入在LoadImage
中被完全处理后,之前添加的导入项就会从其位置移除并插入到列表末尾。因此,最终得到的加载顺序是:显式添加的引导驱动程序位于其任何导入项之前,但其导入项位于它们的导入项之后。可以说这部分至少遵循了严格的"依赖项在其依赖者之前"规则,但实际上仅限于这一部分。
这一切对 Windows 引导加载程序来说有多重要?其他算法是否也能同样有效?恐怕除非微软的人站出来解释,否则这些问题将无法得到答案。在此之前,作为逆向工程师,我们只能推测并尽可能地理解和复制原始实现以实现兼容性。
处理 API Sets 依赖关系
在实现类似LoadImage
的函数时,还有一个重要的第二部分需要考虑:Windows 10 为内核模式组件引入了 API Sets。虽然针对早期 Windows 版本的引导加载程序只需要跟踪驱动程序 PE 头中的导入文件名,但面向现代 Windows 的引导加载程序需要增加几个步骤:
-
检查每个文件名是否为 API Set(以 api-
或ext-
开头)。 -
然后在目标操作系统的 API Set 映射表中查找这个 API Set 文件名(位于其 apisetschema.dll
中)。 -
如果在 API Set 映射表中找到条目,那么这就是要加载的实际导入文件。 -
如果未找到条目,则表示该导入在目标操作系统上不存在。这种情况完全合法:请记住,Windows 现在也运行在完全不同的微软硬件产品上,如 Xbox 和 HoloLens。它们不需要通用 Windows 操作系统的所有功能。
幸运的是,你不需要自己实现 API Sets 背后的细节。我在之前的文章中已经介绍了我的 Rust crate:nt-apiset:Windows API Set 映射文件的 Rust 解析器
整合所有内容
到目前为止,你是否仔细遵循了所有规则和特性?别担心,你不需要自己将其转化为代码。
我已经在一个名为nt-load-order的 Rust 库中实现了所有这些步骤。它可以用于分析当前操作系统或任何目标系统根目录。后者甚至可以在非 Windows 平台上工作(利用我的平台无关的nt-hive crate)。
该库附带了一个 GUI 示例应用程序来演示这些功能。你可以自由开启或关闭创建引导驱动程序加载顺序的任何步骤,并找出某个驱动程序在列表中获得其位置的原因。
我之前的所有 crate 只有基本的控制台示例,部分原因是缺乏广泛接受的跨平台 Rust GUI 工具包。但对于这个特别针对 Windows 的 crate,我认为提供一个专门针对 Windows 的示例应用程序是可以接受的。好处是,这个示例可以是 Win32 GUI 应用程序 - 这是我首次使用native-windows-gui crate。
我要感谢 GitHub 用户CasualX提供的pelite Rust crate,我用它来解析我的库中的 PE 头。
我还需要一个至少具有链表语义的数据结构,以获得与 Windows 引导加载程序完全相同的排序顺序。我找到了这个Rust 链表库调查,在满足所有要求的优胜者中,我选择了 Scott Godwin 的dlv-list(部分原因是它的名字比generational_token_list更便于使用,抱歉!)
哪些方法不可行
这部分通常会被省略,因为人们只喜欢谈论成功。但我认为记录那些我尝试过但失败的方法同样重要。
当我最初开始这项任务时,我的想法是使用 Rust 的sort_by
函数和闭包来定义比较规则来对引导驱动程序进行排序。Rust 的排序算法会为引导驱动程序建立一个完整的顺序。sort_by
会使用"稳定排序",这意味着相等的元素会保持在原始位置。
像大多数试图超越原始实现的尝试一样,这个想法失败了:虽然它确实建立了一个加载顺序,但与原始顺序相比,多个引导驱动程序最终出现在不同的位置。事实证明,只有使用上述原始算法才能复制 Windows 引导加载程序的确切加载顺序。
这在实践中可能有影响也可能没有影响。我不知道微软这样实现的原因,也不知道确切的顺序是否重要。但作为创建兼容重新实现的逆向工程师,我需要最大程度地关注正确性,并尽可能地遵循原始实现。这也适用于那些我无法找出原始原因的情况。因此,我的最终代码使用了一个与 Windows 引导加载程序中发现的算法非常相似的算法。
原文始发于微信公众号(securitainment):nt-load-order 第二部分 - 你想知道的更多内容
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论