Linux动态内存管理机制

堆是用于分配动态内存的一段内存区域,它独立的存在于内存中,介于程序内存基地址和libc地址之间,它是从低地址向高地址生长的

比如说
一个留言板程序,用户可以输入自己的留言,留言长度由用户自己决定,那么栈上存储的是一个函数的局部变量,bss储存全局变量

那么我们怎么储存用户输入的留言,用户的留言可能是1个字符到几百几千个字符,这时候就需要堆来进行动态管理

在lib中,我们可以通过malloc来给用户分配一段长度位size的内存,通过free来释放这段内存空间

这些数据,被统一存放在堆中,维护这些数据的运行机制在glibc中,称为ptmalloc,也就是我们要重点学习的内容

函数

malloc 函数

malloc 的说明如下:

1
2
3
4
5
6
7
8
9
10
11
malloc(size_t n)
Returns a pointer to a newly allocated chunk of at least n bytes, or null
if no space is available. Additionally, on failure, errno is
set to ENOMEM on ANSI C systems.
If n is zero, malloc returns a minumum-sized chunk. (The minimum
size is 16 bytes on most 32bit systems, and 24 or 32 bytes on 64bit
systems.) On most systems, size_t is an unsigned type, so calls
with negative arguments are interpreted as requests for huge amounts
of space, which will often fail. The maximum supported value of n
differs across systems, but is in all cases less than the maximum
representable value of a size_t.

malloc 函数是用于在堆区申请一块连续的指定大小的内存块区域以 void * 类型返回分配的内存区域地址。

最后,当你不再需要分配内存时,应该使用 free 函数来释放它,以便系统回收,后续可以重新使用它。

1
free(ptr);  

free函数

free 的说明如下

1
2
3
4
5
6
7
8
free(void* p)
Releases the chunk of memory pointed to by p, that had been previously
allocated using malloc or a related routine such as realloc.
It has no effect if p is null. It can have arbitrary (i.e., bad!)
effects if p has already been freed.
Unless disabled (using mallopt), freeing very large spaces will
when possible, automatically trigger operations that give
back unused memory to the system, thus reducing program footprint.

calloc函数

calloc 和 malloc 函数相似,都需要包含 头文件才能使用它。

calloc 函数用于动态分配内存空间,并将分配的内存空间初始化零

它在分配内存空间时是需要将内存手动清零的。而 calloc 会在分配内存时自动将内存清零

调用 calloc 函数,它会接受两个参数,第一个参数表示要分配的元素个数,第二个参数表示每个元素的大小(字节单位)。它返回一个指向分配内存的指针,如果分配失败就返回 NULL

1
2
int *ptr;  
ptr = (int*) calloc(10, sizeof(int));

分配完成后,我们就可以使用指针 ptr 来访问分配的内存空间,可以将值储存进其中,然后进行其他操作。

1
2
*ptr = 10;  
printf("*ptr = %d\n", *ptr);

realloc函数

作用:当 malloc 函数或者 calloc 函数申请的空间或者数组的空间不够大或太大时就可以用 realloc 函数对空间的大小进行调整。

头文件需要包含 ,有些编译器是需要 < malloc.h>。

1
void *realloc(void *mem_address, unsigned int newsize);

堆的内存管理机制

堆的管理机制相比于栈是非复杂,但是堆的漏洞比栈有更多的形式和利用方式,而且堆漏洞所产生的条件比栈更少

一般情况下栈溢出起码需要16个字节,也就是说至少溢出到返回地址才能利用,但是堆的话只需要一个字节就可完成利用,甚至这个字节可以是个\x00,也就是空字节,nullbyte

栈基本都会关闭一两个保护机制,堆的话一般全开

堆块

在内存中,堆是一个个堆块构成的,这些堆块称为chunk

在64位中,堆块的大小是8字节对齐的,也就是说,我们申请一个15字节长度的堆块,实际到我们用户可控的数据大小位16字节

但是在管理过程中,一个堆块除用户数据区外,还有头部字段,长度为16字节,一个堆块最小的长度为32字节(包括头部),也就是说,我们分配一个1字节的堆块,它的实际长度为32字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[ 内存低地址 ]
+-------------------------+ <--- Chunk A 起始地址 (假设 0x1000)
| prev_size (8 bytes) | ---> 如果 A 前面的块在用,这里存的是别人的用户数据
+-------------------------+
| size = 0x91 (P=1) | ---> 0x90 大小,P=1 代表 A 前面的块在用
+----> +-------------------------+ <--- 返回给程序的指针 (user_ptr = 0x1010)
| | User Data (8 bytes) | \
| +-------------------------+ |
| | User Data (8 bytes) | |
| +-------------------------+ |---> 这 0x80 个字节全是你的数据
| | ...... | |
| +-------------------------+ |
| | User Data (最后8 bytes) | / ---> (如果申请0x88,这就占了 B 的 prev_size)
+----> +=========================+ <--- Chunk B 起始地址 (0x1090)
| prev_size (8 bytes) | ---> 属于 B 的头部,但暂时不管它
+-------------------------+
| size = 0x??1 (P=1) | ---> 关键点:Chunk B 的 P位是 1 (表示前面的 A 在用)
+-------------------------+
| B 的 User Data |
+-------------------------+
[ 内存高地址 ]

其中,pre_sizesize字段分别代表前一个chunk的大小以及当前chunk的大小,大小都是8字节,两个一共16字节,称之为chunk的头部字段

后面的user_data区域是用户可以输入数据的地方

size

为了节省空间,将size的最低三个bit设置为三个标志位,从低到高分别为non_main_arenais_mmapprev_inuse

  • non_main_arena用来记录当前chunk是否不属于主线程,1表示不属于,0表示属于
  • is_mmap表示当前chunk是否由mmap分配的,1表示属于,0表示不属于
  • prev_inuse用来表示前面紧邻的那个chunk是否正在使用,0表示前面的chunk已经被释放,1表示正在被用户使用

presize

记录前一个chunk的大小。这里注意,presize只有在前面的chunkfree掉的时候才生效,也就是说,只有在prev_inuse为0的时候,系统才会把prev_size字段当作presize

如果chunk正在被使用,那么它会把下一个chunk的presize当作userdata。充分利用空间

也就是说,如果我们申请一个0x80长度的区域,系统实际会给我们0x90大小;如果申请了0x88大小的区域,系统同样也是给我们0x90大小的区域,剩下的8个字节,使用nextchunkprevsize区域。因为,只有当一个chunk被释放的时候,nextchunkprevsize才真正代表前一个chunk的大小

第一次,申请 0x80 空间的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[ 内存低地址 ]
+---------------------+ <--- Chunk A 起始地址
| prev_size (8 bytes) |
+---------------------+
| size = 0x91 (P=1) | ---> 0x90 大小,P=1 表示前一块在用
+----> +---------------------+ <--- 返回给你的指针 (user_ptr)
| | |
| | |
| | 你的用户数据 (128B) | ---> 刚好填满 0x80 字节
| | |
| | |
+----> +---------------------+ <--- Chunk B 起始地址 (A起始地址 + 0x90)
| prev_size (8 bytes) | ---> 属于 B 的区域,目前闲置浪费,没被用到
+---------------------+
| size = 0x?? (P=1) | ---> B 的 size,P=1 表示 A 正在使用
+---------------------+
[ 内存高地址 ]

第二次,申请 0x88 空间的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[ 内存低地址 ]
+---------------------+ <--- Chunk A 起始地址
| prev_size (8 bytes) |
+---------------------+
| size = 0x91 (P=1) | ---> 依然是 0x90 大小!
+----> +---------------------+ <--- 返回给你的指针 (user_ptr)
| | |
| | 你的用户数据 |
| | (前 128 bytes) | ---> 填满了 A 本身的数据区
| | |
| | |
+----> +=====================+ <--- Chunk B 起始地址 (物理分界线)
| 你的用户数据 |
| (后 8 bytes) | ---> 【高能预警】你剩下的 8 字节,写在了这里!
+---------------------+
| size = 0x?? (P=1) | ---> Chunk B 的 size,P=1 保护了它
+---------------------+
[ 内存高地址 ]

topchunk

最开始的时候,程序的堆还未被使用,整个的堆区域属于一个很大的堆块叫做topchunk

当已经被使用的空间不够的时,程序就会从topchunk分割一块出来来给程序使用

堆块的管理

为了保证程序的快速运行,而且方便程序内存管理,所以ptmalloc将释放后的堆块根据大小分成不同的bin

  • fastbin:大小范围:0x20-0x80
  • smallbin:大小范围:0x90-0x400
  • largebin:大小范围:0x410以上
  • unsortedbin:未被归类的bin,临时存储用,存放的堆块大小不确定

chunk被free后的样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[ 内存低地址 ]
+-------------------------+ <--- Chunk A 起始地址 (0x1000)
| prev_size (8 bytes) |
+-------------------------+
| size = 0x91 (P=1) | ---> 大小依然是 0x90
+----> +-------------------------+ <--- 曾经的 user_ptr (0x1010)
| | fd (Forward Pointer) | ---> 【巨变1】前8字节变成了指向下一个空闲块的指针!
| +-------------------------+
| | bk (Backward Pointer) | ---> 【巨变2】后8字节变成了指向上一个空闲块的指针!
| +-------------------------+
| | 旧的 User Data 残留 | ---> 剩下的空间没人管,可能还留着你以前的数据
| | ...... |
| +-------------------------+
| | 旧的 User Data 残留 |
+----> +=========================+ <--- Chunk B 起始地址 (0x1090)
| prev_size = 0x90 | ---> 【巨变3】A 的大小(0x90)被写入了 B 的 prev_size!
+-------------------------+
| size = 0x??0 (P=0) | ---> 【巨变4】Chunk B 的 P位被清零!(P=0)
+-------------------------+
| B 的 User Data |
+-------------------------+
[ 内存高地址 ]

由于chunk被free了,所以按常理说用户不应该能访问到在这个chunk

于是乎在userdata区域存放一些用于管理内存的指针信息

  • fastbin:单链表结构,只有fd
  • small & unsortedbin:双向链表结构,fdbk都有
  • largebin:双向链表,fdbk都有,同时还有fd nextsizebk next

堆块的合并操作

如果我们free掉一个堆块,可能会触发向前合并和向后合并

  • 向前合并
    • 检查当前chunkprevinuse位,如果为0,则根据当前chunkprev size找到prev chunk的头,两个模块合并
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      [ 内存低地址 ]
      +-------------------------+ <--- Prev Chunk 起始地址 (假设 0x1000)
      | prev_size |
      +-------------------------+
      | size = 0x90 (P=1) | ---> 大小是 0x90,处于空闲状态
      +-------------------------+
      | fd / bk | ---> 因为已释放,这里存着链表指针
      | |
      +----> +=========================+ <--- Current Chunk 起始地址 (0x1090) —— 【你正在释放它】
      | | prev_size = 0x90 | ---> 【关键点 1】记录了前一块的大小是 0x90
      | +-------------------------+
      | | size = 0x??0 (P=0) | ---> 【关键点 2】P=0!系统一看这个 0,就知道前面那位大哥是空的!
      | +-------------------------+
      | | User Data | ---> 准备被清理的用户数据
      | +-------------------------+
      [ 内存高地址 ]
  • 向后合并
    • 检查当前chunknext nextchunkprev inuse位(因为一个堆块的状态由它后面的chunkprevinuse位决定,所以确定nextchunk的状态需要检查next next chunkprevinuse位,找size就行),然后根据nextchunk的状态决定是否合并
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      [ 内存低地址 ]
      +-------------------------+ <--- Current Chunk 起始地址 (0x1000) —— 【你正在释放它】
      | prev_size |
      +-------------------------+
      | size = 0x90 (P=1) | ---> 假设当前块大小是 0x90
      +----> +=========================+ <--- Next Chunk 起始地址 (0x1090)
      | | prev_size |
      | +-------------------------+
      | | size = 0x80 (P=?) | ---> 假设大小是 0x80。但注意:它自己并没有标记记录自己是否空闲!
      +----> +-------------------------+
      | | fd / bk |
      | | |
      +----> +=========================+ <--- Next Next Chunk 起始地址 (0x1110)
      | prev_size = 0x80 |
      +-------------------------+
      | size = 0x??0 (P=0) | ---> 【关键点】P=0!系统通过这个 0,判定它前面的 Next Chunk 是空闲的!
      +-------------------------+
      [ 内存高地址 ]

结构体

aren是一块结构体,用于管理 bins。主线程创建的 arena 称之为 main_arena, 其他的叫 threadarena。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct malloc state
{
/* Serialize access.*/
mutex_t mutex;
int flags;
/*Fastbins*/
mfastbinptr fastbinsY[NFASTBINS];
/* Base of the topmost chunk -- not otherwise kept in a bin*/
mchunkptr top;
/* The remainder from the most recent split of a small request */
mchunkptr last remainder;
/*Nromal bins packed as described above*/
mchunkptr bins[NBINS * 2 - 2];
/*Bitmap of bins*/
unsigned int binmap[BINMAPSIZE];
/*Linked list*/
struct malloc state *next;
/*Linked list for free arenas.*/
struct malloc state *next_free;
/* Memory allocated from the system in this arena.*/
INTERNAL_SIZE_T_system_mem;
INTERNAL_SIZE_T_max_system_mem;
};

各种内存块介绍

各种 bins:
image.png

Fastbin

管理 fastbin free chunk,单链表结构,FILO(最后一个进入 fastbin 链表的,会被放在头部)总共有十个 fastbin 链表,每个链表中 fastbin 的 size 一样,0x10 递增。

大小属于 fastbin 的 chunk 被 free 掉时,不会改变 next chunk 的 prev inuse 位,也就是说不会被合并。

image.png

Unsortedbin

管理 unsorted chunk,只有一个双向链表。所有大小大于 fastbin 的 chunk 都会先被暂 时放入 unsortedbin 中,链表中的 chunk 大小不一样。

image.png

Smallbin

管理 small chunk,由 62 个双向链表组成, 每个链表中的 chunk 大小一样,大小以 0x10 递 增。长得和 unsortedbin 差不多的。

Largebin

管理 large chunk,63 个双向链表,FIFO。同一个双线链表中 chunk 大小可以不一样,但是在一定范围内,bins 大小从小到大排列。

image.png

Malloc 运行流程

了解完各种 bin 之后,现在来看看这:

当我们调用 malloc 时,程序都干了些什么?

1、计算真正的堆块的大小(加上堆头部长度、对齐):

判断是否在 fastbin 范围内:

  • 确定在,检查对应大小的 bin 链表中有无 chunk。
    • 有,那就分配给用户,至此完成。
  • 如果不在 fastbin 范围内,或者没用 chunk 可用。(两者满足一个或者都满足的话)
    • 继续判断是否在 smallbin 范围内:
      • 在 smallbin 范围内,检查对应大小的 bin 链表中有无 chunk。
        • 有 chunk,那就取出来给程序,至此完成。
      • 不在 smallbin 范围内,或者 smallbin 里面也没有 chunk。这时候跳到 unsortedbin 的检查。
    • unsortedbin 中有无 chunk?
      • 有,从尾部取出第一个 chunk,看看大小是否满足需求。
        • 满足,切分后大小是否大于 minsize
          • 大于,再切分块,返回给用户,剩下的块放进 unsortedbin。
          • 小于或等于 minsize,直接返回给用户,完成。
        • 不满足大小需求,把这个块放入 smallbin /largebin 对应的链表中,继续遍历下一个块。
      • 没。unsortedbin 的所有块都不满足,那此时就判断是否在 largebin 范围。
        • 是,检查对应的 bin 链表中有无符合的 chunk。
          • 有符合的,找到满足需求最小的 chunk,切分块返回,剩下的放进 unsortedbin 中。
        • 不在,那就再次遍历 smallbin /largebin 找 best fit 的 chunk。
        • 我去?还是没用,那就从 topchunk 中切割。
        • ??搞什么鬼??topchunk 也不够?那就 mmap 系统调用

当我们调用了 free 时,程序都干了些什么?

free 的 chunk 大小属于 fastbin 吗?

  • 是,放进 fastbin,至此完成。
  • 不属于,那就接着判断这个 free 的 chunk 是否是 mmap 分配的。
    • 是,那就调用 munmap 回收,完成。
    • 不是,那就接着判断前一个 chunk 是否是空闲的。
      • 是,那就向前合并
      • 不是,接着判断:后一个 chunk 是 topchunk 吗?
        • 是,那就和 topchunk 合并,至此完成。
        • 不是 topchunk,那就判断:后一个 chunk 是 free 的吗?
          • 是,那就向后合并,然后放进 unsortedbin,终于完成了。

堆溢出

概述

堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数,(人话就是你放太多东西到这个堆块了,然后这个堆块容量有限要溢出东西了)所以导致了数据溢出,并覆盖到了物理相邻的高地址的下一个堆块。

堆溢出是一种特定的缓冲区溢出(还有栈溢出, bss 段溢出等)。但是其与栈溢出所不同的是,堆上并不存在劫持返回地址等能让攻击者劫持执行流程的数据,因此我们一般无法直接通过堆溢出来控制 EIP。

堆的结构比栈要相对复杂些,覆盖顺序依次为:

1
prev_inuse(bit0)→ is_mapped(bit1)→ non_main_arena(bit2);(堆块头部包含 prev_size、size,size 分标志位 + 真实大小,且有 3 个关键标志位)
  • prev_size
  • size,主要有三个比特位,以及该堆块真正的大小。
    • NON_MAIN_ARENA
    • IS_MAPPED
    • PREV_INUSE
    • the True chunk size
  • chunk content,从而改变程序固有的执行流。

了解传递顺序后,在后续可以利用堆中的机制(如 unlink 等 )来实现任意地址写入( Write-Anything-Anywhere)或控制堆块中的内容等效果,从而来控制程序的执行流。

漏洞利用思路

寻找堆分配函数

通常来说堆是通过调用 glibc 函数 malloc 进行分配的,在某些情况下会使用 calloc 分配。calloc 与 malloc 的区别是 calloc 在分配后会自动进行清空,这对于某些信息泄露漏洞的利用来说是致命的

1
2
3
4
5
calloc(0x20);
//等同于
ptr = malloc(0x20);
memset(ptr,0,0x20);

除此之外,还有一种分配是经由 realloc 进行的,realloc 函数可以身兼 malloc 和 free 两个函数的功能。

1
2
3
4
5
6
7
8
#include <stdio.h>
int main(void)
{
char *chunk,*chunk1;
chunk = malloc(16);
chunk1 = realloc(chunk,32);
return 0;
}

危险函数

输入函数

函数 危险原因 堆溢出示例
gets 无长度限制,读取到 \n/EOF 为止,忽略 \x00 char *p = malloc(24); gets(p);
scanf %s/%[ 格式无长度限制,遇空白符停止 char *p = malloc(24); scanf("%s", p);
vscanf 可变参数版 scanf,风险和 scanf 一致 char *p = malloc(24); vscanf("%s", &p);

输出函数

函数 危险原因 堆溢出示例
sprintf 无长度检查的格式化输出,遇 \x00 停止 char *p = malloc(24); sprintf(p, "%s", 超长字符串);

字符串相关

函数 危险原因 堆溢出示例
strcpy 无长度检查,遇 \x00 停止 char *p = malloc(24); strcpy(p, 超长字符串);
strcat 从目标字符串末尾(\x00 位置)拼接,无长度检查 char *p = malloc(24); strcpy(p, "a"); strcat(p, 超长字符串);
bcopy 仅指定拷贝长度,若长度 > 目标内存大小则溢出 char *p = malloc(24); bcopy(源, p, 100);

安全替代函数

危险函数 安全替代 核心区别
gets fgets(p, size, stdin) 指定最大读取长度
scanf scanf("%ns", p) n 为最大读取长度
strcpy strncpy(p, src, size) 指定最大拷贝长度
strcat strncat(p, src, size) 指定最大拼接长度
sprintf snprintf(p, size, ...) 指定最大输出长度

确认填充长度