Windows未初始化栈内存漏洞的解决方案与InitAll技术解析

本文详细介绍了微软通过InitAll技术解决Windows中未初始化栈内存漏洞的方法,包括技术背景、实现原理、性能优化策略以及在实际部署中的兼容性挑战和解决方案。

解决Windows未初始化栈内存问题 | MSRC博客

未初始化内存背景

C和C++编程语言在设计时以性能和开发者控制为首要考虑因素。因此,语言本身没有强制变量初始化的机制。使用未初始化变量是未定义行为。开发者必须在变量使用前进行初始化,而这完全依赖于开发者的正确操作。

这里存在两类明显的漏洞:

  • 未初始化内存泄露:未初始化内存跨越信任边界被复制,其内容被泄露给权限较低的实体。
  • 未初始化内存使用:未初始化内存被直接使用。例如,通过未初始化的指针进行写入操作。

需要注意的是,未初始化内存问题既发生在栈分配也发生在堆分配中。本文主要关注栈内存,后续博客将讨论堆内存问题。

未初始化内存使用示例

1
2
3
int size;
GetSize(&size); // 如果此函数忘记设置size会怎样?
memcpy(dest, src, size); // 使用未初始化的size进行memcpy

这段代码的问题是,如果函数GetSize没有在所有路径上实际设置"size",我们将使用未初始化的size调用memcpy。如果"size"的值恰好大于"src"或"dest"缓冲区的大小,这可能导致越界读取或写入。

未初始化内存泄露示例

1
2
3
4
5
6
struct mystruct {
    uint8_t field1;
    uint64_t field2;
};
mystruct s {1, 5};
memcpy(dest, &s, sizeof(s));

在这个例子中,假设memcpy将结构体跨越信任边界复制(即从内核模式到用户模式)。看起来结构体已完全初始化,但是,在"field1"和"field2"之间有编译器插入的填充字节,这些填充字节没有被显式初始化。

memcpy调用将导致这些填充字节与未初始化数据一起跨越信任边界被复制。填充字节的值将是之前写入该虚拟地址的任何内容。这可能包括加密私钥的一部分(从而将密钥材料泄露给用户模式)、指针(从而破坏ASLR)或其他内容。在某些情况下,可能很容易证明没有敏感信息被泄露,而在其他情况下,这可能极具挑战性。无论如何,这都不是特别有趣的工作,我们都宁愿花时间做其他事情,而不是弄清楚未初始化内存错误的严重程度。

未初始化内存错误统计

![未初始化内存漏洞统计图表]

近年来,未初始化内存使用错误呈上升趋势。这可能部分归因于更多研究人员对这一漏洞类别感兴趣,并编写了优秀的工具来帮助识别这些问题。

当我们更详细地分析这些错误时,出现了一些其他有趣的趋势。

![详细漏洞分类图表]

这些图表说明:

  • 在2017年至2018年间,未初始化内存漏洞约占微软发布的CVE的5-10%
  • 基于栈的漏洞和基于堆/池的漏洞几乎各占一半
  • 未初始化内存泄露超过未初始化内存使用错误

进一步阅读资源

未初始化内存漏洞的潜在解决方案

有多种方法可以尝试解决这类问题:

  • 静态分析(编译时和编译后)
  • 模糊测试
  • 代码审查
  • 自动初始化

静态分析

微软已经有多个静态分析警告来捕获未初始化变量(包括C4700、C4701、C4703、C6001、C26494和C26495)。它们是保守的,意味着为了减少噪音,这些警告会忽略某些可能导致未初始化内存的编码模式。

微软还编写了积极的Semmle静态分析规则,并在Windows的某些代码库中运行它们。这些规则会产生很多噪音,并且难以在庞大的代码库中运行。它们还需要维护规则和修复错误的工作。这些规则最终对开发人员来说摩擦较大,修复成本较高。

模糊测试

模糊测试再次遇到扩展问题。好的模糊测试器需要维护工作,并且需要针对其目标进行定制。在像微软这样庞大的代码库中实现完美的模糊测试覆盖非常具有挑战性。

即使我们能够以完美的覆盖度对所有内容进行模糊测试,模糊测试器也很难检测未初始化内存泄露,因为这些问题不会导致崩溃。要使用模糊测试器检测这类问题,您需要:

  • 理解协议的模糊测试器,能够检测何时将未初始化内存返回给它(或者更确切地说,返回了意外数据)
  • 能够确定何时访问未初始化内存的动态分析

代码审查

代码审查无法扩展且容易出错。许多这些易受攻击的代码路径都经过了代码审查,但问题非常微妙,以至于被遗漏。

一些遭受未初始化内存泄露的代码是在Windows仅运行在32位架构时编写的,当时是正确的。当Windows迁移到64位架构时,指针大小从32位增加到64位。这为某些结构引入了以前不存在的填充字段。以前所有字段都正确初始化的结构现在有了未初始化的填充字段。

InitAll - 自动初始化

除了前面提到的方法外,微软现在使用一个称为InitAll的功能,该功能执行栈变量的自动编译时初始化。

本节记录了Windows如何使用这项技术以及为什么选择这条路径。

当前Windows设置

以下类型会自动初始化:

  • 标量(数组、指针、浮点数)
  • 指针数组
  • 结构体(普通旧数据结构)

以下不会自动初始化:

  • 易失性变量
  • 除指针之外的任何数组(即int数组、结构体数组等)
  • 非普通旧数据的类

对于优化的零售版本,填充模式为零。对于浮点数,填充模式为0.0。

对于CHK版本或开发版本(即未优化的零售版本),填充模式为0xE2。对于浮点数,填充模式为1.0。

InitAll已为以下组件启用:

  • Windows代码存储库中的所有内核模式代码(即使用/KERNEL编译的任何内容)
  • 所有Hyper-V代码(虚拟机监控程序、内核模式组件、用户模式组件)
  • 各种其他项目,如网络相关的用户模式服务

InitAll在编译器前端实现。任何满足上述标准且未在声明时初始化的变量将由前端在声明时初始化。这种方法的一个好处是,从优化器的角度来看,这与开发人员在源代码中声明时初始化看起来完全相同。这意味着我们为在使用InitAll时提高性能而构建的任何优化都不是InitAll特定的,它们有益于任何在声明时(或使用前)初始化的人。

如何避免"分叉语言"

关于"零初始化"的一个合理担忧是,零是语言中的一个特殊值,特别是对于指针。零也可能是事物初始化最常用的单个值。

通过进行零初始化,未正确初始化的指针可能最终只会进入程序的"NULL指针"分支。这导致程序不会崩溃,但可能不会给出您想要的结果。如果将指针初始化为垃圾值,它不会进入程序的"NULL指针"路径,如果程序实际尝试使用它,程序将崩溃。

我们解决这个问题的方法是在CHK版本和我们称为"开发版本"(通常是未优化的发布版本)中使用非零模式(0xE2)。这使我们能够在向客户发布的代码中保持性能,同时为我们开发人员测试的版本提供更可能突出显示缺少初始化的行为。

值得注意的是,C++已经要求对任何具有静态存储持续时间的对象进行自动零初始化。开发人员可以并且确实依赖这些语义。例如,如果静态变量的值为零,开发人员可能知道"我需要初始化这个变量,这是第一次使用它"。在某种程度上,InitAll项目正在为自动(栈)变量带来类似的语义,但重要的警告是我们试图防止开发人员明确依赖特定的初始化值。

如何选择启用InitAll的组件

我们InitAll的初始目标是:

  • 内核模式代码,主要是由于我们观察到大量与未初始化内核内存相关的漏洞
  • Hyper-V代码,主要是由于其对Azure的重要性以及最近栈信息泄露错误的历史

微软内部的许多其他人员听说了InitAll,并决定在自己的组件上主动启用它。

我们没有立即为所有代码推出InitAll的原因是为了确保我们能够成功完成某些事情,而不是在尝试做所有事情时失败。我们一次应用InitAll的代码越多,调试出现的性能回归、处理应用程序兼容性问题等就越困难。既然我们已经成功将这项技术推广到最高优先级的目

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