Windows注册表内核对象深度解析

本文深入分析了Windows注册表的内核模式对象结构,包括_CMHIVE和_HHIVE等关键数据结构,探讨了内存管理、事务处理和安全性等核心机制。通过WinDbg调试实例详细解析了注册表内核对象的组织方式和运行原理。

Windows注册表内核对象深度解析

欢迎回到Windows注册表探索系列!在本系列的前一篇文章中,我们深入研究了regf hive格式的内部结构。理解注册表的这一基础方面至关重要,因为它揭示了该机制背后的设计原则及其固有的优缺点。存储在regf文件中的数据代表了hive的最终状态。了解如何解析这些数据足以处理以此格式编码的静态文件,例如在编写自定义regf解析器以检查从硬盘提取的hive时。然而,对于那些对Windows在运行时如何管理regf文件感兴趣,而不仅仅是它们孤立行为的人来说,还有另一个维度需要探索:在活动hive的整个生命周期中分配和维护的大量内核模式对象。这些辅助对象至关重要,原因如下:

  • 跟踪所有当前加载的hive、它们的属性(例如加载标志)、它们的内存映射以及它们之间的关系(尤其是彼此叠加的差异hive)。
  • 在Windows多线程环境中同步对键和hive的访问。
  • 缓存hive信息,以便比直接内存映射查找更快地访问。
  • 将注册表与NT对象管理器集成,并支持标准操作(打开/关闭句柄、设置/查询安全描述符、强制执行访问检查等)。
  • 在待处理事务完全提交到底层hive之前管理其状态。

为了满足这些多样化的需求,Windows内核采用了众多相互关联的结构。在本文中,我们将研究其中一些最关键的结枃,它们的功能,以及如何使用WinDbg有效地枚举和检查它们。需要注意的是,Microsoft仅通过ntoskrnl.exe的PDB符号官方提供了部分注册表相关结构的定义。在许多情况下,我必须对相关代码进行逆向工程以恢复结构布局,并推断特定字段和枚举的类型和名称。在整篇文章中,我将明确指出每个结构定义是官方的还是逆向工程的。如果您发现任何不准确之处,请告知我。这里给出的定义主要源自应用了2022年3月补丁的Windows Server 2019(内核版本10.0.17763.2686),这是我进行大部分注册表代码分析时使用的内核版本。然而,超过99%的注册表结构定义在该版本与最新的Windows 11之间似乎是相同的,使得这些信息也直接适用于最新系统。

Hive结构

鉴于hive是注册表对象中最复杂的类型,它们的内核模式描述符同样复杂且冗长也就不足为奇了。Windows中主要的hive描述符结构称为_CMHIVE,其大小相当可观,达到0x12F8字节——超过了x86系列架构上的标准内存页大小4 KiB。在_CMHIVE内部,偏移量0处包含另一个类型为_HHIVE的结构,它占用0x600字节,如下图所示:

这种关系反映了其他常见的Windows对象对之间的关系,例如_EPROCESS / _KPROCESS和_ETHREAD / _KTHREAD。因为_HHIVE总是作为更大的_CMHIVE结构的一个组成部分被分配,所以它们的指针类型实际上是可互换的。如果您遇到使用_HHIVE*指针的反编译访问超出了该结构的大小,这几乎肯定表明是对包含它的_CMHIVE对象中某个字段的引用。

但是为什么需要两个不同的结构来表示单个注册表hive?虽然技术上并非必需,但这种分离可能用于划分与hive不同抽象层相关的字段。具体来说:

  • _HHIVE管理hive的低级方面,包括hive头、bin和cell,以及内存映射和与其磁盘副本的同步状态(例如,脏扇区)。
  • _CMHIVE处理关于hive的更抽象信息,例如安全描述符缓存、指向高级内核对象(如根键控制块(KCB))的指针,以及关联的事务资源管理器(_CM_RM结构)。

接下来的小节将更深入地探讨这两个结构的职责和内部工作原理。

_HHIVE结构概述

_HHIVE结构的主要作用是管理hive的内存相关状态。这使得更高级别的注册表代码可以执行诸如分配、释放和将cell标记为“脏”的操作,而无需处理低级的实现细节。_HHIVE结构包含49个顶级成员,其中大部分将在下面按较大的组进行描述:

 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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
0: kd> dt _HHIVE
nt!_HHIVE
   +0x000 Signature        : Uint4B
   +0x008 GetCellRoutine   : Ptr64     _CELL_DATA* 
   +0x010 ReleaseCellRoutine : Ptr64     void 
   +0x018 Allocate         : Ptr64     void* 
   +0x020 Free             : Ptr64     void 
   +0x028 FileWrite        : Ptr64     long 
   +0x030 FileRead         : Ptr64     long 
   +0x038 HiveLoadFailure  : Ptr64 Void
   +0x040 BaseBlock        : Ptr64 _HBASE_BLOCK
   +0x048 FlusherLock      : _CMSI_RW_LOCK
   +0x050 WriterLock       : _CMSI_RW_LOCK
   +0x058 DirtyVector      : _RTL_BITMAP
   +0x068 DirtyCount       : Uint4B
   +0x06c DirtyAlloc       : Uint4B
   +0x070 UnreconciledVector : _RTL_BITMAP
   +0x080 UnreconciledCount : Uint4B
   +0x084 BaseBlockAlloc   : Uint4B
   +0x088 Cluster          : Uint4B
   +0x08c Flat             : Pos 0, 1 Bit
   +0x08c ReadOnly         : Pos 1, 1 Bit
   +0x08c Reserved         : Pos 2, 6 Bits
   +0x08d DirtyFlag        : UChar
   +0x090 HvBinHeadersUse  : Uint4B
   +0x094 HvFreeCellsUse   : Uint4B
   +0x098 HvUsedCellsUse   : Uint4B
   +0x09c CmUsedCellsUse   : Uint4B
   +0x0a0 HiveFlags        : Uint4B
   +0x0a4 CurrentLog       : Uint4B
   +0x0a8 CurrentLogSequence : Uint4B
   +0x0ac CurrentLogMinimumSequence : Uint4B
   +0x0b0 CurrentLogOffset : Uint4B
   +0x0b4 MinimumLogSequence : Uint4B
   +0x0b8 LogFileSizeCap   : Uint4B
   +0x0bc LogDataPresent   : [2] UChar
   +0x0be PrimaryFileValid : UChar
   +0x0bf BaseBlockDirty   : UChar
   +0x0c0 LastLogSwapTime  : _LARGE_INTEGER
   +0x0c8 FirstLogFile     : Pos 0, 3 Bits
   +0x0c8 SecondLogFile    : Pos 3, 3 Bits
   +0x0c8 HeaderRecovered  : Pos 6, 1 Bit
   +0x0c8 LegacyRecoveryIndicated : Pos 7, 1 Bit
   +0x0c8 RecoveryInformationReserved : Pos 8, 8 Bits
   +0x0c8 RecoveryInformation : Uint2B
   +0x0ca LogEntriesRecovered : [2] UChar
   +0x0cc RefreshCount     : Uint4B
   +0x0d0 StorageTypeCount : Uint4B
   +0x0d4 Version          : Uint4B
   +0x0d8 ViewMap          : _HVP_VIEW_MAP
   +0x110 Storage          : [2] _DUAL

签名 等于0xBEE0BEE0,它是_HHIVE / _CMHIVE结构的唯一签名。在数字取证中,它可能有助于在原始内存转储中识别这些结构,并且是Windows注册表实现中又一个与"bee"相关的引用。

函数指针 接下来是六个函数指针,在HvHiveStartFileBacked和HvHiveStartMemoryBacked中初始化,指向用于以下操作的内部内核处理程序:

指针名称 指针值 操作
GetCellRoutine HvpGetCellPaged 或 HvpGetCellFlat 将cell索引转换为虚拟地址
ReleaseCellRoutine HvpReleaseCellPaged 或 HvpReleaseCellFlat 释放先前转换的cell索引
Allocate CmpAllocate 在全局注册表配额内分配内核内存
Free CmpFree 在全局注册表配额内释放内核内存
FileWrite CmpFileWrite 将数据写入hive文件
FileRead CmpFileRead 从hive文件读取数据

正如我们所见,这些函数提供了操作内核内存、cell索引和hive文件的基本功能。在我看来,其中最重要的是GetCellRoutine,其典型目标HvpGetCellPaged执行cell map遍历,以便将cell索引转换为hive映射内的相应地址。

很自然地会想到,如果攻击者能够通过缓冲区溢出或释放后使用条件破坏这些函数指针,那么它们在利用中可能有用。在Windows 10及更早版本中确实如此,但在Windows 11中,这些调用现在已被去虚拟化,大多数调用点直接引用HvpGetCellPaged / HvpGetCellFlat和HvpReleaseCellPaged / HvpReleaseCellFlat之一,而不引用指针。这对安全性非常有利,因为它完全消除了这些字段在任何攻击场景中的用处。

以下是Windows 10中GetCellRoutine调用的示例,在IDA Pro中反汇编:

以及Windows 11中的相同调用:

Hive加载失败信息 这是一个指向公共_HIVE_LOAD_FAILURE结构的指针,每次加载hive发生错误时,都会将其作为第一个参数传递给SetFailureLocation函数。它有助于跟踪给定hive的哪些有效性检查失败,而无需跟踪整个加载过程。

基础块 指向hive头副本的指针,由_HBASE_BLOCK结构表示。

同步锁 有两个锁,用途如下:

  • FlusherLock – 在更改cell内数据的客户端和刷新线程之间同步对hive的访问。
  • WriterLock – 在修改bin/cell布局的写入器之间同步对hive的访问。

它们官方类型为_CMSI_RW_LOCK,但本质上归结为_EX_PUSH_LOCK,并且与标准内核API(如ExAcquirePushLockSharedEx)一起使用。

脏块信息 在偏移量0x58到0x84之间,_HHIVE存储了几个表示内存中和磁盘上hive实例之间同步状态的数据结构。

Hive标志 首先,在偏移量0x8C处有两个标志,指示hive映射是否是平坦的以及hive是否是只读的。其次,有一个32位的HiveFlags成员,存储了进一步的标志,据我所知,这些标志不包含在任何公共Windows符号中。我设法通过逆向工程推断出我观察到的常量的含义,得到以下枚举:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
enum _HV_HIVE_FLAGS
{
  HIVE_VOLATILE                      = 0x1,
  HIVE_NOLAZYFLUSH                   = 0x2,
  HIVE_PRELOADED                     = 0x10,
  HIVE_IS_UNLOADING                  = 0x20,
  HIVE_COMPLETE_UNLOAD_STARTED       = 0x40,
  HIVE_ALL_REFS_DROPPED              = 0x80,
  HIVE_ON_PRELOADED_LIST             = 0x400,
  HIVE_FILE_READ_ONLY                = 0x8000,
  HIVE_SECTION_BACKED                = 0x20000,
  HIVE_DIFFERENCING                  = 0x80000,
  HIVE_IMMUTABLE                     = 0x100000,
  HIVE_FILE_PAGES_MUST_BE_KEPT_LOCAL = 0x800000,
};

下面是每个标志的单行解释:

  • HIVE_VOLATILE: hive仅存在于内存中;例如,为\Registry和\Registry\Machine\HARDWARE设置。
  • HIVE_NOLAZYFLUSH: 对hive的更改不会自动刷新到磁盘,需要手动刷新;例如,为\Registry\Machine\SAM设置。
  • HIVE_PRELOADED: hive是默认的系统hive之一;例如,为\Registry\Machine\SOFTWARE、\Registry\Machine\SYSTEM等设置。
  • HIVE_IS_UNLOADING: hive当前正在另一个线程中被加载或卸载,在操作完成前不应访问。
  • HIVE_COMPLETE_UNLOAD_STARTED: hive的卸载过程已在CmpCompleteUnloadKey中开始。
  • HIVE_ALL_REFS_DROPPED: 通过KCB对hive的所有引用已被丢弃。
  • HIVE_ON_PRELOADED_LIST: hive通过PreloadedHiveList字段链接到链表中。
  • HIVE_FILE_READ_ONLY: 底层hive文件是只读的,不应被修改;指示hive是使用REG_OPEN_READ_ONLY标志集加载的。
  • HIVE_SECTION_BACKED: hive使用section view在内存中映射。
  • HIVE_DIFFERENCING: hive是一个差异hive(版本1.6,加载在\Registry\WC下)。
  • HIVE_IMMUTABLE: hive是不可变的,无法修改;指示它是使用REG_IMMUTABLE标志集加载的。
  • HIVE_FILE_PAGES_MUST_BE_KEPT_LOCAL: 内核始终维护hive每个页面的本地副本,要么通过将其锁定在物理内存中,要么通过CoW机制创建私有副本。

日志文件信息 在偏移量0xA4到0xCC之间,有许多与日志文件管理相关的字段,即伴随主hive文件在磁盘上的.LOG1/.LOG2文件。

Hive版本 Version字段存储hive的次要版本,理论上应该是3到6之间的整数。然而,如上一篇博客文章所述,可以通过将主版本指定为0和任何所需的次要版本,或者诱使内核从日志文件恢复hive头,并滥用HvAnalyzeLogFiles函数比HvpGetHiveHeader更宽松的事实,将其设置为任意的32位值。尽管如此,我尚未发现此行为的任何安全影响。

视图映射 视图映射保存了关于hive如何在内存中映射的所有基本信息。注册表内存管理的具体实现多年来已经发生了显著变化,其细节在连续的系统版本之间发生了变化。在最新的版本中,视图映射由顶级公共结构_HVP_VIEW_MAP表示:

1
2
3
4
5
6
7
8
0: kd> dt _HVP_VIEW_MAP
nt!_HVP_VIEW_MAP
   +0x000 SectionReference : Ptr64 Void
   +0x008 StorageEndFileOffset : Int8B
   +0x010 SectionEndFileOffset : Int8B
   +0x018 ProcessTuple     : Ptr64 _CMSI_PROCESS_TUPLE
   +0x020 Flags            : Uint4B
   +0x028 ViewTree         : _RTL_RB_TREE

其各自字段的语义如下:

  • SectionReference: 包含对应于hive文件的section对象的内核模式句柄,通过CmSiCreateSectionForFile中的ZwCreateSection创建。
  • StorageEndFileOffset: 存储任何时候可以用文件支持的section表示的hive的最大大小。初始设置为加载的hive的大小,对于可变(普通)hive,它可以在运行时动态增加或减少。
  • SectionEndFileOffset: 表示加载时hive文件section的大小。在HvpViewMapStart中的第一次初始化之后永远不会被修改,似乎主要用作防止不可变hive文件扩展超出其原始大小的保障。
  • ProcessTuple: 一个类型为_CMSI_PROCESS_TUPLE的结构,它标识了hive的section view的主机进程。此字段当前始终指向全局CmpRegistryProcess对象,该对象对应于系统中承载所有hive映射的专用"Registry"进程。然而,如果Microsoft选择实现这样的功能,此字段可以实现跨多个进程的更细粒度的hive映射分离。
  • Flags: 表示与整个hive相关的内存管理标志集。这些标志没有公开文档记录;然而,通过逆向工程,我确定了它们的用途如下:
    • VIEW_MAP_HIVE_FILE_IMMUTABLE (0x1): 指示hive已作为不可变加载,意味着没有数据被保存回底层hive文件。
    • VIEW_MAP_MUST_BE_KEPT_LOCAL (0x2): 指示hive的所有数据必须持久存储在内存中,而不仅仅通过文件支持的section可访问。这很可能是为了防止涉及从远程网络共享加载的hive的双重获取条件。
    • VIEW_MAP_CONTAINS_LOCKED_PAGES (0x4): 指示hive的一些页面当前使用ZwLockVirtualMemory锁定在物理内存中。
  • ViewTree: 这是视图树结构的根,它包含内存中映射的每个连续section view的描述符。

总的来说,Windows中低级hive内存管理的实现比最初看起来要复杂。这种复杂性源于内核需要优雅地处理各种边界情况和交互。例如,hive可能作为不可变加载,这表明hive可以在内存中操作,但更改不得刷新到磁盘。同时,系统必须支持从.LOG文件恢复数据,包括将hive扩展到其原始磁盘长度之外的可能性。在运行时,还必须能够高效地修改注册表数据,并根据需要收缩和扩展它。更复杂的是,Windows根据文件的支持卷对锁定hive页面在内存中强制执行不同的规则,仔细平衡最佳内存使用和系统安全保证。这些以及许多其他因素共同导致了hive内存管理的复杂性。

为了更好地理解视图树是如何组织的,我们首先分析hive映射代码的一般逻辑。

Hive映射逻辑

负责在内存中映射hive的主要内核函数是HvLoadHive。它实现了整体逻辑并协调负责执行更专门任务的各种子例程,顺序如下:

  1. 头验证: 内核读取并检查hive的头以确定其完整性,确保hive未被篡改或损坏。相关函数:HvpGetHiveHeader。
  2. 日志分析: 内核处理hive的事务日志,仔细检查它们以识别任何需要恢复过程的待处理更改或不一致。相关函数:HvAnalyzeLogFiles。
  3. 初始Section映射: 基于hive文件创建一个section对象,并进一步分割成多个视图,每个视图对齐到4 KiB边界并上限为2 MiB。此时,内核优先创建初始映射,而不关注hive内各个bin的精细布局。相关函数:HvpViewMapStart。
  4. Cell Map初始化: Cell map是一个将cell索引转换为内存地址的组件,被初始化。其条目被配置为指向新创建的视图。相关函数:HvpMapHiveImageFromViewMap。
  5. 日志恢复(如果需要): 如果前面的日志分析显示需要数据恢复,内核会尝试恢复数据完整性。这是新创建的内存映射可能已经被修改并标记为"脏"的最早点,表明它们的内容已被更改并需要与磁盘表示同步。相关函数:HvpPerformLogFileRecovery。
  6. Bin映射: 在这个最后阶段,内核为hive内的每个bin建立确定的内存映射,确保每个bin占据一个连续的内存区域。此过程可能需要创建新视图、消除现有视图或调整其边界以适应bin的特定排列。相关函数:HvpRemapAndEnlistHiveBins。

现在我们理解了加载过程的主要组成部分,我们可以更详细地检查section视图树的内部结构。

视图树

让我们考虑一个由三个大小分别为256 KiB、2 MiB和128 KiB的bin组成的示例hive。在步骤3(“初始Section映射”)之后,内核创建的section视图如下:

如图所示,此时内核不关心bin边界或连续性:它需要实现的是使hive的每个页面都能通过section view访问,以用于日志恢复目的。简单来说,HvpViewMapStart(或者更具体地说,HvpViewMapCreateViewsForRegion)的工作方式是创建尽可能多的2 MiB视图,然后是覆盖文件剩余部分的最后一个视图。所以在我们的例子中,我们有第一个视图覆盖bin 1和bin 2的开头,第二个视图覆盖bin 2的尾部部分和整个bin 3。需要注意的是,内存连续性仅在单个视图的范围内得到保证,视图1和视图2可能映射到虚拟地址空间中完全不同的位置。

后来在步骤6中,系统在将hive交给客户端之前,确保每个bin都映射为连续的内存块。这是通过遍历所有bin来完成的,对于当前视图映射中跨越多个视图的每个bin,执行以下操作:

  • 如果bin的开始和/或结束落在现有视图的中间,则从任一侧截断这些视图。此外,如果有任何视图完全被bin覆盖,它们将被释放并从树中移除。
  • 为bin创建一个新的、专用的section视图,并将其插入到视图树中。

在我们假设的场景中,最终的视图布局将如下:

如图所示,内核收缩了视图1和2,并创建了一个新的对应bin 2的视图3来填补间隙。section视图描述符的二叉树的最终布局如下图所示:

了解了这一点,我们终于可以检查单个视图树条目的结构。它不包含在公共符号中,但我将其命名为_HVP_VIEW。我逆向工程的版本定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct _HVP_VIEW
{
  RTL_BALANCED_NODE Node;
  LARGE_INTEGER ViewStartOffset;
  LARGE_INTEGER ViewEndOffset;
  SSIZE_T ValidStartOffset;
  SSIZE_T ValidEndOffset;
  PBYTE MappingAddress;
  SIZE_T LockedPageCount;
  _HVP_VIEW_PAGE_FLAGS PageFlags[];
};

每个特定字段的作用如下所述:

  • Node: 这是用于将所有条目链接到单个红黑树中的结构,传递给辅助内核函数,如RtlRbInsertNodeEx和RtlRbRemoveNode。
  • ViewStartOffset和ViewEndOffset: 这个偏移量对指定了底层section view对象在hive文件中覆盖的总字节范围。它们的差异对应于上图中单行红色和绿色框的累积长度。
  • ValidStartOffset和ValidEndOffset: 这个偏移量对指定了通过此视图可访问的hive的有效范围,即图中的绿色矩形。它必须始终是[ViewStartOffset, ViewEndOffset]范围的子集,并且可能在重新映射bin时(如本节所示)以及收缩和扩展hive时动态变化。
  • MappingAddress: 这是section view映射在内存中的基地址,由ZwMapViewOfSection返回。它在由_HVP_VIEW_MAP.ProcessTuple指定的进程上下文中有效(当前始终是"Registry"进程)。它覆盖[ViewStartOffset, ViewEndOffset]之间的整个范围,但只有[ValidStartOffset, ValidEndOffset]之间的页面是可访问的,section view的其余部分被标记为PAGE_NOACCESS。
  • LockedPageCount: 指定在此视图中使用ZwLockVirtualMemory锁定在虚拟内存中的页面数。
  • PageFlags: 一个可变长度数组,为[ViewStartOffset, ViewEndOffset]范围内的每个内存页指定一组标志。

我没有找到任何(非)官方来源记录支持的页面标志集,因此以下是我尝试命名并解释其含义:

标志 描述
VIEW_PAGE_VALID 0x1 指示页面是否有效 – 对于[ValidStartOffset, ValidEndOffset]之间的页面为真,否则为假。如果此标志被清除,所有其他标志都是无关的/未使用的。
VIEW_PAGE_COW_BY_CALLER 0x2 指示内核是否通过写时复制(CoW)机制维护页面的副本,由客户端操作发起,例如修改cell中数据的注册表操作,从而导致页面被标记为脏。
VIEW_PAGE_COW_BY_POLICY 0x4 指示内核是否通过写时复制(CoW)机制维护页面的副本,这是由策略要求的,即所有非本地hive(从系统卷以外的卷加载的hive)的页面必须始终保留在内存中。
VIEW_PAGE_WRITABLE 0x8 指示页面当前是否被标记为可写,通常是页面修改操作尚未刷新到磁盘的结果。
VIEW_PAGE_LOCKED 0x10 指示页面当前是否锁定在物理内存中。

大多数标志的语义是直接的,但也许VIEW_PAGE_COW_BY_POLICY和VIEW_PAGE_LOCKED需要稍长的解释。这两个标志是互斥的,它们代表了实现相同目标的几乎相同的方法:确保每个hive页面的副本保留在内存或页面文件中。在正常情况下,内核可以简单地以其默认形式创建必要的section view,并让内存管理子系统决定如何最有效地处理它们的页面。然而,注册表的保证之一是,一旦hive被加载,只要它在系统中处于活动状态,就必须保持可操作。另一方面,section view的特性是,它们底层数据的(部分)可能被内核完全驱逐,然后 later 从原始存储介质(如硬盘)重新读取。因此,可以想象这样一种情况:

  • 从可移动驱动器(例如CD-ROM或闪存驱动器)或网络共享加载hive,
  • 由于其他应用程序的高内存压力,hive的一些页面从内存中被驱逐,
  • 带有hive文件的可移动驱动器从系统中弹出,
  • 客户端随后尝试操作hive,但其部分内容不可用,并且无法再从原始源获取。

这可能会导致一些严重的问题,并使注册表代码以意外的方式失败。这也将构成一个安全漏洞:内核假设一旦它打开并清理了hive文件,只要hive被使用,其内容就保持一致。这是通过以独占访问方式打开文件来实现的,但如果Windows内存管理器曾经重新读取hive数据,恶意的可移动驱动器或攻击者控制的网络共享可能会忽略独占性请求,并在第二次读取时提供不同的无效数据。这将导致一种"双重获取"条件,并可能导致内核内存损坏。

为了解决可靠性和安全问题,Windows确保永远不会驱逐那些无法保证独占访问的hive的页面。这涵盖了从系统卷以外的位置加载的hive,并且从Windows 10 19H1开始,还包括所有应用程序hive,无论文件位置如何。实现这一点的第一种方法是使用ZwLockVirtualMemory调用直接将页面锁定在物理内存中。它用于加载hive时创建的初始≤2 MiB section view,直到Registry进程当前设置为64 MiB的工作集限制。第二种方法是利用写时复制机制——也就是说,将相关页面标记为PAGE_WRITECOPY,然后使用HvpViewMapTouchPages辅助函数接触每个页面。这会导致内存管理器创建每个内存页的私有副本,其中包含与原始数据相同的数据,从而防止它们对注册表操作不可用。

在这两种类型的常驻页面中,CoW类型在长期内实际上成为默认选项。最终大多数页面都会收敛到这种状态,即使它们最初是锁定的。这是因为锁定的页面在多种场合下会转换到CoW,例如,由每60秒运行一次的后台CmpDoLocalizeNextHive线程转换时,或者在修改cell期间。另一方面,一旦页面转换到CoW状态,它就永远不会恢复到锁定状态。下图说明了从可移动/远程存储加载的hive中页面驻留状态之间的转换:

对于从系统卷加载的普通hive(即没有设置VIEW_MAP_MUST_BE_KEPT_LOCAL标志),状态机要简单得多:

顺便说一下,CVE-2024-43452是一个有趣的漏洞,它利用了页面驻留保护逻辑中的一个缺陷。该漏洞的出现是因为某些数据不能保证驻留在内存中,并且可以在bin映射期间从远程SMB共享两次获取。这发生在hive加载过程的早期,在页面驻留保护完全到位之前。内核信任第二次读取的数据而不重新验证,允许其被恶意设置为无效值,导致内核内存损坏。

Cell Maps

正如第5部分所讨论的,几乎每个cell都包含对hive中其他cell的引用,形式为cell索引。因此,几乎每个注册表操作都涉及多轮将cell索引转换为其相应的虚拟地址,以便遍历注册表结构。Section view存储在一个红黑树中,因此搜索复杂度是O(log n)。这看起来不错,但如果我们考虑到在典型系统上,注册表的读取频率远高于扩展/收缩频率,那么显然以较低的插入/删除效率为代价来进一步优化搜索操作是有意义的。而这正是cell map的作用:一种用O(1)的更快的搜索复杂度来换取O(n)而不是O(log n)的更慢的插入/删除复杂度的方法。得益于这种技术,HvpGetCellPaged——也许是Windows注册表实现中最热门的函数——以常数时间执行。

在技术术语上,cell map是类似页表的结构,将32位hive地址空间划分为更小的、嵌套的层,由所谓的目录、表和条目组成。提醒一下,cell索引和cell map的布局基于Windows Internals书中类似的图表说明,该图表本身源自Mark Russinovich 1999年的文章《Inside the Registry》:

考虑到数据结构的性质,相应的cell map遍历涉及基于cell索引的连续1、10和9位部分解引用三个嵌套数组,然后将最终的12位偏移量添加到目标块的对齐地址。匹配cell map各自层级的内部内核结构是_DUAL、_HMAP_DIRECTORY、_HMAP_TABLE和_HMAP_ENTRY,所有这些都可以通过ntoskrnl.exe PDB符号公开访问。cell map的入口点是_HHIVE结构末尾的Storage数组:

1
2
3
4
0: kd> dt _HHIVE
nt!_HHIVE
[...]
   +0x118 Storage          : [2] _DUAL

双元素数组的索引表示存储类型,0表示稳定,1表示易失,因此单个_DUAL结构描述了特定存储空间的2 GiB视图:

1
2
3
4
5
6
7
8
9
0: kd> dt _DUAL
nt!_DUAL
   +0x000 Length           : Uint4B
   +0x008 Map              : Ptr64 _HMAP_DIRECTORY
   +0x010 SmallDir         : Ptr64 _HMAP_TABLE
   +0x018 Guard            : Uint4B
   +0x020 FreeDisplay      : [24] _FREE_DISPLAY
   +0x260 FreeBins         : _LIST_ENTRY
   +0x270 FreeSummary      : Uint4B

让我们检查每个字段的语义:

  • Length: 表示给定存储空间的当前长度(以字节为单位)。直接加载hive后,稳定长度等于磁盘上hive的大小(包括从日志文件恢复的任何数据,减去头的4096字节),而易失空间根据定义是空的。只有[0, Length - 1]范围内的cell map条目保证是有效的。
  • Map: 指向由_HMAP_DIRECTORY表示的实际目录结构。
  • SmallDir: “small dir"优化的一部分,在下一节讨论。
  • Guard: 其具体作用不清楚,因为该字段在分配时总是初始化为0xFFFFFFFF,并且之后从未使用过。我预计这是注册表开发早期的一些调试残留,可能与small dir优化有关。
  • FreeDisplay: 一种在cell分配过程中优化空闲cell搜索的数据结构。它由24个桶组成,每个桶对应一个特定的cell大小范围,并由_FREE_DISPLAY结构表示,指示hive中哪些页面可能包含给定长度的空闲cell。
  • FreeBins: 双链表的头,该链表链接hive中完全空的bin的描述符,由_FREE_HBIN结构表示。
  • FreeSummary: 一个位掩码,指示FreeDisplay中哪些桶为给定的cell大小设置了任何提示。给定位置的零位意味着hive中任何地方都没有特定大小范围的空闲cell。

cell map层次结构中的下一级是_HMAP_DIRECTORY结构:

1
2
3
0: kd> dt _HMAP_DIRECTORY
nt!_HMAP_DIRECTORY
   +0x000 Directory        : [1024] Ptr64 _HMAP_TABLE

如我们所见,它只是一个1024元素的指针数组,指向_HMAP_TABLE:

1
2
3
0: kd> dt _HMAP_TABLE
nt!_HMAP_TABLE
   +0x000 Table            : [512] _HMAP_ENTRY

进一步,我们得到一个512元素的指针数组,指向cell map的最终级别,_HMAP_ENTRY:

1
2
3
4
5
0: kd> dt _HMAP_ENTRY
nt!_HMAP_ENTRY
   +0x000 BlockOffset      : Uint8B
   +0x008 PermanentBinAddress : Uint8B
   +0x010 MemAlloc         : Uint4B

这最后一级包含hive中单个页面的描述符,值得深入分析。首先要注意的是,PermanentBinAddress的四个最低有效位对应一组未公开的标志,控制页面的各个方面。我能够对它们进行逆向工程并部分恢复它们的名称,这主要归功于一些旧的Windows 10构建包含了操作这些标志的非内联函数,这些函数具有揭示性的名称,如HvpMapEntryIsDiscardable或HvpMapEntryIsTrimmed:

1
2
3
4
5
6
7
enum _MAP_ENTRY_FLAGS
{
  MAP_ENTRY_NEW_ALLOC   = 0x1,
  MAP_ENTRY_DISCARDABLE = 0x2,
  MAP_ENTRY_TRIMMED     = 0x4,
  MAP_ENTRY_DUMMY       = 0x8,
};

根据我的理解,以下是它们含义的简要总结:

  • MAP_ENTRY_NEW_ALLOC: 指示这是bin的第一个页面。指向此页面的cell索引必须指定[0x20, 0xFFF]范围内的偏移量,因为它们不能落入对应_HBIN结构的前32字节。
  • MAP_ENTRY_DISCARDABLE: 指示整个bin是空的,并且由一个空闲cell组成。
  • MAP_ENTRY_TRIMMED: 指示该页面已在HvTrimHive中被标记为"已修剪”。更具体地说,此属性与hive重组相关,并在加载过程中在一些尾随页面上设置,这些页面仅包含在启动期间访问的键,或者自上次重组以来根本未访问的键。总体目标可能是通过避免将具有不同访问历史的键混合在一起来防止在hive中引入不必要的碎片。
  • MAP_ENTRY_DUMMY: 指示该页面是从内核池分配的,不是section view的一部分。

考虑到这一点,让我们深入了解每个_HMAP_ENTRY结构成员的细节:

  • PermanentBinAddress: 低4位包含上述标志。高60位表示与此页面对应的bin映射的基地址。
  • BlockOffset: 此字段具有双重功能。如果设置了MAP_ENTRY_DISCARDABLE标志,它是指向空闲bin描述符_FREE_HBIN的指针,该描述符链接到_DUAL.FreeBins链表中。如果它被清除(典型情况),它表示页面相对于bin起始处的偏移量。因此,内存中块数据的虚拟地址可以计算为(PermanentBinAddress & (~0xF)) + BlockOffset。
  • MemAlloc: 如果设置了MAP_ENTRY_NEW_ALLOC标志,它包含bin的大小,否则为零。

这就完成了对cell map结构方式的描述。考虑到所有这些,HvpGetCellPaged函数的实现开始变得很有意义。其伪代码如下:

1
2
3
4
5
6
7
_CELL_DATA *HvpGetCellPaged(_HHIVE *Hive, HCELL_INDEX Index) {
  _HMAP_ENTRY *Entry = &Hive->Storage[Index >> 31].Map
                          ->Directory[(Index >> 21) & 0x3FF]
                          ->Table[(Index >> 12) & 0x1FF];

  return (Entry->PermanentBinAddress & (~0xF)) + Entry->BlockOffset + (Index & 0xFFF) + 4;
}

例如,WinDbg的!reg cellindex扩展的实现也遵循相同的过程,它将hive指针和cell索引对转换为cell的虚拟地址。

Small Dir优化

这里还有一个关于cell map的实现细节值得提及——small dir优化。让我们从观察开始:Windows中的大多数注册表hive相对较小,大小低于2 MiB。这可以通过使用WinDbg中的!reg hivelist命令并注意"Stable Length"和"Volatile Length"列中的值来轻松验证。它们中的大多数通常包含几千字节到几百千字节的值。这意味着如果内核为这些hive分配完整的第一级目录(在64位平台上占用1024个条目 × 8字节 = 8 KiB),它们仍然只使用其中的第一个元素,导致不小的内存浪费——尤其是在20世纪90年代初注册表首次实现时。为了优化这种常见情况,Windows开发人员采用了一种非常规的方法来模拟一个长度为1项的"数组",使用_DUAL结构中类型为_HMAP_TABLE的SmallDir成员,并在可能时让_DUAL.Map指针指向它,而不是指向单独的内存池分配。后来,每当hive增长并需要超过一个cell map目录元素时,内核会回退到标准行为并为目录数组执行正常的内存池分配。

一个说明小hive的cell map布局的修订图如下所示:

在这里,我们可以看到目录数组的索引1到1023是无效的。它们不是正确初始

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计