解决Windows未初始化内核池内存问题
本文概述了微软为消除Windows中的未初始化内核池内存漏洞所做的工作以及选择这一路径的原因。
理解解决未初始化池内存的可行性
解决未初始化内核池内存的工作在解决未初始化栈内存之后开始。与未初始化栈内存一样,我们希望找到一个能够确定性防止漏洞的解决方案,而不是仅仅依赖静态分析、模糊测试或代码审查。对我们来说,理想的最终状态是通过构建确保代码没有未初始化内核池问题。
最初,解决未初始化池内存似乎比未初始化栈内存更具挑战性。考虑以下差异:
栈分配 | 内核池分配 |
---|---|
典型大小较小(内核栈为20KB且无法增长) | 可能达到许多MB,平均池分配大小大于栈 |
活动栈通常在L1缓存中或即将进入(当代码使用变量时) | 池分配可能由完全不在缓存中的内存满足,且可能不会立即使用(尽管池分配通常在分配后很快使用) |
MSVC在消除对栈变量的冗余存储方面做得很好 | 如果在池API中清零分配,编译器无法自动优化掉初始清零。MSVC需要自定义优化逻辑来识别"此API清零,因此如果调用者在分配后立即将其memset为零,则可以消除memset"。MSVC通常更难优化对池/堆内存的冗余存储 |
即时,栈分配在函数进入时通过调整栈指针批量完成 | 快速但非即时。涉及分支逻辑、多次内存读取以咨询结构和元数据,以及可能获取锁或调用内存管理器以获取额外的虚拟地址空间 |
如果强制初始化无法优化掉,“分配时间"从零增加到初始化所需的时间。这是纯开销 | 堆分配已有开销,希望memset所花费的时间占总分配时间的个位数百分比。对于非常大的memset,我们预计memset成本将主导总分配时间 |
简而言之,我们预计池分配平均比栈分配更大,平均更难(或不可能)优化掉冗余存储,并且将处理较慢CPU缓存中的内存。唯一的补救因素是堆分配比栈分配花费更长时间,因此虽然我们可能在初始化堆分配上花费更多时间,但它有望融入"进行堆分配所花费时间"的噪音中。
为了验证这一理论,我们进行了两组测试。
真实世界Windows性能测试
在此测试中,我们修改了现有的池API,无条件清零所有分配,无法选择退出该行为。然后,我们使用现有的性能门基础设施测量此更改在关键场景中的影响。
这些测试结果非常积极。大多数基准测试显示没有可测量的性能回归。
我们的一个关键基准测试web fundamentals(衡量Web服务器性能)很好地估计了全系统性能,并且已知对内核池分配器性能极其敏感。当我们之前用用户模式中运行的段堆实现替换旧内核池分配器时,web fundamentals最初出现了约15%的回归(注意:我们修复了这些回归)。这里的重点是web fundamentals对池分配器性能非常敏感。
启用池清零后,其中一个web fundamentals测试显示了约1%的噪音级回归。其余web fundamentals测试显示没有回归。
这使我们确信,只要开发人员有方法选择退出导致回归的热分配,池清零是可行的。
微基准测试
我们还构建了微基准测试以帮助理解对不同大小分配进行清零的开销。请注意,这些微基准测试确实有一些噪音;如果您在单个大小看到巨大峰值,很可能只是测试中的噪音。另请注意,这些基准测试不代表经过一些性能调整后的当前性能。这些是初始性能数字。
测试1:使用相同大小的多次分配分配8GB内存
以下基准测试测量当单个线程重复进行某些固定大小的分配时,池清零引起的回归。请注意,对于这些测试,进行了8GB的分配。
该场景有些不太现实,因为在正常的堆操作中,我们预计分配会被创建和释放(因此可以重用虚拟地址空间)。在此测试中,堆定期需要向内存管理器请求额外内存(这较慢)。
[图表显示单线程下不同分配大小的回归百分比]
下一个图表显示相同场景,但每个特定大小由4个线程同时进行。这可能导致锁争用、互锁操作相互冲突以及SList操作冲突。
[图表显示4线程下不同分配大小的回归百分比]
值得注意的一点是,多线程测试通常比单线程测试具有更高的噪音,这在图表中很明显。还值得注意的是,总体而言,这里的回归似乎较低(尽管对于非常小的大小较高)。这是预期的,因为当多个线程同时执行时,分配路径具有额外的开销(如上所述)。
测试2:分配和释放
下一个测试分配和释放内存,减少了内存管理器定期必须向堆提供额外内存的开销。我们预计在这组测试中回归会更严重,因为堆本身应该运行得更快。
[图表显示分配和释放时不同分配大小的回归百分比]
上面的图表显示,当分配被分配和释放时,回归显著更高,如预期那样。
对于4个线程同时呢?
[图表显示4线程分配和释放时不同分配大小的回归百分比]
再次,我们可以看到这里的回归比之前更大。
以下图表显示,随着分配大小的增加,memset完全主导了进行分配所需的时间。
[图表显示memset时间占总分配时间的百分比]
合理化性能数据
真实世界的数据看起来不错,但一些微基准测试数据看起来相当令人担忧。以下是我们如何合理化我们所看到的内容:
- 较小的分配比大分配更可能在热路径中出现。例如,您通常不会在热路径中看到多兆字节甚至多千字节的分配。大分配通常在有限的地方进行,热路径是使用分配的代码,而不是进行分配的代码。
- 小分配性能没有超级回归。仍然有影响,但并非不合理。
- 许多现有代码路径在分配后已经清零分配。我们的真实世界测试设置无条件在池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实现。