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和B(B在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。python3+pwntools:编写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大小的Chunk(0x90用户区 + 0x10元数据)。我们需要确保块A的用户区末尾(A+0x90)正好是块B的Size字段地址。通过调试可以精确计算。块B和C的大小选择需使其在释放后进入fastbin或small bin,便于我们后续利用。
布局完成后,内存视图大致为:[A | B | C | Barrier]
4.2 阶段二:触发Off-by-One与伪造空闲块
现在,我们编辑块A,写满0x90个字节。如果漏洞存在,第0x91个字节(即A+0x90)会被写入\x00,这恰好是块B的Size字段的最低字节。
假设块B原Size为0x71(大小0x70,P=1)。覆盖后变为0x70(P=0)。此时,堆管理器认为块A是“空闲”的。
接着,我们释放块B。free(B)会发生什么?
- 释放器检查前一个块(即A)的状态。根据B的新Size字段(P=0),它认为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的大小(例如0xa0+0x70=0x110)。堆管理器很可能就会把刚刚合并出来的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, 大小需要精确计算,例如原0xa0+0x70=0x110,但需考虑对齐等 # 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时,确保填充的长度分毫不差。多一个或少一个字节都会导致后续的地址错位。
- 确认ASLR:确保调试时关闭了ASLR (
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指针指向一个合法的、对齐的内存地址。
- 双链表完整性 (unlink检查):在合并或从bin中取块时,glibc会检查前后块的
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更稳健。增加超时时间,并处理好所有可能的输出。
- libc版本差异:这是最常见的原因。远程服务器的libc版本可能与你本地不同。你需要通过信息泄露先确定远程libc版本,然后下载对应的libc.so文件,在EXP中更新偏移量。可以使用
堆利用就像解一道多维度的谜题,需要耐心、细致的观察和严谨的逻辑。每一次失败都是对机制理解加深的机会。当你成功看到那个# whoami的shell提示符时,你会觉得这一切的调试和构造都是值得的。这份对底层内存管理机制的深刻理解,正是CTF比赛和真实世界漏洞挖掘中最为宝贵的财富。