此篇文章是我参与 #青训营笔记创作活动 的第7篇笔记
主要是简单记录一下自己在深入学习堆时遇到的一些小要点,
只是个人的一个学习记录,水平不高还望大佬们包容。
堆的概念
在程序运行过程中,堆可以提供动态分配的内存,允许程序申请大小未知的内存。堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址方向增长。我们一般称管理堆的那部分程序为堆管理器。(from wiki)
堆的实现
-
dlmalloc : Genral purpose allocator
-
jemalloc : Freebsd and Firefox
-
tcmalloc : Google
-
libumen : Solaris
-
ptmalloc2 : glibc
-
堆的实现以ptmalloc2中堆的实现为主
-
在 glibc-2.3.x. 之后,glibc 中集成了ptmalloc2,可以下载glibc源码查看ptmalloc
-
查看glibc版本
-
# shell: ldd --version # 输出: ldd (Ubuntu GLIBC 2.35-0ubuntu3.1) 2.35
-
-
堆管理器
堆管理器位于程序与内核之间,主要做两件事:
-
响应用户申请内存的请求 (请求堆)
-
为了保持内存管理的高效性,内核一般都会预先分配很大的一块连续的内存(虚拟内存),即 top _chunk
- 只有当真正访问一个地址的时候,系统才会在虚拟内存和物理页面的映射关系。
- 所以这里的内存其实只是虚拟内存。只有当用户使用到相应的内存时,系统才会真正分配物理内存给用户使用。
-
只有当出现了堆空间不足的情况,堆管理器才会再次与操作系统进行交互
- 新创建一个top_chunk,并且把原来的top_chunk分配到unsorted_bin 里面
-
-
管理用户所释放的内存 (释放堆)
- 用户释放的内存并不是直接返还给操作系统,而是由堆管理器进行管理,除了fastbin以外大都由bins来管理
- 这些释放的内存可以用来响应用户新申请的内存的请求。
系统所调用的函数
malloc和free在动态申请或释放内存时,主要是调用(s)brk和mmap,unmmap函数实现的。
- (s)brk函数机制
# include <stdio.h>
# include <unistd.h>
# icclude <sys/types.h>
int main()
{
void *cuur_bkr,*tmp_brk = NULL;
printf("%d\n",getid());
tm_brk = curr_brk = sbrk(0);//给当前程序一个brk
printf("%p\n",curr_brk);
getchar();
brk(curr_brk+4096);//设置结尾位置,即分配了4096字节的堆块
curr_brk=sbrk(0);
printf("%p\n",curr_brk);
getchar();
brk(tmp_brk);
curr_brk=sbrk(0);
printf("%p\n",curr_brk);
getchar();
return 0;
}
-
初始时,堆的起始地址start_brk以及堆的当前末尾brk指向同一地址。根据是否开启ALSR,两者的具体位置会有所不同。
- 不开启ASMR时,start_brk以及brk会指向data/bss段的结尾。
- 开启ASMR时,start_brk以及brk也会指向同一位置,只是这个位置是在data/bss段结尾后的随机偏移处。
- sbrk创建的chunk紧邻数据段
- mmap函数机制
- malloc会使用mmap来创建独立的匿名映射段。
- 匿名映射的目的主要是可以申请以0填充的内存,并且这块内存仅被调用进程所使用,这块内存为系统随机分配。
- munmap用于释放内存。
- mmap创建的chunk紧邻libc
多线程支持
- 在原来的dlmalloc实现中,当两个线程同时要申请内存时,只有一个线程可以进入临界区申请内存,而另外一个线程必须等待直到临界区中不再有线程。
- 这是因为所有的线程共享一个堆。
- 在glibc和ptmalloc实现中,支持了多线程的快速访问,在新的实现中,所有的线程共享多个堆。
详见 wiki
堆的微观结构
先补充两句,个人认为从总体上看可以把堆的数据结构分为微观结构和宏观结构:
-
微观结构主要是malloc_chunk的数据与结构:
- prev_size
- size
- fd,bk
- fd_nextsize, bk_nextsize
-
宏观结构包括堆块的宏观信息,可以使我们更好地了解glibc的堆管理实现,宏观结构分为以下几个部分:
-
arena & main_arena:可以看作是堆块的管理器,但其实就是堆内存本身
-
bin:用链表结构管理被free的malloc_chunk(堆块)
-
top_chunk:处于一个arena的最顶部(即最高内存地址处)、不属于任何bin的一片内存
-
malloc_state:管理 arena 的核心结构,包含堆的状态信息、bins 链表等
- main arena 对应的 malloc state 结构存储在 glibc 全局变量中
- 其他线程 arena 对应的 malloc_state 存储在 arena 本身中
-
SIZE_SZ 与 size_t
先补充一个小知识,关于堆中结构我们常常会看到SIZE_SZ 与 size_t,但实际上,在一般场景里他们两个的值是一样的,即在32位系统中是8字节,在64位系统中是16字节。
下载glibc源码查看ptmalloc可以看到:
#ifndef INTERNAL_SIZE_T
#define INTERNAL_SIZE_T size_t
#endif
/* The corresponding word size. */
#define SIZE_SZ (sizeof (INTERNAL_SIZE_T))
- INTERNAL_SIZE_T:size_t,为32/64位整数(8字节/16字节)
- SIZE_SZ:同size_t,8字节/16字节
malloc_chunk
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* 如果前面一个堆块是空闲的则表示前一个堆块的大小,否则无意义 */
INTERNAL_SIZE_T size; /* 当前chunk的大小,由于对齐的原因所以低三位作为flag*/
/*######################
1.真正的内存从这里开始分配
2.malloc之后这些指针没有用,这时存放的是数据
3.只有在free之后才有效。
########################*/
struct malloc_chunk* fd; /* 当chunk空闲时才有意义,记录后一个空闲chunk的地址 */
struct malloc_chunk* bk; /* 同上,记录前一个空闲chunk的地址 */
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /*当前chunk为largebin时才有意义,指向比当前chunk大的第一个空闲chunk */
struct malloc_chunk* bk_nextsize;/*指向比当前chunk小的第一个空闲堆块*/
};
每个字段的具体的解释如下(from wiki and zhihu)
prev_size
-
如果该 chunk 的物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。
-
否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk 。
- 当申请的内存大小对 2*SIZE_SZ 取余之后小于等于 size_t 的话就可以用它的下一个 chunk 的 prev_size
- 比如:64位下 malloc(0x55), 0x58 mod 0x10 还差 0x8 <= 0x8字节,那他就可以用后面一个 chunk 的 prev_size,最后加上 chunk header 大小是 0x60(所以下图为6行)
- 还是 64 位下,如果大小是 0x59 的话 mod 0x10之后还差 0x9 > 0x8字节,那就不够用了,只能多申请一块,最后加上 chunk header 用了 0x70(所以下图为7行)
-
malloc(0x58)会分配0x60的内存
-
malloc(0x59)会分配0x70的内存
size
-
该 chunk 的大小,大小必须是 2 * SIZE_SZ 的整数倍。
-
SIZE_SZ 在32位系统下是 32位 即4个字节大小,在64位系统下是 64位 即8个字节大小
-
所以64位chunk的size必须是16字节对齐,32位chunk的size必须是8 字节对齐
-
64位 低4位没用 11110000
-
32位 低3位没用 11111000
-
关于16字节对齐:
- 1字节为8位,即2个16进制位(如 0xff ),所以16字节为32个16进制位
- 在gdb中体现为 0x0011223344556677 0x8899aabbccddeeff 为16字节
-
-
NON_MAIN_ARENA,A:倒数第三位表示当前chunk属于主分配区(0)还是非主分配区(1)
-
IS_MAPPED,M:倒数第二位表示当前chunk是从mmap(1)[多线程]分配的,还是从brk(0)[子线程]分配的
-
PREV_INUSE, P:最低位表示前一个 chunk 块是否被分配。
- 一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。
- 当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
- 对于fastbin的堆块,不管前面还有没有被分配的chunk,P位都为1 。
一些基础的攻击思路:
- chunk1的数据有效区域覆盖到chunk2的prev_size位,并且chunk2的size位的prev_inuse被覆盖为0。系统认为chunk2之前的chunk1已经未在使用了。
- 当free(chunk2)的时候,系统会将chunk2与chunk2中prev_size大小的空间合并到bins。
- 我们可以通过改变chunk2的prev_size的内容,操纵向前合并的大小。
- 造成的问题:overlap(堆块重叠),chunk1被释放了,但是我们可以操纵修改它(堆利用的核心思想),从而修改bins链的内容,泄露其中的地址。
- 形成的攻击:fastbin ---> fd ---> main_arena ---> 分配新的堆块,我们通过修改chunk1的fd内容,达到分配任意内存的目的,造成fastbin attack。
- 最小堆原则 : malloc(0)会分配0x20的空间,prev_size + size + 数据对齐的0x10字节
fd,bk
- chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下
- fd 指向下一个(非物理相邻)空闲的 chunk
- bk 指向上一个(非物理相邻)空闲的 chunk
- 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理
fd_nextsize, bk_nextsize
- 也是只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。
- fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
- bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
- 一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。
一些细节
一个已经分配的 chunk 的样子如下。我们称前两个字段称为 chunk header,后面的部分称为 user data。每次 malloc 申请得到的内存指针,其实指向 user data 的起始处。
当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size 域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。
# 使用中的chunk结构
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) | # 前一个堆未被使用时可以知道其大小
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P| # 重点在P位
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. . # 没有fd和bk指针
. (malloc_usable_size() bytes) .
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) | # 由于本chunk正在使用,
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ # 所以占用了下一个chunk的prev_size位
| Size of next chunk, in bytes |A|0|1| # 下一个chunk的size位
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# 未使用的chunk结构
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
`head:' | Size of chunk, in bytes |A|0|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Forward pointer to next chunk in list | # fd指针(chunk块链表的下一个空闲块)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Back pointer to previous chunk in list | # bk指针
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Unused space (may be 0 bytes long) . # 空内存,不被使用
. . # 不过值并不一定为0
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
`foot:' | Size of chunk, in bytes | # 上一个chunk的大小
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|0| # P位 置0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-
当我们向malloc申请一片内存区域时,这块内存区域就会在内存管理器 ptmalloc 以malloc_chunk结构体来表示,而且当我们释放这个堆块时,这个堆的数据结构依然是同一个,只是表现形式不同。
-
malloc 函数返回的是一个指针,一个对应大小字节的内存块的指针。
-
关于n的一些异常情况:
- 当 n=0 时,返回当前系统允许的堆的最小内存块。(一般分配0x20的空间,prev_size + size + 数据对齐的0x10字节)
- 当 n 为负数时,由于在大多数系统上,size_t 是无符号数(这一点很重要) ,所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。
-
如果一个 chunk 处于 free 状态,那么会有两个位置记录其相应的大小
- 该chunk自身的 size 字段
- 下一个chunk的prev_size字段
附录
学习与参考链接: