Windows注册表探险 #6:内核模式对象
作者:Mateusz Jurczyk,Google Project Zero
欢迎回到 Windows 注册表探险之旅!在本系列的上一篇文章中,我们深入探讨了 regf 配置单元格式的内部原理。理解注册表的这一基础方面至关重要,因为它揭示了该机制背后的设计原则及其固有的优缺点。存储在 regf 文件中的数据代表了配置单元的最终状态。了解如何解析这些数据足以处理以此格式编码的静态文件,例如编写自定义的 regf 解析器来检查从硬盘提取的配置单元。然而,对于那些感兴趣于 Windows 在运行时如何管理 regf 文件,而不仅仅是其孤立行为的人来说,还有另一个维度需要探索:在活动配置单元的整个生命周期中分配和维护的大量内核模式对象。这些辅助对象之所以必不可少,原因如下:
- 跟踪所有当前加载的配置单元、它们的属性(例如,加载标志)、它们的内存映射以及它们之间的关系(尤其是彼此叠加的差异配置单元)。
- 在 Windows 多线程环境中同步对键和配置单元的访问。
- 缓存配置单元信息,以便比直接内存映射查找更快地访问。
- 将注册表与 NT 对象管理器集成,并支持标准操作(打开/关闭句柄、设置/查询安全描述符、强制执行访问检查等)。
- 在待处理事务完全提交到底层配置单元之前,管理其状态。
为了满足这些多样化的需求,Windows 内核使用了众多相互关联的结构。在本文中,我们将研究其中一些最关键的结构、它们的功能,以及如何使用 WinDbg 有效地枚举和检查它们。需要注意的是,Microsoft 仅通过 ntoskrnl.exe 的 PDB 符号为部分与注册表相关的结构提供了官方定义。在许多情况下,我不得不对相关代码进行逆向工程以恢复结构布局,并推断特定字段和枚举的类型和名称。在整篇文章中,我将明确指出每个结构定义是官方的还是逆向工程得出的。如果您发现任何不准确之处,请告诉我。这里提供的定义主要源自应用了 2022 年 3 月补丁的 Windows Server 2019(内核版本 10.0.17763.2686),这是我进行大部分注册表代码分析时使用的内核版本。然而,该版本与最新的 Windows 11 之间,超过 99% 的注册表结构定义似乎是相同的,因此这些信息也直接适用于最新的系统。
配置单元结构
鉴于配置单元是注册表对象中最复杂的类型,其内核模式描述符同样复杂且冗长也就不足为奇了。Windows 中主要的配置单元描述符结构,称为 _CMHIVE,占据了相当大的 0x12F8 字节——超过了 x86 系列架构的标准内存页大小 4 KiB。在 _CMHIVE 内部,偏移量 0 处是另一个类型为 _HHIVE 的结构,它占据了 0x600 字节,如下图所示:

这种关系类似于其他常见的 Windows 对象对,例如 _EPROCESS / _KPROCESS 和 _ETHREAD / _KTHREAD。因为 _HHIVE 总是作为更大的 _CMHIVE 结构的一部分进行分配,所以它们的指针类型实际上是可互换的。如果您遇到使用 _HHIVE* 指针的反编译访问超出了该结构的大小,这几乎肯定表明它引用了包含它的 _CMHIVE 对象内的一个字段。
但是,为什么需要两个不同的结构来表示单个注册表配置单元呢?虽然技术上并非必需,但这种分离可能有助于划分与配置单元不同抽象层相关的字段。具体来说:
- _HHIVE 管理配置单元的低级方面,包括配置单元头、箱(bin)和单元(cell),以及内存映射和与其磁盘对应物的同步状态(例如,脏扇区)。
- _CMHIVE 处理关于配置单元的更抽象的信息,例如安全描述符缓存、指向高级内核对象(如根键控制块(KCB))的指针,以及关联的事务资源管理器(_CM_RM 结构)。
接下来的小节将更深入地探讨这两个结构的职责和内部工作原理。
_HHIVE 结构概述
_HHIVE 结构的主要作用是管理配置单元的内存相关状态。这使得更高级别的注册表代码能够执行诸如分配、释放和将单元标记为“脏”等操作,而无需处理低级的实现细节。_HHIVE 结构包含 49 个顶级成员,其中大部分将在下面按较大的组别进行描述:
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
签名(Signature)
等于 0xBEE0BEE0,它是 _HHIVE / _CMHIVE 结构的唯一签名。在数字取证中,它可能有助于在原始内存转储中识别这些结构,并且是 Windows 注册表实现中又一个对蜜蜂的引用。
函数指针
接下来是六个函数指针,它们在 HvHiveStartFileBacked 和 HvHiveStartMemoryBacked 中初始化,指向用于以下操作的内部内核处理程序:
| 指针名称 | 指针值 | 操作 |
|---|---|---|
| GetCellRoutine | HvpGetCellPaged 或 HvpGetCellFlat | 将单元索引转换为虚拟地址 |
| ReleaseCellRoutine | HvpReleaseCellPaged 或 HvpReleaseCellFlat | 释放先前转换的单元索引 |
| Allocate | CmpAllocate | 在全局注册表配额内分配内核内存 |
| Free | CmpFree | 在全局注册表配额内释放内核内存 |
| FileWrite | CmpFileWrite | 将数据写入配置单元文件 |
| FileRead | CmpFileRead | 从配置单元文件读取数据 |
正如我们所见,这些函数提供了操作内核内存、单元索引和配置单元文件的基本功能。在我看来,其中最重要的是 GetCellRoutine,其典型目标 HvpGetCellPaged 执行单元映射遍历,以便将单元索引转换为配置单元映射内的相应地址。
很自然地会想到,如果攻击者能够通过缓冲区溢出或释放后使用(use-after-free)条件破坏这些函数指针,它们可能对利用(exploitation)有用。在 Windows 10 及更早版本中确实如此,但在 Windows 11 中,这些调用现在已去虚拟化,大多数调用点直接引用 HvpGetCellPaged / HvpGetCellFlat 和 HvpReleaseCellPaged / HvpReleaseCellFlat 之一,而不引用指针。这对安全性来说是件好事,因为它完全消除了这些字段在任何攻击场景中的用处。
以下是 Windows 10 中 GetCellRoutine 调用的示例,在 IDA Pro 中反汇编:

以及 Windows 11 中的相同调用:

配置单元加载失败信息
这是一个指向公共 _HIVE_LOAD_FAILURE 结构的指针,每次加载配置单元时发生错误,都会将其作为第一个参数传递给 SetFailureLocation 函数。它有助于跟踪给定配置单元哪些有效性检查失败,而无需跟踪整个加载过程。
基块(Base block)
指向配置单元头副本的指针,由 _HBASE_BLOCK 结构表示。
同步锁
有两个锁,用途如下:
- FlusherLock – 同步在单元内更改数据的客户端与刷新线程(flusher thread)之间对配置单元的访问。
- WriterLock – 同步修改箱/单元布局的写入器之间对配置单元的访问。
它们的官方类型是 _CMSI_RW_LOCK,但本质上就是 _EX_PUSH_LOCK,并且它们与标准内核 API(如 ExAcquirePushLockSharedEx)一起使用。
脏块信息
在偏移量 0x58 到 0x84 之间,_HHIVE 存储了几个表示内存中和磁盘上配置单元实例之间同步状态的数据结构。
配置单元标志
首先,偏移量 0x8C 处有两个标志,指示配置单元映射是否是平坦的(flat)以及配置单元是否是只读的。其次,有一个 32 位的 HiveFlags 成员,存储了进一步的标志,据我所知,这些标志未包含在任何公共 Windows 符号中。我设法通过逆向工程推断出我观察到的常量的含义,得到了以下枚举:
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:配置单元仅存在于内存中;例如,为 \Registry 和 \Registry\Machine\HARDWARE 设置。
- HIVE_NOLAZYFLUSH:对配置单元的更改不会自动刷新到磁盘,需要手动刷新;例如,为 \Registry\Machine\SAM 设置。
- HIVE_PRELOADED:配置单元是默认的系统配置单元之一;例如,为 \Registry\Machine\SOFTWARE、\Registry\Machine\SYSTEM 等设置。
- HIVE_IS_UNLOADING:配置单元当前正在另一个线程中加载或卸载,在操作完成前不应访问。
- HIVE_COMPLETE_UNLOAD_STARTED:配置单元的卸载过程已在 CmpCompleteUnloadKey 中启动。
- HIVE_ALL_REFS_DROPPED:通过 KCB 对配置单元的所有引用都已删除。
- HIVE_ON_PRELOADED_LIST:配置单元通过 PreloadedHiveList 字段链接到链表中。
- HIVE_FILE_READ_ONLY:底层配置单元文件是只读的,不应修改;表示配置单元是在设置了 REG_OPEN_READ_ONLY 标志的情况下加载的。
- HIVE_SECTION_BACKED:配置单元使用节视图(section view)映射到内存中。
- HIVE_DIFFERENCING:配置单元是差异配置单元(版本 1.6,加载在 \Registry\WC 下)。
- HIVE_IMMUTABLE:配置单元是不可变的,无法修改;表示它是在设置了 REG_IMMUTABLE 标志的情况下加载的。
- HIVE_FILE_PAGES_MUST_BE_KEPT_LOCAL:内核始终维护配置单元每个页面的本地副本,要么通过将其锁定在物理内存中,要么通过 CoW 机制创建私有副本。
日志文件信息
在偏移量 0xA4 到 0xCC 之间,有许多与日志文件管理相关的字段,即磁盘上伴随主配置单元文件的 .LOG1/.LOG2 文件。
配置单元版本
Version 字段存储配置单元的次要版本,理论上应该是 3 到 6 之间的整数。然而,如上一篇博客文章所述,可以通过将主版本指定为 0 和任何所需的次要版本,或者诱使内核从日志文件恢复配置单元头,并利用 HvAnalyzeLogFiles 函数比 HvpGetHiveHeader 更宽松这一事实,将其设置为任意的 32 位值。尽管如此,我尚未发现此行为的任何安全影响。
视图映射(View map)
视图映射保存了关于配置单元如何在内存中映射的所有基本信息。注册表内存管理的具体实现多年来发生了很大变化,其细节在连续的系统版本之间都有所不同。在最新版本中,视图映射由顶级公共结构 _HVP_VIEW_MAP 表示:
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:包含一个对应于配置单元文件的节对象的内核模式句柄,通过 CmSiCreateSectionForFile 中的 ZwCreateSection 创建。
- StorageEndFileOffset:存储在任何给定时间可以通过文件支持的节表示的配置单元的最大大小。初始设置为加载的配置单元的大小,对于可变(普通)配置单元,可以在运行时动态增加或减少。
- SectionEndFileOffset:表示加载时配置单元文件节的大小。在 HvpViewMapStart 中的第一次初始化后永远不会被修改,似乎主要用作防止将不可变配置单元文件扩展到超出其原始大小的保障。
- ProcessTuple:一个类型为 _CMSI_PROCESS_TUPLE 的结构,它标识配置单元节视图的宿主进程。此字段当前始终指向全局 CmpRegistryProcess 对象,该对象对应于系统中承载所有配置单元映射的专用“Registry”进程。然而,如果 Microsoft 选择实现这样的功能,此字段可以实现跨多个进程更细粒度的配置单元映射分离。
- Flags:表示与整个配置单元相关的一组内存管理标志。这些标志没有公开文档;但是,通过逆向工程,我确定了它们的用途如下:
- VIEW_MAP_HIVE_FILE_IMMUTABLE (0x1):表示配置单元已作为不可变加载,意味着没有数据会保存回底层配置单元文件。
- VIEW_MAP_MUST_BE_KEPT_LOCAL (0x2):表示所有配置单元数据必须持久存储在内存中,而不仅仅是通过文件支持的节访问。这可能是为了防止涉及从远程网络共享加载的配置单元的双重获取(double-fetch)条件。
- VIEW_MAP_CONTAINS_LOCKED_PAGES (0x4):表示配置单元的一些页面当前使用 ZwLockVirtualMemory 锁定在物理内存中。
- ViewTree:这是视图树结构的根,其中包含内存中映射的每个连续节视图的描述符。
总的来说,Windows 中低级配置单元内存管理的实现比最初看起来要复杂。这种复杂性源于内核需要优雅地处理各种边界情况和交互。例如,配置单元可能作为不可变加载,这意味着配置单元可以在内存中操作,但更改不得刷新到磁盘。同时,系统必须支持从 .LOG 文件恢复数据,包括将配置单元扩展到超出其原始磁盘长度的可能性。在运行时,还必须能够高效地修改注册表数据,并根据需要收缩和扩展它。更复杂的是,Windows 根据文件的后备卷对锁定配置单元页面在内存中强制执行不同的规则,仔细平衡最佳内存使用和系统安全保证。这些以及许多其他因素共同导致了配置单元内存管理的复杂性。
为了更好地理解视图树的组织方式,我们首先分析配置单元映射代码的一般逻辑。
配置单元映射逻辑
负责在内存中映射配置单元的主要内核函数是 HvLoadHive。它实现了整体逻辑并协调负责执行更专门任务的各种子例程,顺序如下:
- 头验证:内核读取并检查配置单元的头以确定其完整性,确保配置单元未被篡改或损坏。相关函数:HvpGetHiveHeader。
- 日志分析:内核处理配置单元的事务日志,仔细检查它们以识别任何需要恢复过程的待处理更改或不一致。相关函数:HvAnalyzeLogFiles。
- 初始节映射:基于配置单元文件创建一个节对象,并进一步分割成多个视图,每个视图对齐到 4 KiB 边界并限制在 2 MiB。此时,内核优先创建初始映射,而不关注配置单元内各个箱的精细布局。相关函数:HvpViewMapStart。
- 单元映射初始化:单元映射(一种将单元索引转换为内存地址的组件)被初始化。其条目被配置为指向新创建的视图。相关函数:HvpMapHiveImageFromViewMap。
- 日志恢复(如果需要):如果先前的日志分析显示需要数据恢复,内核会尝试恢复数据完整性。这是新创建的内存映射可能已经被修改并标记为“脏”的最早时间点,表明其内容已更改,需要与磁盘表示同步。相关函数:HvpPerformLogFileRecovery。
- 箱映射:在这个最后阶段,内核为配置单元内的每个箱建立确定的内存映射,确保每个箱占据连续的内存区域。此过程可能需要创建新视图、消除现有视图或调整其边界以适应箱的特定排列。相关函数:HvpRemapAndEnlistHiveBins。
现在我们理解了加载过程的主要组成部分,我们可以更详细地检查节视图树的内部结构。
视图树
让我们考虑一个由三个大小分别为 256 KiB、2 MiB 和 128 KiB 的箱组成的示例配置单元。在第 3 步(“初始节映射”)之后,内核创建的节视图如下:

正如我们所见,此时内核不关心箱边界或连续性:它需要实现的是使配置单元的每个页面都能通过节视图访问,以便进行日志恢复。简单来说,HvpViewMapStart(或者更具体地说,HvpViewMapCreateViewsForRegion)的工作方式是创建尽可能多的 2 MiB 视图,然后是覆盖文件剩余部分的最后一个视图。因此,在我们的示例中,第一个视图覆盖箱 1 和箱 2 的开头,第二个视图覆盖箱 2 的尾部部分和整个箱 3。需要注意的是,内存连续性仅在单个视图的范围内得到保证,视图 1 和视图 2 可能映射到虚拟地址空间中完全不同的位置。
稍后在第 6 步中,系统确保在将配置单元移交给客户端之前,每个箱都映射为连续的内存块。这是通过遍历所有箱来完成的,对于当前视图映射中跨越多个视图的每个箱,执行以下操作:
- 如果箱的开始和/或结束落在现有视图的中间,则从任一侧截断这些视图。此外,如果有任何视图完全被箱覆盖,则释放它们并将其从树中移除。
- 为箱创建一个新的专用节视图,并将其插入视图树。
在我们假设的场景中,最终的视图布局如下:

正如我们所见,内核缩小了视图 1 和 2,并创建了一个对应于箱 2 的新视图 3 来填补空白。节视图描述符的二叉树的最终布局如下图所示:

了解了这一点,我们终于可以检查单个视图树条目的结构。它不包含在公共符号中,但我将其命名为 _HVP_VIEW。我逆向工程得出的定义如下:
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:这对偏移量指定了底层节视图对象在配置单元文件中覆盖的总体字节范围。它们的差异对应于上图中单行中红色和绿色框的累积长度。
- ValidStartOffset 和 ValidEndOffset:这对偏移量指定了通过此视图可访问的配置单元的有效范围,即图中的绿色矩形。它必须是 [ViewStartOffset, ViewEndOffset] 范围的子集,并且可能在重新映射箱时(如本节所示)以及收缩和扩展配置单元时动态变化。
- MappingAddress:这是节视图映射在内存中的基地址,由 ZwMapViewOfSection 返回。它在 _HVP_VIEW_MAP.ProcessTuple 指定的进程上下文中有效(当前始终是“Registry”进程)。它覆盖 [ViewStartOffset, ViewEndOffset] 之间的整个范围,但只有 [ValidStartOffset, ValidEndOffset] 之间的页面是可访问的,节视图的其余部分标记为 PAGE_NOACCESS。
- LockedPageCount:指定在此视图中使用 ZwLockVirtualMemory 锁定在虚拟内存中的页面数。
- PageFlags:一个可变长度数组,为 [ViewStartOffset, ViewEndOffset] 范围内的每个内存页指定一组标志。
我没有找到任何(非)官方来源记录支持的页面标志集,因此以下是我尝试命名它们并解释其含义:
| 标志 | 值 | 描述 |
|---|---|---|
| VIEW_PAGE_VALID | 0x1 | 指示页面是否有效 – 对于 [ValidStartOffset, ValidEndOffset] 之间的页面为 true,否则为 false。如果此标志被清除,所有其他标志都无关/未使用。 该标志在以下情况设置: - 在配置单元加载期间创建节视图时,首先是 HvpViewMapStart 中的初始视图,然后是 HvpRemapAndEnlistHiveBins 中特定于箱的视图。 - 在 HvpViewMapExtendStorage 中扩展活动配置单元时。 该标志在以下情况清除: - 在 HvpRemapAndEnlistHiveBins 中修剪现有视图以为新视图腾出空间时。 - 在 HvpViewMapShrinkStorage 中收缩配置单元时。 |
| VIEW_PAGE_COW_BY_CALLER | 0x2 | 指示内核是否通过写时复制(CoW)机制维护页面的副本,由客户端操作发起,例如修改单元中数据的注册表操作,从而导致页面被标记为脏。 该标志在以下情况设置: - 在弄脏配置单元单元时,在 HvpViewMapMakeViewRangeCOWByCaller 中。 该标志在以下情况清除: - 在将注册表更改刷新到磁盘时,在 HvpViewMapMakeViewRangeUnCOWByCaller 中。 |
| VIEW_PAGE_COW_BY_POLICY | 0x4 | 指示内核是否通过写时复制(CoW)机制维护页面的副本,这是由所有非本地配置单元(从系统卷以外的卷加载的配置单元)的页面必须始终保留在内存中的策略所要求的。 该标志在以下情况设置: - 在 HvpViewMapMakeViewRangeValid 中,作为在内存中保留配置单元页面本地副本的替代方式(如果锁定失败,或者调用者不希望页面被锁定)。 - 在 HvpViewMapMakeViewRangeCOWByCaller 中,当将先前锁定的页面转换为“按策略 CoW”状态时。 - 在 HvpMappedViewConvertRegionFromLockedToCOWByPolicy 中,当在每 60 秒运行一次的线程中(由 CmpLazyLocalizeIntervalInSeconds 指示)延迟地将先前锁定的页面转换为“按策略 CoW”状态时。 该标志在以下情况清除: - 在 HvpViewMapMakeViewRangeUnCOWByPolicy 中,目前似乎只发生在从系统卷加载的配置单元中,即全局 CmpWellKnownVolumeList 数组中列出的“\SystemRoot”和“\OSDataRoot”。 |
| VIEW_PAGE_WRITABLE | 0x8 | 指示页面当前是否标记为可写,通常是由于页面上尚未刷新到磁盘的修改操作的结果。 该标志在以下情况设置: - 在 HvpViewMapMakeViewRangeCOWByCaller 中,当将单元标记为脏时。 该标志在以下情况清除: - 在 HvpViewMapMakeViewRangeUnCOWByCaller 中,当将配置单元更改刷新到磁盘时。 - 在 HvpViewMapSealRange 中,当因各种原因将内存设置为只读时(执行日志文件恢复后等)。 |
| VIEW_PAGE_LOCKED | 0x10 | 指示页面当前是否锁定在物理内存中。 该标志在以下情况设置: - 在 HvpViewMapMakeViewRangeValid 中,如果调用者请求页面锁定,并且 Registry 进程的 64 MiB 工作集中还有足够的空间。实际上,这归结为锁定 HvpViewMapStart 中为所有应用程序配置单元以及系统磁盘卷之外的普通配置单元创建的初始 2 MiB 配置单元映射。 该标志在以下情况清除: - 每当页面状态在以下函数中更改为按策略 CoW 或无效时: - HvpViewMapMakeViewRangeCOWByCaller - HvpMappedViewConvertRegionFromLockedToCOWByPolicy - HvpViewMapMakeViewRangeUnCOWByPolicy - HvpViewMapMakeViewRangeInvalid |
大多数标志的语义都很直接,但也许 VIEW_PAGE_COW_BY_POLICY 和 VIEW_PAGE_LOCKED 需要稍长的解释。这两个标志是互斥的,它们代表了实现相同目标的几乎相同的方式:确保每个配置单元页面的副本保留在内存或页面文件中。在正常情况下,内核可以简单地以其默认形式创建必要的节视图,并让内存管理子系统决定如何最有效地处理它们的页面。然而,注册表的保证之一是,一旦配置单元被加载,只要它在系统中处于活动状态,它就必须保持可操作。另一方面,节视图具有(部分)底层数据可能被内核完全驱逐,并稍后从原始存储介质(如硬盘)重新读取的特性。因此,可以想象这样一种情况:
- 配置单元从可移动驱动器(例如 CD-ROM 或闪存驱动器)或网络共享加载,
- 由于其他应用程序的高内存压力,一些配置单元页面从内存中被驱逐,
- 带有配置单元文件的可移动驱动器从系统中弹出,
- 客户端随后尝试操作配置单元,但其部分内容不可用,无法再次从原始源获取。
这可能会导致一些严重的问题,并使注册表代码以意外的方式失败。这也将构成一个安全漏洞:内核假设一旦它打开并清理了配置单元文件,其内容在配置单元使用期间保持一致。这是通过以独占访问方式打开文件来实现的,但如果 Windows 内存管理器曾经重新读取配置单元数据,恶意的可移动驱动器或攻击者控制的网络共享可能会忽略独占性请求,并在第二次读取时提供不同的无效数据。这将导致一种“双重获取”条件,并可能导致内核内存损坏。
为了解决可靠性和安全问题,Windows 确保永远不会驱逐那些无法保证独占访问的配置单元的页面。这涵盖了从系统卷以外的位置加载的配置单元,并且自 Windows 10 19H1 起,也涵盖了所有应用程序配置单元,无论文件位置如何。实现这一点的第一种方法是使用 ZwLockVirtualMemory 调用直接将页面锁定在物理内存中。它用于加载配置单元时创建的初始 ≤ 2 MiB 节视图,直到当前设置为 64 MiB 的 Registry 进程的工作集限制。第二种方法是利用写时复制机制——即将相关页面标记为 PAGE_WRITECOPY,然后使用 HvpViewMapTouchPages 辅助函数接触每个页面。这会导致内存管理器创建每个内存页面的私有副本,其中包含与原始数据相同的数据,从而防止它们对注册表操作不可用。
在这两种类型的驻留页面中,CoW 类型在长期内实际上成为默认选项。最终大多数页面都会收敛到这种状态,即使它们最初是锁定的。这是因为锁定的页面在多种情况下会转换为 CoW,例如,当由每 60 秒运行的后台 CmpDoLocalizeNextHive 线程转换时,或者在修改单元期间。另一方面,一旦页面转换到 CoW 状态,它就永远不会恢复为锁定状态。下图说明了从可移动/远程存储加载的配置单元中页面驻留状态之间的转换:

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

顺便提一下,CVE-2024-43452 是一个有趣的漏洞,它利用了页面驻留保护逻辑中的一个缺陷。该漏洞的出现是因为一些数据不能保证驻留在内存中,并且可能在箱映射期间从远程 SMB 共享获取两次。这发生在配置单元加载过程的早期,在页面驻留保护完全到位之前。内核信任第二次读取的数据而不重新验证,允许其被恶意设置为无效值,从而导致内核内存损坏。
单元映射(Cell maps)
正如第 5 部分所讨论的,几乎每个单元都包含对配置单元中其他单元的引用,形式为单元索引。因此,几乎每个注册表操作都涉及多轮将单元索引转换为其相应的虚拟地址,以便遍历注册表结构。节视图存储在红黑树中,因此搜索复杂度为 O(log n)。这看起来不错,但如果我们考虑到在典型系统上,注册表的读取频率远高于扩展/收缩,那么很明显,以较低效的插入/删除为代价进一步优化搜索操作是有意义的。这正是