堆入门--概述
参考资料:
学堆时,最困扰的一个点就是:这个知识点我不懂,到底要不要一直钻研下去,一旦稍微深入一点,又会牵扯出许多的知识点。这样一来,堆积的不懂愈来愈多,学习pwn也变得枯燥起来,只是与各种不懂的东西打交道,缺少了许多兴趣。所以我个人还是就着“有用就学”的原则,来进行针对学习。
而写这篇博客也是,全是不懂的,与其全部复制黏贴各种实验结论,不如自己跑一遍,得出些心得出来,把一些自己能看懂的记下来。而至于更深层次的知识,还需要自行了解了……
对于理论性知识,作者并没有什么实力来精确性概括总结,所以真的理论性知识只能靠cv大佬们的成果(双手合十),对各位师傅报以无声的感谢!!!
堆概述
在程序运行过程中,堆可以提供动态分配的内存,允许(用户)程序申请大小未知的(操作系统)内存。
管理用户所释放的内存,适时归还给操作系统。
堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址方向增长。我们一般称管理堆的那部分程序为堆管理器。
堆管理器的作用,充当一个中间人的作用。管理从操作系统中申请来的物理内存,如果有用户需要,就提供给他。
上面阿巴阿巴讲一堆,个人理解:用户提出分配内存的请求,堆管理器接收到这个信号,向操作系统中申请内存,并且管理内存,将其返回给用户程序。 类似于我(用户)想向地主(操作系统)从田(heap)里分一块地(内存),我(用户)对管家(堆管理器)施以命令(malloc()),管家(堆管理器)帮我告诉给地主(操作系统),地主(操作系统)由近到远(由低地址到高地址)开始划分地(内存) ,然后管家(堆管理器)把地(内存)的相关信息给我(用户),并且管家还得帮着我管理着块地,等我不需要这块地了就帮我归还回去。
堆申请示例:
参考博客:https://blog.csdn.net/qq_41696518/article/details/126677930
#include <stdio.h> #include <stdlib.h> int main(){ void *a = malloc(0x8); void *b = malloc(0x10); void *c = malloc(0x100); return 0; }
当执行到 void *a = malloc(0x10); 的 call malloc@plt 时查看内存情况(第一个vmmap所示)
ni执行该命令(call malloc@plt )
继续查看内存情况 vmmap
可以发现程序第一次调用malloc时,程序为其分配了0x21000大小的堆空间
arena:内存分配区。这里的0x21000大小的堆空间就是操作系统给堆管理器的,当我们第二次调用malloc就从这段空间中给出
查看heap空间分配,我们malloc(0x8),却申请了0x21大小的空间
继续执行第二个 call malloc@plt ,并查看heap空间分配,我们malloc(0x10),也是分配了0x21大小的空间
继续,malloc(0x100)时,分配了0x111大小的空间
0x111:0x111 = 0x100 + 0x10 + 0x1 0x10就是prev size+size的大小 0x1其实是size最后的3bit中的P=1
上面示例的malloc(0x8)和malloc(0x10) 分配0x21字节的chunk 就需要到下面的堆块的对齐规则进行了解了
先给结论:64位系统中,按0x10字节对齐,chunk最小为0x20字节。
堆管理器
参考资料:https://ctf-wiki.org/en/pwn/linux/user-mode/heap/ptmalloc2/heap-overview/#_4
https://blog.51cto.com/u_15076233/3914352
堆管理器的工作:
堆管理器处于用户程序与内核中间,其作用为:
1.响应用户的申请内存请求
向操作系统申请内存,然后将其返回给用户程序。同时,为了保持内存管理的高效性,内核一般都会预先分配很大的一块连续的内存(第一次调用malloc时分配了0x21000大小空间),然后让堆管理器通过某种算法管理这块内存。只有当出现了堆空间不足的情况,堆管理器才会再次与操作系统进行交互。
2.管理用户所释放的内存
一般来说,用户释放的内存并不是直接返还给操作系统的,而是由堆管理器进行管理。这些释放的内存可以来响应用户新申请的内存的请求。
目前 Linux 标准发行版中使用的堆分配器是 glibc 中的堆分配器:ptmalloc2。ptmalloc2 主要是通过 malloc/free 函数来分配和释放内存块。
两个系统调用:
堆管理器不是由操作系统实现,而是由libc.so.6链接库实现。通过其中封装的系统调用,来为用户提供方便的动态内存分配接口。
其中两种申请内存的系统调用:
- brk
- mmap
bkr():
第一种brk,是将heap下方的data段(bss属于data段),向上扩展申请的内存。
brk()
通过增加break location
来获取内存,一开始heap
段的起点start_brk
和heap
段的终点brk
指向同一个位置。
- ASLR 关闭时,两者指向 data/bss 段的末尾,即end_data
- ASLR 开启时,两者指向 data/bss 段的末尾加上一段随机 brk 偏移
mmap():
第二种mmap,创建独立的匿名映射段。匿名映射的目的主要是可以申请以 0 填充的内存,并且这块内存仅被调用进程所使用。与之进行相反操作的是munmap()
,删除一块内存区域上的映射。
总结:
-
主线程可以用brk和mmap,如果主线程申请的空间过大,那么会使用mmap;如果申请的空间比较小,那么就会再data段上向上扩展一段空间
-
子线程只能使用mmap段
malloc就是向堆管理器申请一块内存空间
free就是将申请来的内存空间归还给堆管理器
用户使用malloc向堆管理器要内存,堆管理器通过brk和mmap向操作系统要内存
(s)bkr调用示例:
前面理论又堆压起来了,那么就歇息一会,调试一下吧。使用ctfwiki上给的例子进行分析调试:
/* sbrk and brk example */ #include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { void *curr_brk, *tmp_brk = NULL; printf("Welcome to sbrk example:%d\n", getpid()); //获取当前进程的进程ID tmp_brk = curr_brk = sbrk(0); //用于获取当前程序的堆内存断点位置 printf("Program Break Location1:%p\n", curr_brk); getchar(); brk(curr_brk+4096); //堆内存大小增加4096字节 curr_brk = sbrk(0); //获取新的程序堆内存断点位置,即之前断点位置+4096字节 printf("Program break Location2:%p\n", curr_brk); getchar(); brk(tmp_brk); //恢复堆内存的大小,将其还原到初始状态 curr_brk = sbrk(0); //来获取最终的程序堆内存断点位置,这将显示还原后的堆内存位置。 printf("Program Break Location3:%p\n", curr_brk); getchar(); return 0; }
程序运行状态分析:跟ctfwiki上略有出入
当我在第一次调用 brk 之前,我们可以发现出现的进程的堆内存,这与wiki上的演示有所出入。
此时注意到 进程的heap区域,注意到它的权限是‘rw-p’表示它是可读写的
当我调用`sbrk(0)`时,它获取了当前堆的结束位置,而虚拟地址空间中的堆段已经包含了实际分配的物理内存。这就是为什么在`/proc/<pid>/maps`中显示了对应的物理内存分配。 但是为什么wiki上的演示,在第一次调用到getchar()时的输出中并没有出现堆呢? 在这里对比,我们可以发现一个细节:wiki里演示的程序 start_brk = brk = end_data 而本地演示的程序 start_bkr=0x564295708000 end_data = 0x564293a83000 猜测发生原因:不同操作系统、编译器的原因,导致内存布局和管理不同,使得start_bkr不等于end_data (疑惑???)
第一次增加 brk 后则是跟wiki演示相同,开辟了0x1000个字节空间
恢复brk后,heap区域回归到了开始时的大小
至于mmap与多线程暂不演示咯
堆基本数据结构chunk
该部分实在找不到好的演示材料,只能靠理论堆砌了
参考资料:《ctf特训营》 https://ctf-wiki.org/en/pwn/linux/user-mode/heap/ptmalloc2/heap-structure/
在glibc中,chunk是内存分配的基本单位,一块由分配器分配的内存块叫做一个 chunk,分为:
- 被用户使用中的叫 allocated chunk
- 被用户释放,处于空闲的叫做 free chunk
- top chunk
- last remainder chunk
malloc_chunk数据结构如下:
struct malloc_chunk { INTERNAL_SIZE_T mchunk_prev_size; //用于记录前一个内存块的大小的字段。它主要用于在释放内存块时合并相邻的空闲块。如果当前内存块是空闲的,那么前一个内存块的大小将存储在这里。 INTERNAL_SIZE_T mchunk_size; //当前内存块的大小 后三字节具有特殊含义 struct malloc_chunk* fd; //双向链表指针 struct malloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */ struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */ struct malloc_chunk* bk_nextsize; };
chunk总结构示意图:
字段具体解释:
-
prev_size:如果上一个chunk处于释放状态,用于表示其大小。否则作为上一个chunk的一个部分,用于保存上一个chunk的数据。这里的上一个chunk指的是较低地址的chunk
-
size:表示当前chunk的大小,大小必须是2*SIZE_SZ的整数倍。如果申请的内存大小不是 2 * SIZE_SZ 的整数倍,会被转换满足大小的最小的 2 * SIZE_SZ 的倍数。默认情况下,SIZE_SZ在64位系统下是8字节,32位下是4字节。受到内存对齐的影响,最后3个比特位被用作状态标识,从高到低分别表示
-
fd和bk:仅在当前chunk处于释放状态有效。chunk被释放后会加入相应的bin链表中,此时fd和bk指向该chunk在链表的下一个和上一个free chunk(不一定时物理相连的)。如果当前chunk处于使用状态,那么这两个字段是无效的,都是用户使用的空间
-
fd_nextsize和bk_nextsize:与fd和bk相似,仅在处于释放状态时有效,否则就是用户使用的空间。不同的是,它们仅仅用于large bin,分别指向前后第一个和当前chunk大小不同的chunk
内存中堆块的对齐规则
-
32位系统中,按0x8字节对齐 ,chunk最小为0x10字节。
-
64位系统中,按0x10字节对齐,chunk最小为0x20字节。
allocated chunk
allocated chunk结构示意图:
第一个部分(32 位上 4B,64 位上 8B)叫做prev_size
,只有在前一个 chunk 空闲时才表示前一个块的大小,否则这里就是无效的,可以被前一个块征用(存储用户数据)。
这里的前一个chunk,指内存中相邻的前一个,而不是freelist链表中的前一个。
PREV_INUSE
代表的“前一个chunk”同理。
第二个部分的高位存储当前 chunk 的大小,低 3 位为size域中的标志位 分别表示:
- N / A:
NON_MAIN_ARENA
当前 chunk 是否属于主线程 0 表示主线程的堆块结构 ; 1 表示子线程的堆块结构 - M:
IS_MMAPED
当前 chunk 是否由mmap分配的 0 表示由堆块中的top chunk分裂产生; 1 表示由mmap分配 - P:
PREV_INUSE
前一个的 chunk 是否处于空闲状态 0 表示处于空闲状态; 1 表示处于使用状态(主要用来判断free时是否能与上一块进行合并)
Free chunk
首先,prev_size
必定存储上一个块的用户数据,因为 Free chunk 的上一个块必定是 Allocated chunk,否则会发生合并。
接着,多出来的fd
指向同一个 bin 中的前一个 Free chunk,bk
指向同一个 bin 中的后一个 Free chunk。
一般情况下,物理相邻的两个空闲 chunk 会被合并为一个 chunk 。堆管理器会通过 prev_size 字段以及 size 字段合并两个物理相邻的空闲 chunk 块。
Top chunk
一个arena
顶部的 chunk 叫做 Top chunk,它不属于任何 bin。当所有 bin 中都没有空闲的可用 chunk 时,我们切割 Top chunk 来满足用户的内存申请。假设 Top chunk 当前大小为 N 字节,用户申请了 K 字节的内存,那么 Top chunk 将被切割为:
- 一个 K 字节的 chunk,分配给用户
- 一个 N-K 字节的 chunk,称为 Last Remainder chunk
后者成为新的 Top chunk。如果连 Top chunk 都不够用了,那么:
- 在
main_arena
中,用brk()
扩张 Top chunk - 在
non_main_arena
中,用mmap()
分配新的堆
注:Top chunk 的 PREV_INUSE 位总是 1
last remainder chunk
当需要分配一个比较小的 K 字节的 chunk 但是 small bins 中找不到满足要求的,且 Last Remainder chunk 的大小 N 能满足要求,那么 Last Remainder chunk 将被切割为:
- 一个 K 字节的 chunk,分配给用户
- 一个 N-K 字节的 chunk,成为新的 Last Remainder chunk
它的存在使得连续的小空间内存申请,分配到的内存都是相邻的,从而达到了更好的局部性。
堆空闲块管理结构bin
参考资料:glibc 内存管理 ptmalloc 源代码分析
当alloced chunk被释放后,会放入bin或者合并到top chunk中去。bin的主要作用是加快分配速度,其通过链表方式(chunk结构体中的fd和bk指针)进行管理。
可以分为:
- 10 个 fast bins,存储在
fastbinsY
中 - 1 个 unsorted bin,存储在
bin[1]
- 62 个 small bins,存储在
bin[2]
至bin[63]
- 63 个 large bins,存储在
bin[64]
至bin[126]
Fast bins
Fast bins 是小内存块的高速缓存,当一些大小小于 64 字节的 chunk被回收时,首先会放入 fast bins 中,在分配小内存时,首先会查看 fast bins 中是否有合适的内存块,如果存在,则直接返回 fast bins 中的内存块,以加快分配速度。
- 除了fastbin的结构是单项链表,其他的bin都是双向链表。因为fastbin只有一个fd指针。
- fastbin的工作方式是后进先出。
fastbin的P永远是1,因为就如同字面的fast意思一样,为了更快的释放和分配。这样就避免了fastbin被合并。也就是这样让它有了fast的属性 - fastbin管理16、24、32、40、48、56、64bytes的free chunks(32位下默认)
在64位系统中,保存的堆块大小在0x200x80之间;在32位系统中,其大小区间为0x100x40(x86)
unsorted bin
主要用于存放刚释放的堆块以及大堆块分配后剩余的堆块,大小没有限制。
small bins
主要用于保存在0x100x400(x86,对x64是0x200x800)区间的堆块,同一条链表中堆块的大小相同,如x86下bin2对应于0x10,bin3对应于0x18……
large bins
主要用来存放大小大于0x400(x86,对x64是0x800)的堆块,同一条链表中堆块的大小不一定相同,在一定范围内,按照从小到大的顺序进行排列。
示例:
从chunk开始学习的难度就上来了……前面都是贴的师傅们的易懂一点的知识点
【Protostar_heap3】
#include <stdlib.h> #include <unistd.h> #include <string.h> #include <stdio.h> #include <sys/types.h> void backdoor(){ system("/bin/sh"); } int main(){ char *a, *b, *c; a = malloc(32); b = malloc(32); c = malloc(32); gets(a); gets(b); gets(c); free(c); free(b); free(a); printf("heap is easy!!!"); } // gcc -o heap3 -no-pie heap3.c
执行完malloc后查看heap结构,其中划线部分前八位表示prev_size,后八位表示size,size域中的最后一位表示PREV_INUSE
,即P=1
当我们free掉c,b后,查看bin结构
0x4052d0 —▸ 0x405300 ◂— 0x0 发现这个表现为一个单项链表
内存分配、释放流程
本文作者:37kola
本文链接:https://www.cnblogs.com/37blog/p/17811161.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步