深度探索编译器安全检查

简介:

安全是高质量软件的重点关注方面,最让人害怕、最多被误解的就是缓冲区溢出。现在,提及缓冲区溢出足以让人们停下来,仔细听。太频繁了,技术细节丢失在抄本中,大部分人们对于这种基础的、值得关注的方面离开了。为了解决这个问题,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 ,那么pBuffer参数可能溢出。如果包含一句assert(strlen(pStr) < _MAX_PATH)就能够在debug版本下发现这个错误,但是release版本不行。用这些容易受到攻击的函数被认为是坏的实践。技术上来讲更不容易受到攻击的相似的函数确实存在,如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]). vulnerable1 vulnerable2都是很普遍的错误,并且可以很容易的预防。然而,如果由于代码Reviewer的疏忽,这些潜在的安全漏洞可能发布到产品中。这就是为什么Visual C++ .NET引入了安全检查,它可以阻止vulnerable1 vulnerable2函数中的缓冲区溢出向容易受到攻击的应用程序注入恶意代码。

 

X86栈的分析

   为了理解如何利用缓冲区溢出以及安全检查如何工作,必须完全理解堆栈的布局结构。在X86体系下,堆栈向下增长,意味着新创建数据的地址小于早期压入栈中元素的地址。每一个函数创建一个新的、有如下布局的栈帧,注意高内存地址在列表的顶部:

·                     Function parameters

·                     Function return address

·                     Frame pointer

·                     Exception Handler frame

·                     Locally declared variables and buffers

·                     Callee save registers

从以上布局很明显的可以看出,缓冲区溢出有可能覆盖掉比该缓冲区分配的早的变量,异常处理帧,栈帧指针,返回地址,函数参数。为了接管程序的执行,一个值必须写进数据中,该数据的值被装载进
EIP寄存器中。函数的返回地址就是一个这样的数据。典型的利用缓冲区溢出是覆盖函数返回地址,让函数的返回指令将返回地址加载到EIP中。

数据元素按照如下方式存入栈中。函数调用之前将函数的参数压入栈中。参数从右到左被压入栈中。CALL指令将函数的返回地址压入栈中,它存储EIP寄存器的当前值。栈帧指针是前一个EB寄存器的值,当没有发生FPO优化时,也压入栈中。因此,栈帧指针不总是在栈中。如果函数包括了try/catch 或者任何其他形式的异常处理结构,编译器将在栈中包含异常处理信息。之后,是局部声明的变量和分配的缓冲区。这些分配的顺序可能根据优化实施而作改变。最后是调用者保存的寄存器如ESIEDIEBX,如果他们在函数执行时被使用。

运行时检查

   缓冲区溢出是cc++程序员普遍犯的错误,也是潜在的最危险的。Visual C++ .NET提供了工具,它可以使开发者在开发阶段很容易发现这些错误并修正。Visual C++ 6.0中的/GZ开关在Visual C++ .NET中的/RTC1中获得了新生。/RTC1开关是/RTsu的别名,其中s代表堆栈检查,u代表未初始化变量检查。所有在堆栈中分配的缓冲区在边界处设置了标签。因此,缓冲区溢出、下溢可以被捕捉。尽管小的缓冲区溢出可能不会改变程序的执行,它可以改变附近的数据,而这都不会被觉察到。

   运行时检查对于那些不仅想写安全的代码、而且关心编写正确代码的基本问题的开发者很有帮助。然而运行时检查仅仅工作在debug版本下,该特性从没有设计为在产品代码中可用。但是,在产品代码中进行这样的检查有很明显的价值。做这些运行时检查需要一小部分的性能损失。结果,Visual C++ .NET引入了/GS开关。

 

/GS开关做什么

/GS开关在缓冲区和返回地址间提供了一个“Speed bump”或cookie。如果一个溢出覆盖了返回地址,那么它也覆盖了放在缓冲区和他之间的Cookie,新的堆栈布局如下:

·                     Function parameters

·                     Function return address

·                     Frame pointer

·                     Cookie

·                     Exception Handler frame

·                     Locally declared variables and buffers

·                     Callee save

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拷贝的指令,接着一条指令是将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指令,而是执行JMP指令,跳转到__security_check_cookie例程。

__security_check_cookie例程是很直观的。如果cookie没有被改变,它执行RET指令并结束函数调用。否则,它调用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存储用户提供的处理器的方式来安装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都在CRTsecfail.c文件中

讨论在用户处理器中应该怎么做是有用的。普遍的动作时抛出异常。然而,因为异常信息存储在堆栈中,抛出异常会将控制传递给异常栈。为了防止这种行为发生,__security_error_handler函数中调用用户函数时使用try/__except来捕捉所有异常,然后终止程序。开发者不应该调用DebugBreak因为它会导致异常,也不应该调用longjmp.用户处理器应该做的是报告错误,尽可能创建一个日志以便修正这个问题。

有时,开发者或许想重写函数__security_error_handler,而不是用函数_set_security_error_handler来达到同样的目的。重写容易出错,主处理器非常重要,如果没有正确的实现将导致危险的结果。

 

Cookie的值

Cookie是一个和指针同样大小的随机数,意味着在X86体系下,cookie4个字节长。它的值存储在CRT全局变量__security_cookie中。它的值由在CRT seccinit.c文件中的函数__security_init_cookie来初始化。Cookie的随机性来自于CPU处理器的计数器。每一个影像文件(使用/GS编译的DLLEXE )在装载时有一个不同的cookie

当时用/GS编译器开关编译程序时可能有两个问题。第一、不包含CRT支持的程序

将缺少随机的cookie,因为CRT初始化时调用__security_init_cookie。如果在装载时cookie没有被初始化,如果有缓冲区溢出,应用程序还是有可能被攻击。为了解决这个问题,应用程序在启动时需要显示的调用__security_init_cookie。第二、调用文档化的函数来初始化的

遗留的应用程序可能遇到不可预期的安全检查失败。例如下面的例子:


    
    
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%。实际上,经验显示对大多说的应用程序、包括高性能的服务器端程序来讲,性能影响是微乎其微的。

使性能不受影响的最重要的因素是只有那些容易受到攻击的函数才执行安全检查。现在,容易受到攻击的函数为在堆栈中分配缓冲区的函数。字符串缓冲区如果分配多于四个字节、缓冲区中每个元素时12个字节,就容易受到攻击。小缓冲区不容易受到攻击并且限制进行安全检查的函数的数量就限制了代码的增长。大部分的可执行程序因为适用/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; 
}


    
    
              这种情况下,在栈中分配了含有许函数的对象的指针。因为对象含有虚函数,对象包含一个vtable指针。供给者能提供一个恶意的pStr并溢出buf。函数返回前,delete操作符调用vuln的虚函数。调用需要在vtable中查找析构函数,它已经被接管了。在函数返回前,程序已经北接管,所以安全检查根本没有检测到缓冲区溢出发生。

例子2


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



   这种情况下,函数容易受到指针修改攻击。当编译器为这两个局部变量分配空间时,它把
func变量放在bName之前。因为这种布局优化器可以提升代码的效率。很不幸,这允许攻击者一个为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; 
}



   这个程序展示了一个特别难的问题,因为它使用了结构化异常处理。如前面提及,使用异常处理的函数将把异常处理信息,例如合适的异常处理函数,放在栈中。本例中,
main函数的异常处理帧因为函数vulnerable5的缺陷而可能被攻击。攻击者利用溢出buf来破坏pchmain函数的异常处理帧。因为vulnerable5函数后来引用了pch,如果攻击者覆盖它的值为0,这将导致访问异常。在堆栈展开的过程中,操作系统在异常处理帧中查找处理该异常的异常处理函数。因为异常处理帧已经被破坏,操作系统可能将程序的控制权交给由攻击者提供的任意代码。安全检查将不能够检查这样的缓冲区溢出,因为函数没有正常返回。

近来一些很流行的攻击都是利用异常处理。其中最流行的Code Red病毒出现在2001年夏。Window 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_handle中的用户提供的处理器或者或者存储在__security_cookie中的默认处理器会接管程序的运行。如果没有覆盖cookie,攻击者将选择函数的返回地址作作为不包含安全检查的函数。这种情况下,程序正常的执行,从函数中正常返回,没有意识到缓冲区溢出;一段时间后,程序悄悄的被接管。

容易受到攻击的代码可能受到另外的攻击如,发生在堆中的缓冲区溢出,它不能够被/GS开关检查出来。数组索引越界攻击,它对数组中的某一个位置进行存取,而不是对数组进行连续写入,这样的问题/GS开关也不能检查出来。一个未检查的数组索引可以访问内存的任意部分,而不会修改cookie的内容。另外一种未检查的索引是有符号、无符号的不匹配。如果索引是有符号整数,简单的验证索引小于数组的大小也是不够的。最后,/GS开关不能够检查缓冲区下溢。

 

结论

              很明显,缓冲区溢出是应用程序的一个非常关键的缺陷。没有比写出紧凑、安全的代码更重要。尽管大多数观点认为,少量的缓冲区溢出很难被发现。在编写安全代码方面,/GS开关对开发者是很有帮助的。但是,它解决不了代码中的缓冲区溢出的问题。尽管安全检查在某种程度上阻止了缓冲区被利用,但程序仍然终止了,一种拒绝服务的攻击,特别是服务器端代码。使用/GS开关是一种安全的方法来减少在没有意识到的情况下,受到缓冲区溢出的攻击。

              尽管存在能够标记可能受到攻击的代码的工具,例如本文所讨论的,但是他们都是由缺点的。被好的代码Review人员检查过得好的代码比什么都更可靠。Michael Howard David LeBlanc < Writing Secure Code>在编写高度安全的应用程序方面,提供了很多其他的、可以降低被攻击的方法。

目录
相关文章
|
6月前
|
存储 算法 编译器
C/C++编译器局部优化技术:局部优化是针对单个函数或基本块进行的优化
C/C++编译器局部优化技术:局部优化是针对单个函数或基本块进行的优化
183 0
|
14天前
|
缓存 编译器 API
C# 一分钟浅谈:Roslyn 编译器平台介绍
【10月更文挑战第27天】Roslyn 是 Microsoft 开发的开源编译器平台,支持 C# 和 VB.NET。它将编译过程分解为多个阶段,并提供丰富的 API 供开发者分析、生成和修改代码。本文介绍了 Roslyn 的基本概念、安装配置、基础示例和高级应用,帮助开发者更好地理解和使用这一强大工具。
32 0
|
3月前
|
存储
hyengine 编译问题之创建对象异常如何解决
hyengine 编译问题之创建对象异常如何解决
|
5月前
|
C++
c++语言核心技术要点,《运行时类型识别RTTI》
c++语言核心技术要点,《运行时类型识别RTTI》
55 2
|
5月前
|
存储 安全 Java
Java泛型:深度解析编译时类型安全的核心机制
【6月更文挑战第28天】Java泛型自JDK 1.5起增强了代码安全与复用。它们允许类、接口和方法使用类型参数,如`&lt;T&gt;`在`Box&lt;T&gt;`中。泛型确保编译时类型安全,例如`List&lt;String&gt;`防止了运行时ClassCastException。尽管运行时存在类型擦除,编译时检查仍保障安全。理解泛型核心机制对于优化Java编程至关重要。
80 0
|
6月前
|
开发框架 安全 编译器
【C/C++ 深入探讨构函数】C++ 编译器在什么情况下无法生成默认的析构函数?
【C/C++ 深入探讨构函数】C++ 编译器在什么情况下无法生成默认的析构函数?
149 1
|
6月前
|
缓存 编译器 程序员
C/C++编译器全局优化技术:全局优化是针对整个程序进行的优化,包括函数之间的优化
C/C++编译器全局优化技术:全局优化是针对整个程序进行的优化,包括函数之间的优化
162 0
【C++11特性篇】右值引用变量的属性会被编译器识别成左值【详解&证明&代码演示】
【C++11特性篇】右值引用变量的属性会被编译器识别成左值【详解&证明&代码演示】
|
C语言 C++
07 C++ - 全局变量检测增强(比较C语言)
07 C++ - 全局变量检测增强(比较C语言)
58 0
|
存储 C语言 C++
c++类型转换与RTTI运行阶段类型识别
我们都知道C++完全兼容C语言,C语言的转换方式很简单,可以在任意类型之间转换,但这也恰恰是缺点,因为极其不安全,可能不经意间将指向const对象的指针转换成非const对象的指针,可能将基类对象指针转成了派生类对象的指针,这种转换很容易出bug,需要严格审查代码才能消除这种隐患,但是C这种转换方式不利于我们审查代码,且程序运行时也可能会出bug。
109 2