本文在 2024.02 编写,于 2026.01 进行修改与完善
||该文章为被压榨要求完成主人任务录视频的新手时期所写,虽然我进行了修正,但是可能会出现包括但不限于语言风格跳跃,质量参差不齐,左右脑互博,思维不连贯等不和谐的地方||
1. 堆与环境的关系
由于堆的特性与 Glibc 的版本息息相关,如果想要了解不同版本的特性,甚至是面对不同的题目,你需要更换你的 Glibc 版本,下面几种方法可供你使用:
1-1 更换题目运行环境的 libc
要观察不同环境下堆的表现,或者是启动对应版本的题目,可以使用下面的方式
patchelf和glibc-all-in-one前者可以指定某个二进制文件使用对应的Glibc版本运行,后者则是可以方便你下载各个版本- 如果你有一个特定版本的
libc和ld,在你写的脚本中打开本地进程这么写:process([" ld 路径 ", "./题目"], env={"LD_PRELOAD":" libc 路径 "})LD_PRELOAD加载的库有很高的优先级,它也可以广泛的用于C++环境的更改中 - 使用一些自动化检测
libc.so.6版本并自动替换环境的脚本
1-2 使用对应 libc 版本编译
如果你要出题,或者是测试一些 Demo 你需要编译对应版本的程序,首先需要注意的是:低版本 Glibc 环境无法运行高版本系统编译出的程序以及程序所用到的在高版本系统上依赖 Glibc 编译的库,其次高版本兼容低版本系统编译的程序但是特性按高版本的来,所以最好是想哪个版本就使用哪个版本的环境编译
- 最简单的,把
Ubuntu 16.04到Ubuntu latest version中所有流行的发行版全下载配置一遍,在对应 libc 版本的系统完成编译与测试,安装libc-dbg和glibc-source两种包来为调试的时候添加调试符号和源码 - 下载好对应版本的
libc和ld在GCC编译时指定参数rpath和dynamic-linkerTerminal window gcc test.c -o test -Wl,--rpath=/path/to/x86_64-linux-gnu/ -Wl,--dynamic-linker=/path/to/ld-linux-x86-64.so.2rpath目录一般包含但不限于下面文件
编译的效果如下
WARNING第二方法似乎不是任何时候都生效的,
__libc_start_main函数的版本不会随着更换库而改变
这是因为 elf 使用的__libc_start_main@GLIBC_X.Y版本,主要由引用它的目标文件决定,这个文件不是 libc.so.6 ,而是 glibc 的启动文件crt1.o/Scrt1.o
这个文件可以在libc6-dev中找到,但是我从来没编译成功过
所以这意味着你在 2.42 版本编译 2.23 的程序会因为__libc_start_main的原因而报错,省事点还是 docker 把
2. 堆是什么?
TIP在C语言中,内存管理有动态内存管理、静态内存管理(Data & BSS 段)、自动内存分配(Stack)三种办法,其中动态内存管理就是对堆的利用
动态内存,也称为堆内存,理论上堆内存在手动释放或程序结束之前均可访问,同时允许我们在程序执行期间随时分配和释放内存,它非常适合存储大型数据结构或大小事先未知的对象,堆使得程序员分配内存变得更加灵活
3. 堆在哪里?
当申请(使用 malloc() 函数)内存块后,堆段才会出现,堆由低地址向高地址方向增长,当我们在 pwndbg 中使用 vmmap 指令可以看heap 段在内存中的位置:
可以看到,堆在内存中的位置处于库函数内存映射段与数据段之间,在 No PIE 的时候它和数据段是紧挨的,如果开启地址随机化的话他们中间会隔一段随机的距离,但是遵循页对齐的规则
4. 堆从哪里来?
IMPORTANT“堆管理”的意义,不仅是因为堆上可以写内容,精髓更在于管理,既然是可以管理的,堆块上就会有用于管理的数据内容
对于一个堆块来说,堆块可以写的部分的前面有 0x8 或 0x10 的空余大小,后面也可能有 0x8 的空余大小,这些我们之后会涉及,
对于有些下文会提到但是已经涉及到的东西先简单说明一下
Top chunk:当程序第一次进行malloc的时候,会有一个初始化的过程,和上图一样,申请一块比较大的 heap 空间(0x21000),在这之后会被分为两块,一块给用户需要的那块,剩下的那块就是Top chunk,它的作用就是再次申请堆块要是没有合适的空间便会使用top chunk的空间- 你申请到的一块堆内存的起始地址 ≠ 你可以写入数据的起始地址,显然,上面有提及
- 你申请的大小 ≠ 实际申请的大小,大小对齐可以更方便的管理,所以他会有一个的取整的步骤
我准备了下面的程序来演示:
#include <stdio.h>#include <stdlib.h>
int main(){
int *p = NULL;
p = (int *)malloc(0x114); p = (int *)malloc(0x514); p = (int *)malloc(0x1919); p = (int *)malloc(0x810);
return 0;}第一次申请之前,在我们申请内存之前,堆段并没有出现
第一次申请后,堆段被建立了,通过vmmap观察堆的size,我们得到了21000字节的堆内存
TIP为什么和实际申请的大小差距很大
不这么干你就没法做题
当申请堆的内存时,我们可以随时随地申请,而且每次申请的内存有大有小,管理申请的堆的工作量会非常大,而申请内存的操作本身需要通过系统调用
如果malloc()函数仅仅封装了可以分配内存的系统调用,那么频繁地调用系统调用会很消耗系统资源
所以针对这个问题,程序第一次可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序,这样的话可以避免上面假设所说的频繁系统调用,就避免了多次内核态与用户态的切换,提高了程序的效率
这 0x21000 不会全用完,我们会从得到的内存上分配所申请的相应的小段内存,称为 Chunk(Allocated Chunk),并返回地址给程序
如果你申请了很多的堆块,不考虑释放堆块,那他们会按申请的顺序依次从低地址到高地址排列
Glibc 有自己的内存管理器来进行内存分配操作 Ptmalloc2,它实现了各种对堆的分配,回收,合并,切割等操作,对应的,不同环境也有不同的堆管理器,而针对堆的特性介绍与漏洞利用也与 Ptmalloc2 息息相关
5. 堆相关函数
TIP用过硬盘的人都知道,当你删除数据的时候,数据并没有被删除,它只是被标记成删除,堆块也是这样,释放一个堆块,堆块不会消失,但是会当成他消失,也会标记他被消失,下次写新内容的时候拿过来会照常用
- 一般使用
free()函数释放的堆块不会立刻被回收,它们会变成一种叫Free Chunk的东西并且叫做xxx bin的名字,在部分情况下这类堆块释放后如果挨着一个也被释放的堆块,或者是Top Chunk,他们会合并- 但也不是一定会合并,请记住
FastBin是一个特例 —— 它不会轻易合并
WARNING这里只针对于在
arena内的情况,看不懂没关系,看不懂就对了
5-1 malloc() 函数
分配所需的内存空间,并返回一个指向它的指针
void* malloc(size_t size);参数以及返回值列表
size:要分配的字节大小,是无符号数,意味着如果你输入了了-1,理论上会有一个很大很大的堆,实际上会报错,因为太大- 返回值:如果分配成功,则返回指向分配内存的指针;如果分配失败,则返回
NULL。
5-2 calloc() 函数
分配所需的内存空间,并返回一个(一组)指向它(它们)的指针
void *calloc(size_t nitems, size_t size)参数以及返回值列表
nitems:需要的堆块数量,也是无符号数- 返回值:如果分配成功,则返回一个(一组)指向分配的堆块(堆块们)的指针,如果分配失败,则返回
NULL
IMPORTANT
malloc()和calloc()之间的不同点是,malloc不会设置内存为零,而calloc会设置分配的内存为零
5-3 realloc() 函数
更改已经配置的内存空间,即更改由 malloc() 函数分配的内存空间的大小
void *realloc(void *ptr, size_t size)参数以及返回值列表
*ptr:一个指针,指向堆块或者为空。- 返回值:看情况
- 如果重新申请的大小大于申请内存的大小,且当前内存段后面有需要的内存空间,则直接扩展这段内存空间,
realloc()将返回原指针 - 如果重新申请的大小大于申请内存的大小且当前内存段后面的空闲字节不够,那么就使用堆中的第一个能够满足这一要求的内存块,将目前的数据复制到新的位置,并将原来的数据块释放掉,返回新的内存块位置,相当于
free()+malloc() - 如果重新申请的大小小于申请内存的大小,堆块会直接缩小,被削减的内存会通过
free()释放 - 如果传入了一个空的堆块地址,但是
size不是 0 ,那么就相当于malloc() - 如果传入了一个正常的堆块地址,但是
size是 0 ,那么就相当于free() - 如果申请失败,将返回
NULL,此时,原来的指针仍然有效
5-4 free() 函数
释放之前调用 calloc()、malloc() 或 realloc() 所分配的内存空间
void free(void *ptr)参数以及返回值列表
*ptr:指针指向一个要释放内存的内存块,如果传递的参数是一个空指针,则不会执行任何动作,注意 free() 不会清除内存块的数据- 返回值:无
5-5 函数演示
#include <stdio.h>#include <stdlib.h>#include <string.h>
int main(){ setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); setvbuf(stderr, 0, 2, 0); //设置缓冲区
int *a = NULL, *b = NULL; a = (int *)ni(0x30); // 验证 malloc() b = (int *)malloc(0x200); // 隔离用,实际没有用 printf("申请的地址为: %p",a); // 验证 malloc() 返回值
memset(a,'m',0x30); free(a); a = (int *)malloc(0x30); // 验证 malloc() 特性 (雾) memset(a,'m',0x30); free(a); a = calloc(1,0x30); // 验证 calloc() 特性
free(a); free(b); //打扫现场 a = (int *)malloc(0x100); b = (int *)malloc(0x200); memset(a,'m',0x100); memset(b,'m',0x200); b = realloc(b,0x256); // 扩大但后面有空间 a = realloc(a,0x512); // 扩大但后面无空间 b = realloc(b,0x200); // 缩小但后面无空间 a = realloc(a,0x486); // 缩小但后面有空间 int *c = NULL; c = realloc(c,0x114); //c 为 nullptr c = realloc(c,0); // 释放
return 0;}我们直接打开 gdb 开始调试,运行到第一个申请 0x30 大小堆块的 malloc()
运行这个命令,查看堆,使用 heap 命令,看到堆块已经建立,堆块地址为 0x602000
执行printf函数,输出参数为数据的起始位置(上文提到了和堆块起始位置的不同),验证了 malloc() 会返回参数
接下来我们用 m 填满申请的堆块

free() 以后回来看,你会发现,填充的东西还在
TIP至于为什么有一块是空的,是因为它在释放后会进入 fastbin,上文提过这是一种标记被释放堆块的类型,它需要维护一个单链表结构带保存相同大小的被释放的堆块,所以作为一个单链表结构,需要保存一个指针指向前一个大小相同的 fastbin 堆块地址,之后会提到

当你经过 calloc() 函数,发现它和前面那个就是不一样,申请的内存被清空了!
现在让另外两个堆块表演
也被填充满了 m,现在让我们看扩大但后面有空间的情况
当我们执行了第一个 realloc() 函数,第二个堆块成功的变大了,原来的内容被保留,因为后面还有空间,所以就直接占用 Top Chunk 扩大自身
我们来看第一个堆块的这种扩大但后面无空间的情况,上一个堆块是因为后面还有 Top Chunk
而第一个堆块本身就是打头的,后面还有第二个堆块,显然不能简单的扩大,所以只能释放当前堆块,后面再申请一个新的
TIP紫色的是刚才的第二个堆块,绿色的是新申请的,被释放的堆块内存里仍然残留着 m
继续运行,刚才提到的堆块缩小有意思的情况来了,上文提到,Fast Bin 不轻易合并,但是当我们紧邻着 Top Chunk 呢?运行完这个函数,我们会发现缩小的堆块就在原地缩小了
但是我们等待的 Fast Bin 并没有出现,很明显是合并了(这里剩下的空间是 0x90,不属于 Fast Bin,所以会合并)||,所以我之前写错了这里就是正常的 free()||,这表示我们的堆块有缩小,并且被 free() 释放
继续运行,当指针为空时 realloc() 就相当于变成了 malloc() ,申请了新的堆块
继续运行,当大小为空时该堆块释放,相当于 free() ,执行后所圈堆块被释放(看不到是被合并了)