从Off-by-One到Chunk Overlapping:堆漏洞利用实战精讲

📅 2026/7/4 11:40:10
从Off-by-One到Chunk Overlapping:堆漏洞利用实战精讲
1. 项目概述一次从实战到原理的堆漏洞精讲在CTF的Pwn二进制安全利用领域堆漏洞的利用一直是区分“脚本小子”和真正理解者的分水岭。相比于栈溢出相对固定的利用模式堆利用充满了不确定性需要利用者对内存管理机制有深刻的理解和灵活的构造能力。今天我们不谈空洞的理论直接从一道经典的CTF题目——HITCON Training lab13出发手把手带你拆解一个典型的Off-by-One漏洞并利用它实现精妙的Chunk Overlapping堆块重叠最终拿到shell。这个过程就像是在完成一次精密的外科手术你需要知道每一刀下在哪里以及为什么这么下刀。这个项目标题的核心是“Off-by-One漏洞”和“Chunk Overlapping”。对于新手来说这两个词可能有些陌生。简单来说Off-by-One是一种边界错误通常发生在读写操作时多写或少写了一个字节最常见是多写一个\x00空字节。而Chunk Overlapping则是我们的目标即通过某种手段让两个本应独立的堆块在内存空间上产生重叠从而实现对其中一个堆块内容的篡改最终导向任意地址读写或代码执行。本篇文章将彻底解析从漏洞触发到利用链构建的完整逻辑并提供可直接复现的EXP漏洞利用程序。无论你是刚接触堆利用的初学者还是想深化理解的进阶者这篇详尽的实战记录都将为你提供清晰的路径。2. 核心漏洞原理Off-by-One的“一字之差”在深入lab13之前我们必须先夯实基础理解Off-by-One漏洞在堆管理上下文中的独特破坏力。2.1 堆管理基础glibc ptmalloc2 速览现代Linux程序默认使用glibc中的ptmalloc2分配器管理堆内存。它并非将堆视为一个整体而是划分为一个个“块”Chunk。每个被分配出去的块Allocated Chunk和空闲的块Free Chunk在内存中都有特定的结构。对我们利用至关重要的是每个Chunk的“元数据”它们位于用户实际可用的数据区user data之前。一个Chunk的基本结构如下以64位系统为例关注关键字段---------------------- -- Chunk指针指向这里 | Prev Size | (仅当上一个Chunk空闲时有效) ---------------------- | Size | (当前Chunk的大小低3位用作标志位) ---------------------- -- mem指针malloc返回的地址指向这里 | User Data | | ... | ----------------------Size字段记录了当前Chunk的总大小包括元数据。这个大小的低3位被用作标志位PREV_INUSE (P)最低位。为1表示上一个物理相邻的Chunk处于已分配状态。这是Off-by-One利用的关键。IS_MMAPPED (M)为1表示该Chunk是通过mmap直接分配的非常见情况。NON_MAIN_ARENA (N)为1表示该Chunk不属于主分配区。 因此我们常说的Chunk大小需要size ~0x7来获取实际长度。2.2 Off-by-One漏洞的精确定义与成因Off-by-One顾名思义“差一个”。在堆的语境下特指在向堆块写入数据时由于循环条件错误、字符串操作不当如strcpy遇到终止符等原因向相邻的下一个Chunk的Size字段写入了单个字节通常是\x00。一个典型的漏洞代码片段char *buf malloc(24); // 分配24字节用户空间 read(0, buf, 24); // 假设这里可以读取24字节 buf[24] \0; // 错误越界写了一个空字节到第25字节处如果第25字节恰好是下一个Chunk的Size字段的最低有效字节LSB这个操作就将下一个Chunk的Size改小了。更常见的是由于字符串函数如strcpy、sprintf会在复制结束后自动添加终止符\x00如果目标缓冲区大小计算失误就可能导致这个终止符覆盖到Size字段。2.3 一字之差的连锁反应从Size改写到布局破坏为什么覆盖一个字节如此危险因为它直接篡改了堆分配器的“地图”——Size字段。假设我们有两个相邻的堆块A和BB在A的高地址方向。A是漏洞发生的块。B是一个正常的堆块。B的Size原值为0x91二进制10010001表示B的大小为0x90字节且上一个块A是已分配的P位为1。如果Off-by-One漏洞向B的Size字段写入了\x00则B的新Size变为0x90二进制10010000。变化在于最低位的P位从1变成了0。这意味着在堆分配器的视角里上一个物理相邻的Chunk即A变成了“空闲”状态尽管A实际上仍被程序使用着。这个错误的状态信息为后续一系列“骚操作”打开了大门是触发Chunk Overlapping的导火索。3. 靶场环境搭建与题目静态分析理论需要实践来验证。我们首先搭建攻击环境并深入分析lab13这道题。3.1 实验环境准备为了完全复现建议在Linux虚拟机如Ubuntu 20.04中进行。关键组件如下操作系统Ubuntu 20.04 LTS (64位)其默认glibc版本2.31包含tcache等现代机制但lab13通常设定为更传统的版本。题目文件从HITCON Training官网或开源仓库获取lab13可执行文件及对应的libc.so.6。调试工具gdb核心调试器必须掌握。pwndbg或gef强大的gdb插件能可视化显示堆内存布局、bin状态等极大提升效率。本文演示使用pwndbg。patchelf用于修改二进制文件的动态链接库确保在本地运行正确的libc。python3pwntools编写EXP的利器。安装和配置好这些工具是成功的第一步。确保你的gdb启动后能看到pwndbg的提示符。3.2 程序功能与漏洞点定位使用checksec lab13查看程序保护Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)关键信息64位程序未开启PIE代码地址固定未开启栈金丝雀但开启了NX堆栈不可执行。这意味着我们的利用需要围绕代码复用如ROP或修改已有函数指针展开。通过反编译使用IDA或Ghidra或直接运行可以了解到程序大概是一个简单的“记事本”程序具有以下功能新建记事分配一个指定大小的堆块并读入内容。显示记事打印指定索引记事的内容。编辑记事对指定索引的记事进行编辑这里就是漏洞点。删除记事释放free指定索引的记事。漏洞通常隐藏在“编辑”功能中。通过逆向分析编辑函数的伪代码你会发现类似这样的逻辑int edit_note(int index) { chunk *note notes[index]; if (!note) return -1; printf(“Input new content: “); int size note-size; // 假设这里存储了用户申请的大小 read_input(note-content, size); // 危险 }问题在于read_input或使用的输入函数可能是fgets、read允许写入的字节数等于用户当初申请的大小。如果申请时大小为N那么该Chunk用户数据区的实际可用空间是N。但是如果输入函数恰好写入了N个字节且没有在末尾自动添加终止符那么是安全的。然而如果程序在之后出于“良好习惯”主动在content[N]的位置添加一个\x00作为字符串终止符就会发生Off-by-One覆盖下一个Chunk的Size字段。通过动态调试在编辑函数处下断点并精心控制输入长度可以验证这一漏洞的存在。你会发现当你编辑一个恰好位于某个特定大小的堆块时下一个堆块的Size字段最低字节被清零。4. 利用链设计与堆风水布局确认漏洞后我们不能盲目行动。需要设计一个周密的利用计划将“覆盖一个字节”的能力放大为“控制程序流”的结果。核心思路是利用被错误标记为free的Chunk A触发堆合并制造Chunk Overlapping然后通过重叠的块实现任意地址写最终劫持控制流。4.1 阶段一堆状态塑造首先我们需要通过程序的分配和释放功能塑造出有利于利用的堆内存布局。这通常被称为“堆风水”Heap Feng Shui。一个经典的布局如下分配块A大小精心计算例如0x98用户申请0x90。目的是让它的用户数据区末尾恰好对齐下一个Chunk的Size字段。分配块B大小例如0x68用户申请0x60。这是我们将要“吞噬”的目标块之一。分配块C大小例如0x68用户申请0.60。这是另一个重要块用于防止与top chunk合并。分配块屏障块一个较大的块防止后续操作影响到更远处的内存。为什么是这个大小在64位系统中malloc(0x90)实际会分配0xa0大小的Chunk0x90用户区 0x10元数据。我们需要确保块A的用户区末尾A0x90正好是块B的Size字段地址。通过调试可以精确计算。块B和C的大小选择需使其在释放后进入fastbin或small bin便于我们后续利用。布局完成后内存视图大致为[A | B | C | Barrier]4.2 阶段二触发Off-by-One与伪造空闲块现在我们编辑块A写满0x90个字节。如果漏洞存在第0x91个字节即A0x90会被写入\x00这恰好是块B的Size字段的最低字节。假设块B原Size为0x71大小0x70P1。覆盖后变为0x70P0。此时堆管理器认为块A是“空闲”的。接着我们释放块B。free(B)会发生什么释放器检查前一个块即A的状态。根据B的新Size字段P0它认为A是空闲的。为了减少碎片堆管理器会尝试进行“向前合并”Coalescing将A和B合并成一个大的空闲块。合并的过程需要找到A的起始地址。它通过B的地址 - B的PrevSize来定位A。这里有一个关键点合并依赖于块B的PrevSize字段是否正确记录了块A的真实大小。因此在释放B之前我们必须通过Off-by-One或其他方式提前修改块B的PrevSize字段将其设置为块A的总大小例如0xa0。这样free(B)时就能正确地找到“空闲”的块A的起始地址并将A和B合并。合并后我们得到了一个大的空闲块D其范围覆盖了原来A和B的空间。但是我们的程序中仍然持有一个指向原来A的用户数据的指针即notes[0]。这就产生了“悬挂指针”并且这个指针指向了这个大空闲块D的内部。4.3 阶段三制造Chunk Overlapping现在我们请求分配一个新的大块E其大小正好等于合并后的大空闲块D的大小例如0xa00x700x110。堆管理器很可能就会把刚刚合并出来的D块分配给我们。于是神奇的事情发生了我们通过notes[0]旧指针仍然可以访问和修改新块E的前半部分即原A区域。我们通过notes[1]如果之前没有真正释放B的指针可能需要管理或新分配的E的指针可以访问整个E块。这意味着我们拥有了两个指针notes[0]和指向E的指针它们操作的内存区域存在重叠。这就是Chunk Overlapping。我们可以通过notes[0]去修改E块的元数据例如修改E的Size字段或者更关键地修改E块内容中的某个指针。4.4 阶段四劫持控制流有了任意写的能力目标就明确了修改某个函数指针或数据指针使其指向我们控制的代码或数据。在NX开启的情况下通常采用以下两种路径攻击GOT表由于是Partial RELRO全局偏移表GOT是可写的。我们可以将某个常用函数如free、puts的GOT项修改为system函数的地址。然后当程序再次调用该函数时例如free(ptr)其中ptr是字符串/bin/sh的地址实际上就会执行system(“/bin/sh”)。攻击__malloc_hook或__free_hook这是glibc中的两个全局函数指针。当调用malloc或free时如果这些钩子不为空就会先执行钩子指向的函数。在较新版本的glibc中__free_hook是常用的目标。我们可以将其修改为system的地址然后在free一个内容为/bin/sh的块时触发。为了实现这个目标我们需要泄露libc基址通过Overlapping我们可以让一个堆块的内容被作为“指针”打印出来例如将一个已释放进入unsorted bin的块的fd/bk指针打印出来它们指向libc中的main_arena区域。这需要结合“显示记事”功能。计算目标地址根据泄露的地址和libc中符号的固定偏移计算出system和__free_hook的绝对地址。写指针利用Overlapping带来的任意写能力将__free_hook的内容改写为system的地址。触发最后释放一个内容为/bin/sh的块弹出shell。5. 手把手调试与EXP编写让我们将上述理论转化为可操作的步骤和代码。以下是一个高度概括的EXP框架使用pwntools编写。5.1 EXP框架与关键步骤#!/usr/bin/env python3 from pwn import * context(arch‘amd64’, os‘linux’, log_level‘debug’) # 启动程序 p process(‘./lab13’) # 如果远程则用 p remote(‘host’, port) libc ELF(‘/path/to/libc.so.6’) def create(size, content): p.sendlineafter(‘Your choice :’, ‘1’) p.sendlineafter(‘Size :’, str(size)) p.sendafter(‘Content :’, content) def show(index): p.sendlineafter(‘Your choice :’, ‘2’) p.sendlineafter(‘Index :’, str(index)) # 接收并返回显示的内容 def edit(index, content): p.sendlineafter(‘Your choice :’, ‘3’) p.sendlineafter(‘Index :’, str(index)) p.sendafter(‘Content :’, content) def delete(index): p.sendlineafter(‘Your choice :’, ‘4’) p.sendlineafter(‘Index :’, str(index)) # 1. 堆布局 create(0x98, ‘A’*0x98) # chunk 0 注意大小计算目的是覆盖chunk1的size create(0x68, ‘B’*0x68) # chunk 1 create(0x68, ‘C’*0x68) # chunk 2 防止合并到top chunk create(0x20, ‘barrier’) # chunk 3 屏障 # 2. 触发Off-by-One并伪造prev_size # 首先我们需要在chunk1的user data末尾布置好prev_size # 由于编辑chunk0会覆盖chunk1的size我们需要先通过编辑chunk1来设置prev_size edit(1, b‘X’*0x60 p64(0xa0)) # 假设chunk0的总大小是0xa0写入chunk1的prev_size字段 # 然后编辑chunk0写满数据触发off-by-one覆盖chunk1的size的P位 edit(0, b‘A’*0x98) # 这里正好写0x98字节如果程序有off-by-one会多写一个‘\x00’ # 3. 释放chunk1触发向前合并 delete(1) # 4. 此时原chunk0和chunk1的空间合并为一个大的空闲块。 # 我们申请一个大小等于这个空闲块的新块就会与chunk0重叠 create(0xf0, ‘D’*0xf0) # chunk 4 大小需要精确计算例如原0xa00x700x110但需考虑对齐等 # 5. 现在chunk0和chunk4重叠。通过chunk0可以修改chunk4的内容。 # 我们需要先泄露libc地址。 # 方法释放chunk2它会进入unsorted bin如果大小合适其fd/bk指向main_arena。 # 然后利用chunk0和chunk4的重叠将chunk4的内容伪装成一个可以打印的块并指向chunk2的地址。 # 这里需要精心构造堆块结构例如利用fastbin attack或直接修改fd指针 # 以下是非常简化的伪代码逻辑实际构造更复杂 edit(0, p64(0)*some_offset p64(some_fake_size) p64(address_of_chunk2_fd)) # 然后通过show某个索引将libc指针打印出来。 leak_addr u64(show(some_index).ljust(8, b‘\x00‘)) libc_base leak_addr - libc.sym[‘__malloc_hook’] - some_offset # 计算基址 system_addr libc_base libc.sym[‘system’] free_hook_addr libc_base libc.sym[‘__free_hook’] # 6. 利用重叠修改chunk4的某个指针为__free_hook的地址例如通过类似unlink或直接写fd的方式 # 然后通过分配操作最终实现在__free_hook处写入system地址。 # 例如将chunk4的fd修改为free_hook_addr - some_offset edit(0, … p64(free_hook_addr - 0x8)) # 再进行两次分配等操作… create(0x68, p64(system_addr)) # 这次分配会写到__free_hook # 7. 准备/bin/sh字符串并触发 create(0x20, ‘/bin/sh\0’) delete(bin_sh_index) # 此时free会跳转到system p.interactive()注意以上代码是高度概念化的伪代码框架省略了大量细节如精确的偏移计算、堆状态恢复、绕过各种检查如size检查、双链表完整性检查的构造等。实际EXP需要根据动态调试的结果进行精细调整。5.2 动态调试技巧实录调试是堆利用的灵魂。以下是一些关键节点的调试命令查看堆布局在pwndbg中heap命令可以展示所有堆块。vis命令可以可视化堆内存。查看bins状态bins命令可以显示fastbins、unsorted bin、small bins、large bins的情况。这在布局和释放后确认状态至关重要。断点设置在malloc、free、以及程序的edit函数上下断点观察参数和内存变化。观察关键写入在触发Off-by-One的编辑操作后使用x/gx [地址]命令查看下一个Chunk的Size字段是否被修改。跟踪合并过程在free目标块之前和之后对比堆布局确认合并是否按预期发生。6. 常见问题与排查技巧实录即使有详细的指南在实际操作中你仍可能遇到各种问题。这里记录了一些常见的坑和解决思路。6.1 堆布局不稳定或偏移计算错误症状EXP在本地偶尔成功经常失败或者完全无法达到预期效果。排查确认ASLR确保调试时关闭了ASLR (set disable-randomization on)或者在EXP中通过泄露地址来动态计算。精确计算大小使用malloc_usable_size或在gdb中直接print malloc_chunk($addr)来查看一个已分配块的真实大小和元数据。用户申请大小与实际Chunk大小之间的转换必须精确。考虑对齐glibc分配的内存有对齐要求64位通常是0x10对齐。你的所有计算都必须基于实际Chunk大小。检查填充在构造payload时确保填充的长度分毫不差。多一个或少一个字节都会导致后续的地址错位。6.2 释放或分配时触发崩溃 (glibc asserts)症状在free或malloc时程序崩溃提示malloc(): memory corruption或free(): invalid pointer等。排查双链表完整性 (unlink检查)在合并或从bin中取块时glibc会检查前后块的fd/bk指针是否满足P-fd-bk P和P-bk-fd P。如果你伪造了一个空闲块必须确保这两个条件成立或者利用某些特性如small bin unlink绕过。现代glibc对此检查很严格。Size字段与Prev_size不匹配当free尝试合并时会检查前一个块的size是否与当前块的prev_size一致。必须提前精确设置好prev_size。tcache poisoning如果涉及tcache需要确保tcache entry的next指针指向一个合法的、对齐的内存地址。6.3 泄露地址失败或地址错误症状打印出来的地址看起来不像libc地址例如很小或者全是\x00或者计算出的libc基址不对。排查确保指针在内容中通过重叠构造的“可打印”块其内容必须包含你想要泄露的指针。使用hexdump或telescope命令查看目标内存区域确认指针位置正确。处理字符串截断如果程序使用puts等字符串函数输出遇到\x00会截断。确保你的泄露指针之前没有空字节或者使用能打印空字节的函数如write。计算正确的偏移从泄露的地址通常是main_arena内部的某个地址到libc基址的偏移会因glibc版本和具体泄露的符号不同而不同。最好在调试器中直接计算p __malloc_hook - [泄露的地址]。6.4 EXP本地成功但远程失败症状本地测试稳定但打远程靶机毫无反应或报错。排查libc版本差异这是最常见的原因。远程服务器的libc版本可能与你本地不同。你需要通过信息泄露先确定远程libc版本然后下载对应的libc.so文件在EXP中更新偏移量。可以使用LibcSearcher等工具辅助。环境差异远程环境可能存在不同的内核版本、堆初始化状态等。确保你的堆布局不依赖于某些未初始化的值。网络延迟与交互使用pwntools的sendlineafter、recvuntil等交互函数比简单的sleep和send更稳健。增加超时时间并处理好所有可能的输出。堆利用就像解一道多维度的谜题需要耐心、细致的观察和严谨的逻辑。每一次失败都是对机制理解加深的机会。当你成功看到那个# whoami的shell提示符时你会觉得这一切的调试和构造都是值得的。这份对底层内存管理机制的深刻理解正是CTF比赛和真实世界漏洞挖掘中最为宝贵的财富。