解决Windows未初始化内核池内存问题
本文概述了微软为消除Windows中的未初始化内核池内存漏洞所做的工作及其背景。未初始化内核池漏洞占2017年至2018年中向微软报告的所有未初始化内存问题的近一半。文章分为以下几个部分:
理解解决未初始化池内存的可行性
解决未初始化内核池内存的工作在解决未初始化栈内存之后开始。与栈内存类似,我们希望找到一个确定性防止漏洞的解决方案,而不是仅依赖静态分析、模糊测试或代码审查。理想的最终状态是通过构造确保代码没有未初始化内核池问题。
初始时,解决未初始化池内存似乎比栈内存更具挑战性,主要差异包括:
- 典型大小:栈分配小(内核栈为20KB且无法增长),池分配可达多MB,平均池分配大小大于栈。
- 缓存:活动栈通常在L1缓存中或即将进入(当代码使用变量时),池分配可能由完全不在缓存中的内存满足,且可能不立即使用(尽管池分配通常在分配后很快使用)。
- 优化能力:MSVC能很好消除对栈变量的冗余存储,但如果池API中分配被清零,编译器无法自动优化掉初始清零。MSVC需要自定义优化逻辑来识别“此API清零,因此如果调用者在分配后立即memset为零,则可以消除memset”。MSVC通常更难优化对池/堆内存的冗余存储。
- 分配时间:栈分配即时(通过调整栈指针批量完成),堆分配快速但非即时(涉及分支逻辑、多次内存读取以咨询结构和元数据,以及可能获取锁或调用内存管理器以获取额外虚拟地址空间)。
- 初始化时间占比:如果强制初始化无法优化,分配时间从零增加到初始化所需时间(纯开销)。堆分配已有开销,希望memset时间占总分配时间的个位数百分比。对于非常大的memset,预计memset成本将主导总分配时间。
总之,预计池分配平均比栈分配大,更难(或不可能)优化冗余存储,且处理的内存位于较慢的CPU缓存中。唯一可取之处是堆分配比栈分配耗时更长,因此虽然初始化堆分配可能花费更多时间,但希望它能融入“堆分配时间”的噪声中。
为测试这一理论,我们进行了两组测试。
真实世界Windows性能测试
在此测试中,我们修改了现有池API,无条件清零所有分配,无法选择退出该行为。然后使用现有性能门基础设施测量此更改在关键场景中的影响。
启用池清零后,一个web fundamentals测试显示约1%的噪声级回归,其余测试无回归。这使我们确信池清零是可行的,只要开发人员有方法选择退出导致回归的热分配。
微基准测试
我们还构建了微基准测试以帮助理解不同大小分配的清零开销。注意这些微基准测试有一些噪声;如果在单个大小看到巨大峰值,可能只是测试中的噪声。另请注意这些基准测试不代表当前性能(进行了一些性能调整后的数字)。以下是初始性能数字。
测试1:使用相同大小的多次分配分配8GB内存
以下基准测试测量当单个线程重复进行某些固定大小分配时,池清零引起的回归。注意在这些测试中,进行了8GB的分配。
该场景有些非现实,因为在正常堆操作中,我们期望分配被创建和释放(因此虚拟地址空间可重用)。在此测试中,堆定期需要向内存管理器请求额外内存(较慢)。
下图显示相同场景,但每个特定大小由4个线程同时创建。这可导致锁争用、互锁操作冲突和SList操作冲突。
值得注意的是,多线程测试通常比单线程测试有更高噪声(图中明显)。另请注意,总体回归似乎更低(尽管非常小尺寸时更高)。这是预期的,因为当多个线程同时执行时,分配路径有额外开销(如上所述)。
测试2:分配和释放
下一个测试分配和释放内存,减少了内存管理器定期向堆提供额外内存的开销。预计在此组测试中回归更差,因为堆本身应运行更快。
上图显示当分配被分配和释放时,回归显著更高(如预期)。对于4个线程同时呢?
再次可见回归比之前更大。以下图表显示随着分配大小增加,memset完全主导分配所需时间。
合理化性能数据
真实世界数据看起来良好,但一些微基准数据相当令人担忧。以下是我们如何合理化所见:
- 较小分配比大分配更可能在热路径中出现。例如,通常不会在热路径中看到多MB甚至多KB分配。大分配通常在有限位置进行,热路径是使用分配的代码,而非进行分配的代码。
- 小分配性能没有超级回归。仍有影响,但并非不合理。
- 许多现有代码路径在分配后已经清零分配。我们的真实世界测试设置无条件在池API内清零分配,因此导致许多分配被双重清零。如果确保分配仅清零一次,可以赢回一些性能。
- 微基准测试仍不完美准确。基准测试通过进行相同大小分配工作,这意味着API内的分支预测器将得到良好训练。在真实世界中,这有时成立(即应用程序有时连续进行一堆相同大小分配),但通常不成立。如果分支预测器未完美训练,正常池分配代码将有额外分支预测错误开销,这些测试未反映。
- 如果清零行为最终成为代码中的瓶颈,我们始终允许开发人员选择退出分配。
如您所猜,我们根据收集的数据最终推进了池清零项目。
潜在实现选项
我们考虑了三种获取初始化池内存的方法:
- 创建默认清零内存的新池API。
- 使用某些编译器魔法清零未在分配后可证明完全初始化的池分配。
- 使现有池API默认清零内存,并提供新标志允许选择退出。
我们排除了#2,因为它将涉及一次性编译器逻辑来识别池分配、检测它们是否完全初始化,并在未初始化时插入初始化。它还将仅受益于使用MSVC编译驱动程序的开发人员,而第一种方法也将帮助使用其他编译器编译驱动程序的开发人员。
我们排除了#3,因为它对现有池API造成破坏性更改。许多公司编写在所有Windows版本上运行的驱动程序。如果我们更改现有池API,驱动程序编写者将面临困境(如果他们的驱动程序需要在旧版Windows上运行):
- 继续清零现有池API返回的分配,以便其驱动程序在无此行为的Windows版本上功能正确。
- 编写仅适用于具有新池API行为的Windows版本的驱动程序。
注意即使我们将此更改向下推送,驱动程序开发人员也无法依赖它。有些客户不安装更新或需要很长时间安装更新。
我们花了一些时间调查允许我们“升级”现有API以具有清零行为的解决方案,但无法想出满足以下要求的良好解决方案:
- 必须比使用非清零API更方便开发人员使用清零API(即我们偏好强制某人选择退出清零而非选择加入)
- 不得在任何支持平台上导致功能正确性问题
- 不得在任何支持平台上要求双重清零
- 如果性能需要,必须能够禁用清零
新Windows内核池API
Windows 10版本2004 API
对于Windows 10版本2004发布,我们引入了默认清零的新池API。这些API是:
ExAllocatePool2
ExAllocatePool3
ExAllocatePool2
参数较少,更易使用。它覆盖最常见场景。较不常见场景(如优先级分配)需要更灵活参数,通过ExAllocatePool3
处理。两个API都设计为未来可扩展,因此我们不需要继续添加新API。
向下兼容API
我们还引入了一组新的包装器API,适用于所有受支持的向下操作系统。这些实现为forceinline函数,并要求驱动程序开发人员:
- 在拉取任何Windows头之前,在其驱动程序中定义
POOL_ZERO_DOWN_LEVEL_SUPPORT
(使用#define)。 - 在使用这些API之前调用
ExInitializeDriverRuntime
。
旧API | 清零包装器 | 未初始化包装器 |
---|---|---|
ExAllocatePoolWithTag |
ExAllocatePoolZero |
ExAllocatePoolUninitialized |
ExAllocatePoolWithQuotaTag |
ExAllocatePoolQuotaZero |
ExAllocatePoolQuotaUninitialized |
ExAllocatePoolWithTagPriority |
ExAllocatePoolPriorityZero |
ExAllocatePoolPriorityUninitialized |
当这些API在本地支持池清零的操作系统上使用时,它们简单地调用池API并允许其进行清零。当在本地不支持池清零的操作系统上使用时(即Windows 10版本2004之前的操作系统),它们将进行池分配,然后memset分配为零。
这里的意图是为驱动程序开发人员提供一种更明确的方式来表达他们在程序中的操作。由于行为在API名称中明确指定,将永远不会有开发人员是否真正意图分配未初始化或清零的问题。
ExAllocatePool2/3相对于旧API的改进
抛出行为
旧池API有令人困惑的错误路径行为。ExAllocatePoolWithQuotaTag
在错误时抛出异常,除非传递POOL_QUOTA_FAIL_INSTEAD_OF_RAISE
标志(此时在错误时返回NULL)。ExAllocatePoolWithTag
和ExAllocatePoolWithTagPriority
在失败时返回NULL,除非传递POOL_RAISE_IF_ALLOCATION_FAILURE
标志(此时抛出异常)。有一组具有不同语义的API有点令人困惑。
ExAllocatePool2/3
在失败时返回NULL,除非指定POOL_FLAG_RAISE_ON_FAILURE
标志(此时抛出异常)。
标签行为
旧池API接受零池标签。这可能使调试更困难。新池API不接受零池标签。
默认不可执行非分页池
使用POOL_FLAGS_NON_PAGED
默认为不可执行内存。必须使用POOL_FLAGS_NON_PAGED_EXECUTABLE
用于可执行非分页池内存。通过使更方便的分配类型更安全,开发人员不太可能意外执行不安全操作。
注意:分页池在x86架构上始终可执行,在所有其他架构上不可执行。
默认清零
新池API默认清零分配。调用者如果需要未初始化分配,必须指定POOL_FLAGS_UNINITIALIZED
标志。
性能优化
由于真实世界测试中的性能看起来良好,没有做太多进一步优化。有几件事值得指出。
- 当请求清零进行大分配时,堆可能需要从内存管理器检索内存。在这种情况下,堆将请求零化页面,内存管理器将尝试使用后台清零线程已经零化的内存提供这些。这允许快速分配大量零化内存。
- 对于非常大的分配,开发人员可以使用适当标志手动选择退出清零。在热路径中进行非常大分配不典型,因此通常不需要使用。
- 创建了一个堆特定清零函数,性能优于正常memset实现。我们计划未来发布另一篇博客文章介绍此内容。此函数利用堆为其分配做出的特定对齐保证。
不需要其他优化。
部署计划
与InitAll不同,新池清零API需要代码更改才能使用。
对于Windows 10版本2004,整个Windows内存管理器已转换为使用新池清零API。除一个位置(可能非常大的位图分配)外,所有位置使用清零分配。
我们还对Hyper-V和许多网络组件进行了更改(将在未来版本中发布)以使用这些新API。我们当前计划在不久的将来使用自动错误归档工具将所有内核模式代码转换为新API,以帮助确保一切得到转换。
迄今为止,对新API的反馈积极。未注意到性能问题,且代码大小减少,因为开发人员不再需要调用池API和memset(如果他们需要清零分配)。
我们还在研究如何帮助第三方驱动程序远离旧池API。在这方面尚无具体计划分享,但工作在进行中。
对客户的影响
一旦我们完成代码转换到新池API,当前影响客户的大多数未初始化内存漏洞将在Windows上得到缓解。未初始化内存漏洞当然仍然可能,但在InitAll保护栈和大多数分配使用清零标志之间,这些问题潜入的机会将小得多。
也可能内存被初始化但未初始化为程序有意义的值(即内存初始化为0,但应为其他值才能使程序正确)。在这些情况下,我们至少最终在程序中具有确定性行为(即程序总是做错误事情,因为值初始化不正确)而非随机行为(即程序根据未初始化值做完全不同事情)。这使得问题更易分类,并使评估错误影响更直接,因为我们总是知道内存设置为什么。在大多数情况下,即使零不是“正确”值,从安全角度来看,它仍然是最安全的自动值。
我们仍然希望这些缓解措施将基本消除漏洞类的威胁,该类占近年来所有Microsoft CVE的5-10%。
前瞻计划
虽然池清零对我们是一个良好开始,我们仍有几个领域需要调查:
- 如何处理用户模式堆。我们应该禁止malloc并强制使用calloc?其他什么?
- 如何处理具有构造函数的C++类。这些应该选择退出清零但需要在构造函数中完全初始化类?内部填充字节呢?
我们还计划发布未来博客文章,介绍如何为内核池创建新的专用memset以清零分配,以及此工作如何导致为所有Windows应用程序提供更高性能的memset实现。