file uaf - N1CTF 2024 heap_master
写在一切之前
不得不说 N1CTF 的题目质量是真的高,下次出题我也要出好一点(其实已经出好了,不过打算花多点时间去优化😋😋,敬请期待)。这次比赛有两题我觉得非常有意思,一题是 heap_master,另外一题是 php_master。其中 php_master 当时并没有做出来,打算以后有时间再研究一下(出题人的预期解是拿到任意反序列化,可是有的师傅认为这道题目其实可以拿到 code exec),感觉如何突破 php 新加的 shadow heap 将会是一个热点。
前置内容
我们都知道内核对物理内存的管理是按照页为基本单位进行的,进程运行起来所需要的数据也是存储在一个一个的物理页中,既然物理内存页可以存储进程的普通数据,那么它也一定可以存储进程虚拟内存与物理内存之间的映射关系。
事实上,内核也是这么干的,内核会从物理内存空间中拿出一个物理内存页来专门存储进程里的这些内存映射关系,而这种物理内存页我们将其称之为页表,从这里可以看出页表的本质其实就是一个物理内存页。
而内核会在页表中划分出来一个个大小相等的小内存块,这些小内存块我们称之为页表项 PTE(Page Table Entry),正是这个 PTE 保存了进程虚拟内存空间中的虚拟页与物理内存页的映射关系,以及控制物理内存访问的相关权限位。
因为内存映射的粒度是按照页为单位进行的,所以进程虚拟内存空间中的每个虚拟页在页表中都会有一个 PTE 与之对应,而虚拟页背后映射的物理内存页的起始地址就保存在 PTE 中。PTE 将会在我们后续的攻击中扮演重要的角色。
漏洞分析与利用
由于代码量比较少,这里直接贴上 ida 的外代码
safenote_init
1 | int __cdecl safenote_init() |
safenote_open
1 | __int64 safenote_open() |
safenote_ioctl
1 | __int64 __fastcall safenote_ioctl(file *f, unsigned int cmd, unsigned __int64 arg) |
safenote_close
1 | int __fastcall safenote_close(inode *inodep, file *filp) |
可以看见出题人自己创建了一个 kmem_cache 并且后面的菜单堆都会从该 kmem_cache 中申请 object,而且题目白给了一次 double free 的机会。根据经验,我们自然而然的就会想到第一步要先让 uaf 的 object 对应的 slab 进入到 cross cache 中,至于要怎么进下面这篇文章已经写的非常清楚,这里不再赘:Cross Cache Attack技术细节分析
我们可以看到出题人为这个新的 kmem_cache 自定义了 cpu_partial 和 cpu_partial_slabs 的值
1 | /* |
可以看到 cpu_partial 的值变得非常的大,如果我们要用上面链接那个做法至少需要申请 2*objs_per_slab*(cpu_partial+1) = 0x6a0
个堆块,而在菜单堆中我们最多同时拥有 0x100 个堆块,所以上文的方法在这里是行不通的,可是这里有个非预期 😋。
我们发现这道题目的 kernel 版本为 6.1.110
我们查看当前版本的 put_cpu_partial 函数:
1 | static void put_cpu_partial(struct kmem_cache *s, struct slab *slab, int drain) |
可以发现当前版本的内核判断 slab 是否要被 buddy system 回收是与 cpu_partial_slabs 进行比较而不是 cpu_partial,而 cpu_partial_slabs 在当前环境里的值为 7,所以我们完全可以直接喷 7 个页的堆块(我喷了12个)然后申请出 uaf 的堆块最后再从头依次释放所有申请出来的堆块就能让 uaf 的堆块对应的 slab 给 buddy system 回收,我的代码如下:
1 | int uaf_index = 0xc0; |
而出题人的预期解为:在不同的 CPU 上执行添加和释放操作。具体来说,在 CPU0 上进行分配,然后在 CPU1 上释放。由于 CPU0 无法访问由 CPU1 管理的 freelist 或 partial slabs,这促使 CPU1 达到其 partial 阈值,触发 put_cpu_partial。
其实现代码如下:
1 | int safe_note_objs_per_slab = 16; |
不得不说这招确实有点牛逼的说(
这道题目在 kernel 的基础上套了一层容器逃逸,所以我们的目标最终是要能够实现任意 shellcode 执行或者 执行我们的 rop,但由于前者的限制更少一点,所以我们选择使用 shellcode,自然而然的我们也会想到 dirty pageTable这种打法。这种打法的原理很简单,就是修改我们在前置内容里面所提到的 PTE,进而能够实现内核上任意物理地址的读写。但是这里又有一个新问题,出题人编写的内核驱动并没有给我们读写 uaf 堆块的机会,这里我们选择使用 file 结构体,该结构体在当前版本的内核定义如下:
1 | struct file { |
我们可以用 file 结构体去占位 uaf 的堆块,然后使用 kfree 释放该 file 结构体就会出现一个 file uaf。由于 file 结构体是使用 filp 进行单独管理的,所以我们这里还是要想办法让 file uaf 对应的 slab 给 buddy system 回收,这里我选用的方法依然是喷射大量的 file 结构体然后全部释放来解决。下一个问题就是如何在用户态知道哪个 file 给释放了,我这里使用的方法是:
- 第一次喷射大量只读权限的文件
- 利用 kfree 释放其中一个 file 结构体
- 第二次喷射大量只写权限的文件
- 对向所以第一次喷射的文件写入数据,如果能够写入成功则说明该文件为 uaf 的文件。
上述过程的流程图大致如下:
上述过程的代码实现如下:
1 | info("spray read file."); |
接下来就能够让 PTE 来占位我们的 file uaf 的堆块
给 PTE 占位后 uaf 块在 gdb 调试的结果如下:
可以发现 PTE 所指向物理地址是以 0x1000 递增的,这正好满足一个页的大小。
然而由于 file 结构体给释放了,所以我们对该 file 进行其他操作基本都会导致 kernel panic,可是 dup 依然可用。通过看 file 结构体的源码我们可以发现有个叫 f_count 的变量在 file + 0x38 的位置,f_count表示文件对象的引用计数,当我们调用dup系统调用复制文件描述符时它将递增。因此,我们获得一个原语来递增 PTE 中的指针。然而正常情况下一个进程最多可以拥有 0x400 个文件描述符,我们无法 dup 很多次,但是我们可以通过 fork 来实现多次 dup。在这道题目中,我们可以看到在 startjail.sh 中有条命令:ulimit -Hn 33000,这表示我们能够在一个进程中最多拥有 33000 个文件描述符,这大大方便了我们对 file uaf 的利用。
接下来我们就可以对 uaf_file dup 0x1000 次,这时就会出现物理地址的重叠:
利用 dup 函数令 PTE 的条目递增 0x1000:
最终效果如下:
可以看见两个 PTE 条目指向了同一个物理地址,这个时候我们再使用 munmap 释放掉我们重叠的 PTE 对应的虚拟内存,我们就能够构造出物理内存上的 page uaf。有了page uaf 后我们第一时间可能会想到用用户页表占据释放页,这样就能控制用户页表,然而这是不太现实的。匿名 mmap() 分配的物理页来自内存区的MIGRATE_MOVABLE free_area,而用户页表是从内存区的 MIGRATE_UNMOVABLE free_area 分配,所以很难通过递增 PTE 使之指向另一用户页表。这里我们采用另外一种方式来分配物理页,使该物理页和用户页表来自同一内存区域,这样如果受害者PTE指向该物理页,就能通过递增该PTE,使该PTE指向某个用户页表。
下面的操作来自 dirty pageTable 的文章Dirty_Pagetable (yanglingxi1993.github.io):
作者选用 dma-buf 系统堆来分配共享页,因为可以从 Android 中不受信任的 APP 来访问 /dev/dma_heap/system,并且 dma-buf 的实现相对简单。通过 open(/dev/dma_heap/system) 可获得一个 dma heap fd,然后用以下代码分配一个共享页:
1 | struct dma_heap_allocation_data data; |
由用户空间中的 dma_buf_fd 来表示一个共享页,可通过 mmap() dma_buf_fd 将共享页映射到用户空间。从 dma-buf 系统堆分配的共享页本质上是从页分配器分配的(实际上 dma-buf 子系统采用了页面池进行优化,对于本利用没有影响)。用于分配共享页的 gfp_flags 如下所示:
1 |
|
共享页分配 vs 页表分配:从 LOW_ORDER_GFP 可以看出,单个共享页是从内存的 MIGRATE_UNMOVABLE free_area 中分配的,和页表分配的出处一样。且单个共享页为 order-1 (order-0 ?),和页表的 order 相同。结论是,单个共享页和页表都是从同一 migrate free_cache 中分配,且 order 相同。
可见,在物理内存中,单个共享页和用户页表分布得比较紧凑。现在,我们成功对共享页和用户页表进行了 heap shaping。
总的来说我们可以利用 dma-buf 来辅助我们让物理地址 page uaf 的堆块给 DMA-buf heap 给占位,而该 heap 在物理地址上与另外一个 PTE 相邻,此时我们即可再次利用 file uaf 来令 victim PTE 指向 PTE 对应的物理地址,进而能够任意修改 PTE 的条目来实现物理地址上的读写,其布置如下:
利用 file uaf 再次 dup 0x1000 后,修改 PTE 的条目:
接下来我们已经拥有修改 PTE 的能力,那我们肯定要先获取 kernel 代码段的基址才能够对代码段进行写操作,这个地方的操作比较牛逼,这里引用Understanding Dirty Pagetable - m0leCon Finals 2023 CTF Writeup - CTFするぞ (hatenablog.com):
Although it’s already 2024, we can find some fixed physical addresses on both Linux and Windows.
The pages around here is always fixed, and data for page table is left. (Credit to shift_crops who found it during HITCON.) The page table has a pointer to kernel-land physical address, which is useful for leaking the physical base address of the kernel.
也就是说我们可以直接在这个固定的物理地址上获取某个内核代码段的地址:
1 | // Leak kernel physical base |
接下来我们就要找到该地址与内核物理基址的偏移,这里有一个技巧,当我们关闭了 kaslr 时内核的物理基址会固定为 0x1000000,我们可以关闭 kaslr 后再进行对偏移的计算,我们可以在 qemu monitor 中进行验证。
可是开启了 kaslr 后这个偏移会有一点点改变 :( 不过问题不大,在开启 kalsr 时调试改改就行。
这里其实还可以使用 gef 升级版本来直接进行物理地址和虚拟地址的转换(笔者觉得这个功能真的好牛逼),相关命令为 p2g、g2p
项目地址为:https://github.com/bata24/gef
获得内核物理地址基址后就可以直接修改内核的代码段了,我这里选择修改 getuid 函数。
我们的最终目标是进行容器逃逸,这里参考[corCTF 2022] CoRJail: From Null Byte Overflow To Docker Escape Exploiting poll_list Objects In The Linux Kernel (syst3mfailure.io)上的 rop 链:
1 | buff = (char *)calloc(1, 1024); |
其对应的 shellcode 如下:
1 | init_cred = 0x2a76b00 |
exp
1 | // musl-gcc exp.c --static -masm=intel -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
当时打远程时的效果😋:
总结
呜呜呜,二进制真的太好玩了,可是能给我玩的时间不多了/(ㄒoㄒ)/~~
参考
一步一图带你构建 Linux 页表体系 —— 详解虚拟内存如何与物理内存进行映射 (qq.com)
Understanding Dirty Pagetable - m0leCon Finals 2023 CTF Writeup - CTFするぞ (hatenablog.com)