深入剖析编译器安全检查机制

本文是Microsoft Visual Studio编译器开发团队成员Brandon Bray在CodeProject上发表的文章。作者深入地剖析了缓冲区溢出问题,详细介绍了Visual Studio .NET编译器中新增的/GS选项。对于从事反向工程的读者来说,一定对Windows XP中系统函数内部大量的带有“cookie”这个字符串的符号感到困惑。本文将为你解开这些迷团。

注:附件中是本文的PDF格式,可读性好:http://bbs.pediy.com/showthread.php?t=49873

深入剖析编译器安全检查机制
作者:Brandon Bray
Microsoft Visual Studio开发团队
2002年二月

摘要:
本文讨论了缓冲区溢出并且完整描述了由Microsoft?nbsp;Visual C++?nbsp;.NET编译器/GS参数提供的安全检查特性。

目录
简介
什么是缓冲区溢出?
剖析x86堆栈
运行时检查
/GS参数的功能
错误处理程序
Cookie值
性能影响
示例
总结

简介
软件安全是高科技工业比较关心的内容。最令人害怕和难以理解的软件攻击就是缓冲区溢出。今天,一提到缓冲区溢出,人们都比较关注。在记录缓冲区溢出攻击时经常缺乏技术细节,一般人对这个问题除了有一个令他吃惊的印象外再无其它。为了解决这个问题,Visual C++ .NET引进了安全检查来辅助开发者确定缓冲区溢出。 

什么是缓冲区溢出?
缓冲区是一块内存,通常以数组形式存在。如果不检查数组的大小,那就有可能写到分配的缓冲区以外的地方。如果这种情况发生在比缓冲区高的内存地址上,它就叫作缓冲区溢出。当写到比缓冲区低的地址时,会发生类似的情况。这种情况被叫作缓冲区下溢。下溢与溢出相比非常少,但是就像本文后面描述的那样,它们确实都会出现。利用缓冲区溢出可以将代码注入到一个进程当中,这样的缓冲区溢出就是可利用的缓冲区溢出。
一类有详细文档的函数,包括strcpy,gets,scanf,sprintf,strcat等等,自身容易受到缓冲区溢出的攻击,因此不鼓励使用它们。下面的例子说明了这种危险性:
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)就完全可以在运行时抓住这个错误,但是对于程序的发行版本却行不通。使用这些有缺陷的函数并不是好习惯。可以使用与它们功能相同但缺陷较少的函数,例如strncpy,strncat和memcpy。这些函数的问题是由开发者指明缓冲区的大小,而不是编译器。下面的函数中包含一个常见的错误:

#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])。vulnerable1和vulnerable2中的错误很常见,并且也很容易避免。然而,如果没有回过头去再看一看代码,潜在的安全威胁就可能存在于最终的应用程序中。这就是Visual C++ .NET引进安全检查的原因,它能阻止利用vulnerable1和vulnerable2中的缓冲区溢出将恶意代码注入到一个易受攻击的应用程序中。

剖析x86堆栈
  要完全理解如何利用缓冲区溢出和如何进行安全检查工作,必须完全理解堆栈布局。在x86体系结构上,堆栈向下生长,这意味着新数据被分配在一个低于前面已经压入堆栈的数据的地址。每一个函数调用都创建一个如下布局的新栈帧(注意高内存地址在列表的上部):
"  函数参数 
"  函数返回地址
"  帧指针
"  异常处理程序帧
"  局部变量和缓冲区
"  被调用例程保存的寄存器
从这个布局可以清楚地看出,缓冲区溢出可能覆盖这个缓冲区前面分配的变量,异常处理帧,帧指针,返回地址以及函数参数。为了控制程序的执行,需要把一个值写入到稍后会被加载到EIP寄存器的数据中。函数的返回地址就是这种类型的数据。经典的利用缓冲区溢出的方法就是覆盖返回地址,然后让函数的返回指令加载这个返回地址到EIP中。
数据以以下方式被存储在堆栈上:函数参数在函数被调用前被压入堆栈。参数入栈顺序是从右至左。函数的返回地址被x86的CALL指令压入堆栈,而CALL指令保存当前EIP寄存器的值。当没有使用帧指针省略(Frame Pointer Omssion,FPO)优化时,帧指针是先前EBP寄存器的值,被放在堆栈上。因此,帧指针并不总是被放在堆栈上。如果一个函数使用了try/catch或者其它的异常处理结构,编译器会在堆栈上存放一些异常处理信息。这之后是函数定义的局部变量和分配的缓冲区。这些内容的顺序依据优化的不同而不同。最后,如果被调用的函数使用ESI、EDI和EBX等寄存器,那么它要在堆栈上保存这些寄存器的内容。

运行时检查
缓冲区溢出是C或C++程序员经常犯的错误,它也是最危险的。Visual C++ .NET提供了一些工具帮助开发者在开发周期中可以很容易地发现这些错误并修复它们。Visual C++ 6.0中的编译器参数/GZ,在Visual C++ .NET中被称为/RTC1。它是/RTCsu的别名,其中s代表堆栈检查(stack check),这是本文讨论的重点,u代表未初始化的变量检查(uninitialized variable check)。所有在堆栈上分配的缓冲区其边界都被作了标记。因此,溢出和下溢都能被捕获。可能比较小的溢出不会改变程序的执行,因此不太引人注意,但是它可能毁坏缓冲区附近的数据。
运行时检查对那些不仅想写出安全的代码而且关心写出正确代码原则的开发者来说非常有用。但是,运行时检查只对程序的调试版本有用。这个特性并不是为最终的产品级代码而设计的。但是,在产品级代码中检查缓冲区溢出的价值显而易见。如果要这样做,那就需要设计一种对性能影响比运行时检查小的方案。最终,Visual C++ .NET编译器引进了/GS参数。

/GS参数的功能
/GS参数在缓冲区和返回地址之间提供了“缓冲带”,或者称为cookie。如果溢出会覆盖返回地址,那么这个溢出必须先覆盖返回地址和缓冲区之间的cookie,这样,就产生了新的堆栈布局:
"  函数参数
"  函数返回地址
"  帧指针
"  Cookie
"  异常处理程序帧
"  局部变量和缓冲区
"  被调用例程保存的寄存器
后面会详细描述cookie。有了这些安全检查,函数的执行发生了改变。首先,当一个函数被调用时,执行的第一条指令在其prolog中。一个函数的prolog代码至少需要为这个函数使用的局部变量在堆栈上分配空间,像下面这条指令:
sub   esp,20h
这条指令保留了32个字节用于函数中使用的局部变量。当带/GS参数编译此函数时,函数的prolog代码多保留了4个字节,并且添加了类似于下面的三条指令:

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

这里,prolog代码先复制一份cookie,然后将它和返回地址进行异或,最后将它直接保存在堆栈上紧靠返回地址下面的位置上。从这里开始,函数就开始正常执行。当函数返回时,最后执行的是与prolog相对的epilog代码。如果没有安全检查,它将回收堆栈空间,然后返回,就像下面的指令那样:

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被重新取回,然后让它再次与返回地址进行异或。这时,ECX寄存器中应该是原来保存在__security_cookie变量中的cookie值。接着,堆栈空间被回收。然后,正常情况应该执行RET指令返回,这时却用一条JMP指令跳到__security_check_cookie例程继续执行。

__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);
}

默认情况下,当一个应用程序安全检查失败时会显示一个对话框来说明“Buffer overrun detected!(检测到缓冲区溢出!)”。当这个对话框被关闭时,应用程序就被终止了。但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函数或者longjump函数,因为它引发一个异常。用户处理程序应该做的是报告这个错误,并且有可能的话,创建一个日志,以便日后修复这个缓冲区溢出。

有时,开发者可能想覆盖__security_error_handler函数而不是使用_set_security_error_handler函数来达到同样的目的。覆盖可能产生新的错误,并且主处理程序非常重要,因此如果它的实现不正确,可能造成很严重的后果。

Cookie值
Cookie是一个与指针大小相同的随机值,也就是说,在x86平台上,cookie是四字节长。这个值保存在__security_cookie变量中,和其它CRT全局数据放在一起。它的值通过调用__security_init_cookie函数被初始化成一个随机值,这个函数定义在CRT源文件中的seccinit.c文件中。它的随机性来自于处理器的计数器。每个映像(也就是说,每个带/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值将与函数退出时的cookie值不同,安全检查认定有缓冲区溢出。解决办法是,避免在活动函数中调用_CRT_INIT函数前定义缓冲区。目前,有一种可行的方法是使用_alloca函数在堆栈上分配缓冲区,因为编译器并不为_alloca分配的缓冲区生成安全检查代码。但是,并不能保证这种方法在将来版本的Visual C++ 上仍然有效。

性能影响
使用安全检查的应用程序必须付出的代价是性能降低。Visual C++ 开发团队尽全力把性能损失降到最低。大多数情况下,性能损失不超过2%。事实上,对大部分应用程序,包括高性能的服务器应用程序的测试显示,观察不到任何性能影响。
只有那些可能受到攻击的函数才被设置进行安全检查,这是保持性能不受大的影响的最重要的因素。目前,对可能受到攻击的函数的定义是,函数在堆栈上分配了符合某些条件的字符串缓冲区。这些条件就是字符串缓冲区超过了四个字节,并且其中的每个元素是一个或两个字节。比较小的缓冲区不可能成为攻击目标。毕竟,限制了需要进行安全检查的函数数目也就是限制了需要额外增加的代码的长度。大多数可执行文件带/GS选项创建时甚至观察不到大小增加。

示例
  因此来说,/GS选项并没有修复缓冲区溢出,但是它能在某些情况下阻止缓冲区溢出被利用。当带/GS选项编译vulnerable1和vulnerable2时,它们的缓冲区溢出都是安全的。任何函数,只要它发生缓冲区溢出后立即返回,那么它的缓冲区溢出就是安全的。由于函数刚开始执行时就可能发生缓冲区溢出,因此可能存在下列情形:要么安全检查还没有得到机会检测缓冲区溢出,要么安全检查自身也被缓冲区溢出攻击,正像下面这个例子那样:
例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;
}

此时,指向带有虚函数的对象的指针被存储在堆栈上。因为这个对象带有虚函数,所以它包含一个虚表指针。攻击者可以抓住这个机会提供一个恶意的pStr值使buf这个缓冲区溢出。在函数返回之前,delete操作符要为vuln这个Vulnerable3对象调用虚析构函数。要做到这些,必须在虚表中查找析构函数,但是虚表现在已经被覆盖了。程序的执行在函数返回之前被截持,因此安全检查自始至终就没有获得检测缓冲区溢出的机会。
例2
void vulnerable4(char *bBuff, in cbBuff) {
    char bName[128];
    void (*func)() = MyFunction;

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

此时,函数易受到指针欺骗攻击。当编译器为这两个局部变量分配空间时,它会把func变量放到bName前。这是由于优化程序要产生这种布局来提高代码效率。不幸的是,这允许攻击者提供一个恶意值给变量bBuff。函数错误地忽略了校验cbBuff值是否小于或等于128。这样,调用memcpy可能使缓冲区溢出,从而覆盖func的值。因为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溢出,覆盖pch和main函数的异常处理帧。因为函数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》)中讨论了编写高安全性的应用程序时降低危险性的其它许多方法。

译者:SmartTech   电子信箱:zhzhtst@163.com