0x01 前言
- 释放重引用的英文名名称是 Use After Free,也就是著名的 UAF 漏洞的全称。从字面意思可以看出 After Free 就是释放后的内存空间,Use 就是使用的意思,使用释放后的内存空间。该漏洞的历史可以追溯到 2005 年,当时第一个 UAF 漏洞编号为 CVE-2005-4360,到 2008 年之后结合有关技术已经能稳定的利用了
- 鉴于 UAF 漏洞的特殊性(需要精确的控制堆内存空间的覆盖),所以该漏洞较为成功的利用多出现在浏览器中,因为浏览器可以运行 JavaScript 代码来申请堆空间,而其他软件利用起来则非常的困难
0x02 UAF 漏洞原理
- 很多程序员在编写程序的时候会使用 new 或者 malloc 等方式动态的申请堆空间来存放数据,之后再使用 delete 或者 free 来释放掉。一般来说这样的方式并不会出现什么问题,但是如果程序员粗心,二次使用释放后堆空间的悬挂指针,那么就会造成安全隐患
- 举个例子
#include <stdio.h>
#define size 32
int main(int argc, char **argv)
{
// 申请两个 char 类型的指针
char *buf1; char *buf2;
// 动态申请一个大小为 size 的堆空间
buf1 = (char *)malloc(size);
// 打印出 buf1 的指针指向的地址
printf("buf1: 0x%p
", buf1);
// 释放 buf1 的堆
free(buf1);
// 动态申请一个大小为 size 的堆空间
buf2 = (char *)malloc(size);
// 打印出 buf2 的指针指向的地址
printf("buf2: 0x%p
", buf2);
// 将 buf2 的堆空间赋值为 0
memset(buf2, 0, size);
printf("buf2: %d
", *buf2);
printf("=== User After Free ===
");
// 将 buf1 指向的内存拷贝为 hack
strncpy(buf1, "hack", 5);
printf("buf2: %s
", buf2);
// 释放 buf2 的堆空间
free(buf2);
return 0;
}
- 首先这个程序首先使用 malloc 申请了第一个堆空间 buf1,之后释放了第一个堆空间的地址(注意是释放第一个堆空间的地址,并没有销毁第一个堆空间的指针,所以在释放第一个堆空间地址之后该指针就会变为悬挂指针,或者说老一辈的 C++ 程序员称之为野指针),然后再申请了第二个堆空间 buf2 并且使用 strncpy 函数将第一个堆空间指针 buf1 赋值为字符串 hack (这个过程就称之为释放重引用) ,但是由于 ‘‘占坑效应’’,实际上改变的是第二个堆空间的内存数据
- 下面就是运行的结果,由于占坑效应,两个堆空间的地址是一样的,并且在释放重引用之后,第二个申请的堆空间内存数据被改为了 hack
注:堆空间的占坑是指在第一个堆空间申请并释放之后,申请第二个堆空间会占用第一个堆空间的地址,简单来说就是两个堆空间的地址是一样的,申请的堆空间释放之后留给下面申请的堆空间使用。当然这个也是有条件的,需要依据操作系统的分配原则,如果申请的堆空间大小一样的话,占坑的成功率会大很多
0x03 结合虚表指针执行任意代码
- 以上这个例子只是证明了释放重引用的原理和可以通过悬挂指针修改内存数据,但是并不可以执行任意代码啊。要想执行任意代码还需要结合一样东西,那就是虚标指针,虚标指针是类中的一个概念,类的初始化会自动的添加虚表指针,并且当调用类中的虚函数时,会查阅虚表指针,如果释放重引用覆盖的内存空间的地址刚好为虚表指针的话,那么就可以控制虚表指针去执行任意代码,例子如下:
#include <iostream>
#include <stdio.h>
using namespace std;
class CTest
{
int one = 10;
public:
virtual void vFun1();
virtual void vFun2();
int getOne()
{
return one;
}
};
void CTest::vFun1()
{
cout << "I am vFun1" << endl;
}
void CTest::vFun2()
{
cout << "I am vFun2" << endl;
}
int main(int argc, char **argv)
{
CTest *test1 = new CTest;
cout << "test1 指针指向的堆地址为: " << test1 << endl;
delete test1;
CTest *test2 = new CTest;
cout << "test2 指针指向的堆地址为: " << test2 << endl;
test2->vFun1();
delete test2;
return 0;
}
- 首先动态的申请了一个 CTest 对象 test1,之后释放它,然后再动态申请一个 CTest 对象 test2,最后执行 test2 对象的 vFun1 函数
- 由于占坑效应,所以两次申请的堆空间地址是一样的:
- 开启断点调试,看看虚表指针是如何运作的
- 首先程序会将 8 压入栈中,8 表示的是类的大小,然后调用 operator new 函数申请堆空间 test1,调用完成之后堆空间的首地址会储存再 eax 中
- 这个可以看出刚刚申请的堆空间的首地址为 0x0085e6c0
- 之后调用类的构造函数,其中类对象的首地址会储存在 ecx 中,继续向下调试
- 到这里时,test1 所指向的堆空间已经被 operator delete 函数释放了,同样的 ecx 中储存的是 test1 对象的首地址
- 之后再以同样的方式申请堆空间 test2
- 最后调用 test2 对象的虚函数,具体步骤如下:
- 第一步将 test2 对象地址的头 4 个字节指向的地址的值放入 edx 当中;第二步将 test2 对象的首地址存放在 ecx 当中;第三步将之前的 edx 的值放入 eax 中,call eax
- 这个是 test2 对象的内存空间 0x00590498,头 4 个字节就是虚表指针
- 查询一下虚表,看看是否与 call eax 的值相对应,果然与 call eax 的值相对应
0x04 总结
- 从以上的实验可以看出申请堆空间的首地址会以指针方式存储,而申请的堆空间首地址就是类的首地址,比如 CTest *test1 = new CTest。如果类中有虚函数,那么类的头 4 个字节一定会是虚表指针,而且调用类的虚函数时会将虚表指针指向的值作为函数进行调用,也就是 call eax,这样的话就可以使用释放过的 test1 这个悬挂指针将 test2 的虚表指针进行覆盖,指向我们想要的数据,从而控制 call 指令,执行任意代码
int main(int argc, char **argv)
{
CTest *test1 = new CTest;
cout << "test1 指针指向的堆地址为: " << test1 << endl;
delete test1;
CTest *test2 = new CTest;
cout << "test2 指针指向的堆地址为: " << test2 << endl;
// 释放重引用 test1 将 test2 的头 4 个字节覆盖掉,达到执行任意代码的目的
test2->vFun1();
delete test2;
return 0;
}
以上对释放重引用漏洞的实验分析到此结束,如有错误,欢迎指正
参考资料:0day 安全 + 漏洞战争