Cross-Cache Overflow & Page-level Heap Fengshui
注:这是两种联合起来的利用手法。
Cross-Cache Overflow
与我们此前一直关注于 slub allocator 的各种利用手法不同,Cross-Cache Overflow 实际上是针对 buddy system 的利用手法,其主要基于如下思路:
slub allocator 底层逻辑是向 buddy system 请求页面后再划分成特定大小 object 返还给上层调用者。
内存中用作不同
kmem_cache的页面在内存上是有可能相邻的。
若我们的漏洞对象存在于页面 A,溢出目标对象存在于页面 B,且 A、B两页面相邻,则我们便有可能实现跨越不同
kmem_cache之间的堆溢出。

Cross-Cache Overflow 打破了不同 kmem_cache 之间的阻碍,可以让我们的溢出漏洞对近乎任意的内核结构体进行覆写。
但这需要达成非常严苛的页级堆排布,而内核的堆页面布局对我们而言通常是未知的,因此我们需要想办法将其变为已知的内存布局,这就需要页级堆风水——
Page-level Heap Fengshui
顾名思义,页级堆风水即以内存页为粒度的内存排布方式,而内核内存页的排布对我们来说不仅未知且信息量巨大,因此这种利用手法实际上是让我们手工构造一个新的已知的页级粒度内存页排布。
首先让我们重新审视 slub allocator 向 buddy system 请求页面的过程,当 freelist page 已经耗空且 partial 链表也为空时(或者 kmem_cache 刚刚创建后进行第一次分配时),其会向 buddy system 申请页面:

接下来让我们重新审视 buddy system ,其基本原理就是以 2 的 order 次幂张内存页作为分配粒度,相同 order 间空闲页面构成双向链表,当低阶 order 的页面不够用时便会从高阶 order 取一份连续内存页拆成两半,其中一半挂回当前请求 order 链表,另一半返还给上层调用者;下图为以 order 2 为例的 buddy system 页面分配基本原理:

我们不难想到的是:从更高阶 order 拆分成的两份低阶 order 的连续内存页是物理连续的,由此我们可以:
向 buddy system 请求两份连续的内存页。
释放其中一份内存页,在
vulnerable kmem_cache上堆喷,让其取走这份内存页。释放另一份内存页,在
victim kmem_cache上堆喷,让其取走这份内存页。
此时我们便有可能溢出到其他的内核结构体上,从而完成 cross-cache overflow
使用 setsockopt 与 pgv 完成页级内存占位与堆风水
那么我们该如何完成这样的页占位与页排布呢?笔者这里给出一个来自于 CVE-2017-7308 的方案:
当我们创建一个 protocol 为 PF_PACKET 的 socket 之后,先调用 setsockopt() 将 PACKET_VERSION 设为 TPACKET_V1 / TPACKET_V2,再调用 setsockopt() 提交一个 PACKET_TX_RING ,此时便存在如下调用链:
在 alloc_pg_vec() 中会创建一个 pgv 结构体,用以分配 tp_block_nr 份 2order 张内存页,其中 order 由 tp_block_size 决定:
在 alloc_one_pg_vec_page() 中会直接调用 __get_free_pages() 向 buddy system 请求内存页,因此我们可以利用该函数进行大量的页面请求:
相应地, pgv 中的页面也会在 socket 被关闭后释放:
setsockopt() 也可以帮助我们完成页级堆风水,当我们耗尽 buddy system 中的 low order pages 后,我们再请求的页面便都是物理连续的,因此此时我们再进行 setsockopt() 便相当于获取到了一块近乎物理连续的内存(为什么是“近乎连续”是因为大量的 setsockopt() 流程中同样会分配大量我们不需要的结构体,从而消耗 buddy system 的部分页面)。
例题:corCTF2022 - cache-of-castaways
题目分析
题目文件连 kconfig 都给了,笔者表示非常感动:
启动脚本看都不用看就知道开了 SMEP、SMAP、KPTI(基本上已经是内核题标配了):
在启动脚本里加载了一个名为 cache_of_castaway.ko 的 LKM,按惯例丢进 IDA,在模块初始化时注册了设备并创建了一个 kmem_cache,分配的 object 的 size 为 512,创建 flag 为 SLAB_ACCOUNT | SLAB_PANIC,同时开启了 CONFIG_MEMCG_KMEM=y,这意味着这是一个独立的 kmem_cache:
设备只定义了一个 ioctl,其中包含分配与编辑堆块的功能且都有锁,最多可以分配 400 个 object,没有释放功能:
漏洞便存在于编辑堆块的 castaway_edit() 当中,在拷贝数据时会故意从 object + 6 的地方开始拷贝,从而存在一个 6 字节的溢出,这里因为是先拷贝到内核栈上再进行内核空间中的拷贝所以不会触发 hardened usercopy 的检查:
编辑堆块时我们应当向内核中传入如下结构:
漏洞利用
Step.I - cross-cache overflow
由于我们的漏洞对象位于独立的 kmem_cache 中,因此其不会与内核中的其他常用结构体的分配混用,我们无法直接通过 slub 层的堆喷 + 堆风水来溢出到其他结构体来进行下一步利用;同时由于 slub 并不会像 glibc 的ptmalloc2 那样在每个 object 开头都有个存储数据的 header,而是将 next 指针放在一个随机的位置,我们很难直接溢出到下一个 object 的 next 域,由于 hardened freelist 的存在就算我们能溢出到下一个相邻 object 的 next 域也没法构造出一个合法的指针;而在我们的 slub 页面相邻的页面上的数据对我们来说也是未知的,直接溢出的话我们并不知道能够溢出到什么页面上 :(
那么我们真的就没有任何办法了吗?答案自然是否定的,让我们把目光重新放到 slub allocator 上,当 freelist page 已经耗空且 partial 链表也为空时(或者 kmem_cache 刚刚创建后进行第一次分配时),其会向 buddy system 申请页面:

buddy system 的基本原理就是以 2 的 order 次幂张内存页作为分配粒度,相同 order 间空闲页面构成双向链表,当低阶 order 的页面不够用时便会从高阶 order 取一份连续内存页拆成两半,其中一半挂回当前请求 order 链表,另一半返还给上层调用者;下图为以 order 2 为例的 buddy system 页面分配基本原理:

我们不难想到的是:从更高阶 order 拆分成的两份低阶 order 的连续内存页是物理连续的,若其中的一份被我们的 kmem_cache 取走,而另一份被用于分配其他内核结构体的 kmem_cache 取走,则我们便有可能溢出到其他的内核结构体上——这便是 cross-cache overflow。
具体的溢出对象也并不难想——6个字节刚好足够我们溢出到 cred 结构体的 uid 字段,完成提权,那么如何溢出到我们想要提权的进程的 cred 结构体呢?我们只需要先 fork() 堆喷 cred 耗尽 cred_jar 中 object,让其向 buddy system 请求新的页面即可,我们还需要先堆喷消耗 buddy system 中原有的页面,之后我们再分配 cred 和题目 object,两者便有较大概率相邻。
cred 的大小为 192,cred_jar 向 buddy system 单次请求的页面数量为 1,足够分配 21 个 cred,因此我们不需要堆喷太多 cred 便能耗尽 cred_jar,不过 fork() 在执行过程中会产生很多的”噪声“(即额外分配一些我们不需要的结构体,从而影响页布局),因此这里我们改用 clone(CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND)。
关于”噪声“问题参见 bsauce 师傅的博客,笔者暂未深入阅读过
fork()相关源码。
由于 slub pages 并不会在释放后立刻被返还给 buddy system,因此我们最好寻找一些会直接调用向 buddy system 请求页面的 API 的结构,这里笔者选择参照官方 writeup 中参照 D3v17 在 CVE-2017-7308 中使用 setsockopt() 进行页喷射的方法:当我们创建一个 protocol 为 PF_PACKET 的 socket 之后,先调用 setsockopt() 将 PACKET_VERSION 设为 TPACKET_V1 / TPACKET_V2,再调用 setsockopt() 提交一个 PACKET_TX_RING ,此时便存在如下调用链:
在 alloc_pg_vec() 中会创建一个 pgv 结构体,用以分配 tp_block_nr 份 2order 张内存页,其中 order 由 tp_block_size 决定:
在 alloc_one_pg_vec_page() 中会直接调用 __get_free_pages() 向 buddy system 请求内存页,因此我们可以利用该函数进行大量的页面请求:
pgv 中的页面会在 socket 被关闭后释放,这也方便我们后续的页级堆风水,不过需要注意的是低权限用户无法使用该函数,但是我们可以通过开辟新的命名空间来绕过该限制。
这里需要注意的是我们提权的进程不应当和页喷射的进程在同一命名空间内,因为后者需要开辟新的命名空间,而我们应当在原本的命名空间完成提权,因此这里笔者选择新开一个进程进行页喷射,并使用管道在主进程与喷射进程间通信。
Step.II - page-level heap fengshui
setsockopt() 也可以帮助我们完成页级堆风水,当我们耗尽 buddy system 中的 low order pages 后,我们再请求的页面便都是物理连续的,因此此时我们再进行 setsockopt() 便相当于获取到了一块近乎物理连续的内存(为什么是”近乎连续“是因为大量的 setsockopt() 流程中同样会分配大量我们不需要的结构体,从而消耗 buddy system 的部分页面)。
本题环境中题目的 kmem_cache 单次会向 buddy system 请求一张内存页,而由于 buddy system 遵循 LIFO,因此我们可以:
先分配大量的单张内存页,耗尽 buddy 中的 low-order pages。
间隔一张内存页释放掉部分单张内存页,之后堆喷 cred,这样便有几率获取到我们释放的单张内存页。
释放掉之前的间隔内存页,调用漏洞函数分配堆块,这样便有几率获取到我们释放的间隔内存页。
利用模块中漏洞进行越界写,篡改
cred->uid,完成提权。
我们的子进程需要轮询等待自己的 uid 变为 root,但是这种做法并不优雅:) ,所以笔者这里选择用一个新的管道在主进程与子进程间通信,当子进程从管道中读出1字节时便开始检查自己是否成功提权,若未提权则直接 sleep 即可。
EXPLOIT
最后的 exp 如下:
REFERENCE
https://bsauce.github.io/2022/11/07/castaways
https://www.willsroot.io/2022/08/reviving-exploits-against-cred-struct.html
Last updated