Background banner
3572 字
18 分钟
Pwn Glibc 堆利用 - 入门

本文在 2024.02 编写,于 2026.01 进行修改与完善
||该文章为被压榨要求完成主人任务录视频的新手时期所写,虽然我进行了修正,但是可能会出现包括但不限于语言风格跳跃,质量参差不齐,左右脑互博,思维不连贯等不和谐的地方||

1. 堆与环境的关系#

由于堆的特性与 Glibc 的版本息息相关,如果想要了解不同版本的特性,甚至是面对不同的题目,你需要更换你的 Glibc 版本,下面几种方法可供你使用:

1-1 更换题目运行环境的 libc#

要观察不同环境下堆的表现,或者是启动对应版本的题目,可以使用下面的方式

  • patchelfglibc-all-in-one 前者可以指定某个二进制文件使用对应的 Glibc 版本运行,后者则是可以方便你下载各个版本
  • 如果你有一个特定版本的 libcld,在你写的脚本中打开本地进程这么写:
    process([" ld 路径 ", "./题目"], env={"LD_PRELOAD":" libc 路径 "})
    LD_PRELOAD 加载的库有很高的优先级,它也可以广泛的用于 C++ 环境的更改中
  • 使用一些自动化检测 libc.so.6 版本并自动替换环境的脚本

1-2 使用对应 libc 版本编译#

如果你要出题,或者是测试一些 Demo 你需要编译对应版本的程序,首先需要注意的是:低版本 Glibc 环境无法运行高版本系统编译出的程序以及程序所用到的在高版本系统上依赖 Glibc 编译的库,其次高版本兼容低版本系统编译的程序但是特性按高版本的来,所以最好是想哪个版本就使用哪个版本的环境编译

  • 最简单的,把 Ubuntu 16.04Ubuntu latest version 中所有流行的发行版全下载配置一遍,在对应 libc 版本的系统完成编译与测试,安装 libc-dbgglibc-source 两种包来为调试的时候添加调试符号和源码
  • 下载好对应版本的 libcldGCC 编译时指定参数 rpathdynamic-linker
    Terminal window
    gcc test.c -o test -Wl,--rpath=/path/to/x86_64-linux-gnu/ -Wl,--dynamic-linker=/path/to/ld-linux-x86-64.so.2
    rpath 目录一般包含但不限于下面文件pwn_glibc-heap_summary-DA656336-6EA6-4A5B-9999-5B6FA36F89B9
    编译的效果如下pwn_glibc-heap_summary-0B631024-B484-4CF9-96D3-F31260D20124
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 段在内存中的位置:
pwn_glibc-heap_summary-68F80BE7-66F6-4FC1-80EF-9DCDEC0790DF可以看到,堆在内存中的位置处于库函数内存映射段数据段之间,在 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;
}

第一次申请之前,在我们申请内存之前,堆段并没有出现pwn_glibc-heap_summary-D1918264-C311-4F2B-853A-6CECD0194E45
第一次申请后,堆段被建立了,通过vmmap观察堆的size,我们得到了21000字节的堆内存pwn_glibc-heap_summary-B1E6BFBF-8028-4CF2-BE70-675B18759A2F

TIP

为什么和实际申请的大小差距很大
不这么干你就没法做题
当申请堆的内存时,我们可以随时随地申请,而且每次申请的内存有大有小,管理申请的堆的工作量会非常大,而申请内存的操作本身需要通过系统调用
如果 malloc() 函数仅仅封装了可以分配内存的系统调用,那么频繁地调用系统调用会很消耗系统资源
所以针对这个问题,程序第一次可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序,这样的话可以避免上面假设所说的频繁系统调用,就避免了多次内核态与用户态的切换,提高了程序的效率

这 0x21000 不会全用完,我们会从得到的内存上分配所申请的相应的小段内存,称为 Chunk(Allocated Chunk),并返回地址给程序pwn_glibc-heap_summary-08B6FA79-9182-4AC2-90F6-2639E017F1B6
如果你申请了很多的堆块,不考虑释放堆块,那他们会按申请的顺序依次从低地址到高地址排列pwn_glibc-heap_summary-836230C2-068F-4D66-98A5-3E2603391D9DGlibc 有自己的内存管理器来进行内存分配操作 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 :一个指针,指向堆块或者为空。
  • 返回值:看情况
  1. 如果重新申请的大小大于申请内存的大小,且当前内存段后面有需要的内存空间,则直接扩展这段内存空间,realloc() 将返回原指针
  2. 如果重新申请的大小大于申请内存的大小且当前内存段后面的空闲字节不够,那么就使用堆中的第一个能够满足这一要求的内存块,将目前的数据复制到新的位置,并将原来的数据块释放掉,返回新的内存块位置,相当于 free() + malloc()
  3. 如果重新申请的大小小于申请内存的大小,堆块会直接缩小,被削减的内存会通过 free() 释放
  4. 如果传入了一个空的堆块地址,但是 size 不是 0 ,那么就相当于 malloc()
  5. 如果传入了一个正常的堆块地址,但是 size 是 0 ,那么就相当于 free()
  6. 如果申请失败,将返回 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 pwn_glibc-heap_summary-6C94AB9F-2365-495F-BC5F-D4767FE2C17A执行printf函数,输出参数为数据的起始位置(上文提到了和堆块起始位置的不同),验证了 malloc() 会返回参数pwn_glibc-heap_summary-740F1209-FB7C-4545-8A1B-DD66E52F7EA2
接下来我们用 m 填满申请的堆块
pwn_glibc-heap_summary-CB91EC19-73E0-493E-A13B-E282E1325711
free() 以后回来看,你会发现,填充的东西还在,如果有什么本该清除不该出现的东西留下了,我们是不是就可以……

TIP

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

pwn_glibc-heap_summary-61500A15-89F4-42AD-A99A-E1FA49433011
当你经过 calloc() 函数,发现它和前面那个就是不一样,申请的内存被清空了!pwn_glibc-heap_summary-DD5B3CF1-28C0-4796-9A2B-A99F0F827340
现在让另外两个堆块表演pwn_glibc-heap_summary-2CB9DF51-5540-495C-AE51-581D79BB93C0也被填充满了 m,现在让我们看扩大但后面有空间的情况pwn_glibc-heap_summary-E672D479-D3BA-4B13-9347-C38EC44F8BB1当我们执行了第一个 realloc() 函数,第二个堆块成功的变大了,原来的内容被保留,因为后面还有空间,所以就直接占用 Top Chunk 扩大自身pwn_glibc-heap_summary-527B14A1-304A-4644-BAC1-3DCF2259393F我们来看第一个堆块的这种扩大但后面无空间的情况,上一个堆块是因为后面还有 Top Chunk
而第一个堆块本身就是打头的,后面还有第二个堆块,显然不能简单的扩大,所以只能释放当前堆块,后面再申请一个新的

TIP

紫色的是刚才的第二个堆块,绿色的是新申请的,被释放的堆块内存里仍然残留着 m

pwn_glibc-heap_summary-9BE68A96-579E-4787-B56D-6F1575813D7E继续运行,刚才提到的堆块缩小有意思的情况来了,上文提到,Fast Bin 不轻易合并,但是当我们紧邻着 Top Chunk 呢?运行完这个函数,我们会发现缩小的堆块就在原地缩小了
但是我们等待的 Fast Bin 并没有出现,很明显是合并了(这里剩下的空间是 0x90,不属于 Fast Bin,所以会合并)||,所以我之前写错了这里就是正常的 free()||,这表示我们的堆块有缩小,并且被 free() 释放pwn_glibc-heap_summary-9775D48D-6D4D-43A4-B07B-CF017970DE3F
继续运行,当指针为空时 realloc() 就相当于变成了 malloc() ,申请了新的堆块pwn_glibc-heap_summary-85064E17-55D0-40E4-A7C8-603C6FE1FEF7
继续运行,当大小为空时该堆块释放,相当于 free() ,执行后所圈堆块被释放(看不到是被合并了)pwn_glibc-heap_summary-EA242F5C-5072-42D1-852C-39A4074B6EB1

Pwn Glibc 堆利用 - 入门
https://blog.lufiende.work/posts/ctf/pwn-tutorial/glibc-heap/pwn_glibc-heap_summary/
作者
Lufiende
发布于
2026-02-18
许可协议
CC BY-NC-SA 4.0