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!
Hint2: Use heap spraying to make the heap space contiguous with the LIBC space.
题目解析
本题是一个解析 VM 指令的题目,构建启动了一个线程来解析 VM 指令,指令的分析过程比较简单,这里略过。题目提供了三种指令,分别是:
- 指令 0 (ADD):允许创建(add)一个堆块 SIZE 小于 0x4000000 的堆块,最多创建 0x10000 个;
- 指令 1 (EDIT):允许编辑先前创建的任意堆块的前 0x1000 字节,并允许溢出 4 字节;
- 指令 2 (EXIT):释放(free)先前所有创建的堆块,并退出 VM 程序。
值得注意的是,EXIT 指令并非必需。本题的利用手法不需要任何 free 操作。EXIT 指令的存在是为了降低利用难度,便于参赛者更好地关注本题的考点。
题目开启的保护措施有:PIE enabled、Partial RELRO、NX enabled。
解题思路
如何申请得到 LIBC 空间所属的 heap_info
结构
通过 Hint1 可以得知,我们需要调用到 grow_heap
函数中的 __mprotect
函数来劫持 LIBC 空间,本文会从如何调用到 __mprotect
开始往回倒推,分析我们完成此所需的条件,并解决这些条件。
|
|
我们的目标是让 __mprotect
的第一个参数为 LIBC 的代码空间,因此我们需要控制 grow_heap
的第一个参数 heap_info
结构体,而此结构来源于 sysmalloc
函数中的 old_heap
变量
|
|
而 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
。
|
|
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 空间的内存属性。
如何申请得到间隔空间
首先,我们面临的问题是,LIBC 堆空间对应的 heap_info 所在的内存区域并不存在。因此,我们需要通过 MMAP 申请这段间隔空间。以上面的情况为例,我们要申请的空间正好位于线程堆空间的末尾,距离下一个空间的差距是 0x7f4be9fff000-0x7f4be8000000=0x1fff000
,如果我们可以恰好申请这个大小的堆块,则可以申请得到 LIBC 堆空间对应的 heap_info
结构体。
但实际上由于 ASLR 的原因,这里的间隔大小并不是固定的。例如在下面的例子中,偏移就变化为了 0x7ff000
,因此我们还要解决需要的申请的大小会变化的问题。
在这里由于题目的限制较少,我们可以利用堆喷的思路,从最大值 HEAP_MAX_SIZE
开始,到最小值 MMAP_THRESHOLD
结束,间隔一个页大小,不断的申请大量堆块。这样当有堆块大小满足时,我们就可以成功的申请到间隔内存,并且申请得到的内存恰好为间隔大小。而其余不符合要求的堆块,则会申请在我们比我们线程堆空间更低地址的部分,不干扰我们后续的利用。在成功使用 MMAP 申请到间隔空间之后,我们还需要让 top_chunk
也申请到间隔空间所处的堆空间。
如何让 old_top
申请到 heap_info
结构位置
我们在上述的过程中,我们让 LIBC 对应的 heap_info
结构位置的空间已经被申请出来。但实际上,我们还需要让 old_top
的值也能够到对应的堆空间。这里通过题目给出了四字节的堆溢出,可以修改 top_chunk
的 SIZE 字段,并通过不断的大堆块申请,申请得到这段空间。
如何绕过申请断言限制
但在实际操作中我们会发现,当我们想要申请得到那段 heap_info
结构的空间时,申请能够成功。但如果我们再次申请,在 __libc_malloc
函数中的这段断言会出错,从而导致程序退出,申请失败。
|
|
我们重点关注这段代码的 ar_ptr == arena_for_chunk (mem2chunk (victim))
这个条件,这个条件要求 ar_ptr
也就是当前线程对应的 arena
等于通过计算得出的 victim
(通过 _int_malloc
申请得到的指针)对应的 heap_info
结构空间指向的 arena
。
|
|
当我们想要申请得到 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,使其恰好为我们想移动的长度。
然后再像先前一样,从大到小的申请,堆喷申请的堆块的 SIZE 只会存在有两种情况,一种是大于 top_chunk
的 SIZE 并且会大于mmap_threshold(0x20000)
,此时会调用 mmap 来申请内存,另外一种是小于等于 top_chunk
的 SIZE 时,此时会更新 top_chunk
的位置,因此堆喷的堆块总是能够恰好申请到我们想要申请的 LIBC
的对应位置,使其恰好为我们想移动的长度,同时也不会提前触发 grow_heap
的流程。然后再通过一次申请,使其触发扩容,并成功的修改 LIBC 的内存属性。
如何通过修改 LIBC 的只读页到 RCE
接下来的攻击手法类似于 House of Muney,我们只需要干扰 free
的解析过程,使其解析为 system
函数,并在执行 EXIT
opcode 的时,触发 system("/bin/sh")
即可 getshell,这里不再赘述。
EXP
在测试过程中,我发现由于本地环境的问题,我的 TLS 段大小与 Docker 环境不一致:我的为 0x800000,而 Docker 环境为 0x803000。为此,我提供了一个 Dockerfile,以下的 EXP 是针对使用该 Dockerfile 构建的靶机环境编写的。
|
|
出题思路
本题的出题思路来源于 N1CTF 2018 NULL 这题,此题没有开启 PIE 保护,因此可以通过堆布局,劫持线程的 arena 中的 fastbins 来申请到函数指针并覆盖为 system 函数获得 shell。在完成这道题的过程中,我发现了当在非主线程使用堆时,可以通过 malloc 调用到 grow_heap
函数,进而调用 __mprotect
。如果能利用这一点劫持 LIBC 的只读段,修改其属性为可写,就可以采用 House of Muney 的思路,实现一次无泄漏的利用。基于这个想法,我进行了多次尝试,逐一解决了遇到的问题,最终构建了这道题目。
总结
本题通过巧妙地利用 GLIBC 的内部机制,实现了对 LIBC 只读段的劫持,进而达成无泄漏的攻击。关键步骤包括通过堆喷射技术获取间隔空间,调整 heap_info
结构,绕过断言限制,以及利用 __mprotect
修改内存属性。希望本文的详细解析能够帮助读者深入理解这一高级堆利用技巧,并通过 BlindVM 这题来实践这一手法。由于能力有限且时间仓促,EXP 仍有许多可以优化的地方。在此抛砖引玉,期待与读者共同探讨,弱化这种手法所需的攻击条件,将其转化为在现实环境中真正可用的攻击方法。