Windows注册表regf文件格式深度解析

本文深入解析Windows注册表regf文件格式的技术细节,涵盖基础块、bin结构、cell类型及安全描述符等核心组件,并探讨其在内存与磁盘中的混合存储特性及其对系统安全的影响。

Windows注册表冒险 #5:regf文件格式

作者:Mateusz Jurczyk, Google Project Zero

正如本系列第二篇博客(“功能简史”)中提到的,从Windows NT 3.1到现代Windows 11,用于编码注册表配置单元的二进制格式称为regf。在某种程度上,它非常特殊,因为它同时代表磁盘和内存中的注册表子树,这与大多数其他常见文件格式不同。文档、图像、视频等通常设计为在磁盘上高效存储数据,并在读取或写入时解析为不同的内存表示形式。这似乎很自然,因为离线存储和RAM具有不同的约束和要求。在磁盘上,数据尽可能紧密打包很重要,而在内存中,通常优先考虑简单高效的随机访问。regf格式旨在绕过重新解析步骤——可能是为了优化内存/磁盘同步过程——并将两种数据编码类型协调为一种既相对紧凑又易于操作的单一格式。例如,这解释了为什么配置单元本身不支持压缩(但客户端当然可以在注册表中存储压缩数据)。这种独特方法带来了一系列挑战,并且是许多历史漏洞的一个促成因素。

在该格式存在的30年中,Microsoft从未发布其官方规范。然而,通过Microsoft符号服务器上可用的Windows内核映像(ntoskrnl.exe)的PDB符号,构成配置单元的所有构建块(文件头、bin头、cell结构)的数据布局实际上是公开的。此外,《Windows Internals》丛书还包括一个深入探讨regf格式细节的部分(名为Hive structure)。最后,取证专家长期以来出于分析目的对该格式感兴趣,导致基于逆向工程、实验和推论创建了几个非官方规范。这些资源已在我早期的学习资源博客文章中列出;两种最广泛的此类规范可以在这里和这里找到。本文的目的不是重复现有资源中编译的信息,而是强调格式中与安全高度相关的特定部分,或在我发现缺少的地方提供一些额外背景。深入理解低级regf格式对于掌握注册表中许多高级概念以及未来博客文章中讨论的软件漏洞的技术细节将证明是无价的。

配置单元结构:头、bin和cell

在最低级别,配置单元中的数据以4 KiB(0x1000字节)的块组织,巧合的是x86架构中标准内存页的大小。第一个4 KiB始终对应头(也称为基块),后跟一个或多个bin,每个bin的长度是4 KiB的倍数。头指定关于配置单元的一般信息(签名、版本等),而bin是一个抽象层,旨在实现虚拟内存中配置单元映射的碎片化——稍后详述。

每个bin以一个32字节(0x20)的头开始,后跟一个或多个完全填充bin的cell。cell是配置单元中具有特定目的的最小数据单元(例如描述键、值、安全描述符等)。cell的数据前有一个32位整数指定其大小,必须是8的倍数(即其三个最低有效位清零),并且处于空闲或已分配状态。空闲(未使用)cell由正大小指示,已分配cell由负大小指示。例如,32字节的空闲cell的长度标记为0x00000020,而128字节的活动cell的大小编码为0xFFFFFF80。这明显展示了配置单元格式的混合磁盘/内存性质,与其他经典格式相反,后者不会故意在文件中留下大块未使用空间。

整体文件结构如下图所示:

在Windows内核中,负责处理这些低级配置单元对象(基块、bin、cell)的内部函数名称以“Hv”开头,例如HvCheckHive、HvpAllocateBin或HvpViewMapCleanup。注册表代码库的这一部分至关重要,因为它构成了注册表逻辑的基础,使配置管理器能够轻松分配、释放和访问配置单元cell,而无需关心内存管理的技术细节。它也是具有重大优化潜力的地方,例如Windows 8.1中添加的增量日志记录,或Windows 10 2018年4月更新(RS4)中引入的基于部分的注册表。这两种机制在《Windows Internals 7(第2部分)》书中有详细描述。

虽然对注册表的正确功能不可或缺,但配置单元管理并不构成整个注册表相关代码库的很大一部分。在我对博客文章#2中显示的注册表代码增长的分析中,我统计了Windows 11内核构建10.0.22621.2134中对应于此子系统的100,007行反编译代码。其中,只有10,407行或约10.4%对应配置单元内存管理。这也反映在我的发现中:在Microsoft分配的52个CVE中,只有两个直接与Hv*函数实现相关——CVE-2022-37988,HvReallocateCell中的逻辑错误导致内存损坏,和CVE-2024-43452,从远程网络共享加载配置单元时的双重获取。这并不是说此机制中没有更多错误,但它们的数量可能与其相对于注册表相关代码其余部分的大小成正比。

现在让我们更仔细地看看配置单元中每个基本对象如何编码以及它们存储什么信息,从基块开始。

基块

基块由Windows内核中称为_HBASE_BLOCK的结构表示,其布局可以在WinDbg中显示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
0: kd> dt _HBASE_BLOCK
nt!_HBASE_BLOCK
   +0x000 Signature        : Uint4B
   +0x004 Sequence1        : Uint4B
   +0x008 Sequence2        : Uint4B
   +0x00c TimeStamp        : _LARGE_INTEGER
   +0x014 Major            : Uint4B
   +0x018 Minor            : Uint4B
   +0x01c Type             : Uint4B
   +0x020 Format           : Uint4B
   +0x024 RootCell         : Uint4B
   +0x028 Length           : Uint4B
   +0x02c Cluster          : Uint4B
   +0x030 FileName         : [64] UChar
   +0x070 RmId             : _GUID
   +0x080 LogId            : _GUID
   +0x090 Flags            : Uint4B
   +0x094 TmId             : _GUID
   +0x0a4 GuidSignature    : Uint4B
   +0x0a8 LastReorganizeTime : Uint8B
   +0x0b0 Reserved1        : [83] Uint4B
   +0x1fc CheckSum         : Uint4B
   +0x200 Reserved2        : [882] Uint4B
   +0xfc8 ThawTmId         : _GUID
   +0xfd8 ThawRmId         : _GUID
   +0xfe8 ThawLogId        : _GUID
   +0xff8 BootType         : Uint4B
   +0xffc BootRecover      : Uint4B

首先突出的事实是,即使基块长4096字节,它实际上只存储约236字节的有意义数据,其余(Reserved1和Reserved2数组)填充零。有关每个字段的详细描述,我鼓励您参考前面提到的两个非官方regf规范。在以下部分中,我分享关于一些最有趣头成员的使用和相关性的额外想法。

Sequence1, Sequence2

这些32位数字由内核在注册表写入操作期间更新,以跟踪配置单元的一致性状态。如果在加载期间两个值相等,则配置单元处于“干净”状态,不需要任何类型的恢复。如果它们不同,这表明并非所有挂起的更改都已完全提交到主配置单元文件,必须基于随附的.LOG/.LOG1/.LOG2文件应用额外修改。从安全角度来看,手动控制这些字段可能有助于确保内核执行日志恢复逻辑(HvAnalyzeLogFiles、HvpPerformLogFileRecovery及相关函数)。这就是我在制作CVE-2023-35386和CVE-2023-38154的概念验证文件时所做的事情。

Major, Minor

这些是头中一些最重要的字段:它们代表配置单元的主版本和次版本。唯一有效的主版本是1,而次版本历史上是0到6之间的整数。以下是存在的不同1.x版本的概述:

版本 年份 引入于 新功能
1.0 1992 Windows NT 3.1 Pre-Release 初始格式
1.1 1993 Windows NT 3.1
1.2 1994 Windows NT 3.5 预定义键
1.3 1995 Windows NT 4.0 快速叶
1.4 2000 Windows Whistler Beta 1 大值支持
1.5 2001 Windows XP 哈希叶
1.6 2016 Windows 10周年更新 分层键

后期版本在概念和实际实现上都广泛借鉴了早期版本——Windows NT 3.1 Beta中有非平凡部分的代码至今仍在最新的Windows 11中使用。但当涉及到纯二进制兼容性时,版本1.0到1.2与较新版本差异太大,早已被视为过时。这给我们留下了版本≥1.3,它们都是交叉兼容的,可以在当前系统上自由使用。在这个组中,版本1.4是格式开发中的中间步骤,仅在Windows XP(代号Whistler)的测试版中观察到。其他三个都在积极使用中,可以在Windows 10和11的默认安装中找到:

  • 1.3:编码易失性配置单元(根配置单元,HKLM\HARDWARE)、BCD配置单元(HKLM\BCD00000000)、用户类配置单元(HKU<SID>_Classes)和一些应用程序配置单元(由settings.dat支持)。
  • 1.5:编码HKLM中的大多数系统配置单元(SYSTEM、SOFTWARE、SECURITY、SAM、DRIVERS)、所有用户配置单元(HKU<SID>)和大多数应用程序配置单元(由ActivationStore.dat支持)。
  • 1.6:编码所有差异配置单元,即由在应用程序和服务器隔离中运行的进程使用的配置单元,挂载在\Registry\WC下。

值得注意的是,配置单元版本应该指示内部使用的功能;例如,只有版本≥1.4的配置单元应使用大值(长度超过1 MiB的值),只有版本≥1.5的配置单元应使用哈希叶等。然而,这在加载配置单元时并未实际强制执行,在较旧配置单元中使用较新功能将完全正常工作。如果注册表代码的任何部分仅基于其版本对配置单元的结构做出任何假设,这种行为可能会成为问题。此类漏洞的一个例子是CVE-2022-38037,这是由于CmpSplitLeaf内核函数基于配置单元版本而不是列表本身的二进制表示来确定子键列表的格式。通常,在编写注册表特定的模糊器时,在3-6之间翻转次版本可能是一个好主意,以增加命中与版本处理相关的一些有趣角落情况的机会。

最后一点,版本号在内部使用以下公式转换为存储在_HHIVE.Version结构成员中的单个32位整数:Minor+(Major*0x1000)-0x1000。在典型情况下,主版本为1,最后两个组件相互抵消,例如版本1.5变为简单的“5”。这本来没问题,如果不是HvpGetHiveHeader也允许主版本为0,在这种情况下次版本可以是任何大于或等于3的值。此外,如果内核进入头恢复路径(因为配置单元头损坏需要从.LOG文件恢复),那么可以完全任意设置主/次字段,它们将被接受,因为HvAnalyzeLogFiles不执行与HvpGetHiveHeader相同的严格检查。因此,可以欺骗保存在_HHIVE.Version中的版本,并使其几乎取32位范围内的任何值,但我没有发现此行为的任何安全影响,我分享它只是出于好奇。

RootCell

这是根键的cell索引(配置单元文件中的偏移量),它标志着配置管理器解析配置单元树的起点。根cell在许多方面都很特殊:它是配置单元中唯一没有父级的cell,它不能被删除或重命名,其名称未使用(而是由其挂载点的名称引用),并且其安全描述符被视为安全描述符链表的头部。虽然RootCell成员本身并未直接参与任何我已知的错误,但在进行注册表安全研究时记住其特殊属性是值得的。

Length

指定配置单元中所有bin的累积大小,即其文件大小减去4096(头的大小)。它限制为0x7FFFE000,这反映了配置单元稳定存储(配置单元驻留在磁盘上的部分)的2 GiB容量。结合另一个2 GiB的易失性空间(内存中配置单元数据在重启时被擦除),当两种存储空间完全最大化时,我们得到约4 GiB的总最大大小。巧合的是,这与单个32位cell索引可以寻址的范围相同。

Flags

目前只有两个支持的配置单元标志:0x1,指示是否有任何涉及配置单元的挂起事务,和0x2,表示配置单元是否是差异配置单元并包含分层键。后一个标志通常在配置单元版本为1.6时设置。

LastReorganizeTime

为了解决随时间积累的碎片问题,Windows 8.1引入了一种新机制,在加载期间缩小和优化配置单元,称为重组。如果上次重组发生在七天前且配置单元的碎片率大于1 MiB,它会自动发生。重组通过从空配置单元开始并递归复制所有现有键来实现其目标,考虑哪些键在启动期间、系统运行时使用,以及自上次重组以来根本未使用。最终结果是配置单元变得更紧凑,感谢消除了占用不必要空间的空闲cell,并且更高效操作,因为“热”键被分组得更近。

正如其名称所示,LastReorganizeTime成员存储上次成功重组发生的时间戳。从攻击者的角度来看,可以调整它以控制内部CmpReorganizeHive函数的行为,并根据所需的最终结果确定性地触发重组或跳过它。除了指示时间戳,LastReorganizeTime字段也可能等于两个特殊标记值之一:0x1以在下次加载时无条件重组配置单元,和0x2以清除配置单元中所有键的访问位,即重置迄今为止收集的键使用信息。

CheckSum

偏移0x1FC处的CheckSum字段存储头前508字节(即此字段之前的所有数据)的校验和,并且只是将头数据视为一系列127个连续DWORD的32位XOR。如果计算值等于0xFFFFFFFF(-1),则校验和设置为0xFFFFFFFE(-2),如果计算值为0x0,则校验和设置为0x1。这意味着0(所有位清零)和-1(所有位设置)永远不是有效的校验和值。如果您希望检查算法的内核实现,可以在内部HvpHeaderCheckSum函数中找到它。

在校验和修改现有配置单元时,校验和尤为重要,无论是用于实验还是模糊测试期间。如果文件前508字节内的任何数据被修改,则需要相应调整校验和。否则,系统将在加载过程的早期拒绝该文件,并返回STATUS_REGISTRY_CORRUPT错误代码,并且不会执行任何更深层的代码路径。因此,修复校验和是配置单元模糊器为了最大化成功机会应该做的最低限度。

其他字段

头中还有其他一些有价值的信息,更多是在数字取证和事件响应的背景下,而不是严格低级系统安全。例如,“Signature”将文件标识为regf配置单元,可能使在原始内存/磁盘转储中更容易识别格式,而“TimeStamp”指示配置单元上次写入的时间,这对于在调查期间建立事件时间线可能至关重要。此外,离线注册表库(offreg.dll)在生成的配置单元文件中留下进一步痕迹:偏移0xB0处的4字节“OfRg”标识符(名义上是Reserved1字段)和偏移0x200处的序列化时间戳(名义上是Reserved2)。有关头每个部分的意义和有用性的更多信息,请参考非官方格式规范之一。

Bins

注册表配置单元中的bin是一个简单的组织概念,用于将潜在大型配置单元拆分为可以在内存中独立映射的较小块。每个bin以一个32字节的_HBIN结构开始:

1
2
3
4
5
6
7
8
0: kd> dt _HBIN
nt!_HBIN
   +0x000 Signature        : Uint4B
   +0x004 FileOffset       : Uint4B
   +0x008 Size             : Uint4B
   +0x00c Reserved1        : [2] Uint4B
   +0x014 TimeStamp        : _LARGE_INTEGER
   +0x01c Spare            : Uint4B

这里有四个有意义的字段:四字节签名(“hbin”)、bin在文件中的偏移量、bin的大小和时间戳。其中,签名是常量,文件大小在配置单元过程中早期被清理,实际上也是常量,时间戳与安全无关。这给我们留下了大小作为头中最有趣的部分。它的唯一约束是必须是0x1000的倍数,并且偏移和大小的总和不能超过配置单元的总长度(_HBASE_BLOCK.Length)。在运行时,bin被分配为适合请求大小的cell的最小4 KiB对齐区域,因此在实践中,它们通常最终大小在4-16 KiB之间,但可能有机地长达1 MiB。虽然Windows内核无法产生更长的bin,但没有什么能阻止 specially crafted 的配置单元在系统中加载,其bin大小约为2 GiB,即配置单元整体的最大长度。这种行为似乎没有任何直接的安全影响,但更一般地说,它是Windows写入的配置单元状态是加载期间接受为有效的状态集的严格较小子集的一个很好的例子:

Cells

Cell是注册表配置单元中最小的数据单元——它们是任意长度的连续缓冲区。它们没有像_HBASE_BLOCK或_HBIN那样的专用头结构,而是每个cell简单地由一个带符号的32位大小标记后跟cell的数据组成。大小字段受以下约束:

  • cell可能处于两种状态之一——已分配和空闲——由大小值的符号指示。正值用于空闲cell,负值用于已分配cell。
  • 大小值计入自身占用的四个字节。
  • 大小值必须是8的倍数(即其三个最低位设置为零)。如果在运行时分配大小不能被8整除的cell,它将对齐到下一个8的倍数,可能导致cell末尾有一些未使用的填充字节。
  • bin中所有连续cell的总和必须等于bin的长度。换句话说,bin
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计