当访问数组的时候,C不会执行数组边界检查。如果数组的存储空间是在栈中分配的,同时栈中保存了其他有用的数据,例如caller的返回地址、局部变量以及被保存的寄存器值,当向越界的数组元素存放数据时就会覆盖掉之前保存在栈中的已有数据,由此将会产生严重的程序错误。下面通过GDB调试工具来演示一下该错误产生的过程:
编写测试代码getline.c
char *getline(){ char buf[8]; char *result; gets(buf); result = malloc(strlen(buf)); strcpy(result, buf); return result;}int main(){ getline(); return 0;}
函数getline内部调用了库函数gets,从标准输入中获取字符串并保存在字符数组buf中,库函数gets的大体实现如下:
1 /* Sample implementation of library function gets() */ 2 char *gets(char *s) 3 { 4 int c; 5 char *dest = s; 6 int gotchar = 0; /* Has at least one character been read? */ 7 while ((c = getchar()) != ’\n’ && c != EOF) { 8 *dest++ = c; /* No bounds checking! */ 9 gotchar = 1;10 }11 *dest++ = ’\0’; /* Terminate string */12 if (c == EOF && !gotchar)13 return NULL; /* End of file or error */14 return s;15 }
可以看出gets函数在接收用户输入的数据时只是一味的接收,并没有防止数组越界的任何检查。
使用GCC进行编译:
[root@localhost c]# gcc -o1 -o getline getline.c
getline.c: In function `getline':
getline.c:6: warning: assignment makes pointer from integer without a cast/tmp/ccCkdYkA.o(.text+0xe): In function `getline':: the `gets' function is dangerous and should not be used.虽然编译通过了,其实gcc已经给出了警告,提示库函数gets有危险,不应该使用。在实际应用中不应该使用gets而应该使用fgets,fgets多了一个参数,表示允许接收字符的最大个数,这里使用gets只是为了说明存在的问题。
接下来使用GDB调试getline:
[root@localhost c]# gdb getline
我们在getline内部调用gets前后的地方设置断点来判断当前main函数的stack frame(一个函数调用对应着一个栈帧)的return address是否被破坏。这里解释一下return address的作用:被调用的函数(这里是getline)返回后,此时程序该执行哪一条指令呢?从getline.c中很明显地看出是return 0语句。cpu是根据指令寄存器%eip的值(该值表示下一条将要执行的指令的地址)来一条一条地执行指令的,在调用一个函数之前要先把return address(这里是return 0语句的地址)压栈,当函数调用返回时return address退栈,同时要把%eip赋值为return address,这样cpu就知道在每次函数调用返回后该执行哪条指令了。
通过gdb的反汇编命令disas找到调用gets指令前后某条指令的地址,进而设置断点:
(gdb)disas getline
结果如下:
1 Dump of assembler code for function getline: 2 0x080483e4: push %ebp 3 0x080483e5 : mov %esp,%ebp 4 0x080483e7 : sub $0x18,%esp 5 0x080483ea : sub $0xc,%esp 6 0x080483ed : lea 0xfffffff8(%ebp),%eax 7 0x080483f0 : push %eax 8 0x080483f1 : call 0x80482e4 9 0x080483f6 : add $0x10,%esp10 0x080483f9 : sub $0xc,%esp11 0x080483fc : lea 0xfffffff8(%ebp),%eax12 0x080483ff : push %eax13 0x08048400 : call 0x8048304 14 0x08048405 : add $0x4,%esp15 0x08048408 : push %eax16 0x08048409 : call 0x80482f4 17 0x0804840e : add $0x10,%esp18 0x08048411 : mov %eax,0xfffffff4(%ebp)19 0x08048414 : sub $0x8,%esp20 0x08048417 : lea 0xfffffff8(%ebp),%eax21 0x0804841a : push %eax22 0x0804841b : pushl 0xfffffff4(%ebp)23 0x0804841e : call 0x8048324
指令call gets的地址是0x080483f1,我们在该条指令紧邻的前后两条指令各设一个断点,执行gdb命令break:
(gdb) break *0x080483f0
Breakpoint 1 at 0x80483f0(gdb) break *0x080483f6Breakpoint 2 at 0x80483f6断点设置好后就可以调试了,执行gdb命令run运行程序:
(gdb) run
Starting program: /opt/c/getlineBreakpoint 1, 0x080483f0 in getline ()
此时程序停在断点1处,我们查看一下此时main函数栈帧的return address是多少,调用者函数栈帧的returne address起始地址可以通过被调用函数栈帧基地址的4得到,即%ebp+4,下图是调用函数与被调用函数栈帧交界处的示意图
(gdb) print /x *(int*)($ebp+4)
$4 = 0x8048440说明:($ebp+4)获取的是return address保存在栈中的起始地址,*(int*)($ebp+4)访问以该地址开始的4个字节的数据,/x表示以16进制格式打印出来。其实也可以通过gdb命令查看当前栈帧信息的命令info frame得到:
(gdb) info frame
Stack level 0, frame at 0xbfffe598: eip = 0x80483f0 in getline; saved eip 0x8048440 called by frame at 0xbfffe5a8 Arglist at 0xbfffe598, args: Locals at 0xbfffe598, Previous frame's sp in esp Saved registers: ebp at 0xbfffe598, eip at 0xbfffe59c其中saved eip就是main栈帧保存的return address的值。
此时%esp与%ebp相差:
(gdb) print /x $ebp-$esp
$10 = 0x24说明getline栈帧使用了40个字节来保存相关的局部变量、寄存器的值等其他有用的信息,而数组buf最多只能保存7个字符(不算最后的'\0'),如果调用gets时我们输入个数大于40+4个字符,则return address一定会遭到破坏,事实上输入 个就已经破坏了,执行GDB命令continue,并输入字符串012345678901234567890123456789012345678901234:
(gdb) continue
Continuing.012345678901234567890123456789012345678901234Breakpoint 2, 0x080483f6 in getline ()
此时gets已经返回,我们重新检查一下main栈帧的return address有没有改变,可以用info frame命令也可以用print /x *(int*)($ebp+4),两种方法结果一样:
(gdb) print /x *(int*)($ebp+4)
$11 = 0x35343332(gdb) info frame
Stack level 0, frame at 0xbfffe518: eip = 0x80483f6 in getline; saved eip 0x35343332 Arglist at 0xbfffe518, args: Locals at 0xbfffe518, Previous frame's sp in esp Saved registers: ebp at 0xbfffe518, eip at 0xbfffe51c很明显main栈帧的return address的值由0x8048440变为0x35343332,也就是说执行gets函数的过程中我们破坏了栈中之前保存的数据,正在情况下是不能修改的,这里因为gets没有做数组边界检查所导致的,接下来执行continue命令,在getline返回时%eip被恢复成一个错误的值,导致程序不能正确执行下去:
(gdb) continue
Continuing.Program received signal SIGSEGV, Segmentation fault.
0x35343332 in ?? ()程序报段错误,根据gdb打印出来的信息我们得知cpu无法识别内存0x35343332处的数据是哪条指令,因此报错。
现在我们重新执行上面的过程,只不过这时我们输入字符的个数不超过8,我们看一下main栈帧的return address是否会改变,程序是否会报错:
(gdb) continue
Continuing.0123456Breakpoint 2, 0x080483f6 in getline ()
(gdb) continueContinuing.Program exited normally.
由此可见,当输入字符的个数不超过字符数组的预先分配的空间大小时程序正确执行。事实上只要我们往大小为8的字符数组中存放超过7个但小于44个的字符(不算gets自动往后加的'\0'),这时虽然main栈帧的return address没有被破坏但是栈中的其他数据被破坏了,也会导致段错误,读者可以尝试输入8个字符验证一下。