0x10 UAF(Use After Free) 漏洞原理
这里,需要先介绍一下堆分配内存的原则。ptmalloc 是 glibc 的堆管理器,前身是 dlmalloc,Linux 中进程分配内存的两种方式:brk
和 mmap
。当程序使用 malloc 申请内存的时候,如果小于 128K,使用 brk 方式,将数据段(.data)的最高地址指针_edata往高地址推;如果大于 128K,使用 mmap 方式,堆和栈之间找一块空闲内存分配。同样的,当用户释放内存时,ptmalloc
也不会立马释放空间,当应用程序调用 free() 释放内存时,如果内存块小于256kb,dlmalloc将内存块标记为空闲状态。
Use After Free 就是其字面所表达的意思,当一个内存块被释放之后再次被使用,可能会导致意想不到的后果。分为以下三种情况1
- 内存块被释放后,其对应的指针被设置为 NULL , 然后再次使用,自然程序会崩溃。
- 内存块被释放后,其对应的指针没有被设置为 NULL ,然后在它下一次被使用之前,没有代码对这块内存块进行修改,那么程序很有可能可以正常运转。
- 内存块被释放后,其对应的指针没有被设置为 NULL,但是在它下一次使用之前,有代码对这块内存进行了修改,那么当程序再次使用这块内存时,就很有可能会出现奇怪的问题。
学过 C 语言的人应该都知道,垂悬指针(dangling pointer)是指指向的内存被 free,但是没有置空,UAF 如果想利用的话,就是垂悬指针被再次使用。
如下所示,展示了一种简单的情况,p1 被释放之后并没有置空,随后 p2 又申请了与 p1 曾申请的大小一样的内存,p2 指向的内存被修改之后,我们发现悬垂指针 p1 指向的内存也被修改了。情况一如下
#include <stdio.h>
#include <stdlib.h>
int main()
{
int * p1;
int * p2;
p1 = (int *)malloc(sizeof(int));
printf("p1 = %p, * p1 = %d\n", p1, *p1);
free(p1);
p2 = (int *)malloc(sizeof(int));
*p2 = 20;
printf("p2 = %p, * p2 = %d\n", p2, *p2);
printf("p1 = %p, * p1 = %d\n", p1, *p1);
free(p2);
return 0;
}
也就是说,p1 被释放后,不代表是野指针,而且仍然能够使用,如果置空后,又被使用了呢?
#include <stdio.h>
#include <stdlib.h>
int main()
{
int * p1;
int * p2;
p1 = (int *)malloc(sizeof(int));
printf("p1 = %p, * p1 = %d\n", p1, *p1);
free(p1);
* p1 = 20;
printf("p1 = %p, * p1 = %d\n", p1, *p1);
p1 = NULL;
* p1 = 30;
printf("p1 = %p, * p1 = %d\n", p1, *p1);
return 0;
}
可以看到,free 后,p1 仍然可以被赋值,但是被置空后,就会崩溃
0x20 UAF 漏洞利用案例分析
0x21 [Toddler’s Bottle]-uaf
题目如下
Mommy, what is Use After Free bug?
ssh uaf@pwnable.kr -p2222 (pw:guest)
将代码复制下来,发现是 c++,代码如下
class Human{
private:
virtual void give_shell(){
system("/bin/sh");
}
protected:
int age;
string name;
public:
virtual void introduce(){
cout << "My name is " << name << endl;
cout << "I am " << age << " years old" << endl;
}
};
class Man: public Human{
public:
Man(string name, int age){
this->name = name;
this->age = age;
}
virtual void introduce(){
Human::introduce();
cout << "I am a nice guy!" << endl;
}
};
class Woman: public Human{
public:
Woman(string name, int age){
this->name = name;
this->age = age;
}
virtual void introduce(){
Human::introduce();
cout << "I am a cute girl!" << endl;
}
};
int main(int argc, char* argv[]){
Human* m = new Man("Jack", 25);
Human* w = new Woman("Jill", 21);
size_t len;
char* data;
unsigned int op;
while(1){
cout << "1. use\n2. after\n3. free\n";
cin >> op;
switch(op){
case 1:
m->introduce();
w->introduce();
break;
case 2:
len = atoi(argv[1]);
data = new char[len];
read(open(argv[2], O_RDONLY), data, len);
cout << "your data is allocated" << endl;
break;
case 3:
delete m;
delete w;
break;
default:
break;
}
}
return 0;
}
0x22 关于 C++ 内存分布
该代码有三个类,woman 和 man 继承自 human,每个类都有虚函数,子类重写了父类的虚函数,关于虚函数,请参考 从Java角度说起C++虚函数
在C++中,每一个含有虚函数的类都会有一个虚函数表,简称虚表。与之对应的,每一个对象都会有其专属的虚表指针指向这个虚表
虚表
这里,我们还需要知道对象的内存分布情况: 对象在内存开辟空间后,按照虚表指针、继承自基类的成员、类自身的成员的顺序进行存储。(如果是多重继承和虚继承,可能存在多个虚表指针)。当然,还需要了解 C++ 创建对象的调用栈
--> std::allocator< char >::allocator(void)
--> std::allocator< char >>::basic_string()
--> operator new(ulong)
--> Class Conductor
--> std::allocator< char >>::~basic_string()
--> std::allocator< char >::~allocator(void)
如下图所示
我们有如下结论 2 :
- 每个类都有虚指针和虚表;
- 如果不是虚继承,那么子类将父类的虚指针继承下来,并指向自身的虚表(发生在对象构造时)。有多少个虚函数,虚表里面的项就会有多少。多重继承时,可能存在多个的基类虚表与虚指针;
- 如果是虚继承,那么子类会有两份虚指针,一份指向自己的虚表,另一份指向虚基表,多重继承时虚基表与虚基表指针有且只有一份。
0x23 调试
因此,我们的解题思路就是,对二进制文件进行逆向,找到虚表的地址,类似于 GOT 覆写技术浅析与实际应用,利用 UAF 漏洞,修改该虚函数表的地址即可。
C++ 函数调用栈设置断点,观察情况。operator new()
函数的参数 0x18,表示为 man 对象创建 24B 的内存空间。Man::Man(std::string,int)
函数初始化该内存
看一下m对象的内存空间具体分布。依次存放了虚表指针,即指向类 man 的虚函数指针;继承自父类的类成员变量 age、类成员变量 name,这里 name 是一个指针,存放的是 name 的地址。你们会发现,这里只存放了虚表的一个地址,并没有看到 introduce
的地址,这个后面在说。
由于二进制文件并没有开启地址随机化,所以,内存中的虚表指针的位置,就是 IDA 逆向得到的虚表位置,如下图所示
0x24 关于堆的 chunk 与 bin
这里还需要知道堆的一些知识,更多详情请访问:https://wiki.x10sec.org/pwn/heap/heap_structure/
在程序执行中,由 malloc 申请的内存,称为 chunk。这块内存在 ptmalloc 内部用 malloc_chunk 结构体来表示。当程序申请的 chunk 被 free 后,会被加入到相应的空闲管理列表中。
用户释放掉的 chunk 不会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区域中的空闲的 chunk。当用户再一次请求分配内存时,ptmalloc 分配器会试图在空闲的chunk中挑选一块合适的给用户。这样可以避免频繁的系统调用,降低内存分配的开销 3 。
而 bin 就是这样一种数据结构,用来存放返回的 chunk。根据空闲的 chunk 的大小以及使用状态将 chunk 初步分为4类:fast bins,small bins,large bins,unsorted bin。详情我们不说,因为堆涉及的东西实在太多了,这里只介绍与本次 UAF 漏洞利用有关的知识。
大多数程序经常会申请以及释放一些比较小的内存块。如果将一些较小的 chunk 释放之后发现存在与之相邻的空闲的 chunk 并将它们进行合并,那么当下一次再次申请相应大小的 chunk 时,就需要对 chunk 进行分割,这样就大大降低了堆的利用效率。因为我们把大部分时间花在了合并、分割以及中间检查的过程中。因此,ptmalloc 中专门设计了 fast bin
因此,大部分时候我们调用的都是 fastbin 来存放被释放的 chunk,也就是说,为了更加高效地利用 fast bin,glibc 采用单向链表对其中的每个 bin 进行组织,并且每个 bin 采取 LIFO 策略,最近释放的 chunk 会更早地被分配,所以会更加适合于局部性。也就是说,当用户需要的 chunk 的大小小于 fastbin 的最大大小时, ptmalloc 会首先判断 fastbin 中相应的 bin 中是否有对应大小的空闲块,如果有的话,就会直接从这个 bin 中获取 chunk。
对于本例来说,如果我们选择 3,按照代码来说,先释放 m,后释放 w,这时,它们都会被 ptmalloc 堆管理器放进 fastbin 中,如果我们再选择 2,分配小于 0x18 的内存空间,根据我们刚刚所说的,LIFO 策略,堆管理器直接利用 w 释放的 chunk。
0x25 漏洞利用
因此,我们先要释放程序在刚开始运行时,创建的对象 m 和 w,所以先选择 3;然后需要选择 2,进行覆盖,这里当然是要覆盖 w 内存空间中的虚函数 Woman::introduce(void)
,将其改为 Human::give_shell(void)
指针通过一个 +8 的操作调用 introduce
的时候。那么我们如果实现将指针减8,那么当调用introduce的时候加8,那么结果就是+0,即没有偏移,那就是giveshell的地址,刚好完成了我们的要求。ida中.rodata区段是存放虚表的地方。
回到 0x22
所述,无论是 m 还是 w 对象,其内存空间中,最先放入的是虚表的地址(对象的内存并不会把虚表中的每个虚函数地址都存放),实际上,每个 m 和 w 都有两个虚函数,give_shell
在前,introduce
在后。内存访问 introduce
是通过整个虚表首地址 + 偏移地址 的形式,来找到正确虚函数的位置。而不是像 GOT 表那样,直接定位函数在 GOT 表中的地址。所以,这里要更改 w (或者 m)对象的内存第一位的数据,也就是虚表的基地址。而不是想当然的,认为将其改为多少,访问的就是哪个函数。
0x401550 - 8 = 0x401548
这里还有一点要注意的是,需要覆盖两次,即选择两次 2,如果只选择一次,直接选择 1,这时候,虽然 w 对象也被释放了,但是被覆盖了,依然还是能够输出的。然而 m 对象已经释放,打印会出错的。
0x30 总结
UAF 漏洞主要是因为程序员对已经释放的内存操作不当,这类问题其实也是堆的问题,在一些较为复杂的代码中,数见不鲜,但其利用往往不是那么容易的,需要结合其他漏洞,达到组合的效果。本文中的例子,还需要我们掌握 C++ 的内存分配原理,还有一些虚函数的知识,才能够将问题迎刃而解。
来源:CSDN
作者:姑苏城外的江枫
链接:https://blog.csdn.net/song_lee/article/details/104559033