65.9K
CodeProject 正在变化。 阅读更多。
Home

编译器安全检查深入分析

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (12投票s)

2002年2月19日

17分钟阅读

viewsIcon

233164

本文讨论了缓冲区溢出以及 Microsoft® Visual C++® .NET 安全检查功能 (/GS 选项) 的完整图景。

目录

引言
什么是缓冲区溢出?

x86 堆栈的解剖

运行时检查

/GS 的作用

错误处理程序

Cookie 值

性能影响

示例

结论

引言

软件安全是高科技行业的一个主要关注点,而最令人恐惧和误解的软件漏洞就是缓冲区溢出。如今,提到缓冲区溢出就足以让人停下来倾听。然而,技术细节常常在传播过程中丢失,公众对这个相当基本的问题产生了一种相当令人担忧的看法。为了解决这个问题,Visual C++ .NET 推出了安全检查功能,以协助开发人员识别缓冲区溢出。

什么是缓冲区溢出?

缓冲区是内存块,通常以数组的形式存在。当未验证数组的大小时,可能会写入超出分配的缓冲区。如果此类操作发生在比缓冲区地址更高的内存地址,则称为缓冲区溢出。当写入低于分配的缓冲区地址的缓冲区时,也存在类似的问题。在这种情况下,称为缓冲区下溢。下溢比溢出少见得多,但它们确实会发生,正如本文后面所述。

一类有详细文档记录的函数,包括 strcpygetsscanfsprintfstrcat 等,本身就容易发生缓冲区溢出,并且不鼓励使用它们。一个简单的例子展示了这类函数的危险性。

int vulnerable1(char * pStr) {
    int nCount = 0;
    char pBuff[_MAX_PATH];

    strcpy(pBuff, pStr);

    for(; pBuff; pBuff++)
       if (*pBuff == '\\') nCount++;

    return nCount;
}

这段代码存在明显的漏洞——如果 pStr 指向的缓冲区比 _MAX_PATH 长,则 pBuff 参数可能会发生溢出。简单地包含 assert(strlen(pStr) < _MAX_PATH) 可以在调试版本中捕获这个错误,但不能在发布版本中捕获。如果想完全避免此类问题,使用这些易受攻击的函数被认为是坏习惯。类似地,也有技术上不太容易受到攻击的函数,例如 strncpystrncatmemcpy。这些函数的问题在于,是开发人员断言了缓冲区的大小,而不是编译器。一个非常常见的错误在下面的函数中得到了体现。

#define BUFLEN 16

void vulnerable2(void) {
    wchar_t buf[BUFLEN];
    int ret;

    ret = MultiByteToWideChar(CP_ACP, 0, "1234567890123456789", -1, buf, sizeof(buf));
    printf("%d\n", ret);
}

在这种情况下,用于声明缓冲区大小的是字节数而不是字符数,从而导致了溢出。要修复此漏洞,MultiByteToWideChar 的最后一个参数应为 sizeof(buf)/sizeof(buf[0])vulnerable1vulnerable2 都是常见的错误,可以轻松避免;但是,如果代码审查遗漏了这些错误,则可能将危险的安全漏洞发布到应用程序中。这就是为什么 Visual C++ .NET 推出了安全检查,它将防止 vulnerable1vulnerable2 中的缓冲区溢出向易受攻击的应用程序注入恶意代码。将代码注入运行进程的缓冲区溢出称为可利用的缓冲区溢出。

x86 堆栈的解剖

要完全理解缓冲区溢出可能被利用的环境以及安全检查的工作原理,就必须完全理解堆栈的布局。在 x86 体系结构上,堆栈向下增长,这意味着新数据将被分配到比先前推入堆栈的元素更小的地址。每次函数调用都会创建一个新的堆栈帧,其布局如下,请注意,高内存位于列表顶部。

函数参数
函数返回地址
帧指针
异常处理程序帧
局部声明的变量和缓冲区
被调用者保存的寄存器

从布局可以看出,缓冲区溢出有机会覆盖在缓冲区之前分配的其他变量、异常帧、帧指针、返回地址和函数参数。要控制程序的执行,必须将一个值写入稍后将加载到 EIP 寄存器中的数据。函数返回地址就是这些值之一。经典的缓冲区溢出攻击会溢出返回地址,然后让函数的返回指令将返回地址加载到 EIP。

数据元素以以下方式存储在堆栈上。在调用函数之前,函数参数被推入堆栈。参数从右到左推入。函数返回地址由 x86 CALL 指令放置在堆栈上,该指令存储 EIP 寄存器的当前值。帧指针是 EBP 寄存器的前一个值,当不进行帧指针省略 (FPO) 优化时,它会被放入堆栈。因此,帧指针并不总是在堆栈帧中。如果函数包含 try/catch 或任何其他异常处理构造,编译器将在堆栈上包含异常处理信息。之后,分配局部声明的变量和缓冲区。这些分配的顺序可能会根据发生的优化而改变。最后,如果调用者保存的寄存器(如 ESI、EDI 和 EBX)在函数执行的任何时间点都被使用,则会存储它们。

运行时检查

缓冲区溢出是 C 或 C++ 程序员常见的错误,而且可能是最危险的错误之一。Visual C++ .NET 提供了可以使开发人员在开发周期中更轻松地找到这些错误并进行修复的工具。Visual C++ 6.0 中的 /GZ 开关在 Visual C++ .NET 的 /RTC1 开关中获得了新的生命。/RTC1 开关是 /RTCsu 的别名,其中 s 代表堆栈检查(本文的重点),u 代表未初始化变量检查。所有在堆栈上分配的缓冲区在边缘都被标记;因此,可以捕获溢出和下溢。虽然小的溢出可能不会改变程序的执行,但它们可能会破坏缓冲区附近的数据,而这些数据可能会被忽视。

运行时检查对于不仅想编写安全代码,还关心编写正确代码这一基本问题的开发人员来说非常有用。但是,运行时检查仅适用于调试版本;此功能从未设计为在生产代码中运行。尽管如此,在生产代码中进行缓冲区溢出检查显然是有价值的。这样做需要一个比运行时检查实现具有更小性能影响的设计。为此,Visual C++ .NET 编译器引入了 /GS 开关。

/GS 的作用

/GS 开关在缓冲区和返回地址之间提供了一个“减速带”或“Cookie”。如果溢出覆盖了返回地址,它将不得不覆盖位于其和缓冲区之间的 Cookie,从而导致新的堆栈布局。

函数参数
函数返回地址
帧指针
Cookie
异常处理程序帧
局部声明的变量和缓冲区
被调用者保存的寄存器

稍后将详细讨论 Cookie。函数的执行会因这些安全检查而改变。首先,当调用函数时,执行的第一条指令是在函数的 prolog 中。Prolog 最少会为堆栈上的局部变量分配空间,例如以下指令。

sub   esp,20h

此指令为函数中的局部变量预留了 32 字节。当函数使用 /GS 编译时,函数的 prolog 会额外预留四个字节,并添加三条额外的指令,如下所示。

sub   esp,24h
mov   eax,dword ptr [___security_cookie (408040h)]
xor   eax,dword ptr [esp+24h]
mov   dword ptr [esp+20h],eax

Prolog 包含一条获取 Cookie 副本的指令,然后是一条对 Cookie 和返回地址进行逻辑 xor 的指令,最后是一条将 Cookie 存储在返回地址正下方的堆栈上的指令。从这一点开始,函数将正常执行。当函数返回时,最后执行的是函数的 epilog,这与 prolog 相反。没有安全检查时,它会收回堆栈空间并返回,例如以下指令。

add   esp,20h
ret

当使用 /GS 编译时,安全检查也被放置在 epilog 中。

mov   ecx,dword ptr [esp+20h]
xor   ecx,dword ptr [esp+24h]
add   esp,24h
jmp   __security_check_cookie (4010B2h)

将检索堆栈上的 Cookie 副本,然后跟随对返回地址的 XOR 指令。ECX 寄存器应包含一个与存储在 __security_cookie 变量中的原始 Cookie 匹配的值。然后收回堆栈空间,然后,而不是执行 RET 指令,而是执行跳转到 __security_check_cookie 例程的 JMP 指令。

__security_check_cookie 例程很简单:如果 Cookie 未更改,它将执行 RET 指令并结束函数调用。如果 Cookie 不匹配,该例程将调用 report_failure。然后,report_failure 函数调用 __security_error_handler(_SECERR_BUFFER_OVERRUN, NULL)。这两个函数都定义在 C 运行时 (CRT) 源文件中的 seccook.c 文件中。

错误处理程序

CRT 支持对于使这些安全检查生效至关重要。当发生安全检查失败时,程序控制权会传递给 __security_error_handler,这里对其进行了总结。

void __cdecl __security_error_handler(int code, void *data)
{
    if (user_handler != NULL) {
      __try {
        user_handler(code, data);
      } __except (EXCEPTION_EXECUTE_HANDLER) {}
    } else {
      //...prepare outmsg...
      __crtMessageBoxA(
          outmsg,
          "Microsoft Visual C++ Runtime Library",
          MB_OK|MB_ICONHAND|MB_SETFOREGROUND|MB_TASKMODAL);
    }
    _exit(3);
}

默认情况下,安全检查失败的应用程序会显示一个对话框,声明“检测到缓冲区溢出!”。当用户关闭对话框时,应用程序将终止。CRT 库为开发人员提供了一个选项,可以使用不同的处理程序,该处理程序可以以对应用程序更合理的方式响应缓冲区溢出。该函数 __set_security_error_handler 用于安装用户处理程序,方法是将用户定义的处理程序存储在 user_handler 变量中,如下例所示。

void __cdecl report_failure(int code, void * unused)
{
    if (code == _SECERR_BUFFER_OVERRUN)
      printf("Buffer overrun detected!\n");
}

void main()
{
    _set_security_error_handler(report_failure);
    // More code follows
}

在此应用程序中检测到的缓冲区溢出会将一条消息打印到控制台窗口,而不是显示一个对话框。虽然用户处理程序不显式终止程序,但当用户处理程序返回时,__security_error_handler 将通过调用 _exit(3) 来终止程序。__security_error_handler_set_security_error_handler 函数都位于 CRT 源文件中的 secfail.c 文件中。

讨论用户处理程序中应执行的操作很有用。一种常见的反应是抛出异常。但是,由于异常信息存储在堆栈上,抛出异常可能会将控制权传递给损坏的异常帧。为防止这种情况,__security_error_handler 函数将对用户函数的调用包装在一个 __try/__except 块中,该块捕获所有异常,然后终止程序。开发人员不应调用 DebugBreak,因为它会引发异常,也不应使用 longjmp。用户处理程序应做的只是报告错误,并可能创建一个日志,以便修复缓冲区溢出。

有时,开发人员可能希望重写 __security_error_handler 而不是使用 _set_security_error_handler 来达到相同的目标。重写容易出错,而主处理程序非常重要,如果实现不正确可能会产生毁灭性的后果。

Cookie 值

Cookie 是一个随机值,其大小与指针相同,这意味着在 x86 体系结构上,Cookie 长度为 4 字节。该值存储在 __security_cookie 变量中,以及其他 CRT 全局数据。该值通过 CRT 源文件中的 seccinit.c 文件中的 __security_init_cookie 调用初始化为随机值。Cookie 的随机性来源于处理器计数器。每个映像(即,每个使用 /GS 编译的 DLL 或 EXE)在加载时都有一个单独的 Cookie 值。

当应用程序开始使用 /GS 编译器开关进行构建时,可能会出现两个问题。首先,不包含 CRT 支持的应用程序将缺少随机 Cookie,因为 __security_init_cookie 调用发生在 CRT 初始化期间。如果在加载时未随机设置 Cookie,则在发现缓冲区溢出时,应用程序仍然容易受到攻击。要解决此问题,应用程序需要显式调用 __security_init_cookie 在启动时进行初始化。其次,调用已记录的 _CRT_INIT 函数进行初始化的旧应用程序可能会遇到意外的安全检查失败,例如下面的示例。

DllEntryPoint(...) {
    char buf[_MAX_PATH];   // a buffer that triggers security checks
    ...
    _CRT_INIT();
    ...

}

问题在于,对 _CRT_INIT 的调用在已经为安全检查设置好的函数运行时更改了 Cookie 的值。因为函数退出时的 Cookie 值将不同,安全检查会将其解释为发生了缓冲区溢出。解决方案是避免在 _CRT_INIT 调用之前在活动函数中声明缓冲区。目前,可以使用 _alloca 函数在堆栈上分配缓冲区来解决此问题,因为如果使用 _alloca 进行分配,编译器将不会生成安全检查。此解决方法不能保证在 Visual C++ 的未来版本中有效。

性能影响

在应用程序中使用安全检查必须权衡性能。Visual C++ 编译器团队专注于将性能下降控制在很小的范围内。在大多数情况下,性能下降不应超过 2%。事实上,经验表明,大多数应用程序,包括高性能服务器应用程序,都没有注意到任何性能影响。

防止性能影响成为问题的最重要因素是,只针对易受攻击的函数。目前,易受攻击函数的定义是那些在堆栈上分配字符串类型缓冲区的函数。被认为是易受攻击的字符串缓冲区分配的存储空间大于 4 字节,并且缓冲区中的每个元素是 1 或 2 字节。小型缓冲区不太可能成为攻击目标,并且限制具有安全检查的函数数量可以限制代码增长。大多数可执行文件在使用 /GS 构建时甚至不会注意到大小的增加。

示例

因此,/GS 开关并不能修复缓冲区溢出,但它可以在某些情况下防止缓冲区溢出被利用。使用 /GS 编译的 vulnerable1vulnerable2 不易受到利用。任何在返回之前缓冲区溢出是最后发生的动作的函数都不易受到利用。由于缓冲区溢出可能在函数执行早期发生,因此存在安全检查要么没有机会检测到缓冲区溢出,要么安全检查本身可能已经被溢出攻击的情况,如下例所示。

示例 1

class Vulnerable3 {
public:
    int value;

    Vulnerable3() { value = 0; }
    virtual ~Vulnerable3() { value = -1; }
};

void vulnerable3(char * pStr) {
    Vulnerable3 * vuln = new Vulnerable3;
    char buf[20];

    strcpy(buf, pStr);
    delete vuln;
}

在这种情况下,在堆栈上分配了一个带有虚函数的对象的指针。由于该对象具有虚函数,因此该对象包含一个 vtable 指针。攻击者可以通过提供恶意 pStr 值并溢出 buf 来利用此机会。在函数返回之前,delete 操作符调用 vuln 的虚析构函数。这需要查找 vtable 中的析构函数,而 vtable 现在已经被控制。在函数返回之前,程序的执行就已经被劫持,因此安全检查从未有机会检测到缓冲区溢出。

示例 2

void vulnerable4(char *bBuff, in cbBuff) {
    char bName[128];
    void (*func)() = foo;

    memcpy(bName, bBuff, cbBuff);
    (func)();
}

在这种情况下,该函数容易受到指针欺骗攻击。当编译器为两个局部变量分配空间时,它会将 func 变量放在 pName 之前。这是因为优化器可以通过这种布局提高代码效率。不幸的是,这允许攻击者提供一个恶意的 bBuff 值。此外,攻击者可以提供 cbBuff 的值,它表示 bBuff 的大小。该函数错误地省略了 cbBuff 小于或等于 128 的验证。因此,对 memcpy 的调用可能会溢出缓冲区并破坏 func 的值。由于在 vulnerable4 函数返回之前,使用 func 指针欺骗来调用它指向的函数,因此在安全检查发生之前就发生了劫持。

示例 3

int vulnerable5(char * pStr) {
    char buf[32];
    char * volatile pch = pStr;

    strcpy(buf, pStr);
    return *pch == '\0';
}

int main(int argc, char* argv[]) {
    __try { vulnerable5(argv[1]); }
    __except(2) { return 1; }
    return 0;
}

此程序显示了一个特别棘手的问题,因为它使用了结构化异常处理。如前所述,使用异常处理的函数将信息(如相应的异常处理函数)放在堆栈上。在这种情况下,即使 vulnerable5 存在缺陷,main 函数中的异常处理帧也可以被攻击。攻击者将利用机会溢出 buf 并破坏 mainpch 和异常处理帧。由于 vulnerable5 函数稍后会解引用 pch,如果攻击者提供了像零这样的值,就可能导致访问冲突,进而引发异常。在堆栈展开过程中,操作系统会查找异常帧中的异常处理程序,并将控制权传递给它们。由于异常处理帧被损坏,操作系统会将程序的控制权传递给攻击者提供的任意代码。安全检查未能检测到此缓冲区溢出,因为函数未正确返回。

最近一些最流行的攻击利用了异常处理的漏洞。最引人注目的一次是 2001 年夏季出现的 Code Red 病毒。Windows XP 已经创建了一种环境,使得异常处理攻击更加困难,因为异常处理程序的地址不能在堆栈上,并且在调用异常处理程序之前,所有寄存器都被清零。

示例 4

void vulnerable6(char * pStr) {
    char buf[_MAX_PATH];
    int * pNum;

    strcpy(buf, pStr);
    sscanf(buf, "%d", pNum);
}

与前三个示例不同,使用 /GS 编译的此函数无法通过简单地溢出缓冲区来控制程序执行。此函数需要一个两阶段攻击来控制程序的执行。知道 pNum 将在 buf 之前分配,使其容易被 pStr 字符串提供的任意值覆盖。攻击者必须选择要覆盖的四个字节的内存。如果缓冲区覆盖了 Cookie,那么就有机会控制存储在 user_handler 变量中的用户处理程序函数指针或存储在 __security_cookie 变量中的值。如果 Cookie 未被覆盖,攻击者将选择一个不包含安全检查的函数的返回地址。在这种情况下,程序将正常执行,从函数返回时对缓冲区溢出毫不知情;短暂延迟后,程序将被静默控制。

易受攻击的代码也可能受到其他攻击,例如堆上的缓冲区溢出,这不在 /GS 的解决范围内。针对数组特定索引而非顺序写入数组的索引越界攻击也不在 /GS 的解决范围内。未检查的越界索引实际上可以定位内存的任何部分,并可以避免覆盖 Cookie。另一种未检查的索引形式是符号/无符号整数不匹配,其中负数被提供给数组索引。如果索引是符号整数,仅仅验证索引小于数组大小是不够的。最后,/GS 安全检查不解决一般的缓冲区下溢问题。

结论

缓冲区溢出显然是应用程序中的一个毁灭性缺陷。没有什么比首先编写紧凑、安全的代码更重要。尽管普遍看法如此,但某些缓冲区溢出极难发现。/GS 开关是针对编写安全代码的开发人员的一个有用工具。然而,它并不能解决代码中存在缓冲区溢出这一问题。即使有安全检查可以防止缓冲区溢出在某些情况下被利用,程序仍然会终止,这是一种拒绝服务攻击,尤其针对服务器代码。使用 /GS 构建是开发人员减轻他们没有意识到的易受攻击缓冲区风险的一种安全有效的方式。

虽然存在用于标记潜在漏洞(如本文讨论的)的工具,但它们是已知不完美的。没有什么比由了解要查找什么的开发人员进行良好的代码审查更重要了。Michael Howard 和 David LeBlanc 的著作《编写安全代码》(Writing Secure Code) 提供了关于编写高度安全应用程序时减轻风险的许多其他方法的绝佳讨论。

 

本文档包含的信息代表了 Microsoft Corporation 在发布日期时对所讨论问题的当前观点。由于 Microsoft 必须应对不断变化的市场条件,因此不应将其解释为 Microsoft 的承诺,并且 Microsoft 不能保证发布日期之后所提供信息的准确性。

本白皮书仅供参考。MICROSOFT 对本文档中的信息不作任何明示或暗示的保证。

遵守所有适用的版权法是用户的责任。在不限制版权权利的情况下,未经 Microsoft Corporation 明确书面许可,不得以任何形式或通过任何方式(电子、机械、影印、录制或其他方式),或出于任何目的,复制、存储或引入检索系统,或传输本文档的任何部分。

Microsoft 可能拥有涵盖本文档主题的专利、专利申请、商标、版权或其他知识产权。除 Microsoft 的任何书面许可协议中明确规定外,提供本文档并不授予您对这些专利、商标、版权或其他知识产权的任何许可。

© 2002 Microsoft Corporation。保留所有权利。

© . All rights reserved.