详解C语⾔之缓冲区溢出
⽬录
⼀、缓冲区溢出原理
⼆、缓冲区溢出实例
三、缓冲区溢出防范
3.1、gets
3.2、strcpy
3.3、 strncpy/strncat
3.4、sprintf
3.5、scanf
3.6、streadd/strecpy
3.7、strtrns
3.8、realpath
⼀、缓冲区溢出原理
栈帧结构的引⼊为⾼级语⾔中实现函数或过程调⽤提供直接的硬件⽀持,但由于将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来隐患。若将函数返回地址修改为指向⼀段精⼼安排的恶意代码,则可达到危害系统安全的⽬的。此外,堆栈的正确恢复依赖于压栈的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处)。以下改进版本⽤于⾃动计算该偏移量:
printf函数是如何实现的
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 e4 83 4 8 23 85 4 8";
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 e4 83 4 8 23 85 4 8",其中转义字符串" e4 83 4 8"对应NaughtyBoy函数⼊⼝地址0x080483e4(⼩字节序),⽽" 23 85 4 8"对应调⽤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个字节!此时," 23 85 4 8"可将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 14 84 4 8 33 85 4 8"; //转义字符串稍有变化
CarelessPapa(szArr);
printf("Come Home!\n");
printf("[3]EBP=%p\n", gEbp);
exit(0); //#include <stdlib.h>
}
编译运⾏结果如下:
这次没有重复执⾏,也未出现段错误。
三、缓冲区溢出防范