ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

how2heap学习指南:掌握glibc堆管理与三个经典利用案例

how2heap学习指南:掌握glibc堆管理与三个经典利用案例 说实话我最早看how2heap的时候是有点懵的。源码摆在眼前每行都认识连起来却不知道它到底在干嘛。后来发现问题是出在“学习顺序”上——直接一头扎进某个利用技巧的代码里却没有先建立起glibc堆管理器的全局认知也没有一套适合调试堆问题的操作习惯。这篇是how2heap深入学习系列的第一篇核心任务是把地基打牢。我会先用比较直白的方式把堆管理器最关键的几个数据结构讲清楚再带你搭一套顺手的调试环境然后挑how2heap里面三个最经典的入门案例——first_fit、fastbin_dup、poison_null_byte——一行一行拆开看。每个案例都会说清楚它到底利用了堆管理器的什么弱点以及你在GDB里应该看哪里才能验证自己的理解。整个过程尽量按我自己踩坑总结出来的顺序来讲希望能让你少走一些弯路。1. 内容整体设计与思路拆解1.1 为什么堆利用这么“绕”栈上的漏洞利用思路通常很直接溢出覆盖返回地址控制执行流就行。堆不一样堆管理器在用户程序和内核之间加了一层非常复杂的缓存逻辑你操作的是同一片内存但中间隔着一个时刻在变状态的分配器。所以堆利用的核心往往不是“往哪里写”而是“怎么让堆管理器的状态变成攻击者想要的样子”。how2heap这个项目就是围绕这个思路设计的。它把每一种经典的堆利用手法拆成独立的C源码文件每个文件都有一段精心构造的malloc/free序列配合注释说明让你看到“仅仅通过合法的malloc和free调用就能把堆布局折腾出漏洞”。用一句话概括how2heap训练的不是写exp的能力而是推演堆状态变化的能力。1.2 how2heap适合什么样的学习者如果你已经掌握了C语言指针和基本的GDB调试但对堆的理解还停留在“malloc返回一块内存free回收一块内存”这个层面那这个项目就是为你准备的。how2heap上手后你大概会经历三个阶段第一阶段是“看天书”每个词都认识但不知道为什么要连续malloc好几个chunk也不知道free之后再去malloc会发生什么。第二阶段是“顿悟”当你亲手在GDB里盯着堆内存看看到free之后的chunk头里被写入了fd和bk指针你突然明白“哦原来空闲chunk的组织方式是这样的”。第三阶段是“主动设计”看到题目能反推比如“这里可以伪造一个chunk size让分配器把某个特定地址当作chunk返回”。我这篇文章主要帮你完成第一阶段到第二阶段的跨越。第三阶段需要大量刷题积累不是一篇博文能解决的。1.3 学习路线怎么规划how2heap按利用对象可以分成几大类类别代表案例底层原理分配逻辑利用first_fit, tcache_dup, fastbin_dup空闲链表分配策略chunk头篡改poison_null_byte, unsafe_unlinkprev_size size 校验逻辑索引越界house_of_lore, house_of_spirit链表指针伪造环境变量攻击large_bin_attack, house_of_einherjarunsorted/large bin 的BK/FD写我建议的学习顺序不是按文件名字母排而是按“对堆管理器依赖程度从低到高”来排。先学不依赖特定版本甚至不依赖地址泄露的first_fit、fastbin_dup、poison_null_byte。这几个理解透了再看unsafe_unlink、house_of系列才会觉得顺。这篇文章我们先覆盖前三个。2. 前置知识glibc堆管理器到底在忙什么2.1 核心数据结构malloc_chunk在glibc中所有堆块无论已分配还是已释放都用同一个结构体描述定义在malloc.c里struct malloc_chunk { INTERNAL_SIZE_T mchunk_prev_size; /* 前一个chunk的大小如果前一个空闲 */ INTERNAL_SIZE_T mchunk_size; /* 当前chunk大小含头部低3位是标志位 */ struct malloc_chunk* fd; /* 空闲链表前驱指针 */ struct malloc_chunk* bk; /* 空闲链表后继指针 */ };这里有两个非常容易混淆的点我先帮你踩平第一prev_size字段不是给自己用的是给“物理相邻的前一个chunk”用的。如果前一个chunk是空闲的那它的size会写在这里方便向后合并如果前一个chunk正在被程序使用那这个字段就是废的程序数据可以直接覆盖它。很多堆漏洞就是利用了这个“被覆盖也不报错”的特性。第二fd和bk指针只有在chunk处于空闲状态时才有意义。一个已分配出去的chunk它的fd、bk位置存的是用户数据。所以你在GDB里看到一个已分配chunk里有奇怪数据不要慌那是用户写进去的内容不是堆管理器的链表指针。chunk的size字段低三位是标志位位名称含义0PREV_INUSE前一个chunk是否被占用1IS_MMAPPED是否通过mmap分配2NON_MAIN_ARENA是否属于非主分配区平时最常打交道的还是PREV_INUSE。释放一个chunk时分配器会检查物理相邻的下一个chunk的PREV_INUSE位如果它等于0说明前一个chunk空闲就触发向前合并。2.2 空闲chunk怎么组织bins的层级体系glibc用一组双向链表bins来管理空闲chunk按大小分为几档fast bins尺寸在64字节到128字节之间默认配置下的小chunk释放后不合并采用单链表LIFO后进先出方式组织存取速度最快。这是入门阶段打交道最多的bin。unsorted bin一个特殊的缓存层。释放一个非fastbin大小的chunk或者从top chunk切分后剩余部分过大都会先进unsorted bin。下次malloc时优先从这里找找不到再分到对应size的small/large bin。small bins尺寸小于1024字节64位下的空闲chunk按大小精确分桶每个桶是双向链表。large bins大于等于1024字节的空闲chunk按大小范围分桶链表中按大小降序排列。分层结构用生活场景类比fast bins像餐厅吧台的免费小零食随到随取unsorted bin像厨房刚出锅的一盘菜谁都能先看一眼合不合胃口small/large bins则是后厨冷柜里按尺寸分好的备菜。这个分层的存在决定了malloc并不是“每次请求都从头找一块”而是先查最方便的bin。所有堆利用本质上都是在骗这个“查找逻辑”。2.3 top chunk与堆扩展堆区最顶端有一个特殊的chunk叫top chunk它代表堆剩余可分配空间的边界。当所有bin里都找不到满足大小的chunk时malloc会从top chunk中切一块出来。top chunk的size字段通常很大因为它是“所有剩余空间的总和”。top chunk本身也可以被利用。比如house_of_force就是伪造top chunk的size让下一次malloc返回任意地址。不过这个技巧在较新的glibc里已经被加了校验这里先有个印象即可。3. 环境准备一套顺手的堆调试工作台3.1 方案选型Docker pwntools pwndbg我推荐的环境组合如下Ubuntu 22.04或20.04Docker镜像pwntoolsCTF中使用最广泛的Python利用开发库gdb pwndbg插件pwn调试必备能直接显示heap布局和bin链表patchelf glibc源码用于切换不同版本的libc按需调试环境搭建的核心思路是尽量用Docker隔离不同版本的libc。因为how2heap里有些技巧只对特定glibc版本有效比如glibc 2.27之前没有tcache很多技巧行为完全不同你不可能在宿主机上频繁切换系统libcDocker是最省心的方案。3.2 拉取镜像并安装工具如果你已经有了Docker环境直接docker pull ubuntu:22.04 docker run -it -v /your/how2heap/path:/root/how2heap ubuntu:22.04 /bin/bash进入容器后安装基础工具apt update apt install -y build-essential gdb python3 python3-pip git patchelf pip3 install pwntoolspwndbg的安装建议直接用官方脚本git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh安装完成后重启gdb会自动加载pwndbg。它提供的heap、bins、fastbins等命令能让我们非常直观地看到堆内存的当前状态。如果你在切换glibc版本时遇到问题可以用patchelf修改二进制的动态链接器路径配合LD_PRELOAD加载指定版本的libcpatchelf --set-interpreter /glibc/2.27/lib/ld-2.27.so ./exp LD_PRELOAD/glibc/2.27/lib/libc-2.27.so ./exp不过这里有个坑patchelf只能改解释器路径不会自动把libc的路径一起改掉。所以通常得在用patchelf之后再手动设置LD_PRELOAD或者用pwntools的pwninit这类工具自动处理。3.3 编译how2heap源码how2heap仓库里的源码是按glibc版本分类存放的。先clone下来git clone https://github.com/shellphish/how2heap.git cd how2heap编译某个文件比如first_fit.cgcc -o first_fit first_fit.c -g编译时一定要加-g选项把调试符号打进去否则GDB里看不到变量名和源码行号调试效率会大打折扣。注意how2heap仓库里有些文件是针对特定libc版本写的直接编译可能会因为分配器行为差异而出现不同的输出。当你看到某个文件在本地环境跑出来的结果和注释不一致先别急着怀疑代码检查一下当前libc版本。4. 案例一first_fit首次适应分配4.1 源码与运行效果我们先看how2heap里最简单的first_fit.c。这个文件很短核心逻辑是#include stdio.h #include stdlib.h #include string.h int main() { char* a malloc(512); char* b malloc(256); char* c; printf(a %p\n, a); printf(b %p\n, b); free(a); c malloc(500); printf(c %p a %p\n, c, a); return 0; }运行后会看到c的地址和a完全一样。这就意味着你释放了a然后又申请到了一块和被释放的a相同地址的内存而旧的指针a仍然指向那个地址。4.2 背后的分配逻辑这个行为并不神秘。glibc的malloc在分配时采用“首次适应”策略——它会在空闲链表里找第一个大小足够的chunk来满足请求。你释放的a是512字节后来请求500字节fast bin和small bin里恰好有这块空闲chunk于是直接返回。注意这里有个关键细节malloc返回的地址和free之前的指针完全一致包括chunk头都还在原位置。也就是说分配器并没有“重置”这块内存。如果你在free后、再次malloc前通过另一个指针修改了这块内存的内容那么malloc之后拿到的数据就是被修改过的。4.3 安全影响与局限性first_fit本身不是漏洞但它是一切“堆重叠”利用的地基。很多堆漏洞的本质是程序里存在两个指针指向同一块内存或者逻辑上可以通过UAF访问已释放内存。first_fit机制恰好允许你释放一块内存后再申请一块同地址的内存——如果你手里还保留着旧指针就获得了对同一块内存的双重访问能力。它的局限性也很明显单纯通过first_fit只能实现“地址重用”并不能直接控制执行流。要想把它变成实际漏洞利用通常需要结合UAF释放后使用漏洞。比如程序free了一个对象但没有把指针置NULL你之后又能触发malloc分配同样大小旧指针就指向了“新分配但内容可控”的内存进而实现类型混淆。4.4 实操在GDB里观察bin的变化这一步我强烈建议你亲手做一遍它能帮你建立对“释放后内存变成什么样”的直觉。gdb ./first_fit在free(a)那行打断点然后b free run heap在调用free之前输入heap你会看到当前堆上有两个chunk分别对应a和b还有顶部的top chunk。注意此时a的chunk虽然还在原地址但属于“已分配”状态size字段的标志位PREV_INUSE等于1。然后继续执行进入free内部finish heap bins现在heap会显示a所在的chunk大小变化可能还是一样的size并且bins命令能看到它进入了空闲链表——如果是fastbin会显示在fastbins列表里fd指针可能是NULL因为它是唯一一项。如果你再调用一次malloc(500)这个chunk就会从fastbin里被弹出来重新变成已分配状态。实操心得我第一次做这个实验时最大的困惑是“为什么bins命令看不到small bin里的chunk”。后来才反应过来fastbin大小的chunk释放后只在fastbins里不会进small bin。而fastbin的单向链表结构在printf里显示可能不直观用x/20gx chunk地址直接看内存反而更清楚被释放的chunk头部fd字段被改成链表下一项的地址bk字段被清空。这就是“空闲chunk使用fd/bk字段”最直观的体现。5. 案例二fastbin_dupfastbin双重释放5.1 源码与运行效果fastbin_dup是how2heap里第二个经典入门案例。它的核心代码很短#include stdio.h #include stdlib.h int main() { void* a malloc(8); void* b malloc(8); void* c malloc(8); printf(a %p\n, a); free(a); free(b); free(a); // double free! void* d malloc(8); void* e malloc(8); void* f malloc(8); printf(d %p\ne %p\nf %p\n, d, e, f); return 0; }运行后你会看到d和e的地址完全相同都是a原来的地址而f的地址是b原来的地址。也就是说通过“对同一个chunk释放两次”我们让malloc连续两次返回了同一块内存。5.2 为什么fastbin允许double freefastbin是单链表后进先出。释放a时fastbin链表的头部变成a释放b时头部变成ba变为b的fd再释放a时头部变成aa的fd指向b——链表变成a - b - a。然后连续三次malloc第一次malloc从fastbin头部弹出a链表变成b - a第二次malloc从fastbin头部弹出b链表变成a第三次malloc又弹出a。等等第一次已经把a弹出去了为什么第三次还能从链表里弹出a问题就出在“第二次弹出b之后链表头部是a但a已经被分配出去了它作为chunk的size还在分配器没法判断a是不是“正在被使用”。只要fastbin链表里还有指向a的指针它就会再次返回a。fastbin的double free之所以能成功是因为fastbin在free时只检查了“要释放的chunk和fastbin链表的首元素是否相同”并没有深入检查整个链表。5.3 从double free到任意地址分配你要是觉得fastbin_dup只是“能拿到两个相同指针”那就太小看它了。完整利用链还要继续先double free拿回两个指向同一块内存的指针d和e。修改d指向的数据或者释放后再修改chunk头部。把某个恶意构造的地址比如target_addr - 0x10写进chunk的fd字段。下一次malloc时fastbin链表头部指向这个伪造地址。再下一次malloc分配器就会返回伪造地址处的内存。经典的fastbin attack就是这样通过改写fastbin里空闲chunk的fd让malloc返回一个“你指定地址-0x10”处的内存从而向任意地址写入数据。不过这里有个glibc的检查从fastbin里取出chunk时会校验这个chunk的size是否落在对应fastbin的合法范围内即64~128字节且8字节对齐。所以攻击者通常需要找一个size合法的伪chunk来绕过。这个检查不是绝对安全的很多CTF题目都提供了size可控的堆块来突破。5.4 实操验证double free的链表变化为了看清单链表的变化我们稍微改动一下源码在关键位置加断点和打印#include stdio.h #include stdlib.h int main() { void* a malloc(8); void* b malloc(8); void* c malloc(8); free(a); getchar(); // 断点1观察此时fastbin free(b); getchar(); // 断点2观察此时fastbin free(a); // double free getchar(); // 断点3观察double free后fastbin void* d malloc(8); void* e malloc(8); void* f malloc(8); return 0; }编译后在GDB里跑每到一个getchar就停下来用bins和x/20gx heap地址查看fastbin链表内容第一次free后fastbin头部指向aa的fd为NULL。第二次free后fastbin头部指向bb的fd指向a。第三次free后double freefastbin头部指向aa的fd指向b。这个“a的fd指向bb的fd又指回a”的循环就是fastbin_dup的链表环。理解了这个环后续malloc的弹出顺序就完全在掌控之中了。注意在较新的glibc2.27里tcache机制对同样大小的chunk会做额外的key检查防止double free。所以fastbin_dup的原始版本在默认环境下可能无法直接复现。how2heap仓库里专门为不同glibc版本存放了不同目录新版对应的是glibc_2.27目录下的fastbin_dup.c但思路完全一致只是把fastbin换成了tcache。这点我放到下一篇文章详细讲。6. 案例三poison_null_byte空字节溢出6.1 漏洞场景先看一个非常典型的程序片段#include stdio.h #include stdlib.h #include string.h int main() { char* a malloc(0x100); char* b malloc(0x100); char* c malloc(0x100); // 模拟一个漏洞向a缓冲区写入时多写了一个\0 memset(a, A, 0x100); // 假设这里存在off-by-null漏洞实际代码可能是: // a[0x100] \0; // 越界写了一个字节的\0 free(b); free(a); return 0; }很多真实程序在处理字符串、读取固定长度数据时会不小心多写入一个字节的\0C语言字符串必须以\0结尾。这个“多写一个空字节”看起来微不足道但它直接命中了下个chunk的size字段。6.2 攻击原理篡改PREV_INUSE位在64位系统下chunk的size字段是8字节对齐的所以它的低3位永远是0。malloc和free在判断“前一个chunk是否空闲”时只看size字段的最低位的PREV_INUSE。如果我们在chunk a的缓冲区越界写一个\0它正好会覆盖chunk b的size字段的低字节。正常情况下chunk b的size可能是0x1110x100用户数据 0x10头部 0x1标志位被覆盖成0x100后PREV_INUSE位直接变为0意味着“前一个chunk也就是a被释放了”。但事实上a并没有被free——这只是攻击者伪造的假象。这个操作叫“poison null byte”关键点在于成功条件前面有一个大小合适的chunka后面紧跟着另一个chunkb攻击者能越界写一个空字节覆盖b的size低字节。直接后果b的PREV_INUSE位被清零后续free(b)时分配器会认为b前面的a已经空闲从而触发合并把a和b合并成一个大的空闲chunk但这个大chunk的实际覆盖范围比正常情况更大。6.3 构造overlapping chunk合并发生后a和b在物理上是连续的现在它们是一个大的空闲chunk。但攻击者手里还保留着对a和b的指针因为free的只是ba并没有真正被free逻辑上仍然可用。于是接下来申请一个比a更大的chunk它会落在这个合并区域上和旧指针a/b形成重叠。这就是overlapping chunk同一个物理内存区域被多个逻辑堆块引用。有了overlapping chunk能做的事就多了通过新申请的chunk修改旧chunk内容实现任意内存读写。利用旧chunk更新某些指针实现GOT表劫持或__free_hook劫持。不过poison_null_byte的利用有个细节合并时分配器会校验下一个chunk的prev_size是否等于前一个chunk的size。所以攻击者往往还得伪造prev_size字段。在how2heap的poison_null_byte.c里它专门构造了一个0x80大小的chunk通过某种方式让prev_size和size对齐。这个过程比较绕但核心思路就是“伪造prev_size以通过校验”。6.4 实操用GDB观察合并过程直接在poison_null_byte.c上打断点gdb ./poison_null_byte在free(b)处断开运行到此处heap命令应该能看到三个chunka0x100用户区、b0x100用户区、c0x100用户区。此时先看每个chunk的size字段特别是b的size和PREV_INUSE位。执行finish跳过free(b)后再输入heap你会发现a和b已经被合并成一个0x210左右的大chunk0x100 0x10 0x100并且已经进入unsorted bin。c仍然保持已分配状态size字段不变。注意a的物理地址还在但因为已经被合并它的头部不再是独立的chunk而是被当作大chunk的一部分。实操心得我在第一次做这个实验时一直没搞懂为什么合并后a的地址还“看起来”是一个chunk。后来才明白物理上和逻辑上是两回事。物理上a和b是连续的合并没有移动任何数据逻辑上分配器只把它当作一个大的空闲chunk。你的指针还指向那个地址但分配器已经不认识“a”这个概念了。这就是堆利用里经常说的“逻辑与物理分离”。7. 常见问题与排查技巧实录7.1 GDB里看不到bin变化如果你在调用free之后用heap命令看不到bin的变化大概率是fastbin和small bin的显示方式问题。pwndbg的bins命令左侧会把fastbin单独列一栏如果你释放的chunk大小超过了fastbin范围默认128字节它不会出现在fastbins里而是进unsorted bin。进unsorted bin后pwndbg会把它列在largebins或unsortedbin条目下注意区分。如果完全没显示检查是否忘记加-g编译选项或者gdb没有正确加载pwndbg插件。7.2 free同一个chunk两次导致程序崩溃在新版本glibc上直接跑fastbin_dup很可能会在第二次free时触发malloc(): double free or corruption (fasttop)错误。这不是你写错了而是glibc版本行为差异。解决方案有三个关闭ASLRsetarch -R ./program只是治标不治本检查还是存在。用老版本libc环境跑比如glibc 2.23经典fastbin_dup在这个版本上能直接成功。改用tcache_dup的思路在tcache机制下double free更容易触发tcache只检查头部相同但利用时通常需要先填满tcache再操作。how2heap仓库把不同glibc版本的案例分目录存放就是为了避免这种版本冲突。建议你对照自己的glibc版本选择正确目录。7.3 poison_null_byte里prev_size校验失败如果你观察到合并没有发生而是直接报错退出最常见原因是攻击者构造的prev_size和上一个chunk的实际size不匹配。上一个chunk的PREV_INUSE位没有被正确清零。合并时下一个chunk被分配器检查发现size异常。排查方法在触发合并前用x/4gx b的chunk地址打印b的size和prev_size手动确认chunk b 的prev_size字段 chunk a 的 size chunk b 的size字段 0x100PREV_INUSE位为0如果不满足就回去检查是哪个字节没有覆盖到位。7.4 如何快速判断一个堆利用适合什么libc版本简单说先看它利用的是哪个bin再查这个bin在不同版本glibc里的行为变化。涉及fastbin double freeglibc 2.26及以下是经典fastbin_dup2.27及以上必须考虑tcache。涉及unsorted bin attack2.27前后行为差别很大很多老的unsorted bin技巧在2.29后被加入校验不再生效。涉及large bin attackglibc 2.30之后修改了large bin的插入逻辑老方法基本失效。how2heap每个源码文件头部的注释都会标注适用版本。养成看注释的习惯能省去很多调试时间。8. 后续学习路线图到这里我们已经通过三个案例理解了堆分配器的三个核心弱点first_fit暴露了“释放后地址可重用”的特性fastbin_dup暴露了“fastbin链表不回查导致双重释放”的漏洞poison_null_byte展示了“通过一个字节的越界写伪造chunk头制造堆重叠”的完整攻击链。下面一条比较自然的学习路线后续文章会逐一展开阶段内容对应how2heap文件第二阶段tcache机制与tcache_dup、tcache_poisontcache_dup.c, tcache_poison.c第三阶段unsorted bin attack、large bin attackunsafe_unlink.c, house_of_lore.c第四阶段house of系列house_of_spirit、house_of_force、house_of_einherjar等house_of_*.c第五阶段结合真实题目练习将how2heap技巧迁移到CTF场景各CTF writeup每个阶段我都会沿用这篇文章的方式先讲清楚glibc内部机制再逐行拆解源码最后用GDB实操验证。我个人在实际操作中最大的体会是看how2heap的源码只是第一步真正动手在GDB里一步一步观察内存变化才是收获最大的环节。建议你每篇文章的每个案例都亲手跑一遍哪怕只是跟着把命令敲一遍也比只看不练强十倍。后面几篇我们接着把这些经典利用一个个啃下来。
返回列表