Windows注册表探险 #5:regf文件格式
作者:Mateusz Jurczyk,Google Project Zero
正如本系列博客第二篇(“功能简史”)中提到的,从Windows NT 3.1到现代Windows 11,用于编码注册表配置单元的二进制格式称为regf。在某种程度上,它非常特殊,因为它同时代表磁盘和内存中的注册表子树,这与大多数其他常见文件格式不同。文档、图像、视频等通常设计为在磁盘上高效存储数据,并在读取或写入时解析为不同的内存表示形式。这似乎很自然,因为离线存储和RAM具有不同的约束和要求。在磁盘上,数据尽可能紧密打包很重要,而在内存中,通常优先考虑高效且方便的随机访问。regf格式旨在绕过重新解析步骤——可能是为了优化内存/磁盘同步过程——并将两种数据编码类型协调为一种既相对紧凑又易于操作的格式。例如,这解释了为什么配置单元本身不支持压缩(但客户端当然可以在注册表中存储压缩数据)。这种独特的方法带来了自身的挑战,并且是许多历史漏洞的一个促成因素。
在该格式存在的30年中,微软从未发布其官方规范。然而,组成配置单元的所有构建块(文件头、bin头、cell结构)的数据布局通过Windows内核映像(ntoskrnl.exe)的PDB符号在Microsoft符号服务器上有效公开。此外,《Windows内部原理》丛书还包括一个深入探讨regf格式细节的部分(名为“配置单元结构”)。最后,取证专家长期以来出于分析目的对该格式感兴趣,导致基于逆向工程、实验和推论创建了几个非官方规范。这些资源已在我之前的学习资源博客文章中列出;两种最广泛的此类规范可以在这里和这里找到。本文的目的不是重复现有资源中编译的信息,而是强调格式中与安全高度相关的特定部分,或在我发现缺失的地方提供一些额外背景。深入理解低级regf格式对于掌握注册表中许多高级概念以及未来博客文章中讨论的软件漏洞的技术细节将证明是无价的。
配置单元结构:头、bins和cells
在最低级别,配置单元中的数据以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内核中,负责处理这些低级配置单元对象(基块、bins、cells)的内部函数名称以“Hv”开头,例如HvCheckHive、HvpAllocateBin或HvpViewMapCleanup。注册表代码库的这一部分至关重要,因为它构成了注册表逻辑的基础,使配置管理器能够轻松分配、释放和访问配置单元cell,而无需关心内存管理的技术细节。这也是具有重大优化潜力的地方,例如Windows 8.1中添加的增量日志记录,或Windows 10 2018年4月更新(RS4)中引入的基于节的注册表。这两种机制在《Windows内部原理7(第2部分)》书中都有详细描述。
虽然配置单元管理对注册表的正确功能不可或缺,但它并不构成整个注册表相关代码库的很大一部分。在我对博客文章#2中显示的注册表代码增长的分析中,我统计了Windows 11内核构建10.0.22621.2134中对应于此子系统的100,007行反编译代码。其中,只有10,407行(约10.4%)对应于配置单元内存管理。这也反映在我的发现中:在微软分配的52个CVE中,只有两个直接与Hv*函数实现相关——CVE-2022-37988,HvReallocateCell中的逻辑错误导致内存损坏,以及CVE-2024-43452,从远程网络共享加载配置单元时的双重获取。这并不是说此机制中没有更多错误,但它们的数量可能与其相对于注册表相关代码其余部分的大小成正比。
现在让我们仔细看看配置单元中每个基本对象的编码方式以及它们存储的信息,从基块开始。
基块
基块在Windows内核中由一个称为_HBASE_BLOCK的结构表示,其布局可以在WinDbg中显示:
|
|
首先突出的是,即使基块长度为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
注册表配置单元中的bins是一个简单的组织概念,用于将潜在大的配置单元分割成可以在内存中独立映射的较小块。每个bin以一个32字节的_HBIN结构开始:
|
|
这里的四个有意义的字段是四字节签名(“hbin”)、bin在文件中的偏移量、bin的大小和时间戳。其中,签名是常量,文件大小在配置单元过程中早期被清理,实际上也是常量,时间戳与安全无关。这给我们留下了大小作为头中最有趣的部分。对其的唯一约束是它必须是0x1000的倍数,并且偏移量和大小的总和不能超过配置单元的总长度(_HBASE_BLOCK.Length)。在运行时,bins被分配为适合请求大小的cell的最小4 KiB对齐区域,因此在实践中,它们通常最终大小在4-16 KiB之间,但它们可能有机地长达1 MiB。虽然Windows内核无法产生更长的bins,但没有什么可以阻止 specially crafted 的配置单元在系统中加载,其bin大小约为2 GiB,即配置单元整体的最大长度。此行为似乎没有任何直接的安全影响,但更一般地说,它是Windows写入的配置单元状态是加载期间接受为有效的状态集的严格较小子集的一个很好的例子。
Cells
Cells是注册表配置单元中最小的数据单元——它们是任意长度的连续缓冲区。它们没有像_HBASE_BLOCK或_HBIN那样的专用头结构,而是每个cell简单地由一个带符号的32位大小标记后跟cell的数据组成。大小字段受以下约束:
- cell可能处于两种状态之一——已分配和空闲——由大小值的符号指示。正值用于空闲cell,负值用于已分配cell。
- 大小值计入其自身占用的四个字节。
- 大小值必须是8的倍数(即其最低三位设置为零)。如果在运行时分配了大小不能被8整除的cell