前言
本文将会介绍关于缓冲区溢出类型漏洞的原理及其利用方法。因为缓冲区溢出漏洞是一个大类,这其中包含了很多种小类漏洞,故笔者专写下此篇博客,以记录自己在学习和内存相关的内容时的知识点记录以及心得体会。以下就是本篇博文的全部内容。
1、缓冲区溢出漏洞概述
1.1、漏洞概述
在学习缓冲区溢出漏洞之前,我们要先明白关于漏洞的概念。漏洞也称为脆弱性(Vulnerability),是计算机系统的硬件、软件、协议在系统设计、具体实现、系统配置或安全策略上存在的缺陷。这些缺陷一旦被发现并被恶意利用,就会使攻击者在未授权的情况下访问或破坏系统,从而影响计算机系统的正常运行甚至造成安全损害。
- 对于漏洞有多种称呼,包括Hole、Error、Fault、Weakness、Failure等,这些称呼都不能涵盖漏洞的含义(脆弱性)
- 软件漏洞专指计算机系统中的软件系统漏洞
1.2、缓冲区溢出漏洞概述
我们现在已经清楚了漏洞的概念,那什么又是缓冲区溢出漏洞呢?很明显这是漏洞的一种,不过在学习缓冲区溢出漏洞之前,我们还要搞清楚缓冲区这一概念。缓冲区是一块连续的内存区域,用于存放程序运行时加载到内存的运行代码和数据。
那什么又是缓冲区溢出呢?缓冲区溢出是指程序运行时,向固定大小的缓冲区写入超过其容量的数据,多余的数据会越过缓冲区的边界覆盖相邻内存空间,从而造成溢出。
一般情况下,缓冲区的大小是由用户输入的数据决定的,如果程序不对用户输入的超长数据进行长度检查,同时用户又对程序进行了非法操作或者错误输入,就会造成缓冲区溢出。
综合以上,缓冲区溢出漏洞就是由缓冲区溢出引发的程序的脆弱性。
1.3、缓冲区溢出攻击概述
缓冲区溢出攻击是指发生缓冲区溢出时,溢出的数据会覆盖相邻内存空间的返回地址、函数指针、堆管理结构等合法数据,从而使程序运行失败、或者发生转向去执行其它程序代码、或者执行预先注入到内存缓冲区中的代码。
值得注意的一点是,缓冲区溢出后执行的代码,会以原有程序的身份权限运行。
1.4、引发缓冲区溢出的原因
引发缓冲区溢出的原因是缺乏类型安全功能的程序设计语言(C、C++等)。出于效率的考虑,部分函数不对数组边界条件和函数指针引用等进行边界检查。例如,C标准库中和字符串操作有关的函数,像strcpy
、strcat
、sprintf
、gets
等函数中,数组和指针都没有自动进行边界检查。
所以说,程序员开发时必须自己进行边界检查,以防范数据溢出,否则所开发的程序就会存在缓冲区溢出的安全隐患,而实际上这一行为往往被程序员忽略或检查不充分。
值得注意的是,缓冲区溢出漏洞并不是一种漏洞,而是一类漏洞。常见的缓冲区溢出漏洞包括:
- 栈溢出漏洞
- 堆溢出漏洞
- 整数溢出漏洞
- SHE结构基础漏洞
- 单字节溢出漏洞
- 格式化字符串漏洞
- C++虚函数漏洞
2、栈溢出漏洞
2.1、栈溢出漏洞概述
栈溢出漏洞,即发生在栈区的溢出漏洞。当被调用的子函数中写入数据的长度,大于栈帧的基址到ESP之间预留的保存局部变量的空间时,就会发生栈的溢出。要写入的数据的填充方向是从低地址向高地址增长,多余的数据就会越过栈帧的基址,覆盖基址以上的地址空间。
下面我们通过一个代码示例,来更深刻的了解什么是栈溢出漏洞。
#include <stdio.h>
void why_here(void)
{
printf("why u r here?!\n");
exit(0);
}
void f()
{
int buff[1];
buff[2] = (int)why_here;
}
int main(int argc, char *argv[])
{
f();
return 0;
}
这段代码看起来很简单,在main
函数中调用了f
函数,在f
函数中,声明了长度为1的buff
数组,并对此数组的第3个位置进行了赋值为why_here
函数的地址,并将其类型转换为int类型,看起来这段代码与why_here
函数并没有什么直接关系,只是将why_here
函数的地址进行了赋值操作,并没有直接调用why_here
函数,故更不会打印why_here
函数中的why u r here?!
。我们可以将其编译并运行。
我们惊讶的发现,main
函数调用了函数f
,并没有调用why_here
函数,但是上面的运行结果却显示调用了why_here
函数。有的读者可能就有疑问了,为什么会这样呢?其实,这就是一个栈溢出漏洞的经典例子。
我们清楚,在函数f
中,所声明的数组buff
长度为1,但是由于没有对访问下标的值进行校验,程序中对数组外的内存进行了读写,这就是一个典型的栈溢出漏洞。
为了分析这个栈溢出漏洞示例,我们要分析其栈的结构。在调用函数f
之前,要将其参数入栈(在这里例子中我们并不关心其参数),然后将调用函数f
的返回地址入栈,然后将以上一个函数的EBP入栈,然后给整个函数f
分配其栈帧(在这个例子中,栈帧中只有局部变量buff
,且其长度为1)。当然,还有其它内容入栈,不过在这里我们并不关心,所以就不讨论了。以上我们讨论的内容如下图所示。
基本情况我们了解之后,来主要关注局部变量buff
的指针,其指向了buff
这个局部变量数组的起始地址,那么我们就可以通过buff[0]
来访问这个数组的第1个元素,通过buff[1]
来访问这个数组的第2个元素,通过buff[2]
来访问这个数组的第3个元素,……。
重点是,我们在代码中,通过buff[2] = (int)why_here;
语句,将函数why_here
的入口地址赋值给buff[2]
,故将栈中的返回地址
覆写为函数why_here
的入口地址。可以看到,虽然我们并没有直接调用函数why_here
,但是我们将栈中的返回地址
改写为了函数why_here
的入口地址,那么当CPU来到栈中的返回地址
中取值的时候,取到的是函数why_here
的入口地址,所以说程序发生了跳转,去执行why_here
函数了,故最终将why_here
函数中的why u r here?!
打印了出来。
故此例就是一个通过栈溢出漏洞改写栈中的返回地址
来执行本不应该执行的函数的例子。那么通过这个例子,我们已经清楚了什么是栈溢出漏洞。对于如何利用栈溢出漏洞,请参阅下一章节。
2.2、栈溢出漏洞利用
2.2.1、利用方法一:修改返回地址
栈的存取采用先进后出的策略,程序用它来保存函数调用时的有关信息,如函数参数,返回地址,函数中的非静态局部变量存放在栈中。如果返回地址被覆盖,当覆盖后的地址是一个无效地址,则程序运行失败。如果覆盖返回地址的是恶意程序的入口地址,则源程序将转向去执行恶意程序。下面以一段程序为例说明通过栈溢出漏洞修改返回地址的原理。
void stack_overflow(char *argument)
{
char local[4];
for(int i = 0; argument[i]; i++)
{
local[i] = argument[i];
}
}
函数stack_overflow
被调用时的堆栈布局如下图所示。图中local
是栈中保存局部变量的缓冲区,根据char local[4]
预先分配的大小为4个字节,当向local
中写入超过4个字节的字符时,就会发生溢出。
如果溢出的值为攻击代码的入口地址,即CCCC
地址为攻击代码的入口地址,就会调用攻击代码。
2.2.2、利用方法二:覆盖临接变量
我们知道,函数的局部变量在栈中一个挨着一个排列。如果这些局部变量中有数组之类的缓冲区,并且程序中存在数组越界的缺陷,那么越界的数组元素就有可能破坏栈中相邻变量的值,甚至破坏栈帧中所保存的EBP值、返回地址等重要数据,以此为基础,就可以通过覆盖临近变量的值,以更改程序的执行流程。下面用一个例子来说明破坏栈内局部变量对程序的安全性有什么影响。
#include <stdio.h>
#include <iostream>
#include <string.h>
#define PASSWORD "1234567"
int verify_password(char *password)
{
int authenticated;
// add local buff to be overflowed
char buffer[8];
authenticated = strcmp(password, PASSWORD);
strcpy(buffer, password);
return authenticated;
}
int main()
{
int valid_flag = 0;
char password[1024];
while(1)
{
printf("please input password: ");
scanf("%s", password);
valid_flag = verify_password(password);
if(valid_flag)
{
printf("incorrect password!\n\n");
}
else
{
printf("Congratulation! You have passed the verification!\n\n");
}
}
return 0;
}
这段代码并不是很复杂,通过观察源代码不难发现,authenticated
变量的值来源于strcmp
函数的返回值,之后会返回给main
函数作为密码验证成功与否的标志变量,即当authenticated
为0时,表示验证成功,反之,验证不成功。
此时我们注意,如果我们输入的密码超过了7个字符(注意:字符串截断符NULL将占用一个字节),则越界字符的ASCII码会修改掉authenticated
的值。如果这段溢出数据恰好把authenticated
改为0,则程序执行流程将会被改变,即构造一个精心设计的字符串(不需要这个字符串是真正的密码)使authenticated
为0,然后返回给main
函数就会将这个精心设计的字符串判断为真正的密码,从而实现密码破解的目的。
不过问题是,我们该如何将authenticated
覆写为0呢?其实要成功覆盖临近变量authenticated
并使其为0,需要满足两个条件:
- 输入一个8位的字符串的时候,比如“22334455”,此时,字符串的结束符恰好是0,则覆盖变量
authenticated
的高字节并使其为0
解释:如果字符串的结束符不为0,那么就无法覆盖变量authenticated
的高位字节使其为0,故无法实现密码的破解,因为只有变量authenticated
的值为0时,程序才会认为密码输入正确 - 输入的字符串应该大于“12345678”(即真正的密码),因为执行
strcpy
之后要确保变量authenticated
的值为1,也就是只有高字节是1,其他字节为0(小端存储,即Little Endian)
解释:因为变量authenticated
为int类型,占4个字节,如果其值不为1(当然,如果变量authenticated
为0,就说明密码正确,这种情况我们不考虑),就会导致其在内存中存储的其它位也不为0,故就算将其高位字节覆写为1,最终变量authenticated
的值也不为0,也就无法成功破解密码。所以我们要保证变量authenticated
的值为1,这样将其高位字节覆写后,其余位也都为0,最终变量authenticated
的值就为0,从而成功破解密码
当满足以上两点要求后,继续执行验证密码的程序,首先通过strcmp
函数得到authenticated
的值为1,然后通过strcpy
函数实现缓冲区溢出,将authenticated
在内存中的高位字节的1覆写为0(使用值为0的字符串的结束符覆写),最终authenticated
的值就被设置为0,然后将其返回给main
函数进行验证,因为authenticated
已经被设计为0,所以就算我们输入一个错误的密码,仍然会通过密码检查,从而实现密码的破解。
理论知识我们已经讲述清楚了,我们现在可以在Visual C++ 6.0上对此程序进行我们介绍过的验证,看看是否可以按照我们所述成功破解密码。
可以发现,正如我们所介绍的一样,我进行了三组测试:
- 输入“7654321”:这是一个错误密码,没有通过密码检查,故密码破解失败
- 输入“1234567”:这是一个正确密码,通过了密码检查,这并不是一次密码破解
- 输入“22334455”:这是一个错误密码,通过了密码检查,故密码破解成功
所以说,我们成功通过栈溢出漏洞,并利用其覆盖临接变量的栈溢出方法,成功破解了密码,达到了我们的目的。
3、堆溢出漏洞
3.1、堆溢出漏洞概述
堆溢出是指在堆中发生的缓冲区溢出。堆溢出后,数据可以覆盖堆区的不同堆块的数据,带来安全威胁。我们将通过下面一个简单例子,来演示一个简单的堆溢出漏洞:该漏洞在产生溢出的时候,将覆盖一个目标堆块的块身数据。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define FILENAME "myoutfile"
int main(int argc, char *argv[])
{
FILE *fd;
char bufchar[100];
char* buf1 = (char*)malloc(20);
char* buf2 = (char*)malloc(20);
ptrdiff_t diff = buf2 - buf1;
strcpy(buf2, FILENAME);
printf("-----------打印信息-----------\n");
printf("buf1存储地址:%p\n", buf1);
printf("buf2存储地址:%p, 存储内容:%s\n", buf2, buf2);
printf("两个存储地址之间的距离:%d个字节\n\n", diff);
printf("-----------打印信息-----------\n");
if(argc < 2)
{
printf