Ptmalloc2内存管理分析 基础知识


x86 平台 Linux 进程内存布局

Linux 系统在装载 elf 格式的文件时,会调用 loader 把可执行文件中的各个段依次载入到从某一地址开始的空间中(载入地址取决 link editor(ld)和机器位数,在 32 位机器上是 0x8048000,即 128M 处。前提是没有pie保护)。

如下图所示,以 32 位机器为例,首先被载入的是.text段,然后是.data 段,最后是.bss段。这可以看作是程序的开始空间。程序所能访问的最后的地址是 0xbfffffff,也就是到 3G 地址处,3G 以上的 1G 空间是内核使用的,应用程序不可以直接访问。应用程序的堆栈从最高地址处开始向下生长,.bss段与堆栈直接的空间是空闲的,空闲空间被分成两部分,一部分为 heap,一部分为 mmap 映射区域,mmap 映射区域一般从 TASK_SIZE/3 的地方开始,但在不同的 Linux 内核和机器上,mmap 区域的开始位置一般是不同的。Heap 和 mmap 区域都可以供用户自由使用,但是它在刚开始的时候并没有映射到内存空间内,是不可访问的。在向内核请求分配该空间之前,对这个空间的访问会导致 segmentation fault 。用户程序可以直接使用系统调用来管理 heap 和 mmap 映射区域,但更多的时候程序都是使用 C 语言提供的 malloc() 和 free() 函数来动态的分配和释放内存。Stack 区域是唯一不需要映射,用户却可以访问的内存区域,这也是利用堆栈溢出进行攻击的基础。

32位模式下进程内存经典布局

这种布局是 Linux 内核 2.6.7 以前的默认进程内存布局形式,mmap 区域与栈区域相对增长,这意味着堆只有 1GB 的虚拟空间可以使用,继续增长就会进入 mmap 映射区域,这显然不是我们想要的。这是由于 32 模式地址空间限制造成的,所以内核引入了另一种虚拟地址空间的布局形式,将在后面介绍。但对于 64 位系统,提供了巨大的虚拟地址空间,这种布局就相当好。

32位模式下进行默认内存布局

从上图可以看到,栈至顶向下扩展,并且栈是有界的。堆至底向上扩展,mmap 映射区域至顶向下扩展,mmap 映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,这种结构便于 C 运行时库使用 mmap 映射区域和堆进行内存分配。上图的布局形式是在内核 2.6.7 以后才引入的,这是 32 位模式下进程的默认内存布局形式。

64位模式下进程内存布局

对于 AMD64 系统,内存布局采用经典内存布局,text的起始地址为 0x00000000 00400000,堆紧接着 BSS 段向上增长,mmap 映射区域开始位置一般设为 TASK_SIZE/3 。

计算一下可知,mmap 的开始区域地址为 0x00002AAAAAAAA000,栈顶地址为 0x00007FFF FFFFF000。

上图是 x86_64 下 Linux 进程的默认内存布局形式,这只是一个示意图,当前内核默认配置下,进程的栈和 mmap 映射区域并不是从一个固定地址开始,并且每次启动时的值都不一样,这是程序在启动时随机改变这些指的设置,使得使用缓冲区溢出进行攻击更加困难。当然也可以让进程的栈和 mmap 映射区域从一个固定位置开始,只需要设置全局变量 randomize_va_space 值为0,默认为1.

这在 pwn 中被称为 ASLR 保护。可以随机化堆栈、堆、动态链接库的地址。
通过设置 /proc/sys/kernel/randomize_va_space 来修改特性

操作系统内存分配的相关函数

上文提到 heap 和 mmap 映射区域是可以提供给用户程序使用的虚拟内存空间,如何获得该区域的内存呢?操作系统提供了相关的系统调用来完成相关工作。对 heap 的操作,操作系统提供了 brk() 函数,C 运行时库提供了 sbrk()函数;对 mmap 映射区域的操作,操作系统提供了 mmap() 和 munmap() 函数。sbrk(),brk() 或者 mmap() 都可以用来向我们的进程添加额外的虚拟内存。Glibc 同样是使用这些函数向操作系统申请虚拟内存。

这里要提到一个很重要的概念,内存的延迟分配,只有在真正访问一个地址的时候才建立这个地址的物理映射,这是 Linux 内存管理的基本思想之一。Linux 内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。

Heap 操作相关函数

Heap 操作函数主要有两个,brk() 为系统调用,sbrk() 为库函数。系统调用通常提供一种最小功能,而库函数通常提供比较复杂的功能。Glibc 的 malloc 函数族(realloc,calloc 等)就调用 sbrk() 函数将数据段的下界移动,sbrk() 函数在内核的管理下将虚拟地址空间映射到内存,供 malloc() 函数使用。

内核数据结构 mm_struct 中的成员变量 start_code 和 end_code 是进程代码段的起始和终止地址,start_data 和 end_data 是进程数据段的起始和终止地址,start_stack 是进程堆栈段起始地址,start_brk 是进程动态内存分配起始地址(堆的起始地址),还有一个 brk (堆的当前最后地址),就是动态内存分配当前的终止地址。C 语言的动态内存分配基本函数是 malloc(),在 Linux 上的实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用,知识简单地改变mm_struct 结构的成员变量 brk 的值。

这两个函数的定义如下:

1
2
3
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

需要说明的是,但 sbrk() 的参数 increment 为0时,sbrk() 返回的是进程的当前 brk 值,increment 为正数时扩展 brk值,当 increment 为负值时收缩 brk 值。

Mmap 映射区域操作相关函数

mmap() 函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap 执行相反的操作,删除特定地址区域的对象映射。

函数的定义如下:

1
2
3
#include <sys/mman.h>
void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset);
int munmap(void *addr,size_t length);

在这里不准备对这两个函数做详细介绍,只是对 ptmalloc 中用到的功能做一下介绍,其他的用法请参看相关资料。

参数:

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
start:映射区的开始地址
length:映射区的长度

prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过 or 运算合理地组合在一起。Ptmalloc 中主要使用了如下的几个标志:
PROT_EXEC
页内容可以被执行,ptmalloc 中没有使用
PROT_READ
页内容可以被读取,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设置该标志
PROT_WRITE
页内容可以被写入,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设置该标志
PROT_NONE
页不可访问,ptmalloc 用 mmap 向系统 “批发” 一块内存进行管理时设置该标志

flags:指定映射对象的类型,映射选项和映射页是否可以分享。它的值可以是一个或者多个以下位的组合体
MAP_FIXED
使用指定的映射起始地址,如果由 start 和 len 参数指定的内存区重叠于现存的映射区域,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。Ptmalloc 在回收从系统中 “批发” 的内存时设置该标志
MAP_PRIVATE
建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。Ptmalloc 每次调用 mmap 都设置该标志。
MAP_NORESERVE
不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。Ptmalloc 向系统 “批发” 内存块时设置该标志。
MAP_ANONYMOUS
匿名映射,映射区不与任何文件关联。Ptmalloc 每次调用 mmap 都设置该标志。

fd:有效的文件描述词。如果 MAP_ANONYMOUS 被设定,为了兼容问题,其值应为-1
offset:被映射对象内容的起点。