内存管理中free的行为

说明

首先,我不是研究linux内核的。所以这篇文章也不会过于深入的探讨linux中有关内存管理的行为。写这篇文章的目的只是为了介绍一下在堆溢出漏洞利用当中可能会涉及到的有关free的一些行为,以及一些相关的检查。

可以在这里看到libc中内存管理的源代码。
malloc.c
那么,我们开始吧

内存结构

首先是申请的内存块在内存中的结构。所有malloc等等函数申请的内存都会被系统申请在一个叫做堆的地方,其实也就是一块比较大的内存。然后当程序需要内存时,系统就会从堆里面找一块还没有被使用的内存出来告诉程序,这一块内存给你用。然后如果你使用了超出你申请范围的内存。如果被系统发现了,会强制结束程序

一般的情况,如果程序连续申请内存的话,操作系统会按次序的在堆里码放内存,一块紧挨着一块。中间没有空隙。打个比方吧,就像一座旅馆一样,所有需要的入住的旅客会老板从0号房开始依次安排下去。

然后如果申请了一大堆内存块,然后将其中的某几块给释放掉的。就像旅店里面有几间房子的人离开了一样,这个时候老板需要知道哪几间房子是空的,以便让新来的人住进去。

那么操作系统是怎么知道那些内存被释放了呢?先看看内存中chunk的结构吧
复制自glibc中源码,并翻译了注释

1
2
3
4
5
6
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* 前一个chunk的大小 (如果前一个已经被free)*/
INTERNAL_SIZE_T size; /* 字节表示的chunk大小,包括chunk头 */
struct malloc_chunk* fd; /* 双向链表 -- 只有在被free后才存在 */
struct malloc_chunk* bk;
};

解释一下,这里的的结构是chunk头部分的内容。在内存块free之前,最后两个指针是不存在的,只有前2项的内容。然后在第二项之后就是可供程序使用的内存了,也就是malloc返回的那个指针指向的地址。而在内存被释放之后,系统在内存中添加最后的这两个指针。这两个指针的作用是构成双向链表,它们分别指向了前一个和后一个已经被释放的空闲内存。(顺带一体,这是个环形的双向链表,收尾是相接的)

当程序申请一块内存的时候,系统会遍历这个由空闲内存构成的双向链表。如果有合适(>=)的空闲内存,就会将它(或者一部分,具体没有研究)分配给程序。

然后是prev_size和size的作用,prev_size是前一个chunk的大小,值得注意的是如果前一个chunk在使用中,这里会是0,唯有前一个chunk已经被释放的情况下这里才会数值。所以prev_size应该叫前一个空闲堆快的大小。然后是size,这个就是当前chunk的大小,包括给程序使用的和chunk头的大小。然后因为所有的chunk的大小都是4字节对齐的,所以size最低3位是0。所以被操作系统拿来当做flag标志位。(最低位:指示前一个chunk是否正在使用;倒数第二位:指示这个chunk是否是通过mmap方式产生的;倒数第三位:这个chunk是否属于一个线程的arena)这里我们只关心最低位的涵义,它指示前一个chunk是否是空闲的。这个flag加上prev_size一起作为系统判断一块内存是否正在使用,从哪里开始的依据。

这里要注意的是,只有大小合适的内存才会用这种方法分配,太小的内存会用fastbins的方法分配,有兴趣的可以自己了解一下(其实是不会)。这里给出会使用fastbins的阈值。32位操作系统上是0x40,64位操作系统上是0x80。然后是chunk头中几个数据的大小,INTERNAL_SIZE_T其实就是unsigned long型的数据,而另外2个是指针这个不用多说。说以chunk头的大小也和操作系统的位数有关。(某人被坑过)

free行为

然后我们来看看free的源代码吧。在刚才我提供的源代码的3829行开始到4100行结束共200行左右的代码就是free函数的源代码→_→还不只。当然其实只有3978-4040中的代码是我们实际要看的,其他的都是fastbins和mmap等内存的free,我们不用关心。

首先是一大堆的检查,这个咱不管。然后就是各种操作,这里选取最关心的,和堆溢出有关的部分来说(有兴趣的可以慢慢看)。free时的主要操作是这样的,先看看这个被free的内存块的前后2个内存块是否是空闲的。通过当前chunk的flag和下下个chunk的flag来查看上一个和下一个chunk是否是空闲的。如果空闲,会先把他们从空闲链表中删除。从链表中删除的工作是通过一个unlink的宏来完成的。关于这个unkink的宏我们待会再来说明。

主要的行为操作分别是在4011行和4037行,先看4011行。

1
clear_inuse_bit_at_offset(nextchunk, 0);

因为是宏,所以先将他宏展开

1
(((mchunkptr) (((char *) (nextchunk)) + (0)))->size &= ~(0x1))

这一行的作用就是将当前被free的内存块的下一个内存块的flag的第一位清空为0,指示当前内存块已经被free。

然后就是4037行的代码

1
set_foot(p, size);

宏定义的非常彻底,所以也要宏展开再看

1
(((mchunkptr) ((char *) (p) + (size)))->prev_size = (size))

把下一个内存块的prev_size更改为当前内存块,或者是已经合并了的更大内存块的大小。然后就是一些各种各样的别的操作,就不详细解释了。

然后我们来仔细看看unlink的宏代码(毕竟是为了讲unlink漏洞
直接把unlink宏内容贴出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define unlink(AV, P, BK, FD) {
FD = P->fd;
BK = P->bk;
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
else {
FD->bk = BK;
BK->fd = FD;
// if (!in_smallbin_range (P->size)
// && __builtin_expect (P->fd_nextsize != NULL, 0)) {
// if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
// || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
// malloc_printerr (check_action,
// "corrupted double-linked list (not small)",
// P, AV);
// if (FD->fd_nextsize == NULL) {
// if (P->fd_nextsize == P)
// FD->fd_nextsize = FD->bk_nextsize = FD;
// else {
// FD->fd_nextsize = P->fd_nextsize;
// FD->bk_nextsize = P->bk_nextsize;
// P->fd_nextsize->bk_nextsize = FD;
// P->bk_nextsize->fd_nextsize = FD;
// }
// } else {
// P->fd_nextsize->bk_nextsize = P->bk_nextsize;
// P->bk_nextsize->fd_nextsize = P->fd_nextsize;
// }
// }
}
}

当然很多的代码其实是在当内存块的大小过大的时候才会执行的代码(就是被我注释掉的那一部分),在内存块不大的情况下我们不需要关心,最主要的代码就是下面4行

1
2
3
4
FD = P->fd;
BK = P->bk;
FD->bk = BK;
BK->fd = FD;

这里在宏中传入参数FD,BK,P分别是指向后一个,前一个,还有当前的chunk(当然,是从chunk头而不是data段开始的)。很经典的链表节点删除,当然万一被溢出覆盖的的话就糟糕了。不过也是有防止溢出的检测代码存在的,就是这个if判断

1
2
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);

当前内存块的上一块内存中指向下一块内存指针和当前内存块的下一块内存块的指向上一块内存块的指针如果不是指向当前内存块的话,程序就会崩溃退出。233333直接看代码也比解释简单。