2017 年 12 月

第 32 卷,第 12 期

C++ - 面向基于堆栈的缓冲区保护的 Visual C++ 支持

作者 Hadi Brais | 2017 年 12 月

当软件执行了不符合其功能规范的操作时,即表示有缺陷或错误。该规范中的规则规定何时应允许访问和修改数据和其他资源,共同构成了安全策略。安全策略从本质上定义了软件安全的含义,以及何时应将特定缺陷视为安全缺陷,而不仅仅是又一个 bug。

鉴于来自世界各地的各种威胁层出不穷,安全性比以往任何时候都更加重要,因此必须成为软件开发生命周期 (SDL) 中不可或缺的一部分。这包括存储数据的位置、使用的 C/C++ 运行时 API、哪些工具可以帮助提升软件安全性等选择。遵循 C++ Core Guidelines (bit.ly/1LoeSRB) 大大有助于编写正确、可维护的代码。另外,Visual C++ 编译器提供了许多可以通过编译器开关轻松访问的安全功能。这些功能可以分为静态或动态安全分析。静态安全检查的例子包括使用 /Wall 和 /analyze 开关以及 C++ Core Guidelines 检查器。这些检查是静态执行的,不会影响生成的代码,但会增加编译时间。相比之下,动态检查由编译器或链接器插入到已发出的可执行二进制文件中。我将在本文中专门讨论一个动态安全分析选项,即 /GS,它可以防范基于堆栈的缓冲区溢出。我将解释当打开该开关时代码是如何转换的以及它何时能够或不能保护代码的安全。我将使用 Visual Studio Community 2017。

你可能会想,为什么不打开所有这些编译器开关来完成任务。一般来说,无论你是否了解其工作原理,都应该使用所有推荐的开关。  但是,详细了解特定技术的工作原理,可以让你确定它对代码的影响以及如何更好地利用它。例如,思考一下缓冲区溢出。编译器确实提供了一个处理此种缺陷的开关,但它使用了一个检测机制,当检测到缓冲区溢出时会强制程序崩溃。这样做能提高安全性吗?这要视情况而定。首先,尽管所有的缓冲区溢出都不好,但并非都是安全漏洞,所以不一定意味着会被利用。即使被利用,在触发检测机制时可能已发生损害。而且,根据应用程序的设计方式,使程序突然崩溃的做法可能并不恰当,因为它本身可能是拒绝服务 (DoS) 漏洞,或可能导致更糟的数据丢失或损坏的情况。  正如我在本文中将要解释的那样,唯一合理的做法是,使应用程序能应对这种崩溃,而不是禁用或更改保护机制。

我为《MDSN 杂志》撰写了大量关于编译器优化的文章(你可在 msdn.com/magazine/dn904673 上找到第一篇)。目标主要是改进执行时间。安全性也可以被视为编译器转换的目标。也就是说,不是优化执行时间,而是通过减少潜在安全缺陷的数量来优化安全性。这个观点很有用,因为它表明当你指定多个编译器开关来改进执行时间和安全性时,编译器可能有多个发生潜在冲突的目标。在这种情况下,它必须以某种方式平衡这些目标或确定其优先级。我将讨论 /GS 对代码的某些方面产生的影响,特别是速度、内存占用率和可执行文件的大小。这是了解这些开关对你的代码执行什么操作的另一个原因。

在下一部分中,我将介绍控制流攻击,尤其是堆栈缓冲区溢出。我将讨论它们是如何发生的以及攻击者如何利用它们。然后,我将详细介绍 /GS 如何影响你的代码以及它可以缓解此类攻击的程度。最后,我将演示如何使用 BinSkim 静态二进制分析工具对给定的可执行二进制文件执行一些关键的验证检查,而不需要源代码。

控制流攻击

缓冲区是用于临时存储待处理数据的内存块。缓冲区可以直接使用 Windows VirtualAlloc API 或作为全局变量从运行时堆、线程堆栈中分配。可以使用 C 内存分配函数(如 malloc)或 C++ new 运算符从运行时堆中分配缓冲区。可以使用自动数组变量或 _alloca 函数从堆栈中分配缓冲区。缓冲区最小可以是零个字节,最大则取决于最大空闲块的大小。

C 和 C++ 编程语言的两个特殊功能可以将它们与其他语言(如 C#)真正区分开来,这两个功能是:

  • 你可以对指针执行任意算法。
  • 只要指针指向已分配的内存(从操作系统的角度来看),你就可以随时成功取消引用该指针,但如果指针没有指向它所属的内存,可能没有定义应用程序的行为。

这些功能使语言非常强大,但同时也构成了巨大的威胁。特别是,用于访问或循环访问缓冲区内容的指针可能会被错误或恶意地修改,以致它指向缓冲区边界之外,这样会读取或写入相邻或其他内存位置。我们将超出缓冲区最大地址的写入称为缓冲区溢出。将在缓冲区最小地址(即缓冲区的地址)之前的写入称为缓冲区下溢。

最近在一个非常受欢迎的软件(名字保密)中发现了一个基于堆栈的缓冲区溢出漏洞。这是因为没有安全使用 sprintf 函数造成的,如下面的代码所示:

sprintf(buffer, "A long format string %d, %d", var1, var2);

缓冲区是从线程堆栈分配的,并且采用固定大小。但是,要写入缓冲区的字符串的大小取决于表示两个指定整数所需的字符数。缓冲区的大小不足以保存最大可能的字符串,导致在指定大整数时发生缓冲区溢出的情况。发生溢出时,堆栈中较高的相邻内存位置被损坏。

为了说明为什么这种情况是危险的,请考虑按照标准的 x86 调用约定,从堆栈中分配的缓冲区通常位于声明函数的堆栈帧中的位置,并考虑编译器优化,如图 1 所示。

典型的 x86 堆栈帧
****图 1 典型的 x86 堆栈帧

首先,调用方按照一定的顺序将没有通过寄存器传递的参数推送到堆栈。然后,x86 CALL 指令将返回地址推送到堆栈并跳转到被调用方的第一条指令。如果帧指针省略 (FPO) 优化没有发生,被调用方将当前帧指针推送到堆栈。如果被调用方使用任何未经优化的异常处理构造,则接下来将异常处理帧放置在堆栈上。该帧包含指向被调用方中定义的异常处理程序的指针和相关的其他信息。未经优化和不能保存在寄存器中、或从寄存器中溢出的非静态局部变量将按照一定的顺序从堆栈中分配。接下来,被调用方使用的任何由被调用方保存的寄存器都必须保存在堆栈中。最后,将使用 _alloca 分配的动态调整大小的缓冲区放置在堆栈帧的底部。

堆栈中的任何数据项可能都有一定的对齐要求,所以可以根据需要分配填充块。被调用方中用于设置堆栈帧(除了参数)的一段代码被称为 prolog。函数即将返回给调用方时,称为 epilog 的一段代码负责将堆栈帧解除分配到返回地址并包括该返回地址。

x86/x64 和 ARM 调用约定之间的主要区别在于,返回地址和帧指针保存在 ARM 的专用寄存器中,而不是堆栈中。尽管如此,堆栈缓冲区越界访问会在 ARM 上造成严重的安全问题,因为堆栈中的其他值可能是指针。

堆栈缓冲区溢出(超出缓冲区上限的写入)可能会覆盖存储在缓冲区上方的任何代码或数据指针。堆栈缓冲区下溢(在缓冲区下限之下的写入)可能会覆盖被调用方保存的寄存器的值(也可能是代码或数据指针)。任意的越界写入将导致应用程序崩溃或以未定义的方式运行。但是,恶意设计的攻击使攻击者能够控制应用程序或整个系统的执行。这可以通过覆盖代码指针(如返回地址)使其指向执行攻击者意图的一段代码来实现。

GuardStack (GS)

要缓解基于堆栈的越界访问,可以手动添加必要的边界检查(添加 if 语句来检查给定的指针是否在边界内),或使用执行此类检查的 API(例如 snprintf)。但是,由于不同的原因(例如用于确定缓冲区边界或执行边界检查的整数算法或类型转换不正确),漏洞可能仍然存在。因此,需要一个动态缓解机制来防止或减少被利用的可能性。

一般的缓解技术包括随机化地址空间和使用不可执行的堆栈。专门的缓解技术可以根据其目标进行分类:是通过在越界访问发生之前捕获来阻止越界访问发生,还是在越界访问发生之后的某个时刻检测越界访问。这两种目标都可以实现,但预防会大幅增加性能开销。

Visual C++ 编译器提供了两种有些类似的检测机制,但其目的和性能成本不同。第一种机制是运行时错误检查的一部分,可以使用 /RTCs 开关启用。第二种是 GuardStack(在文档中称为“缓冲区安全检查”,在 Visual Studio 中称为“安全检查”),可以使用 /GS 开关启用。

使用 /RTCs 时,编译器以交错方式从堆栈中分配额外的小内存块,以使堆栈中的每个局部变量夹在两个这样的块之间。每个这样的附加块都填充一个特殊值(当前为 0xCC)。这由被调用方的 prolog 处理。在 epilog 中,调用一个运行时函数来检查这些块是否有损坏并报告潜在的缓冲区溢出或下溢。这种检测机制在性能和堆栈空间方面增加了一些开销,但它旨在用于调试和确保程序的正确性,而不仅仅是一种缓解机制。

另一方面,GuardStack 在设计上具有较低的开销,作为一种缓解机制可实际用于潜在的恶意生产环境。所以 /RTCs 应该用于调试版本,而 GuardStack 应该用于两个版本。另外,编译器不允许同时使用 /RTCs 和编译器优化,而 GuardStack 是兼容的,不会影响编译器优化。默认情况下,在调试配置中这两种机制均启用,而在 Visual C++ 项目的发布配置中仅启用 GuardStack。在这篇文章中,我只详细讨论 GuardStack。

启用 GuardStack 时,典型的 x86 调用堆栈类似于图 2 中所示。

使用 GuardStack (/GS) 保护的典型 x86 堆栈帧
****图 2 使用 GuardStack (/GS) 保护的典型 x86 堆栈帧

与图 1 中显示的堆栈布局相比,有三个不同之处。首先,在局部变量的上方分配一个称为 cookie 或 canary 的特殊值。其次,更可能出现溢出的局部变量是在所有其他局部变量之上分配的。第三,对缓冲区溢出特别敏感的一些参数被复制到局部变量下面的区域。当然,为了促成这些变化,将使用一个不同的 prolog 和 epilog,我们现在就来讨论。

受保护函数的 prolog 在 x64 上大致包括以下附加说明:

sub         rsp,8h
mov         rax,qword ptr [__security_cookie] 
xor         rax,rbp 
mov         qword ptr [rbp],rax

从堆栈中分配额外的 8 个字节,并将其初始化为 __security_cookie 全局变量 XOR(其值保存在 RBP 寄存器中)值副本。指定 /GS 时,编译器自动链接从 gs_cookie.c 源文件构建的对象文件。该文件分别在 x64 和 x86 上将 __security_cookie 定义为 uintptr_t 类型的 64 位或 32 位全局变量。因此,使用 /GS 编译的每个可移植可执行 (PE) 图像都包含该图像的 prolog 和 epilog 函数使用的变量的单个定义。在 x86 上,除了使用 32 位寄存器和 cookie 之外,代码是相同的。

使用安全 cookie 的基本理念是在函数返回之前检测 cookie 的值是否与引用 cookie(全局变量)的值不同。这表明潜在的缓冲区溢出可能是由利用企图或只是一个无意的 bug 导致的。关键是,Cookie 具有非常高的熵,使攻击者难以猜测。如果攻击者能够确定特定堆栈帧中使用的 cookie,则 GuardStack 将失败。我将在本部分稍后深入讨论 GuardStack 可以和不可以做什么的问题。

编译器发出图像时,引用 cookie 会获得一个任意的常量值。因此,必须在执行任何代码之前仔细对引用 cookie 执行初始化。最近的 Windows 版本可感知 GuardStack,并在加载时将 cookie 初始化为高熵值。启用 /GS 时,EXE 或 DLL 的入口点首先要调用在 gs_support.c 中定义并在 process.h 中声明的 __security_init_cookie 来初始化 cookie。如果图像的 cookie 没有被 Windows 加载器正确初始化,则由该函数初始化。

请注意,如果没有包含 RBP 的 XOR 运算,则仅在执行期间任何时候泄漏引用 cookie(例如使用越界读取)就足以破坏 GuardStack。包含 RBP 的 XOR 运算使你能够高效地生成不同的 cookie,攻击者需要知道引用 cookie 和 RBP 才能找出一个堆栈帧的 cookie。RBP 本身不能保证具有高熵,因为其值取决于编译器如何优化代码、到目前为止所消耗的堆栈空间以及地址空间布局随机化 (ASLR)(如果启用)所执行的随机化。

受保护函数的 epilog 在 x64 上大致包括以下附加指令:

mov         rcx,qword ptr [rbp]
xor         rcx,rbp 
call        __security_check_cookie
add         esp,8h

首先,对堆栈中的 cookie 执行 XOR 运算,生成一个与引用 cookie 相同的值。编译器发出指令以确保 prolog 和 epilog 中使用的 RBP 值相同(除非它被损坏)。

在 vcruntime.h 中声明的 __security_check_cookie 函数由编译器链接,其目的是验证堆栈上的 cookie。这主要通过将 cookie 与引用 cookie 进行比较来完成。如果检查失败,则代码跳转到 gs_report.c 中定义的 __report_gsfailure 函数。在 Windows 8 和更高版本中,该函数通过调用 __fastfail 来终止进程。在其他系统上,该函数通过在移除任何可能的处理程序后调用 UnhandledExceptionFilter 来终止进程。无论哪种方式,错误是由 Windows 错误报告 (WER) 记录,它包含有关哪个堆栈帧中的安全 cookie 被损坏的信息。

当 /GS 在 Visual C++ 2002 中首次引入时,可以通过指定回调函数替代堆栈 cookie 检查失败的行为。但是,由于堆栈处于未定义状态,还由于在检测到溢出之前已经执行了一些代码,所以当时几乎不能可靠地完成任何任务。因此,从 Visual C++ 2005 开始的更高版本就淘汰了此功能。

GuardStack 的开销

为了尽量减少开销,只有编译器认为易受攻击的那些函数才受到保护。不同版本的编译器可能会使用不同的未记录的算法来确定函数是否易受攻击,但通常情况下,如果函数定义了一个数组或一个大型数据结构,并且获得了指向此类对象的指针,那么它很可能会被认为易受攻击。可以通过对其声明应用 __declspec(safebuffers) 来指定某个特定的函数不受保护。但是,当应用于受保护函数中内联的函数,或其中内联有受保护函数时,该关键字将被忽略。还可以使用 strict_gs_check pragma 强制编译器保护一个或多个函数。使用 /sdl 启用的安全开发生命周期 (SDL) 检查会对所有源文件和其他动态安全检查指定严格的 GuardStack。

GuardStack 将易受攻击的参数复制到局部变量下面更安全的位置,这样如果发生溢出,将更难以破坏这些参数。作为指针或 C++ 引用的参数可能会被视为易受攻击的参数。有关更多信息,请参阅有关 /GS 的文档。

我已经使用 C/C++ 生产应用程序进行了大量实验,以确定与性能和图像大小相关的开销。我已经对所有源文件应用了 strict_gs_check,所以结果与编译器视为易受攻击的函数的对象无关(我避免使用 /sdl,因为它启用的其他安全检查会产生各自的开销)。我获得的最大性能开销是 1.4%,最大的图像大小开销是 0.4%。程序中发生的最坏情况是,大部分时间都用来调用一些很少用的受保护函数。精心设计的实际程序不会发生此类行为。还要记住,GuardStack 会带来潜在的不可忽略的堆栈空间开销。

有关 GuardStack 的有效性

GuardStack 旨在仅缓解特定类型的漏洞,即堆栈缓冲区溢出。更重要的是,仅使用 GuardStack 防御该漏洞可能无法提供高度的保护,因为攻击者会通过多种方法绕过它:

  • 只有当函数返回时,才会检测损坏的 cookie。很多代码可能会在 cookie 实际已损坏和检测到损坏之间的时间差内执行。该代码可能会使用堆栈中已覆盖的在 cookie 的上方或下方的其他值。这为攻击者提供了一个机会来(部分)控制应用程序的执行。在这种情况下,甚至根本不可能执行检测。
  • 缓冲区溢出仍然可以发生而不覆盖 cookie。最危险的情况是溢出使用 _alloca 分配的缓冲区。在这种情况下,即使受保护的参数和被调用方保存的寄存器也可以被覆盖。
  • 使用越界内存读取可能会泄漏一些 cookie。由于不同的图像使用不同的引用 cookie,还由于 cookie 执行了 XOR 运算(含 RBP),所以会为攻击者利用泄漏的 cookie 造成更大的困难。然而,Windows Subsystem for Linux (WSL) 可能引入了另一种方式来泄漏 cookie。WSL 提供了分支 Linux 系统调用的仿真,该调用创建了一个复制父进程的新进程。如果被攻击的应用程序派生新的进程来处理传入的客户端请求,恶意客户端可以发出相当少量的请求来确定安全 cookie 的值。
  • 目前已经有许多技术来在某些情况下推测图像的引用 cookie。虽然我还没有发现任何成功的推测出引用 cookie 的实际攻击,但其确实有成功的可能性,不可忽视。包含 RBP 的 XOR 运算增加了另一个非常重要的防御层以防御这种攻击。
  • GuardStack 通过引入不同的潜在漏洞(特别是 DoS 和数据丢失)来缓解漏洞。检测到 cookie 损坏时,应用程序会突然终止。对于服务器应用程序,攻击者可能会使服务器崩溃,这可能导致丢失或破坏有价值的数据。

因此,首先要努力在静态分析工具的帮助下编写正确、安全的代码,这一点很重要。然后,遵循纵深防御策略,在你发布的代码中采用由 Visual C++ 提供的 GuardStack 和其他动态缓解措施(其中许多措施在发布版本中默认启用)。

/GS 与 /ENTRY

在你进行编译以生成 EXE 或 DLL 文件时由编译器指定的默认入口点函数 (*CRTStartup) 按顺序执行以下四项操作:初始化引用安全性 cookie;初始化 C/C++ 运行时;调用应用程序的主要功能;以及终止该应用程序。可以使用 /ENTRY 链接器开关以指定一个自定义入口点。但是,将自定义入口点与 /GS 的效果相结合可能会产生一些有趣的场景。

自定义入口点及其调用的任何函数都是要保护的候选对象。如果 Windows 加载程序正确地初始化了 cookie,那么任何受保护的函数都将使用引用 cookie 的副本,这些 cookie 在 prolog 和 epilog 中是相同的。所以,不会发生任何问题。

如果 Windows 没有正确地初始化 cookie,并且自定义入口点所执行的第一项操作是调用 __security_init_cookie,则除了入口点之外,所有受保护的函数都将使用正确的引用 cookie。回想一下,我们在 epilog 中创建了引用 cookie 的副本。因此,如果入口点正常返回,将在 epilog 中对 cookie 进行检查,且检查会失败,并导致误报。为了避免这个问题,你应该调用一个函数来终止程序(比如退出)而不是正常返回。

如果 Windows 没有正确初始化 cookie 并且入口点没有调用 __security_init_cookie,那么所有受保护的函数都将使用默认的引用 cookie。幸运的是,由于此 cookie 执行了包含 RBP 的 XOR 运算,因此所使用 cookie 的熵不会为零。所以你仍然会得到一些保护,尤其是对 ASLR。但是,建议你通过调用 __security_init_cookie 对引用 cookie 进行正确的初始化。

使用 BinSkim 验证 GuardStack

BinSkim 是一个轻量级的静态二进制分析工具,用于验证给定 PE 二进制文件中一些安全功能的用途的正确性。BinSkim 支持的一个特殊功能是 GuardStack。BinSkim 是 MIT 许可下的开放源代码 (github.com/Microsoft/binskim),完全用 C# 编写。它支持使用最新版本的 Visual C++ (2013+) 编译的 x86、x64 和 ARM Windows 二进制文件。可以将其用作一个独立工具,或者更有趣的是,将其(部分)包含在代码中。例如,如果你有一个支持 PE 插件的应用程序,则可以使用 BinSkim 来验证确保插件使用了建议的安全功能,否则拒绝加载。我将在本部分中讨论如何将 BinSkim 用作一个独立工具。

就 GuardStack 而言,该工具将验证指定的二进制文件是否符合以下四条规则:

  • EnableStackProtection:检查存储在关联 PDB 文件中的相应标志。如果找不到标志,则该规则失效。反之,则视为通过。
  • InitializeStackProtection:循环访问关联 PDB 文件中定义的全局函数列表,以便查找函数 __security_init_cookie 和 __security_check_cookie。如果找不到这两个函数,该工具认为未启用 /GS。在这种情况下,EnableStackProtection 应失效。如果未定义 __security_init_cookie,则该规则失效。反之,则视为通过。
  • DoNotModifyStackProtectionCookie:使用图像的加载配置数据查找引用 cookie 的位置。如果找不到这个位置,则该规则失效。如果加载配置数据指示已定义 cookie,但其偏移量无效,则该规则失效。反之,则视为通过。
  • DoNotDisableStackProtectionForFunctions:使用关联 PDB 文件来确定是否有任何应用 __declspec(safebuffers) 属性的函数。如果找到此类函数,则规则失效。反之,则视为通过。Microsoft SDL 不允许使用 __declspec(safebuffers)。

要使用 BinSkim,首先从 GitHub 存储库下载源代码并生成它。要运行 BinSkim,请在你喜欢的 shell 中执行以下命令:

binskim.exe analyze target.exe --output results.sarif

要分析多个图像,可以使用以下命令:

binskim.exe analyze myapp\*.dll --recurse --output results.sarif --verbose

请注意,你可以在文件路径中使用通配符。--recurse 开关指定 BinSkim 也应该分析子目录中的图像。--verbose 开关通知 BinSkim 在结果文件中包含通过的规则(不仅仅是未通过的规则)。

结果文件采用静态分析结果交换格式 (SARIF)。如果你在文本编辑器中打开该文件,则会找到类似****如图 3 中所示的条目。

图 3 BinSkim 分析结果文件

{
  "ruleId": "BA2014",
  "level": "pass",
  "formattedRuleMessage": {
    "formatId": "Pass ",
    "arguments": [
      "myapp.exe",
    ]
  },
  "locations": [
    {
      "analysisTarget": {
        "uri": "D:/src/build/myapp.exe"
      }
    }
  ]
}

每个规则都有一个规则 ID。规则 ID BA2014 是 DoNotDisableStackProtectionForFunctions 规则的 ID。Microsoft SARIF SDK (github.com/Microsoft/sarif-sdk) 包括在 Visual Studio 中查看 SARIF 文件的 Visual Studio 扩展的源代码。

总结

GuardStack 动态缓解技术是极其重要的基于检测的缓解措施,可以防御堆栈缓冲区溢出漏洞。它在 Visual Studio 的调试和发布版本中默认处于启用状态。对大多数程序而言,该技术的开销可以忽略不计,因此可以广泛使用。但是,它并不能为应对这些漏洞提供最终的解决方案。缓冲区溢出问题对于从堆栈分配的缓冲区是很常见的,但是它们也可能出现在任何分配的内存区域中。最显著的是,基于堆的缓冲区溢出同样危险。由于这些原因,使用控制流防护 (CFG)、地址空间布局随机化 (ASLR)、数据执行保护 (DEP)、安全结构化异常处理 (SAFESEH)、结构化异常处理覆盖保护 (SEHOP) 等 Visual C++ 和 Windows 提供的其他缓解技术是非常重要的。所有这些技术协同工作,可强化你的应用程序。有关这些技术和其他技术的更多信息,请参阅 bit.ly/2iLG9rq.


Hadi Brais 博士是德里印度理工学院的学者,主要研究编译器优化、计算机体系结构以及相关工具和技术。他的博客地址为 hadibrais.wordpress.com,还可以通过 hadi.b@live.com 联系他。

衷心感谢以下技术专家对本文的审阅:  Shayne Hiet-Block (Microsoft)、Mateusz Jurczyk (Google)、Preeti Ranjan Panda (IITD)、Andrew Pardoe (Microsoft)


在 MSDN 杂志论坛讨论这篇文章