1 缓冲区溢出原理
缓冲区是一块连续的计算机内存区域,可保存相同数据类型的多个实例。缓冲区可以是堆栈(自动变量)、堆(动态内存)和静态数据区(全局或静态)。在C/C++语言中,通常使用字符数组和malloc/new之类内存分配函数实现缓冲区。溢出指数据被添加到分配给该缓冲区的内存块之外。缓冲区溢出是最常见的程序缺陷。
栈帧结构的引入为高级语言中实现函数或过程调用提供直接的硬件支持,但由于将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来隐患。若将函数返回地址修改为指向一段精心安排的恶意代码,则可达到危害系统安全的目的。此外,堆栈的正确恢复依赖于压栈的EBP值的正确性,但EBP域邻近局部变量,若编程中有意无意地通过局部变量的地址偏移窜改EBP值,则程序的行为将变得非常危险。
由于C/C++语言没有数组越界检查机制,当向局部数组缓冲区里写入的数据超过为其分配的大小时,就会发生缓冲区溢出。攻击者可利用缓冲区溢出来窜改进程运行时栈,从而改变程序正常流向,轻则导致程序崩溃,重则系统特权被窃取。
例如,对于下图的栈结构:
若将长度为16字节的字符串赋给acArrBuf数组,则系统会从acArrBuf[0]开始向高地址填充栈空间,导致覆盖EBP值和函数返回地址。若攻击者用一个有意义的地址(否则会出现段错误)覆盖返回地址的内容,函数返回时就会去执行该地址处事先安排好的攻击代码。最常见的手段是通过制造缓冲区溢出使程序运行一个用户shell,再通过shell执行其它命令。若该程序有root或suid执行权限,则攻击者就获得一个有root权限的shell,进而可对系统进行任意操作。
除通过使堆栈缓冲区溢出而更改返回地址外,还可改写局部变量(尤其函数指针)以利用缓冲区溢出缺陷。
注意,本文描述的堆栈缓冲区溢出不同于广义的“堆栈溢出(Stack OverFlow)”,后者除局部数组越界和内存覆盖外,还可能由于调用层次太多(尤其应注意递归函数)或过大的局部变量所导致。
2 缓冲区溢出实例
本节给出若干缓冲区溢出相关的示例性程序。前三个示例为手工修改返回地址或实参,后两个示例为局部数组越界访问和缓冲区溢出。更加深入的缓冲区溢出攻击参见相关资料。
示例函数必须包含stdio.h头文件,并按需包含string.h头文件(如strcpy函数)。
【示例1】改变函数的返回地址,使其返回后跳转到某个指定的指令位置,而不是函数调用后紧跟的位置。实现原理是在函数体中修改返回地址,即找到返回地址的位置并修改它。代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 //foo.c 2 void foo(void){ 3 int a, *p; 4 p = (int*)((char *)&a + 12); //让p指向main函数调用foo时入栈的返回地址,等效于p = (int*)(&a + 3); 5 *p += 12; //修改该地址的值,使其指向一条指令的起始地址 6 } 7 int main(void){ 8 foo(); 9 printf("First printf call "); 10 printf("Second printf call "); 11 return 0; 12 }
编译运行,结果输出Second printf call,未输出First printf call。
下面详细介绍代码中两个12的由来。
编译(gcc main.c –g)和反汇编(objdump a.out –d)后,得到汇编代码片段如下:
从上述汇编代码可知,foo后面的指令地址(即调用foo时压入的返回地址)是0x80483b8,而进入调用printf("Second printf call“)的指令地址是0x80483c4。两者相差12,故将返回地址的值加12即可(*p += 12)。
指令<804838a>将-8(%ebp)的地址赋值给%eax寄存器(p = &a)。可知foo()函数中的变量a存储在-8(%ebp)地址上,该地址向上8+4=12个单位就是返回地址((char *)&a + 12)。修改该地址内容(*p += 12)即可实现函数调用结束后跳转到第二个printf函数调用的位置。
用gdb查看汇编指令刚进入foo时栈顶的值(%esp),如下所示:
可见%esp值的确是调用foo后main中下条待执行指令的地址,而代码所修改的也正是该值。%eip则指向当前程序(foo)的指令地址。
【示例2】暂存RunAway函数的返回地址后修改其值,使函数返回后跳转到Detour函数的地址;Detour函数内尝试通过之前保存的返回地址重回main函数内。代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 //RunAway.c 2 int gPrevRet = 0; //保存函数的返回地址 3 void Detour(void){ 4 int *p = (int*)&p + 2; //p指向函数的返回地址 5 *p = gPrevRet; 6 printf("Run Away! "); //需要回车,或打印后fflush(stdout);刷新缓冲区,否则可能在段错误时无法输出 7 } 8 int RunAway(void){ 9 int *p = (int*)&p + 2; 10 gPrevRet = *p; 11 *p = (int)Detour; 12 return 0; 13 } 14 int main(void){ 15 RunAway(); 16 printf("Come Home! "); 17 return 0; 18 }
编译运行后输出:
Run Away! Come Home! Run Away! Come Home! Segmentation fault |
运行后出现段错误?There must be something wrong!错误原因留待读者思考,下面给出上述代码的另一版本,借助汇编获取返回地址(而不是根据栈帧结构估算)。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 register void *gEbp __asm__ ("%ebp"); 2 void Detour(void){ 3 *((int *)gEbp + 1) = gPrevRet; 4 printf("Run Away! "); 5 } 6 int RunAway(void){ 7 gPrevRet = *((int *)gEbp + 1); 8 *((int *)gEbp + 1) = Detour; 9 return 0; 10 }
【示例3】在被调函数内修改主调函数指针变量,造成后续访问该指针时程序崩溃。代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 //Crasher.c 2 typedef struct{ 3 int member1; 4 int member2; 5 }T_STRT; 6 T_STRT gtTestStrt = {0}; 7 register void *gEbp __asm__ ("%ebp"); 8 9 void Crasher(T_STRT *ptStrt){ 10 printf("[%s]: ebp = %p(0x%08x) ", __FUNCTION__, gEbp, *((int*)gEbp)); 11 printf("[%s]: ptStrt = %p(%p) ", __FUNCTION__, &ptStrt, ptStrt); 12 printf("[%s]: (1) = %p(0x%08x) ", __FUNCTION__, ((int*)&ptStrt-2), *((int*)&ptStrt-2)); 13 printf("[%s]: (2) = %p(0x%08x) ", __FUNCTION__, (int*)(*((int*)&ptStrt-2)-4), *(int*)(*((int*)&ptStrt-2)-4)); 14 printf("[%s]: (3) = %p(0x%08x) ", __FUNCTION__, (int*)(*((int*)&ptStrt-2)-8), *(int*)(*((int*)&ptStrt-2)-8)); 15 *(int*)( *( (int*)&ptStrt - 2 ) - 8 ) = 0; //A:此句将导致代码B处发生段错误 16 } 17 18 int main(void){ 19 printf("[%s]: ebp = %p(0x%08x) ", __FUNCTION__, gEbp, *((int*)gEbp)); 20 T_STRT *ptStrt = >TestStrt; 21 printf("[%s]: ptStrt = %p(%p) ", __FUNCTION__, &ptStrt, ptStrt); 22 23 Crasher(ptStrt); 24 printf("[%s]: ptStrt = %p(%p) ", __FUNCTION__, &ptStrt, ptStrt); 25 ptStrt->member1 = 5; //B:需要在此处崩溃 26 printf("Try to come here! "); 27 return 0; 28 }
运行结果如下所示:
根据打印出的地址及其存储内容,可得到以下堆栈布局:
&ptStrt为形参地址0xbff8f090,该地址处在main函数栈帧中。(int*)&ptStrt - 2地址存储主调函数的EBP值,根据该值可直接定位到main函数栈帧底部。(*((int*)&ptStrt - 2) - 8)为主调函数中实参ptStrt的地址,而*(int*) (*((int*)&ptStrt - 2) - 4) = 0将该地址内容置零,即实参指针ptStrt设置为NULL(不再指向全局结构gtTestStrt)。这样,访问ptStrt->member1时就会发生段错误。
注意,虽然本例代码结构简单,但不能轻率地推断main函数中局部变量ptStrt位于帧基指针EBP-4处(实际上本例为EBP-8处)。以下改进版本用于自动计算该偏移量:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 static int gOffset = 0; 2 void Crasher(T_STRT *ptStrt){ 3 *(int*)( *(int*)gEbp - gOffset ) = 0; 4 } 5 6 int main(void){ 7 T_STRT *ptStrt = >TestStrt; 8 gOffset = (char*)gEbp - (char*)(&ptStrt); 9 Crasher(ptStrt); 10 ptStrt->member1 = 5; //在此处崩溃 11 printf("Try to come here! "); 12 return 0; 13 }
当然,该版本已失去原有意义(不借助寄存器层面手段),纯为示例。
【示例4】越界访问造成死循环。代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 //InfinteLoop.c 2 void InfinteLoop(void){ 3 unsigned char ucIdx, aucArr[10]; 4 for(ucIdx = 0; ucIdx <= 10; ucIdx++) 5 aucArr[ucIdx] = 1; 6 }
在循环内部,当访问不存在的数组元素aucArr[10]时,实际上在访问数组aucArr所在地址之后的那个位置,而该位置存放着变量ucIdx。因此aucArr[10] = 1将ucIdx重置为1,然后继续循环的条件仍然成立,最终将导致死循环。
【示例5】缓冲区溢出。代码如下:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 //CarelessPapa.c 2 register int *gEbp __asm__ ("%ebp"); 3 void NaughtyBoy(void){ 4 printf("[2]EBP=%p(%#x), EIP=%p(%#x) ", gEbp, *gEbp, gEbp+1, *(gEbp+1)); 5 printf("Catch Me! "); 6 } 7 void CarelessPapa(const char *pszStr){ 8 printf("[1]EBP=%p(%#x) ", gEbp, *gEbp); 9 printf("[1]EIP=%p(%#x) ", gEbp+1, *(gEbp+1)); 10 char szBuf[8]; 11 strcpy(szBuf, pszStr); 12 } 13 int main(void){ 14 printf("[0]EBP=%p(%#x) ", gEbp, *gEbp); 15 printf("Addr: CarelessPapa=%p, NaughtyBoy=%p ", CarelessPapa, NaughtyBoy); 16 char szArr[]="0123456789ABxe4x83x4x8x23x85x4x8"; 17 CarelessPapa(szArr); 18 printf("Come Home! "); 19 printf("[3]EBP=%p ", gEbp); 20 return 0; 21 }
编译运行结果如下:
可见,当CarelessPapa函数调用结束后,并未直接执行Come Home的输出,而是转而执行NaughtyBoy函数(输出Catch Me),然后回头输出Come Home。该过程重复一次后发生段错误(具体原因留待读者思考)。
结合下图所示的栈帧布局,详细分析本示例缓冲区溢出过程。注意,本示例中地址及其内容由内嵌汇编和打印输出获得,正常情况下应通过gdb调试器获得。
首先,main函数将字符数组szArr的地址作为参数(即pszStr)传递给函数CarelessPapa。该数组内容为"0123456789ABxe4x83x4x8x23x85x4x8",其中转义字符串"xe4x83x4x8"对应NaughtyBoy函数入口地址0x080483e4(小字节序),而"x23x85x4x8"对应调用CarelessPapa函数时的返回地址0x8048523(小字节序)。CarelessPapa函数内部调用strcpy库函数,将pszStr所指字符串内容拷贝至szBuf数组。因为strcpy函数不进行越界检查,会逐字节拷贝直到遇见' '结束符。故pszStr字符串将从szBuf数组起始地址开始向高地址覆盖,原返回地址0x8048523被覆盖为NaughtyBoy函数地址0x080483e4。
这样,当CarelessPapa函数返回时,修改后的返回地址从栈中弹出到EIP寄存器中,此时栈顶指针ESP指向返回地址上方的空间(esp+4),程序跳转到EIP所指地址(NaughtyBoy函数入口)开始执行,首先就是EBP入栈——并未像正常调用那样先压入返回地址,故NaughtyBoy函数栈帧中EBP位置相对CarelessPapa函数上移4个字节!此时,"x23x85x4x8"可将EBP上方的EIP修改为CarelessPapa函数的返回地址(0x8048523),从而保证正确返回main函数内。
注意,返回main函数并输出Come Home后,main函数栈帧的EBP地址被改为0x42413938("89AB"),该地址已非堆栈空间,最终产生段错误。EBP地址会随每次程序执行而改变,故试图在szArr字符串中恢复EBP是非常困难的。
从main函数return时将返回到调用它的启动例程(_start函数)中,返回值被启动例程获得并用其作为参数调用exit函数。exit函数首先做一些清理工作,然后调用_exit系统调用终止进程。main函数的返回值最终传给_exit系统调用,成为进程的退出状态。以下代码在main函数中直接调用exit函数终止进程而不返回到启动例程:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 //CarelessPapa.c 2 register int *gEbp __asm__ ("%ebp"); 3 void NaughtyBoy(void){ 4 printf("[2]EBP=%p(%#x), EIP=%p(%#x) ", gEbp, *gEbp, gEbp+1, *(gEbp+1)); 5 printf("Catch Me! "); 6 } 7 void CarelessPapa(const char *pszStr){ 8 printf("[1]EBP=%p(%#x) ", gEbp, *gEbp); 9 printf("[1]EIP=%p(%#x) ", gEbp+1, *(gEbp+1)); 10 char szBuf[8]; 11 strcpy(szBuf, pszStr); 12 } 13 int main(void){ 14 printf("[0]EBP=%p(%#x) ", gEbp, *gEbp); 15 printf("Addr: CarelessPapa=%p, NaughtyBoy=%p ", CarelessPapa, NaughtyBoy); 16 char szArr[]="0123456789ABx14x84x4x8x33x85x4x8"; //转义字符串稍有变化 17 CarelessPapa(szArr); 18 printf("Come Home! "); 19 printf("[3]EBP=%p ", gEbp); 20 exit(0); //#include <stdlib.h> 21 }
编译运行结果如下:
这次没有重复执行,也未出现段错误。
3 缓冲区溢出防范
防范缓冲区溢出问题的准则是:确保做边界检查(通常不必担心影响程序效率)。不要为接收数据预留相对过小的缓冲区,大的数组应通过malloc/new分配堆空间来解决;在将数据读入或复制到目标缓冲区前,检查数据长度是否超过缓冲区空间。同样,检查以确保不会将过大的数据传递给别的程序,尤其是第三方COTS(Commercial-off-the-shelf)商用软件库——不要设想关于其他人软件行为的任何事情。
若有可能,改用具备防止缓冲区溢出内置机制的高级语言(Java、C#等)。但许多语言依赖于C库,或具有关闭该保护特性的机制(为速度而牺牲安全性)。其次,可以借助某些底层系统机制或检测工具(如对C数组进行边界检查的编译器)。许多操作系统(包括Linux和Solaris)提供非可执行堆栈补丁,但该方式不适于这种情况:攻击者利用堆栈溢出使程序跳转到放置在堆上的执行代码。此外,存在一些侦测和去除缓冲区溢出漏洞的静态工具(检查代码但并不运行)和动态工具(执行代码以确定行为),甚至采用grep命令自动搜索源代码中每个有问题函数的实例。
但即使采用这些保护手段,程序员自身也可能犯其他许多错误,从而引入缺陷。例如,当使用有符号数存储缓冲区长度或某个待读取内容长度时,攻击者可将其变为负值,从而使该长度被解释为很大的正值。经验丰富的程序员还容易过于自信地"把玩"某些危险的库函数,如对其添加自己总结编写的检查,或错误地推论出使用潜在危险的函数在某些特殊情况下是"安全"的。
本节将主要讨论一些已被证明危险的C库函数。通过在C/C++程序中禁用或慎用危险的函数,可有效降低在代码中引入安全漏洞的可能性。在考虑性能和可移植性的前提下,强烈建议在开发过程中使用相应的安全函数来替代危险的库函数调用。
以下分析某些危险的库函数,较完整的列表参见表3-1。
1. gets
该函数从标准输入读入用户输入的一行文本,在遇到EOF字符或换行字符前,不会停止读入文本。即该函数不执行越界检查,故几乎总有可能使任何缓冲区溢出(应禁用)。
gcc编译器下会对gets调用发出警告(the `gets' function is dangerous and should not be used)。
2. strcpy
该函数将源字符串复制到目标缓冲区,但并未指定要复制字符的数目。若源字符串来自用户输入且未限制其长度,则可能引发危险。规避的方法如下:
1) 若知道目标缓冲区大小,则可添加明确的检查(不建议该法):
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 if(strlen(szSrc) >= dwDstSize){ 2 /* Do something appropriate, such as throw an error. */ 3 } 4 else{ 5 strcpy(szDst, szSrc); 6 }
2) 改用strncpy函数:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 strncpy(szDst, szSrc, dwDstSize-1); 2 szDst[dwDstSize-1] = '