详解C语言之缓冲区溢出
一、缓冲区溢出原理
栈帧结构的引入为高级语言中实现函数或过程调用提供直接的硬件支持,但由于将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来隐患。若将函数返回地址修改为指向一段精心安排的恶意代码,则可达到危害系统安全的目的。此外,堆栈的正确恢复依赖于压栈的ebp值的正确性,但ebp域邻近局部变量,若编程中有意无意地通过局部变量的地址偏移窜改ebp值,则程序的行为将变得非常危险。
由于c/c++语言没有数组越界检查机制,当向局部数组缓冲区里写入的数据超过为其分配的大小时,就会发生缓冲区溢出。攻击者可利用缓冲区溢出来窜改进程运行时栈,从而改变程序正常流向,轻则导致程序崩溃,重则系统特权被窃取。
例如,对于下图的栈结构:
若将长度为16字节的字符串赋给acarrbuf数组,则系统会从acarrbuf[0]开始向高地址填充栈空间,导致覆盖ebp值和函数返回地址。若攻击者用一个有意义的地址(否则会出现段错误)覆盖返回地址的内容,函数返回时就会去执行该地址处事先安排好的攻击代码。最常见的手段是通过制造缓冲区溢出使程序运行一个用户shell,再通过shell执行其它命令。若该程序有root或suid执行权限,则攻击者就获得一个有root权限的shell,进而可对系统进行任意操作。
除通过使堆栈缓冲区溢出而更改返回地址外,还可改写局部变量(尤其函数指针)以利用缓冲区溢出缺陷。
注意,本文描述的堆栈缓冲区溢出不同于广义的“堆栈溢出(stack overflow)”,后者除局部数组越界和内存覆盖外,还可能由于调用层次太多(尤其应注意递归函数)或过大的局部变量所导致。
二、缓冲区溢出实例
本节给出若干缓冲区溢出相关的示例性程序。前三个示例为手工修改返回地址或实参,后两个示例为局部数组越界访问和缓冲区溢出。更加深入的缓冲区溢出攻击参见相关资料。
示例函数必须包含stdio.h头文件,并按需包含string.h头文件(如strcpy函数)。
【示例1】改变函数的返回地址,使其返回后跳转到某个指定的指令位置,而不是函数调用后紧跟的位置。实现原理是在函数体中修改返回地址,即找到返回地址的位置并修改它。代码如下:
//foo.c void foo(void){ int a, *p; p = (int*)((char *)&a + 12); //让p指向main函数调用foo时入栈的返回地址,等效于p = (int*)(&a + 3); *p += 12; //修改该地址的值,使其指向一条指令的起始地址 } int main(void){ foo(); printf("first printf call\n"); printf("second printf call\n"); return 0; }
编译运行,结果输出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函数内。代码如下:
//runaway.c int gprevret = 0; //保存函数的返回地址 void detour(void){ int *p = (int*)&p + 2; //p指向函数的返回地址 *p = gprevret; printf("run away!\n"); //需要回车,或打印后fflush(stdout);刷新缓冲区,否则可能在段错误时无法输出 } int runaway(void){ int *p = (int*)&p + 2; gprevret = *p; *p = (int)detour; return 0; } int main(void){ runaway(); printf("come home!\n"); return 0; }
编译运行后输出:
run away!
come home!
run away!
come home!
segmentation fault
运行后出现段错误?there must be something wrong!错误原因留待读者思考,下面给出上述代码的另一版本,借助汇编获取返回地址(而不是根据栈帧结构估算)。
register void *gebp __asm__ ("%ebp"); void detour(void){ *((int *)gebp + 1) = gprevret; printf("run away!\n"); } int runaway(void){ gprevret = *((int *)gebp + 1); *((int *)gebp + 1) = detour; return 0; }
【示例3】在被调函数内修改主调函数指针变量,造成后续访问该指针时程序崩溃。代码如下:
//crasher.c typedef struct{ int member1; int member2; }t_strt; t_strt gtteststrt = {0}; register void *gebp __asm__ ("%ebp"); void crasher(t_strt *ptstrt){ printf("[%s]: ebp = %p(0x%08x)\n", __function__, gebp, *((int*)gebp)); printf("[%s]: ptstrt = %p(%p)\n", __function__, &ptstrt, ptstrt); printf("[%s]: (1) = %p(0x%08x)\n", __function__, ((int*)&ptstrt-2), *((int*)&ptstrt-2)); printf("[%s]: (2) = %p(0x%08x)\n", __function__, (int*)(*((int*)&ptstrt-2)-4), *(int*)(*((int*)&ptstrt-2)-4)); printf("[%s]: (3) = %p(0x%08x)\n", __function__, (int*)(*((int*)&ptstrt-2)-8), *(int*)(*((int*)&ptstrt-2)-8)); *(int*)( *( (int*)&ptstrt - 2 ) - 8 ) = 0; //a:此句将导致代码b处发生段错误 } int main(void){ printf("[%s]: ebp = %p(0x%08x)\n", __function__, gebp, *((int*)gebp)); t_strt *ptstrt = >teststrt; printf("[%s]: ptstrt = %p(%p)\n", __function__, &ptstrt, ptstrt); crasher(ptstrt); printf("[%s]: ptstrt = %p(%p)\n", __function__, &ptstrt, ptstrt); ptstrt->member1 = 5; //b:需要在此处崩溃 printf("try to come here!\n"); return 0; }
运行结果如下所示:
根据打印出的地址及其存储内容,可得到以下堆栈布局:
&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处)。以下改进版本用于自动计算该偏移量:
static int goffset = 0; void crasher(t_strt *ptstrt){ *(int*)( *(int*)gebp - goffset ) = 0; } int main(void){ t_strt *ptstrt = >teststrt; goffset = (char*)gebp - (char*)(&ptstrt); crasher(ptstrt); ptstrt->member1 = 5; //在此处崩溃 printf("try to come here!\n"); return 0; }
当然,该版本已失去原有意义(不借助寄存器层面手段),纯为示例。
【示例4】越界访问造成死循环。代码如下:
//infinteloop.c void infinteloop(void){ unsigned char ucidx, aucarr[10]; for(ucidx = 0; ucidx <= 10; ucidx++) aucarr[ucidx] = 1; }
在循环内部,当访问不存在的数组元素aucarr[10]时,实际上在访问数组aucarr所在地址之后的那个位置,而该位置存放着变量ucidx。因此aucarr[10] = 1将ucidx重置为1,然后继续循环的条件仍然成立,最终将导致死循环。
【示例5】缓冲区溢出。代码如下:
//carelesspapa.c register int *gebp __asm__ ("%ebp"); void naughtyboy(void){ printf("[2]ebp=%p(%#x), eip=%p(%#x)\n", gebp, *gebp, gebp+1, *(gebp+1)); printf("catch me!\n"); } void carelesspapa(const char *pszstr){ printf("[1]ebp=%p(%#x)\n", gebp, *gebp); printf("[1]eip=%p(%#x)\n", gebp+1, *(gebp+1)); char szbuf[8]; strcpy(szbuf, pszstr); } int main(void){ printf("[0]ebp=%p(%#x)\n", gebp, *gebp); printf("addr: carelesspapa=%p, naughtyboy=%p\n", carelesspapa, naughtyboy); char szarr[]="0123456789ab\xe4\x83\x4\x8\x23\x85\x4\x8"; carelesspapa(szarr); printf("come home!\n"); printf("[3]ebp=%p\n", gebp); return 0; }
编译运行结果如下:
可见,当carelesspapa函数调用结束后,并未直接执行come home的输出,而是转而执行naughtyboy函数(输出catch me),然后回头输出come home。该过程重复一次后发生段错误(具体原因留待读者思考)。
结合下图所示的栈帧布局,详细分析本示例缓冲区溢出过程。注意,本示例中地址及其内容由内嵌汇编和打印输出获得,正常情况下应通过gdb调试器获得。
首先,main函数将字符数组szarr的地址作为参数(即pszstr)传递给函数carelesspapa。该数组内容为"0123456789ab\xe4\x83\x4\x8\x23\x85\x4\x8",其中转义字符串"\xe4\x83\x4\x8"对应naughtyboy函数入口地址0x080483e4(小字节序),而"\x23\x85\x4\x8"对应调用carelesspapa函数时的返回地址0x8048523(小字节序)。carelesspapa函数内部调用strcpy库函数,将pszstr所指字符串内容拷贝至szbuf数组。因为strcpy函数不进行越界检查,会逐字节拷贝直到遇见'\0'结束符。故pszstr字符串将从szbuf数组起始地址开始向高地址覆盖,原返回地址0x8048523被覆盖为naughtyboy函数地址0x080483e4。
这样,当carelesspapa函数返回时,修改后的返回地址从栈中弹出到eip寄存器中,此时栈顶指针esp指向返回地址上方的空间(esp+4),程序跳转到eip所指地址(naughtyboy函数入口)开始执行,首先就是ebp入栈——并未像正常调用那样先压入返回地址,故naughtyboy函数栈帧中ebp位置相对carelesspapa函数上移4个字节!此时,"\x23\x85\x4\x8"可将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函数终止进程而不返回到启动例程:
//carelesspapa.c register int *gebp __asm__ ("%ebp"); void naughtyboy(void){ printf("[2]ebp=%p(%#x), eip=%p(%#x)\n", gebp, *gebp, gebp+1, *(gebp+1)); printf("catch me!\n"); } void carelesspapa(const char *pszstr){ printf("[1]ebp=%p(%#x)\n", gebp, *gebp); printf("[1]eip=%p(%#x)\n", gebp+1, *(gebp+1)); char szbuf[8]; strcpy(szbuf, pszstr); } int main(void){ printf("[0]ebp=%p(%#x)\n", gebp, *gebp); printf("addr: carelesspapa=%p, naughtyboy=%p\n", carelesspapa, naughtyboy); char szarr[]="0123456789ab\x14\x84\x4\x8\x33\x85\x4\x8"; //转义字符串稍有变化 carelesspapa(szarr); printf("come home!\n"); printf("[3]ebp=%p\n", gebp); exit(0); //#include <stdlib.h> }
编译运行结果如下:
这次没有重复执行,也未出现段错误。
三、缓冲区溢出防范
防范缓冲区溢出问题的准则是:确保做边界检查(通常不必担心影响程序效率)。不要为接收数据预留相对过小的缓冲区,大的数组应通过malloc/new分配堆空间来解决;在将数据读入或复制到目标缓冲区前,检查数据长度是否超过缓冲区空间。同样,检查以确保不会将过大的数据传递给别的程序,尤其是第三方cots(commercial-off-the-shelf)商用软件库——不要设想关于其他人软件行为的任何事情。
若有可能,改用具备防止缓冲区溢出内置机制的高级语言(java、c#等)。但许多语言依赖于c库,或具有关闭该保护特性的机制(为速度而牺牲安全性)。其次,可以借助某些底层系统机制或检测工具(如对c数组进行边界检查的编译器)。许多操作系统(包括linux和solaris)提供非可执行堆栈补丁,但该方式不适于这种情况:攻击者利用堆栈溢出使程序跳转到放置在堆上的执行代码。此外,存在一些侦测和去除缓冲区溢出漏洞的静态工具(检查代码但并不运行)和动态工具(执行代码以确定行为),甚至采用grep命令自动搜索源代码中每个有问题函数的实例。
但即使采用这些保护手段,程序员自身也可能犯其他许多错误,从而引入缺陷。例如,当使用有符号数存储缓冲区长度或某个待读取内容长度时,攻击者可将其变为负值,从而使该长度被解释为很大的正值。经验丰富的程序员还容易过于自信地"把玩"某些危险的库函数,如对其添加自己总结编写的检查,或错误地推论出使用潜在危险的函数在某些特殊情况下是"安全"的。
本节将主要讨论一些已被证明危险的c库函数。通过在c/c++程序中禁用或慎用危险的函数,可有效降低在代码中引入安全漏洞的可能性。在考虑性能和可移植性的前提下,强烈建议在开发过程中使用相应的安全函数来替代危险的库函数调用。
以下分析某些危险的库函数,较完整的列表参见表3-1。
3.1、gets
该函数从标准输入读入用户输入的一行文本,在遇到eof字符或换行字符前,不会停止读入文本。即该函数不执行越界检查,故几乎总有可能使任何缓冲区溢出(应禁用)。
gcc编译器下会对gets调用发出警告(the `gets' function is dangerous and should not be used)。
3.2、strcpy
该函数将源字符串复制到目标缓冲区,但并未指定要复制字符的数目。若源字符串来自用户输入且未限制其长度,则可能引发危险。规避的方法如下:
1) 若知道目标缓冲区大小,则可添加明确的检查(不建议该法):
if(strlen(szsrc) >= dwdstsize){ /* do something appropriate, such as throw an error. */ } else{ strcpy(szdst, szsrc); }
2) 改用strncpy函数:
strncpy(szdst, szsrc, dwdstsize-1); szdst[dwdstsize-1] = '\0'; //always do this to be safe!
若szsrc比szdst大,则该函数不会返回错误;当达到指定长度(dwdstsize-1)时,停止复制字符。第二句将字符串结束符放在szdst数组的末尾。
3) 在源字符串上调用strlen()来为其分配足够的堆空间:
pszdst = (char *)malloc(strlen(szsrc)); strcpy(pszdst, szsrc);
4) 某些情况下使用strcpy不会带来潜在的安全性问题:
strcpy(szdst, "hello!"); //usually by initialization, such as char szdst[] = “hello!”;
即使该操作造成szdst溢出,但这几个字符显然不会造成危害——除非用其它方式覆盖字符串“hello”所在的静态存储区。
安全的字符串处理函数通常体现在如下几个方面:
- 显式指明目标缓冲区大小
- 动态校验
- 返回码(以指明成功或失败原因)
与strcpy函数具有相同问题的还有strcat函数。
3.3、 strncpy/strncat
该对函数是strcpy/strcat调用的“安全”版本,但仍存在一些问题:
1) strncpy和strncat要求程序员给出剩余的空间,而不是给出缓冲区的总大小。缓冲区大小一经分配就不再变化,但缓冲区中剩余的空间量会在每次添加或删除数据时发生变化。这意味着程序员需始终跟踪或重新计算剩余的空间,而这种跟踪或重新计算很容易出错。
2) 在发生溢出(和数据丢失)时,strncpy和strncat返回结果字符串的起始地址(而不是其长度)。虽然这有利于链式表达,但却无法报告缓冲区溢出。
3) 若源字符串长度至少和目标缓冲区相同,则strncpy不会使用nul来结束字符串;这可能会在以后导致严重破坏。因此,在执行strncpy后通常需要手工终止目标字符串。
4) strncpy还可复制源字符串的一部分到目标缓冲区,要复制的字符数目通常基于源字符串的相关信息来计算。这种操作也会产生未终止字符串。
5) strncpy会在源字符串结束时使用nul来填充整个目标缓冲区,这在源字符串较短时存在性能问题。
3.4、sprintf
该函数使用控制字符串来指定输出格式,该字符串通常包括"%s"(字符串输出)。若指定字符串输出的精确指定符,则可通过指定输出的最大长度来防止缓冲区溢出(如%.10s将复制不超过10个字符)。也可以使用"*"作为精确指定符(如"%.*s"),这样就可传入一个最大长度值。精确字段仅指定一个参数的最大长度,但缓冲区需要针对组合起来的数据的最大尺寸调整大小。
注意,"字段宽度"(如"%10s",无点号)仅指定最小长度——而非最大长度,从而留下缓冲区溢出隐患。
3.5、scanf
scanf系列函数具有一个最大宽度值,函数不能读取超过最大宽度的数据。但并非所有规范都规定了这点,也不确定是否所有实现都能正确执行这些限制。若要使用这一特性,建议在安装或初始化期间运行小测试来确保它能正确工作。
3.6、streadd/strecpy
这对函数可将含有不可读字符的字符串转换成可打印的表示。其原型包含在libgen.h头文件内,编译时需加-lgen [library ...]选项。
char *strecpy(char *pszout, const char *pszin, const char *pszexcept);
char *streadd(char *pszout, const char *pszin, const char *pszexcept);
strecpy将输入字符串pszin(连同结束符)拷贝到输出字符串pszout中,并将非图形字符展开为c语言中相应的转义字符序列(如control-a转为“\001”)。参数pszout指向的缓冲区大小必须足够容纳结果字符串;输出缓冲区大小应为输入缓冲区大小的四倍(单个字符可能转换为\abc共四个字符)。出现在参数pszexcept字符串内的字符不被展开。该参数可设为空串,表示扩展所有非图形字符。strecpy函数返回指向pszout字符串的指针。
streadd函数与strecpy相同,只不过返回指向pszout字符串结束符的指针。
考虑以下代码:
#include <libgen.h> int main(void){ char szbuf[20] = {0}; streadd(szbuf, "\t\n", ""); printf(%s\n", szbuf); return 0; }
打印输出\t\n,而不是所有空白。
3.7、strtrns
该函数将pszstr字符串中的字符转换后复制到结果缓冲区pszresult。其原型包含在libgen.h头文件内:
char * strtrns(const char *pszstr, const char *pszold, const char *psznew, char *pszresult);
出现在pszold字符串中的字符被psznew字符串中相同位置的字符替换。函数返回新的结果字符串。
如下示例将小写字符转换成大写字符:
#include <libgen.h> int main(int argc,char *argv[]){ char szlower[] = "abcdefghijklmnopqrstuvwxyz"; char szupper[] = "abcdefghijklmnopqrstuvwxyz"; if(argc < 2){ printf("usage: %s arg\n", argv[0]); exit(0); } char *pszbuf = (char *)malloc(strlen(argv[1])); strtrns(argv[1], szlower, szupper, pszbuf); printf("%s\n", pszbuf); return 0; }
以上代码使用malloc分配足够空间来复制argv[1],因此不会引起缓冲区溢出。
3.8、realpath
该函数在libc 4.5.21及以后版本中提供,使用时需要limits.h和stdlib.h头文件。其原型为:
char *realpath(const char *pszpath, char *pszresolvedpath);
该函数展开pszpath字符串中的所有符号链接,并解析pszpath中所引用的/./、/../和'/'字符(相对路径),最终生成规范化的绝对路径名。该路径名作为带结束符的字符串存入pszresolvedpath指向的缓冲区,长度最大为path_max字节。结果路径中不含符号链接、/./或/../。
若pszresolvedpath为空指针,则realpath函数使用malloc来分配path_max字节的缓冲区以存储解析后的路径名,并返回指向该缓冲区的指针。调用者应使用free函数去释放该该缓冲区。
若执行成功,realpath函数返回指向pszresolvedpath(规范化绝对路径)的指针;否则返回空指针并设置errno以指示该错误,此时pszresolvedpath的内容未定义。
调用者需要确保结果缓冲区足够大(但不应超过path_max),以处理任何大小的路径。此外,不可能为输出缓冲区确定合适的长度,因此posix.1-2001规定,path_max字节的缓冲区足够,但path_max不必定义为常量,且可以通过pathconf函数获得。然而,pathconf输出的结果可能超大,以致不适合动态分配内存;另一方面,pathconf函数可返回-1表明结果路径名超出path_max限制。pszresolvedpath为空指针的特性被posix.1-2008标准化,以避免输出缓冲区长度难以静态确定的缺陷。
应禁用或慎用的库函数如下表所示:
表3-1
函数 |
危险性 |
解决方案 |
gets |
最高 |
禁用gets(buf),改用fgets(buf, size, stdin) |
strcpy |
高 |
检查目标缓冲区大小,或改用strncpy,或动态分配目标缓冲区 |
strcat |
高 |
改用strncat |
sprintf |
高 |
改用snprintf,或使用精度说明符 |
scanf |
高 |
使用精度说明符,或自己进行解析 |
sscanf |
高 |
使用精度说明符,或自己进行解析 |
fscanf |
高 |
使用精度说明符,或自己进行解析 |
vfscanf |
高 |
使用精度说明符,或自己进行解析 |
vsprintf |
高 |
改为使用vsnprintf,或使用精度说明符 |
vscanf |
高 |
使用精度说明符,或自己进行解析 |
vsscanf |
高 |
使用精度说明符,或自己进行解析 |
streadd |
高 |
确保分配的目标参数缓冲区大小是源参数大小的四倍 |
strecpy |
高 |
确保分配的目标参数缓冲区大小是源参数大小的四倍 |
strtrns |
高 |
手工检查目标缓冲区大小是否至少与源字符串相等 |
getenv |
高 |
不可假定特殊环境变量的长度 |
realpath |
高(或稍低,实现依赖) |
分配缓冲区大小为path_max字节,并手工检查参数以确保输入参数和输出参数均不超过path_max |
syslog |
高(或稍低,实现依赖) |
将字符串输入传递给该函数之前,将所有字符串输入截成合理大小 |
getopt |
高(或稍低,实现依赖) |
将字符串输入传递给该函数之前,将所有字符串输入截成合理大小 |
getopt_long |
高(或稍低,实现依赖) |
将字符串输入传递给该函数之前,将所有字符串输入截成合理大小 |
getpass |
高(或稍低,实现依赖) |
将字符串输入传递给该函数之前,将所有字符串输入截成合理大小 |
getchar |
中 |
若在循环中使用该函数,确保检查缓冲区边界 |
fgetc |
中 |
若在循环中使用该函数,确保检查缓冲区边界 |
getc |
中 |
若在循环中使用该函数,确保检查缓冲区边界 |
read |
中 |
若在循环中使用该函数,确保检查缓冲区边界 |
bcopy |
低 |
确保目标缓冲区不小于指定长度 |
fgets |
低 |
确保目标缓冲区不小于指定长度 |
memcpy |
低 |
确保目标缓冲区不小于指定长度 |
snprintf |
低 |
确保目标缓冲区不小于指定长度 |
strccpy |
低 |
确保目标缓冲区不小于指定长度 |
strcadd |
低 |
确保目标缓冲区不小于指定长度 |
strncpy |
低 |
确保目标缓冲区不小于指定长度 |
vsnprintf |
低 |
确保目标缓冲区不小于指定长度 |
以上就是详解c语言之缓冲区溢出的详细内容,更多关于c语言 缓冲区溢出的资料请关注其它相关文章!