解决Windows未初始化栈内存漏洞的技术实践

微软详细介绍了通过InitAll技术自动初始化栈变量来消除未初始化内存漏洞,包括技术原理、性能优化及实际应用效果,有效减少安全漏洞并提升系统稳定性。

解决Windows未初始化栈内存漏洞

本文概述了微软在消除Windows未初始化栈内存漏洞方面的工作,并解释了选择这一路径的原因。文章分为以下几个部分:

  • 未初始化内存背景
  • 未初始化内存漏洞的潜在解决方案
  • InitAll – 自动初始化
  • InitAll的有趣发现
  • 性能优化
  • 对客户的影响
  • 未来计划

这些工作离不开Visual Studio团队、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的代码越多,调试出现的性能回归、处理应用程序兼容性问题等就越困难。既然我们已经成功将这项技术推广到最高优先级目标,我们可以将重点转向其余代码。

InitAll是否破坏静态分析?

静态分析在让开发者知道他们在使用前忘记初始化方面非常有用。

InitAll功能向PREfast和编译器后端(两者都有未初始化警告)指示变量赋值是否由InitAll引起。这允许分析工具忽略这些警告的InitAll变量赋值。启用InitAll后,如果开发者忘记初始化变量,即使InitAll强制为其初始化,他们仍会收到静态分析警告。

为什么不初始化所有类型

在初始测试中,当我们强制初始化栈上的所有类型数据时,我们在几个关键场景中看到超过10%的性能回归。

仅使用POD结构,性能更合理。编译器优化以消除冗余存储(基本块内部和基本块之间)能够进一步将POD结构引起的回归从可观察到大多数测试的噪音水平。

我们计划重新审视零初始化所有类型(特别是现在我们的优化器有更强大的优化),只是还没有做到。

为什么零初始化

零初始化具有最佳性能特征(运行时性能和代码大小)以及最佳安全属性。

安全属性

零初始化导致:

  • NULL指针,如果在Windows上解引用将抛出SEH异常(即,最坏情况下拒绝服务而不是远程代码执行),通常会导致程序崩溃。
  • 零大小或零索引,如果变量用于跟踪这些值。这有望最小化未初始化大小传递给像memcpy这样基于传递大小操作缓冲区的函数的影响。
  • 零指针,在NULL检查中测试时,将走“指针为NULL”的路径,不尝试使用指针。这至少给程序正确处理开发者忘记初始化指针的机会(因为跟随模式初始化指针总是会崩溃)。
  • 零布尔值为false,可能表示“失败”。

零初始化也有一些负面影响:

  • NTSTATUS值为STATUS_SUCCESS
  • HRESULT值为S_OK

但因为返回值都不同,没有通用的“好”值来初始化返回值。特别是考虑到这也将用于初始化大小、索引、指针等。

性能属性

用于自动初始化的模式在运行时性能成本和代码大小方面都很重要。我们从未使用非零模式测量Windows在性能或代码大小上的差异。我们想要零模式的安全好处,并且我们知道使用它性能和代码大小会更好。我们在Google的同行已经做了一些测量,并证明在Clang上,零初始化目前在代码大小和运行时性能上明显优于模式初始化。

以下数据说明了为什么零初始化对代码大小更好。

示例1:使用通用寄存器初始化

零初始化:

1
2
31 c0                         xor    eax,eax
48 89 01                      mov    QWORD PTR [rcx],rax

模式初始化:

1
2
48 b8 e2 e2 e2 e2 e2 e2 e2 e2   movabs rax,0xe2e2e2e2e2e2e2e2
48 89 01                      mov    QWORD PTR [rcx],rax

在这个例子中有两个有趣的事情需要注意:

首先,将RAX寄存器设置为零占用两个字节的代码,而将其设置为模式占用10个字节。这花费代码大小和性能。许多CPU一次读取16字节指令,因此占用10字节设置固定常量会阻止CPU获取可能并行执行的其他指令。

其次,在完成对RCX的存储之前,必须将RAX设置为固定模式。这可能会停滞CPU。CPU在流水线早期识别诸如“xor eax, eax”之类的代码序列,不需要执行实际的XOR,它们只会将RAX设置为零。最终结果是由于减少流水线停滞而获得更好的性能。

示例2:使用XMM寄存器初始化

对于较大的存储,编译器通常使用XMM寄存器(如果您使用AVX或AVX512支持编译,它也可以使用YMM或ZMM)。CPU通常限制每个时钟周期退休单个存储指令,因此使用设置尽可能多字节的存储指令是有意义的。

零初始化:

1
2
0f 57 c0                      xorps  xmm0,xmm0
f3 0f 7f 01                   movdqu XMMWORD PTR [rcx],xmm0

模式初始化(从全局加载模式,编译器通常这样做):

1
2
66 0f 6f 04 25 00 00 00 00    movdqa xmm0,XMMWORD PTR ds:0x0
f3 0f 7f 01                   movdqu XMMWORD PTR [rcx],xmm0

模式初始化(使用代码中的固定常量加载模式,编译器不这样做):

1
2
3
4
48 ba e2 e2 e2 e2 e2 e2 e2 e2   movabs rdx,0xe2e2e2e2e2e2e2e2
66 48 0f 6e c2                movq   xmm0,rdx
0f 16 c0                      movlhps xmm0,xmm0
f3 0f 7f 01                   movdqu XMMWORD PTR [rcx],xmm0

XMM寄存器的情况类似。零初始化的代码大小非常小。

不可能直接将固定常量加载到XMM寄存器中。相反,必须将固定常量加载到通用寄存器中,然后移动到XMM寄存器,然后将XMM寄存器的低64位复制到XMM寄存器的高64位。这导致大量代码和3条指令,每条指令都依赖于前一条指令的结果。

为了避免这种情况,编译器通常将固定常量存储为全局变量。然后它们可以读取这个全局变量,这导致代码大小大幅减少。不幸的是,在使用XMM寄存器之前,加载需要完成。如果全局变量被分页出去,这可能花费数千个时钟周期。即使在数据位于L1缓存的最佳情况下,加载操作也需要几个时钟周期。而且它仍然比简单清零寄存器更大的代码。

这暴露了零初始化的另一个好处:更确定的结果。初始化成本不依赖于特定全局变量是否在L1缓存、L2缓存、L3缓存、分页等。

InitAll的有趣发现

性能

Windows 10 1903是第一个启用InitAll的Windows版本(2019年春季发布)。自从发布以来,我们没有收到任何与InitAll相关的性能投诉。

兼容性

反作弊

在Windows中启用InitAll后不久,我们收到投诉称某些反作弊软件导致内核崩溃。调查显示,这些反作弊解决方案包括内核模式驱动程序。这些驱动程序扫描内存中的NT内核映像,并查找字节模式以定位未记录的函数。这些模式匹配器的工作方式是通过搜索指示函数开头的特定字节模式。

当启用InitAll时,一些额外的初始化(无法被证明 away)被添加到这些函数的开头,这有效地改变了它们的签名。我们联系了这些反作弊公司,他们更新了驱动程序以停止导致内核崩溃。

FAT32中的释放后使用

在为标量(即整数、浮点数等)启用InitAll后不久,我们在FAT文件系统驱动程序中遇到了一个有趣的问题,该问题阻止人们从USB驱动器升级内部Windows版本。

代码大致如下:

1
2
3
4
5
for(int i = 0; i < size; i++)
{
      int tmp;
      DoStuff(&tmp, i);
}

这段代码在循环中运行。一个变量在循环内部声明。在循环的第一次迭代中,函数DoStuff将初始化传递给它的变量“tmp”。在循环的每次额外迭代中,变量“tmp”被用作输入/输出参数。换句话说,变量将被读取然后更新。

这里的问题是变量在循环的每次迭代开始时进入作用域,并在每次迭代结束后离开作用域。启用InitAll后,此变量在循环的每次迭代中都被零初始化。这实际上是释放后使用。这段代码依赖于“tmp”的值在每次迭代中保留,即使它在每次迭代结束时离开作用域。不幸的是,这对我们来说没有导致驱动程序崩溃。它导致不正确的驱动程序逻辑,从而使文件系统行为异常。经过内核团队的一些调试,他们意识到发生了什么,并通过将变量声明移到循环外来修复问题。

这是一个缓解措施如何最终破坏多年未触及代码的示例。

性能优化

InitAll性能优化可分为3类:

  • 让开发者能够选择退出InitAll
  • 尽可能优化掉冗余存储
  • 使剩余存储尽可能快

选择退出InitAll

我们最明显的优化是允许代码:

  • 完全禁用InitAll
  • 对特定类型禁用InitAll(即结构体typedef)
  • 对函数中的所有分配禁用InitAll
  • 对函数中的特定变量声明禁用InitAll

到目前为止,InitAll仅因性能原因在单一类型上被禁用:_CONTEXT结构体,它存储每个寄存器的值。强制初始化此结构体在性能测试基础设施中导致性能回归。

_CONTEXT结构体超过1,000字节大,包含存储每个寄存器值的空间。当为上下文交换启用ETW日志记录时,每次上下文切换发生时,所有寄存器的值将被记录。_CONTEXT结构体将在栈上分配,通过汇编函数填充,然后传递给ETW。编译器无法优化掉InitAll完成的初始化,因为结构体由汇编函数初始化。由于此结构体已经包含敏感数据(每个寄存器的状态),非常大,并在非常性能敏感的路径中使用,我们决定选择退出InitAll。

InitAll尚未因性能原因在任何其他类型、变量或函数上禁用。

更好的冗余存储消除

冗余存储消除是Visual Studio编译器中的优化过程,编译器消除对证明不必要的变量的存储。

以下是一些显示Visual Studio可以做的优化类型的示例。

多次Memset消除

Godbolt链接:https://msvc.godbolt.org/z/Ldu7AP

以下代码模式(及其变体)非常常见。原始的NT编程指南要求所有变量在函数顶部声明,并且初始化尽可能推迟。结果是我们最终遇到变量在函数顶部声明,仅在使用前的单个分支中初始化的情况。

当我们启用InitAll时,变量在函数顶部有第二次初始化。由编译器消除重复初始化,但编译器并不总是容易做到这一点。

1
2
#include <stdio.h>
#include <string.h
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计