缓冲区溢出实例解析

前言

缓冲区溢出攻击是黑客利用程序漏洞攻击系统的一种手段。1988年11月著名的Internet蠕虫病毒通过四种不同的方法获取对很多计算机的访问权限。其中有一种是对系统fingerd服务的缓冲区溢出攻击,通过一个特殊的字符串调用其FINGER函数,造成远程服务的缓冲区溢出并且执行一段异常代码,然后获得远程服务的执行权限,获得权限之后该蠕虫就会自我复制,从而消耗计算机资源,导致机器瘫痪。一直到今天,还有很多黑客在对有安全漏洞的系统进行缓冲区溢出攻击,所以理解缓冲区溢出的原因和掌握避免缓冲区溢出的技术是很有必要的。本文将从一个实际的例子出发,介绍缓冲区溢出的原因,以及一些避免缓冲区溢出的方法。

什么是缓冲区?

我们通常所说的缓冲区是指:在读取磁盘或者进行标准的IO操作时,为了解决输入输出设备和CPU之间的速度不匹配问题,通常会开辟一段内存空间来存储这些从输入设备读取的数据,这段在内存预留的存储空间叫做缓冲区。也就是说缓冲区就是一段存储输入数据的内存空间。缓冲区分为下面三类:

  1. 全缓冲
    在这种情况下,当填满标准I/O缓存后才进行实际I/O操作。全缓冲的典型代表是对磁盘文件的读写。
  2. 行缓冲
    在这种情况下,当在输入和输出中遇到换行符时,执行真正的I/O操作。这时,我们输入的字符先存放在缓冲区,等按下回车键换行时才进行实际的I/O操作。典型代表是标准输入(stdin)和标准输出(stdout)。
  3. 不带缓冲
    也就是不进行缓冲,标准出错情况stderr是典型代表,这使得出错信息可以直接尽快地显示出来。

本文所指的缓冲区是:在应用程序中使用到的一块用来存放用户输入数据的内存,这样的内存称作缓冲区。

缓冲区溢出攻击实例

我们用C语言来写一个简单的模拟用户登录的例子,在该例子中,让用户输入的字符和用户密码相比较,如果密码正确就输出Welcome!,如果输入的密码错误就输出Sorry, your password is wrong.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//  main.c
#include <stdio.h>
#include <string.h>
int main(int argc, const char * argv[]) {

char passsword[8] = "secret", input[8];
while (1) {
printf("Enter your password:");
gets(input);
if (strcmp(input, passsword) == 0) {
printf("Welcome!\n");
break;
}else {
printf("Sorry,your password is wrong.\n");
}
}
return 0;
}

这是一个非常简单的例子。单纯从语言的层面上看没有什么错误,我们来编译执行这个程序会发生意想不到的事情:

缓冲区溢出输出

从操作中可以看到,第一次输入的内容是12345678ok,第二次输入的内容是ok,结果根据程序的判断,结果却是正确的。究竟问什么会出现这种现象呢?从C语言的表面,我们看不出任何的逻辑错误可以造成这种结果。我们再以一个简单的例子来说说函数调用的过程。

函数的调用过程

我们现在以一个简单的函数来说明函数的执行过程,这个函数是这样的:

1
2
3
4
5
void echo(){
char buf[8];
gets(buf);
puts(buf);
}

这个方法输入一个字符串再将这个字符串输出出来。为了将这个方法的详细执行过程我们利用gcc的编译指令将其生成汇编语言,以确定其究竟是怎样操作寄存器,来完成echo方法调用的。生成的汇编代码如下:

1
2
3
4
5
6
7
8
echo:
subq $24, %rsp
movq %rsp, %rdi
call gets
movq %rsp, %rdi
call puts
addq %24, %rsp
ret

其中:%rsp是栈指针寄存器,%rdi是第一个参数寄存器。然后我们来分析下这段程序。通过调用

1
subq  $24, %rsp

我们将栈指针减少了24个字节,也就是通常所说的压栈,因为我们的数组是char型的,buf的长度是8个字节(这就是我们所指的缓冲区),所以还有16个字节的空闲,因此在栈中内存分布是这样的:

echo的内存分布

为什么在调用gets和puts函数之前需要执行:

1
movq  %rsp, %rdi

这段代码呢?这里的意思是要将栈指针赋值给rdi寄存器,然后调用gets和pust函数的时候,这个函数就可以用rdi寄存器中取出栈指针,这也就完成了函数调用过程中的参数传递过程。

问题就出在这里,如果我们给定的数组长度大于8会怎样呢?会造成溢出,根据字符串的长度不同,造成的破坏性也不同:

输入的字符数量 破坏的状态
0~7
9~23 未被使用的栈空间
24~31 返回地址
32+ caller中保存的状态

从中我们可以看到如果输入的内容是小于23个字符长度的,那么造成的破坏相对较小,如果要是大于23个字符,那么程序的返回就可能不返回原来程序调用的地址了,可能就是黑客在输入的字符串中嵌入的可执行代码的字节编码,也就是攻击代码

实例分析

在明白了缓冲区溢出的原因之后我们就来分析本文开头所举的例子。在这个例子中,我们的password在内存中占有八个字节,input数组也是八个字节,那么经过编译,运行以后内存是这样分布的:

7 6 5 4 3 2 1 0
- \0 t e r c e s
7 6 5 4 3 2 1 0

当我们输入12345678ok之后内存中的分布就会变成下面的样子:

7 6 5 4 3 2 1 0
\0 t e r \0 k o
7 6 5 4 3 2 1 0
8 7 6 5 4 3 2 1

为什么会在输入的ok后面加上”\0”呢?我们来看下C语言标注库给我们提供的gets函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
char *gets(char *s)
{
int c;
char *dest s;
while((c = getchar()) != '\n' && c != EOF)
*des ++ = c;

if(c == EOF && des == s)
return NULL;

*des ++= '\0'; //字符串结尾
return s;
}

从中我们可以看到该函数以从标准输入中读入一行,以换行符或者某个错误作为结束。然后将字符串复制到s指定的位置,同时在结尾加上’\0’,作为结束。这时问题就出现了。在C语言中’\0’是字符数组结束的标志,也就是说在第一次输入12345678ok之后,password数组变成了ok缓冲区溢出覆盖掉了password原来的值,将其替换成了新的值。然后当用户再次输入ok的时候,比较输入的值和password中的值相同,于是就登录成功了。这样就利用缓冲区溢出就越过了登录的检测,成功进行了非法登录。

对抗缓冲区溢出的方法

从上面我们可以看到缓冲区溢出的原因就是C语言对数组越界没有做边界检查。如果我们对插入数组元素的长度做以限制就可以在一定程度上避免缓冲区溢出。通常所用的方法有以下几种:

  1. 对于C语言中不安全的函数我们要使用安全的函数来替代,用fgets()、strncpy()、strncat()来替代gets()、strcpy()、strcat()等不限制字符串长度,不检查数组越界的函数。实际上编译器在编译完代码之后就已经提示了一个警告warning, this program uses gets(), which is unsafe.,所以,我们应该重视编译器给我们的提示,这样往往能避免常见的错误。
  2. 在向一块内存中写入数据之前要确认这块内存是否可以写入,同时检查写入的数据是否超过这块内存的大小。
  3. 栈随机化法。也就是说让栈的位置在程序每次运行时都不一样,然后黑客将可执行代码插入内存之后就不容易找到指向该字符串的地址,也就不能执行插入的程序了。
  4. 栈破坏检测。也就是说在实际的缓冲区上面做个标记,保存这个标记,然后在函数返回之前检查这个标记,如果这个标记和函数调用之前不一样了,就说明在函数调用的过程中发生了溢出,这是就抛异常,让程序异常终止。
  5. 操作系统限制可执行代码的区域来阻止黑客代码的执行。

这里面1,2是业务开发人员需要注意的,3,4,5是操作系统或者编译,链接器的开发人员需要考虑的。

小结

C程序被编译链接之后,其各个变量在内存中的相对位置就已经确定了,然而C对数组越界等非法的内存访问并没有很好地限制,特别是其中某些不安全的函数调用,会引起缓冲区溢出,黑客可能会利用缓冲区溢出来破坏程序原来设想的执行逻辑,并且可能被黑客插入恶意代码来对系统进行攻击。我们可以通过良好的代码编程习惯,多加非法判断,多使用安全的库函数来在一定程度上避免缓冲区溢出攻击。

参考资料

深入理解计算机系统第三章
C语言程序设计
https://www.cnblogs.com/buyizhiyou/p/5505280.html