文章目录
- 前言
- 1、缓冲区溢出漏洞概述
- 1.1、漏洞概述
- 1.2、缓冲区溢出漏洞概述
- 1.3、缓冲区溢出攻击概述
- 1.4、引发缓冲区溢出的原因
- 2、栈溢出漏洞
- 2.1、栈溢出漏洞概述
- 2.2、栈溢出漏洞利用
- 2.2.1、利用方法一:修改返回地址
- 2.2.2、利用方法二:覆盖临接变量
- 3、堆溢出漏洞
- 3.1、堆溢出漏洞概述
- 3.2、堆溢出漏洞利用
- 3.2.1、前置知识
- 3.2.1.1、堆管理结构
- 3.2.2、堆溢出漏洞利用
- 3.2.2.1、Dword Shoot攻击概述
- 3.2.2.1.1、Dword Shoot攻击概述
- 3.2.2.1.2、Dword Shoot攻击示例
- 4、整数溢出漏洞
- 4.1、整数溢出漏洞概述
- 4.2、整数溢出漏洞利用
- 5、SEH结构溢出漏洞
- 5.1、SEH结构溢出漏洞概述
- 5.2、SEH结构溢出漏洞利用
- 6、单字节溢出漏洞
- 6.1、单字节溢出漏洞概述
- 6.2、单字节溢出漏洞利用
- 7、格式化字符串漏洞
- 7.1、前置知识
- 7.1.1、格式化字符串
- 7.2、格式化字符串漏洞概述
- 7.2.1、数据泄露漏洞概述
- 7.2.2、数据写入漏洞概述
- 7.3、格式化字符串漏洞利用
- 7.3.1、数据泄露漏洞利用
- 7.3.2、数据写入漏洞利用
- 8、C++虚函数漏洞
- 8.1、前置知识
- 8.1.1、多态
- 8.1.2、虚函数
- 8.1.3、虚表
- 8.1.4、虚函数和类的关系
- 8.2、C++虚函数漏洞概述
- 8.3、C++虚函数漏洞利用
- 总结
前言
本文将会介绍关于缓冲区溢出类型漏洞的原理及其利用方法。因为缓冲区溢出漏洞是一个大类,这其中包含了很多种小类漏洞,故笔者专写下此篇博客,以记录自己在学习和内存相关的内容时的知识点记录以及心得体会。以下就是本篇博文的全部内容。
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("请输入要写入buf1的字符串:");
gets(bufchar);
strcpy(buf1, bufchar);
}
else
{
strcpy(buf1, argv[1]);
}
printf("\n-----------打印信息-----------\n");
printf("buf1存储内容:%s \n", buf1);
printf("buf2存储内容:%s \n", buf2);
if(diff <= strlen(bufchar))
{
printf("提示:buf1已溢出,溢出部分覆盖buf2中的myoutfile\n");
}
getchar();
return 0;
}
这段代码的主要作用是通过malloc
函数从堆区申请两个堆块,分别是处于低地址的buf1
和处于高地址的buf2
。buf2
存储了一个名为“myoutfile”的字符串,buf1
用来接收输入。此外,还定义了diff
变量,用来记录buf1
和buf2
之间的地址距离,也就是说buf1
和buf2
之间还有多少存储空间。当然,还有一些信息打印的语句,在这里就不强调了。
当我们搞清楚示例代码的主要逻辑后,来关注第28行~第37行的代码,这段代码是为了向buf1
中拷贝数据,不过在向buf1
拷贝数据之前,没有进行边界检查。如果bufchar
的数据长度大于buf1
所能容纳的最大范围,那么就会导致buf1
缓冲区溢出(很明显会发生这种情况)。而我们注意到,buf1
和buf2
之间的距离我们是可以计算得到的(第15行),所以说,当buf1
发生缓冲区溢出后,就会导致buf2
可能变成设计的目标文件,而非原始文件。
我们下面通过运行这段代码来体会上面我们介绍的关于buf1
存在的缓冲区溢出的问题。当我们运行这段代码后,首先会打印buf1
和buf2
的存储地址,以及两个存储地址之间的距离,在这里两个存储地址之间的距离为32个字节。
然后我们注意到,提示我们输入要写入到buf1
的字符串,此时我们思考。若我们构造输入字符串的长度大于32个字节,而且刻意构造一个自定义的字符串hostility
,此时输入字符串为“32字节填充数据+hostility”,那么最终写入到buf1
中的内容的长度就是超过了32个字节的,其中32个字节刚好填满了buf1
和buf2
存储地址之间的空间,多出来的hostility
就会被溢出写入到buf2
中,最终导致buf2
中的内容就变成了hostility
。所以我们构造输入字符串为12345678912345678912345678912345hostility
,并继续运行程序。
可以发现,以上实验内容印证了我们的想法,buf1
确实发生了关于堆的缓冲区溢出的问题,并将溢出的数据覆写到了buf2
中,覆盖了buf2
原本的内容。
3.2、堆溢出漏洞利用
相比于栈溢出,堆溢出的实现难度更大,而且往往要求进程在内存中具备特定的组织结构。然而,堆溢出攻击也已经成为缓冲区溢出攻击的主要方式之一。堆溢出带来的威胁远远不止上面示例演示的那样,结合堆管理结构,堆溢出漏洞可以在任意位置写入任意数据。
3.2.1、前置知识
3.2.1.1、堆管理结构
首先,我们知道。在Windows里,可以使用Windows缺省堆,也可以用户自己创建新堆:
- 获取缺省堆可以通过
GetProcessHeap
函数(无参数)得到句柄 - 创建新堆可以用
HeapCreat
函数 - 除了
malloc
、new
等函数外,C/C++也提供了HeapAlloc
、HeapFree
等函数用于堆的分配和释放
根据堆管理结构可知,在Windows系统中,占有态的堆块被使用它的程序索引,而堆表只索引所有空闲态的堆块。其中,最重要的堆表有两种:
- 空闲双向链表,即freelist(简称空表)
- 快速单向链表,即lookaside(简称快表)
我们以空闲双向链表,即freelist(简称空表)为例进行下面的学习。下图是一个空闲双向链表的示意。
我们清楚,对于堆块有三类操作:
- 堆块分配
- 堆块释放
- 堆块合并
以上操作归根结底是对空表链的修改。这些修改无外乎要向链表里链入和卸下堆块。根据对链表操作的常识,我们可以知道,从链表上卸载(unlink)一个节点的时候会发生如下操作(假设要卸载的节点为上图中名为“node”的节点)。
- node->blink->flink = node->flink;
- node->flink->blink = node->blink;
具体的,在Windows堆内存分配时会调用函数RtlAllocHeap
,该函数从空闲堆链上摘下一个空闲堆块,完成空闲双向链表里相关节点的前后指针的变更操作,它会执行如下操作。
- mov dword ptr[edi], ecx;
- mov dword ptr[ecx + 4], edi;
其中:
ecx
为空闲可分配的堆区块的前向指针edi
为空闲可分配的堆区块的后向指针
这两条汇编语句恰好对应了上述两个链表节点对应的前后向指针变化的操作。这种卸载堆链表节点的操作可能会导致:空闲堆块的前向指针(数值)写入到空闲堆块的后向指针(地址)里去。
3.2.2、堆溢出漏洞利用
3.2.2.1、Dword Shoot攻击概述
3.2.2.1.1、Dword Shoot攻击概述
如果我们通过堆溢出覆写了一个空闲堆块的块首的前向指针flink
和后向指针blink
,我们就可以精心构造一个地址和一个数据,当这个空闲堆块从链表里卸下的时候,就获得一次向内存构造的任意地址写入一个任意数据的机会。这种能够向内存任意位置写入任意数据的机会称为“Arbitrary Dword Reset”(又称Dword Shoot)。具体如下图所示。
基于Dword Shoot攻击,攻击者甚至可以劫持进程,运行植入的恶意代码。比如,当构造的地址为重要函数调用地址、栈帧中函数返回地址、栈帧中SEH的句柄等时,写入的任意数据可能就是恶意代码入口地址。
3.2.2.1.2、Dword Shoot攻击示例
下面程序演示了一个Dword Shoot攻击的例子。
#include <windows.h>
int main()
{
HLOCAL h1, h2, h3, h4, h5, h6;
HANDLE hp;
// 创建自主管理的堆
hp = HeapCreate(0, 0x1000, 0x10000);
// 从堆里申请空间
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h3 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h4 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h5 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
// 依次释放奇数堆块,避免堆块合并
HeapFree(hp, 0, h1); // 释放堆块
HeapFree(hp, 0, h3);
HeapFree(hp, 0, h5); // 现在freelist[2]有3个元素
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
return 0;
}
下面对整个程序的执行流程进行解析:
- 程序首先创建了一个大小为0x1000的堆区,并从其中连续申请了6个块身大小为8字节的堆块,加上块首实际上是6个16字节的堆块
- 释放奇数次申请的堆块是为了防止堆块合并的发生
- 三次释放结束后,会形成3个16字节的空闲堆块放入空表。因为是16个字节,所以会被依次放入
freelist[2]
所标识的空表,它们依次是h1
、h2
、h5
- 再次申请8字节的堆区内存,加上块首是16个字节,因为会从
freelist[2]
所标识的空表中摘取第一个空闲堆块出来,即h1
- 如果我们手动修改
h1
块首中的前后向指针,即将h1
的Flink
和Blink
改写为特定地址或特定数值,那么就完成一次Dword Shoot攻击
4、整数溢出漏洞
4.1、整数溢出漏洞概述
如果各位读者学习过计算机组成原理,那么应该清楚,在高级程序语言中,整数分为无符号数和有符号数两类,其中有符号负整数最高位为1,正整数最高位为0,无符号整数则无此限制。
常见的整数类型有8位、16位、32位以及64位等,对应的每种类型整数都包含一定的范围。当对整数进行加、乘等运算时,计算的结果如果大于该类型的整数所表示的范围时,就会发生整数溢出。
而根据溢出的原理的不同,整数溢出可以分为以下三类:
- 存储溢出
存储溢出是使用另外的数据类型来存储整型数据造成的。例如,把一个大的变量放入一个小变量的存储区域,最终是只能保留小变量能够存储的位,其它的位都无法存储,以致造成安全隐患。 - 运算溢出
运算溢出是对整型变量进行运算时没有考虑到其边界范围,造成运算后的数值范围超出了其存储空间。 - 符号问题
整型数据可分为有符号整型数和无符号整型数两种。在开发过程中,一般长度变量使用无符号整型数,然而如果程序员忽略了符号,在进行安全检查判断的时候就可能出现问题。
我们下面来看一个关于整数溢出的例子。
char* integer_overflow(int* data, unsigned int len)
{
unsigned int size = len + 1;
char *buffer = (char*)malloc(size);
if(!buffer)
{
return NULL;
}
memcpy(buffer, data, len);
buffer[len] = '\0';
return buffer;
}
在这段代码中,该函数将用户输入的数据拷贝到新的缓冲区,并在最后写入结尾符’\0’。如果攻击者将0xFFFFFFFF
作为参数传入len
,当计算size
时会发生整数溢出,malloc
会分配大小为0的内存块(将得到有效地址),后面执行memcpy
时会发生堆溢出的问题。
4.2、整数溢出漏洞利用
整数溢出漏洞一般不能被单独利用,而是用来绕过目标程序中的条件检测,进而实现其它攻击。下面我们可以看一个整数溢出漏洞利用的示例。
#include<iostream>
#include<windows.h>
#include<shellapi.h>
#include<stdio.h>
#include<stdlib.h>
#define MAX_INFO 32767
using namespace std;
void func()
{
// 打开记事本
ShellExecute(NULL, "open", "notepad", NULL, NULL, SW_SHOW);
}
void func1()
{
// 打开计算器
ShellExecute(NULL, "open", "calc", NULL, NULL, SW_SHOW);
}
int main()
{
// 数据初始化
void (*func_ptr)() = func;
char info[MAX_INFO];
char info1[30000];
char info2[30000];
// 读取数据
freopen("input.txt", "r", stdin);
cin.getline(info1, 30000, ' ');
cin.getline(info2, 30000, ' ');
// 计算长度
short len1 = strlen(info1);
short len2 = strlen(info2);
// 可能会发生整数溢出
short all_len = len1 + len2;
if(all_len < MAX_INFO)
{
strcpy(info, info1);
strcat(info, info2);
}
//可能会通过整数溢出漏洞覆盖func_ptr(),从而改变程序的执行顺序
func_ptr();
return 0;
}
在这段示例代码中,首先通过读取文件input.txt
来初始化info1
和info2
数组。然后,计算info1
和info2
的长度,并将它们的长度相加,得到all_len
变量。接着,程序通过比较all_len
和MAX_INFO
的大小来判断是否会发生溢出。如果all_len
小于MAX_INFO
,则将info1
和info2
拼接到一起,并存储到info
数组中。最后,通过函数指针func_ptr
调用函数func()
。
上面的代码看起来很简单,也没发现什么太大的问题,不过让我们着眼于第36行~第49行的代码。其中,short类型整数表示范围为-32768~32767,当len1 + len2
超过了short类型整数的最大范围后会变为一个负数,将满足all_len < MAX_INFO
的判断条件,进而进入if
的分支语句。于是继续执行if
语句的时候,将info1
与info2
的内容都写进info
中。那么此时写入的数据范围就可能超过了info
可以容纳的最大范围,从而发生缓冲区溢出的问题,攻击者就可以通过构造恶意的输入文件input.txt
,来覆盖func_ptr()
指向的数据(即示例代码将要执行的函数地址),从而改变程序的执行顺序,进而实现各种攻击。
5、SEH结构溢出漏洞
5.1、SEH结构溢出漏洞概述
为了保证系统在遇到错误时不至于崩溃,仍能够健壮稳定地继续运行下去,Windows会对运行在其中的程序提供一次补救的机会来处理错误,这种机制就是异常处理机制。
异常处理结构体SEH是Windows异常处理机制所采用的重要数据结构,其示意图及具体特点如下所示。
- SEH结构体存放在栈中,栈中的多个SEH通过链表指针在栈内由栈顶向栈底串成单向链表
- 位于链表最顶端的SEH通过线程环境块(TEB,Thread Environment Block)0字节偏移处的指针表示
- 每个SEH包含两个DWORD指针,分别为SHE链表指针和异常处理函数句柄,共8个字节
当SEH结构用作异常处理时,主要包括如下三个方面:
- 当线程初始化时,会自动向栈中安装一个SEH,作为线程默认的异常处理。如果程序源代码中使用了
_try{}_except{}
或者Assert
宏等异常处理机制,编译器将最终通过向当前函数栈帧中安装一个SEH来实现异常处理 - 当异常发生时,操作系统会中断程序,并首先从TEB的0字节偏移处取出距离栈顶最近的SEH,使用异常处理函数句柄所指向的代码来处理异常。当最近的异常处理函数运行失败时,将顺着SEH链表依次尝试其它的异常处理函数
- 如果程序安装的所有异常处理函数都不能处理这个异常,系统会调用默认的系统处理程序,这通常会显示一个对话框,我们可以选择关闭或者最后将其附加到调试器上的调试按钮。如果没有调试器能被附加于其上或者调试器也处理不了,系统就调用
ExitProcess
终结程序
SEH结构溢出漏洞是指通过栈溢出或者其它漏洞,使用精心构造的数据覆盖SEH链表的入口地址、异常处理函数句柄或链表指针等,从而实现程序执行流程的控制。
因为发生异常的时候,程序会基于SEH链表转去执行一个预先设定的回调函数,攻击者可以利用这个结构进行漏洞利用攻击,具体来说:
- 由于SHE存放在栈中,那么利用缓冲区溢出就可以覆盖SHE
- 如果精心设计溢出数据,则有可能把SHE中异常处理函数的入口地址更改为恶意程序的入口地址,从而实现进程的控制
5.2、SEH结构溢出漏洞利用
下面我们对一个SHE结构溢出漏洞的利用代码进行分析,仔细体会如何利用SHE结构进行缓冲区溢出,从而实现漏洞攻击的目的。
#include <stdio.h>
#include <windows.h>
char shellcode[] = "";
void HackExceptionHandler()
{
printf("got an exception, press Enter to kill process");
getchar();
ExitProcess(1);
}
void test(char* input)
{
char buf[200];
int zero = 0;
__try
{
strcpy(buf, input);
zero = 4 / zero;
}
__except(HackExceptionHandler())
{
printf("An exception occurred!\n");
}
}
int main()
{
test(shellcode);
return 0;
}
在这段程序中定义了一个test
函数,该函数接受一个字符串作为参数,然后尝试将该字符串拷贝到一个缓冲区中,并且故意引发一个除零异常,从而导致程序崩溃。然后在test
函数中使用了__try
和__except
关键字来捕获和处理异常/崩溃。
注意,在test
函数中使用了strcpy
函数将用户输入的数据拷贝到一个长度为200
的字符数组buf
中,但没有检查输入数据的长度,导致可能发生栈溢出。如果输入数据的长度超过了buf
的长度,就会覆盖buf
后面的栈帧数据,包括函数的返回地址。
攻击者可以通过构造特定的输入数据,使其覆盖到buf
后面的栈帧数据中的返回地址处,从而改变程序的执行流程。如果成功覆盖了SEH
异常处理器的返回地址,当发生异常时,程序会跳转到被攻击者指定的地址执行,从而实现攻击目的。
6、单字节溢出漏洞
6.1、单字节溢出漏洞概述
单字节溢出是指在进行内存操作时,溢出只涉及到一个字节的情况。通常情况下,溢出是指写入或读取超出了所分配内存块的边界,导致对相邻内存单元的非法访问。
单字节溢出是一种特殊情况,只影响到一个字节的内存空间,即指程序中的缓冲区仅能溢出一个字节。这可能是由于内存操作的不正确、数据类型的转换错误、或者对数据的错误处理等引起的。虽然单字节溢出影响的内存量较小,但仍然可能导致程序的不稳定、崩溃或者安全漏洞。让我们看下面的示例程序。
void single_func(char *src)
{
char buf[256];
int i;
for(i = 0; i <= 256; i++)
{
buf[i] = src[i];
}
}
具体来说,函数内部定义了一个长度为256
的字符数组buf
,然后使用for
循环从src
数组中复制数据到buf
数组中。循环的终止条件是i <= 256
,但是数组索引是从0
开始计数的,因此在循环的最后一次迭代时,i
的值会等于256
。
然而,buf
数组的长度是256
,有效的索引范围是0
到255
,因此在尝试将src
数组的第256
个字节复制到buf
数组时,会发生数组越界访问,导致单字节溢出。
这种情况下,会将src
数组中的第256
个字节的值写入到buf
数组之后的内存位置,这可能会影响到其他变量或者控制流的相关数据,造成程序的不稳定性或者安全漏洞。
6.2、单字节溢出漏洞利用
值得注意的是,缓冲区溢出一般是通过覆盖堆栈中的返回地址,使程序跳转到shellcode或指定程序处执行。然而在一定条件下,单字节溢出也是可以利用的,它溢出的一个字节必须与栈帧指针紧挨,就是要求必须是函数中首个变量,一般这种情况很难出现。尽管如此,程序员也应该对这种情况引起重视。
下面来看一个单字节溢出漏洞的示例代码,在学习的过程中要体会单字节溢出漏洞本质仍是缓冲区溢出漏洞的一种,要注意单字节溢出漏洞相对于其它缓冲区溢出漏洞的区别。
#include <stdio.h>
void single_byte_overflow() {
// 定义一个长度为10的字符数组
char buffer[10];
// 为了确保buffer在strcpy中以NULL结尾
buffer[10] = '\0';
// 输入的字符串长度为11
char input[11] = "ABCDEFGHIJ";
// 尝试将input字符串的内容拷贝到buffer中,可以由于buffer只有10个字节,因此尝试拷贝11个字节会导致单字节溢出
strcpy(buffer, input);
// 输出buffer中的内容
printf("Buffer: %s\n", buffer);
}
int main() {
// 调用单字节溢出函数
single_byte_overflow();
return 0;
}
在这个示例中,input
数组的长度为11,而buffer
数组的长度为10。因此,当我们尝试将input
字符串的内容复制到buffer
中时,会尝试将11个字节的内容拷贝到buffer
中,但是buffer
只能容纳10个字节,因此会发生单字节溢出。
7、格式化字符串漏洞
7.1、前置知识
7.1.1、格式化字符串
首先,我们得清楚什么是格式化字符串。在讨论这个问题之前,我们清楚,print()
和fprint()
等*print()系列函数可以按照一定的格式将数据进行输出。了解这个概念后,让我们来看一个最简单的例子:
printf("My Name is: %s", "IronmanJay");
执行该函数后将返回字符串:“My Name is: IronmanJay”。该printf()
函数的第一个参数就是格式化字符串,它来告诉程序将数据以什么格式输出。
print()
函数的一般形式为:printf("format", 输出表列);
,format
的结构为:“%[标志][输出最小宽度][.精读][长度][类型]”。其中类型有以下常见的几种:
%d
:整型输出%ld
:长整型输出%o
:以八进制数形式输出整数%x
:以十六进制数形式输出整数%u
:以十进制数输出unsigned型数据(无符号型)%c
:用来输出一个字符%s
:用来输出一个字符串%f
:用来输出实数,以小数形式输出
在控制format
参数之后,结合printf()
函数特性就可以进行相应攻击。
7.2、格式化字符串漏洞概述
格式化字符串漏洞和普通的栈溢出漏洞有相似之处,但又有所不同,这两类漏洞都是利用了程序员的疏忽大意来改变程序运行的正常流程。我们首先会介绍格式化字符串漏洞所需要的前置知识,然后会学习几种格式化字符串漏洞可能带来的几种漏洞类型。
7.2.1、数据泄露漏洞概述
C语言中的格式化函数(*printf
族函数,包括printf()
,fprintf()
,sprintf()
,snprintf()
等)允许可变参数,它根据传入的格式化字符串获知可变参数的个数和类型,并依据格式化符号进行参数的输出。
如果调用这些函数时,给出了格式化字符串,但没有提供实际的对应参数时,这些函数会将格式化字符串后面的多个栈中的内容取出作为参数,并根据格式化符号将其输出。比如,当格式化符号为%x
时以16进制的形式输出堆栈的内容,当格式化符号为%s
时则输出对应地址所指向的字符串。
下面以下述程序样本为例,来分析格式化字符串漏洞中数据泄露漏洞类型中的格式化字符串溢出的原理。
void formatstring_fun1(char *buf)
{
char mark[] = "ABCD";
printf(buf);
}
对于该函数,调用printf()
函数时如果传入%x%x…%x
,则printf()
函数会打印出堆栈中的内容,不断增加%x
的个数会逐渐显示堆栈中高地址的数据,从而导致堆栈中的数据泄露。
7.2.2、数据写入漏洞概述
格式化字符串不仅可以带来数据泄露漏洞,还会带来一种危害程度更高的漏洞,即数据写入漏洞。数据写入漏洞利用的是%n
格式符,%n
格式符的作用是将格式化函数输出字符串的长度,写入函数参数指定的位置。
值得注意的是,%n
不向printf()
函数传递格式化参数,而是令printf()
把自己到该点已打出的字符总数放到相应变元指向的整型变量中,比如:
printf("Jamsa%n", &first_count);
以上程序最终会向整型变量first_count
处写入整数5,这是因为字符串“Jamsa”的总长度为5。
此外,我们还需要关注sprintf()
函数,sprintf()
函数的作用是把格式化的数据写入某个字符串缓冲区。其函数原型为:
int sprintf(char *buffer, const char *format, [argument…]);
关于sprintf()
函数还有一点值得注意,就是对于C语言中的可变参数函数(比如sprintf()
函数),参数是从右向左入栈的。这意味着在调用sprintf()
这样的可变参数函数时,参数是从右边的第一个参数开始入栈的,直到最左边的参数。下面我们可以来看一个关于sprintf()
函数的代码示例。
int formatstring_fun2(int argc, char *argv[])
{
char buffer[100];
sprintf(buffer, argv[1]);
}
这段程序很简单,不过我们思考一个问题,如果我们调用该函数时使用aaaabbbbcc%n
作为参数,将会带来什么后果呢?
如果我们真的将aaaabbbbcc%n
作为formatstring_fun2()
函数的参数使用,那么最终数值10将被写入到地址为0x61616161(即aaaa
)的内存单元中。为什么会发生这种情况呢?我们来看一下整个程序的执行流程。
在这个函数中,sprintf(buffer, argv[1])
语句的作用是将argv[1]
中的内容格式化后写入到buffer
中。在这个例子中,argv[1]
的内容是aaaabbbbcc%n
。当sprint()
函数处理到%n
标记时,它会将已经写入到buffer
中的字符数量(整数)写入到相应地址中。
然而,在这个例子中,%n
后面没有指定地址。按照C标准的规定,如果没有指定地址,sprint()
函数会尝试将已经写入到buffer
中的字符数量写入到前面的格式化字符串中的某个参数所指向的地址。在这个例子中,%n
前面的字符串是aaaabbbbcc
,而argv[1]
正好是一个指向这个字符串的指针。
由于argv[1]
的值被解析为整数/地址(即0x61616161),故sprint()
函数会尝试将已经写入到buffer
中的字符数量(在这种情况下是10,因为aaaabbbbcc
字符串的长度为10)写入到地址为0x61616161的内存单元中。因此,整数值10最终会被写入到地址为0x61616161的内存单元中。通过这种格式化字符串的数据写入漏洞,就可以实现向任意内存写入任意数值。
7.3、格式化字符串漏洞利用
经过上面的介绍,我们发现格式化字符串可以引发多种漏洞,造成各种各样的危害。那么本章节将会通过分析实例的方式介绍如何利用上一章节介绍的各种格式化字符串类型的漏洞。
7.3.1、数据泄露漏洞利用
下面我们对一个数据泄露漏洞的利用代码进行分析,仔细体会如何利用格式化字符串漏洞对内存中的数据进行读取,从而实现数据泄露的目的。比如我们再来看一个实际的例子。
#include <stdio.h>
int main(void)
{
int a = 1;
int b = 2;
int c = 3;
char buf[] = "test";
printf("%s %d %d %d %x\n", buf, a, b, c);
return 0;
}
编译上面的代码后运行,将会打印“test 1 2 3 12C62E”。出现这种情况的原因是函数调用需要参数入栈,而printf()
函数会到入栈的位置去取参数。所以printf()
函数就通过或这种方式访问到了栈中的数据,然而这些数据本并不应该被访问到,从而就造成了数据泄露的问题。
7.3.2、数据写入漏洞利用
下面我们对一个数据写入漏洞的利用代码进行分析,仔细体会如何利用格式化字符串漏洞向内存中写入数据。比如我们再来看一个实际的例子。
#include <stdio.h>
int main()
{
int num = 66666666;
printf("Before: num = %d\n", num);
printf("%d%n\n", num, &num);
printf("After: num = %d\n", num);
}
根据前面所学的知识,让我们来分析一下这个程序。第一次printf()
将会打印num
的值,即66666666
,这没问题,第二次printf()
也会打印num
的值,不过还有一个%n
,%n
就会将num
变量的长度(即8
)写入到指定的内存地址中,故在这里我们就会将8写入到num
变量中,所以最后一次printf()
将会打印被修改后的num
变量的值,即8
。最终就实现了向内存中写入数据的操作。
现在我们已经知道可以利用%n
向内存中写入值,那如果我们写入的值(比如一个返回地址)非常大,该如何来构造这样的值呢?为了实现这个目的,我们可以通过控制字符串宽度的方式来实现。
关于通过控制打印字符串宽度的方式,就是在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出。若实际位数少于定义的宽度则补以空格或0。我们可以将刚刚的代码修改一下并看一下效果。
#include <stdio.h>
int main()
{
int num = 66666666;
printf("Before: num = %d\n", num);
printf("%100d%n\n", num, &num);
printf("After: num = %d\n", num);
}
运行以上代码,最终的打印效果如下:
Before: num = 66666666
(100个空格)66666666
After: num = 100
可以发现,我们可以通过控制打印字符串宽度的方式,来向内存中写入非常大的值。当然,我们也可以使用%02333d
这种方式,在打印数值右侧用0补齐不足位数的方法来补齐,而不是使用空格。不管使用那种方法,我们都能实现最终的目的。
8、C++虚函数漏洞
8.1、前置知识
8.1.1、多态
C++中的多态性是面向对象编程的一个重要概念,它允许不同的类对象对相同的消息做出不同的响应。多态性是通过继承和虚函数实现的。在C++中,通过以下方式实现多态性:
- 继承:子类可以继承父类的属性和方法。子类可以重写(覆盖)父类中的虚函数,也可以新增自己的函数。这使得在父类指针或引用指向子类对象时,可以根据对象的实际类型来调用相应的函数。
- 虚函数:在基类中声明虚函数,在派生类中可以选择性地重写这些函数。当通过基类指针或引用调用虚函数时,会根据对象的实际类型调用对应的派生类函数。这种机制使得程序可以在运行时确定函数调用的实际目标。
下面是一个简单的示例说明多态性的使用:
#include <iostream>
// 基类
class Animal {
public:
virtual void sound() {
std::cout << "Animal makes a sound\n";
}
};
// 派生类1
class Dog : public Animal {
public:
void sound() override {
std::cout << "Dog barks\n";
}
};
// 派生类2
class Cat : public Animal {
public:
void sound() override {
std::cout << "Cat meows\n";
}
};
int main() {
Animal* ptr;
Dog dog;
Cat cat;
ptr = &dog;
ptr->sound(); // 输出 "Dog barks"
ptr = &cat;
ptr->sound(); // 输出 "Cat meows"
return 0;
}
在这个示例中,Animal
类有一个虚函数sound()
,Dog
和Cat
类都重写了这个函数。当基类指针ptr
分别指向Dog
对象和Cat
对象时,调用sound()
函数时会根据指针指向的对象的实际类型来确定调用哪个版本的sound()
函数,从而实现了多态性。
8.1.2、虚函数
虚函数是在基类中声明为虚拟的成员函数。它允许在派生类中重新定义该函数,以便在运行时根据对象的实际类型调用相应的函数。在C++中,通过在函数声明前面加上virtual
关键字来声明虚函数。虚函数的关键特征包括:
- 动态绑定(动态多态性):当通过基类指针或引用调用虚函数时,实际调用的函数版本是根据指针或引用所指向的对象的实际类型确定的,而不是根据指针或引用的静态类型(声明类型)确定的。这使得在运行时才确定调用的函数版本,称为动态绑定或运行时多态性。
- 可重写性:虚函数可以在派生类中重新定义(覆盖)。派生类可以选择性地覆盖基类中的虚函数,提供特定于派生类的行为。
使用虚函数的主要目的是实现多态性,以便在编程时能够更灵活地处理不同类型的对象,同时提高代码的可维护性和扩展性。下面的示例演示了虚函数的使用:
#include <iostream>
// 基类
class Animal {
public:
// 声明虚函数
virtual void sound() {
std::cout << "Animal makes a sound\n";
}
};
// 派生类1
class Dog : public Animal {
public:
// 重写基类中的虚函数
void sound() override {
std::cout << "Dog barks\n";
}
};
// 派生类2
class Cat : public Animal {
public:
// 重写基类中的虚函数
void sound() override {
std::cout << "Cat meows\n";
}
};
int main() {
Animal* ptr;
Dog dog;
Cat cat;
ptr = &dog;
ptr->sound(); // 输出 "Dog barks"
ptr = &cat;
ptr->sound(); // 输出 "Cat meows"
return 0;
}
在这个示例中,Animal
类中的sound()
函数被声明为虚函数。Dog
和Cat
类分别重写了这个虚函数。在main()
函数中,通过Animal
类型的指针分别指向Dog
对象和Cat
对象,并调用sound()
函数,实际调用的是对象的类型所对应的版本,这样就实现了多态性。
8.1.3、虚表
虚表(virtual table),也称为虚函数表(virtual function table),是C++中实现多态性的一种关键机制之一。虚表是每个包含虚函数的类的实例的一部分,它存储了该类的虚函数的地址。
在C++中,当一个类包含虚函数时,编译器会为该类创建一个虚表。这个虚表是一个数组,其中每个条目对应于一个虚函数,存储着相应虚函数的地址。每个类的实例都包含一个指向其虚表的指针(通常称为虚指针),这个指针被添加到实例的内存布局中的开头或结尾,以便程序在运行时能够访问到它。
当调用一个虚函数时,实际上是通过对象的虚指针找到对应的虚表,然后在虚表中查找相应函数的地址并调用它。这使得在运行时能够根据对象的实际类型来动态地确定要调用的函数,实现了多态性。以下是一个简单示例说明虚表的工作原理:
#include <iostream>
// 基类
class Base {
public:
virtual void func1() {
std::cout << "Base::func1()\n";
}
virtual void func2() {
std::cout << "Base::func2()\n";
}
};
// 派生类
class Derived : public Base {
public:
void func1() override {
std::cout << "Derived::func1()\n";
}
void func2() override {
std::cout << "Derived::func2()\n";
}
};
int main() {
Base* ptr = new Derived(); // 使用基类指针指向派生类对象
// 调用虚函数
ptr->func1(); // 实际上调用的是 Derived::func1()
ptr->func2(); // 实际上调用的是 Derived::func2()
delete ptr; // 释放内存
return 0;
}
在这个示例中,基类Base
中有两个虚函数func1()
和func2()
。派生类Derived
重写了这两个函数。当基类指针ptr
指向派生类对象时,通过ptr
调用虚函数时,实际上会根据对象的实际类型(派生类)在虚表中查找相应的函数地址并调用,从而实现了多态性。
8.1.4、虚函数和类的关系
在面向对象编程中,虚函数是一种允许在派生类中重写的函数。它是通过在基类中将函数声明为虚函数来定义的。
当基类中的函数声明为虚函数时,派生类可以通过重写(覆盖)这个虚函数来提供自己的实现。这意味着在调用虚函数时,实际上会根据对象的动态类型(而不是静态类型)来决定调用哪个版本的函数。
类与虚函数的关系在于,虚函数允许通过类的继承和多态性来实现运行时的动态行为。通过将函数声明为虚函数,可以构建具有不同行为的对象层次结构,并在运行时根据对象的实际类型来选择执行哪个函数。下面是一个简单的示例,演示了类和虚函数的关系:
#include <iostream>
// 基类
class Base {
public:
// 虚函数
virtual void print() {
std::cout << "Base class\n";
}
};
// 派生类
class Derived : public Base {
public:
// 重写虚函数
void print() override {
std::cout << "Derived class\n";
}
};
int main() {
// 创建派生类对象
Derived derivedObj;
// 基类指针指向派生类对象
Base* basePtr = &derivedObj;
// 调用虚函数,根据对象的实际类型选择执行哪个版本的函数
basePtr->print(); // 输出 "Derived class"
return 0;
}
在这个例子中,Base
类中的print
函数被声明为虚函数,而Derived
类中的print
函数重写了基类中的虚函数。在main
函数中,我们创建了一个Derived
类的对象,并将其地址赋值给一个Base
类的指针。然后,通过基类指针调用虚函数print
,最终输出了派生类中的实现版本。这就展示了类和虚函数之间的关系,以及如何通过虚函数实现多态性。
上面我们介绍了C++的虚函数和类在代码层面(逻辑层面)的关系,下面我们再来学习C++的虚函数和类在内存中的关系(物理关系)。C++虚函数和类在内存中的位置关系如下图所示。
通过上图我们可以很容易的分辨出在C++中的虚函数和类在内存中的位置关系。具体来说,可总结为两点。
- 虚表指针保存在对象的内存空间中,紧接着虚表指针的是其它成员变量
- 虚函数入口地址被统一存放在虚表中
8.2、C++虚函数漏洞概述
根据以上分析,我们已经明白了关于多态、虚函数、虚表以及虚函数和类在内存中的关系。那问题是我们该如何利用这些概念完成对C++虚函数的漏洞攻击呢?
经过上面的学习,我们应该注意到,对于C++的对象,当其使用虚函数时,需要通过调用虚表指针找到虚表,然后从虚表中取出最终的函数入口地址进行调用。
按照以上逻辑,如果虚表里存储的虚指针被篡改,程序调用虚函数的时候就会执行篡改后的指定地址的shellcode,从而引发虚函数攻击。我们可以通过下述实例代码来体会C++虚函数漏洞。
char shellcode[] = "xFC\x68\x6A…\xA4\x8B\x42\x00";
class Failwest{
public:
char buf[200];
virtual void test(void){
cout<< "Class Vtable::test()" <<endl;
};
};
Failwest overflow, *p;
void main(void){
char *p_vtable;
p_vtable = overflow.buf - 4;
int len = strlen(shellcode);
p_vtable[0] = 0x54;
p_vtable[1] = 0x8c;
p_vtable[2] = 0x42;
p_vtable[3] = 0x00;
strcpy(overflow.buf, shellcode);
p = &overflow;
p->test();
}
这段代码的逻辑还是比较好理解的,即定义了一个名为“Failwest”的类,其中包含一个字符数组buf
和一个虚函数test()
。在main()
函数中,通过修改Failwest
对象的虚表指针,从而将其指向特定地址,然后调用虚函数test()
,从而执行相应地址处的指令序列。
很明显,我们这里的“特定地址”指的就是恶意代码的地址,最终调用test()
虚函数来执行恶意代码。具体来说,该段代码进行C++虚函数漏洞攻击的逻辑为。
- 获取虚表指针
虚表指针位于对象overflow
成员变量char buf[200]
之前,程序中通过p_vtable = overflow.buf - 4
定位到虚表指针 - 覆盖虚函数地址
当我们获取到虚表指针之后,就可以将原有指向的虚函数地址修改为目标地址(即overflow.buf
的地址) - 准备恶意代码
使用strcpy(overflow.buf, shellcode)
将恶意代码(即shellcode
)存储到overflow.buf
- 调用虚函数
此时我们调用test()
虚函数的时候,由于我们已经将虚函数地址覆盖为恶意代码的地址,故将回去执行恶意代码
上述逻辑比较清楚,说白了就是通过覆盖虚函数的地址,以调用我们植入的恶意代码。当然,这只是理想状态下的理论,实际我们应该如何利用呢?请参考下一章节。
8.3、C++虚函数漏洞利用
本章我们将会介绍如何利用C++虚函数漏洞,我们还是沿用上一章节的代码示例。不过此时我们假设虚表地址为0x00428C54
,overflow.buf
的地址为0x00428BA4
。此外,我们还假设shellcode
的最后四个字节就是虚表。
char shellcode[] = "xFC\x68\x6A…\xA4\x8B\x42\x00";
class Failwest{
public:
char buf[200];
virtual void test(void){
cout<< "Class Vtable::test()" <<endl;
};
};
Failwest overflow, *p;
void main(void){
char *p_vtable;
p_vtable = overflow.buf - 4;
int len = strlen(shellcode);
p_vtable[0] = 0x54;
p_vtable[1] = 0x8c;
p_vtable[2] = 0x42;
p_vtable[3] = 0x00;
strcpy(overflow.buf, shellcode);
p = &overflow;
p->test();
}
有了以上信息,我们就可以利用该C++虚函数漏洞,整个利用过程就是之前章节介绍的方法。以下就是漏洞利用的整个逻辑。
- 修改虚表地址
将对象overflow
的虚表地址修改为数组shellcode
的最后四个字节的地址,因为数组shellcode
的倒数四个字节就是虚表地址,此时我们已经将虚表地址指针修改为我们自定义的虚表地址 - 修改虚函数指针
修改数组shellcode
最后四个字节(虚表)来指向overflow.buf
的内存地址(虚函数地址),即让虚函数指针指向保存shellcode
的overflow.buf
(恶意代码)区域 - 执行恶意代码(漏洞利用)
此时,当我们调用test()
虚函数后,就会通过我们自定义的虚表找到刚刚设计好的虚函数,即恶意代码,从而引发漏洞
可以发现,C++虚函数漏洞利用的核心就是修改虚表地址和修改虚函数地址,这样当我们使用虚函数的时候,就会去执行我们自定义的恶意代码了。
总结
以上就是本篇博文的全部内容。由于笔者能力有限,出现纰漏在所难免,若各位读者有所想法或建议,可以及时与我讨论。我们下篇博客见!