Linux动态内存管理机制

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

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

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

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

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

堆的内存管理机制

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

一般情况下栈溢出起码需要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的头部字段

后面的useer_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 是空闲的!
      +-------------------------+
      [ 内存高地址 ]