0x04. Linux kernel基础:堆喷射
来一点小小的heap spray震撼
[toc]
Linux kernel之堆喷射 (heap spray)
什么叫堆喷射?简单理解就是通过申请大量的内存,来保证一定能够获得某种内存排布/对某个数据结构进行溢出的方式。
0x00. Linux Kernel的内存管理
这一部分实际上在UAF部分我们已经写了,但是由于堆喷射和内存管理的知识高度相关,因此我们这里直接复制过来,熟悉的师傅们可以直接跳过。
内存管理的数据结构
Linux Kernel下的内存管理系统分为Buddy System(伙伴系统)和slub allocator,而笔者对于buddy system的理解有点类似于用户态下通过mmap来分配的、更大的内存。而slub allocator即管理更小的、零散的内存。
首先来看slub allocator的组成。笔者看到slub allocator的示意图后,初见觉得非常复杂,而细看后,更复杂了。但从Linux kernel pwn的角度来看,其实并不需要到熟读源码和细致的结构体的程度(初学阶段)。
与用户态的chunk对应的(只是类似,并不是完全对应),kernel中有一个结构体叫做object。它即为slub allocator分配的基本单元。而与用户态对应的bins分为两种,一个叫做kmem_cache_cpu,而另一种叫做kmem_cache_node,它们将管理我们提到的object。
简要介绍一下kmem_cache_cpu。kmem_cache_cpu是一个**percpu变量**(这意味着,每个CPU上都独立有一份kmem_cache_cpu的副本,通过gs寄存器作为percpu基址进行寻址),表示当前CPU使用的slub,直接从当前CPU来存取object,不需要加锁,能够提高性能。然而这对于我们在Linux Kernel Pwn中,只会成为负担,毕竟我们并不希望额外考虑当前正在使用哪个CPU~。因此,我们在利用前,可以将我们的程序绑定到某个CPU上,即可无视掉这条规则。
而对于kmem_cache_node,它包括两个链表,其中一个叫做partial,另一个叫做full。顾名思义,partial链表中,存在部分空闲的object;而full链表中,全部object都已经被分配了。
分配过程
首先,slub allocator从kmem_cache_cpu上取object,若kmem_cache_cpu上存在,则直接返回;
若kmem_cache_cpu上的slub无空闲对象了,那么该slub会被加入到kmem_cache_node中的full链表,并从partial链表中取一个slub挂载到kmem_cache_cpu上,然后重复第一步的操作,取出object并返回。
若kmem_cache_cpu的partial链表也空了,那么会向buddy system请求分配新的内存页,划分为多个object,并给到kmem_cache_cpu,取出object并返回。
释放过程
释放过程需要看被释放的object属于的slub现在位于哪里。
若其slub现在位于kmem_cache_cpu,则直接头插法插入当前kmem_cache_cpu的freelist链表。
若其slub属于kmem_cache_node的partial链表上的slub,则同样通过头插法插入对应的slub中的freelist。
若其slub属于kmem_cache_node的full链表上的slub,则会使其成为对应slub的freelist的头结点,并将该slub从full链表迁移到partial。
0x01. 基础知识:user_key_payload (kmalloc-any, GFP_KERNEL)
前言
本文用作堆喷射的示例结构体为内核中的密钥相关的结构体,总的来说就是围绕user_key_payload相关的系统调用和数据结构。因此,本文在详细介绍堆喷射之前,有必要先讲一下user_key_payload。
add_key系统调用
在Linux内核中,有一个系统调用叫做add_key,其函数原型如下:
1 | |
而简单来说,通过add_key可以为用户申请密钥,而一个密钥包括类型、description描述、payload内容、plen内容长度。
而由于我们此处为Linux kernel pwn相关利用,因此其原本的作用我们不多过赘述。
使用add_key总共会申请若干个结构体,我们按照流程如下:
- 从内核中分配
obj1和obj2,分别用于保存description和payload。其中desciption的大小为其内容长度,而payload大小由我们设置的plen指定。 - 再次分配
obj3和obj4。obj3和obj1一模一样,并将obj1内容复制到obj3;obj4和obj2一模一样,并将obj4内容复制到obj2。 - 释放
obj1和obj2,返回分配密钥的id
可以看到,无论是对于desctiption还是payload,它们**都会有一个临时的obj**。此外,在我们利用时,我们最好将description的值设置为和payload大小以及别的结构体毫不相关,直接不考虑desciption来简化利用过程。
如此一来,只考虑payload的情况下,流程为:
- 申请大小为
plen的保存payload的obj1,其flag为GFP_KERNEL - 再次申请一个大小和类型都一样的
obj2,将obj1复制到obj2,并释放obj1
如此我们可以理清楚add_key系统调用的流程。
user_key_payload数据结构
我们上面提到的payload由user_key_payload数据结构管理,如下所示:
1 | |
其中,struct rcu_head的定义如下:
1 | |
因此,可以看到user_key_payload的头部(rcu + datalen)共有0x18字节,以及data[]来保存上文的payload。
数据泄露
当密钥被释放时,rcu->func将会被赋值为user_free_payload_rcu函数的地址,该函数地址与内核偏移固定,以及可以通过/proc/kallsyms查看其地址。因此,我们若释放掉密钥后又通过其他方式查看到了该内存(例如越界读),我们便可以泄露出内核基地址。
此外。rcu->next会被赋值为堆地址,因此可以使用同样的方式进行数据泄露。
读取内容 & 越界读 (KEYCTL_READ系统调用)
其实就是根据struct user_key_payload中的datalen来读取data[]中保存的payload。若我们将datalen覆盖为特别大的值,便可以越界读到其它内容。若其数据结构后面存在一些别的被释放掉的user_key_payload,便可以越界读到内核基地址。
释放密钥(KEYCTL_REVOKE系统调用)
很简单,通过KEYCTL_REVOKE系统调用即可释放掉密钥。
CTF板子
笔者直接用了arttnba3师傅写好的模板,支持musl:
1 | |
可以看到包装好了如下函数:
key_allockey_updatekey_readkey_revokekey_unlink
0x02. 基础知识:pipe管道相关结构体 (GFP_KERNEL_ACCOUNT)
我们可以通过如下方式来创建一个管道:
1 | |
此时其会创建两个结构体,分别是pipe_inode_info和pipe_buffer。
pipe_inode_info (kmalloc-192 | GFP_KERNEL_ACCOUNT)
Linux kernel中,管道本质上会创建一个虚拟的inode来表示,对应的为一个pipe_inode_info结构体,包含管道的所有信息。其定义如下:
1 | |
知道创建管道的时候有这个结构体即可~用处一般
需要注意的是pipe_inode_info有一个指向struct pipe_buffer的指针,可以通过该指针获取申请到的pipe_buffer的地址。(pipe_inode_info[16])
pipe_buffer (kmalloc-1k | GFP_KERNEL_ACCOUNT)
创建管道时还会创建另一个比较有用的结构体,那就是pipe_buffer。其数据结构如下:
1 | |
可以看到,其中含有一张函数指针表。其定义如下:
1 | |
当我们调用close()来关闭管道的两端时,就会调用pipe_buffer->pipe_bufer_operations->release这个指针。因此,只要劫持了该函数表到可控区域,并关闭管道的两端即可劫持内核执行流~
0x03. 初探堆喷射:以RWCTF2023体验赛 Digging into kernel 3为例
题目信息
题目启动脚本如下:
1 | |
可以看到,题目开启了如下机制:
kaslr,这意味着我们需要计算内核基地址和其偏移kpti,这意味着我们需要无法使用ret2user,以及需要使用swapgs_restore_regs_and_return_to_usermode进行切换smap & smep,这意味着内核无法访问和执行用户态的代码
此外,根据arttnba3师傅所述,本题目没有开启CONFIG_MEMCG_KMEM,这意味着GFP_KERNEL与GFP_KERNEL_ACCOUNT会从同样的kmalloc-xx中分配,而不会存在隔离。
题目的rcS脚本如下:
1 | |
可以看到如下信息:
- 设置
kptr_restrict为1,这意味着无法通过/proc/kallsyms查看函数地址 - 设置
dmesg_restrict为1,这意味着无法通过dmesg查看printk内容 - 没有挂载
pts,这意味无法通过打开/dev/ptmx来获得tty_struct结构体,需要利用其它方法
而实际上题目给得非常简单。只有ioctl函数,没有read和write等功能:
1 | |
可以看到,其提供了两个功能,第一个是UAF的kfree,另一个为kmalloc,其flag为GFP_KERNEL。
题目近乎为一个裸的UAF,但是没有提供edit功能,也没有提供read和write功能。又由于程序开启了kaslr,因此必然需要通过结构体来泄露内核基地址了。
这里,我们就考虑使用user_key_payload进行越界读,使其读到user_free_payload_rcu。
堆喷射
我们上面已经提到了user_key_payload的分配方式。因此我们知道,user_key_payload会先申请一个临时的obj,因此,若我们通过题目功能UAF释放掉一个obj,那么打开user_key_payload时,UAF的obj只会作为临时obj来临时复制数据。因此,此时我们就可以考虑heap spray这样的手法来确保可以分配到UAF obj。当然,师傅需要已经了解slub的分配和释放过程。
这里相对来说比较复杂,笔者尽量写得详细一些。先来个简略版:
- 通过题目功能申请一个
obj,然后释放,存在UAF,此时题目UAF obj位于kmem_cache_cpu上 - 不断堆喷射
user_key_payload,UAF obj总会作为临时obj,完成后又回到kmem_cache_cpu - 直到
kmem_cache_cpu被完全申请完毕。此时slub allocator会从kmem_cache_node的partial中取出一个链表到kmem_cache_cpu,此时UAF obj仍然作为临时obj,但释放后被放到kmem_cache_node的full中,并由此放到kmem_cache_node的partial中 - 继续不断堆喷射
user_key_payload,此时一直不会申请到UAF obj,直到当前kmem_cache_cpu完全耗尽 - 耗尽后,从
kmem_cache_node的partial中取出一个链表。若此链表为UAF obj的链表,则UAF obj由于是第二个obj,因此不再会作为临时obj,而是作为真正的user_key_payload。
上面笔者已经大概进行了阐述。比较抽象,对于笔者这样的初学者,笔者初次理解起来也是非常困难的。因此,我们下面直接以一个具体的例子,来看堆喷射是如何将UAF obj作为user_key_payload,而不是临时obj的。
假设kmem_cache_cpu此时有三个obj,分别为a -> b -> c。其中,a为我们的UAF obj
申请一次
user_key_payload,a作为临时obj,而b作为user_key_payload分配。释放a,此时kmem_cache_cpu为a -> c。申请一次
user_key_payload,a作为临时obj,而c作为user_key_payload分配。释放a,此时kmem_cache_cpu仅剩a。申请一次
user_key_payload,a作为临时obj,此时需要再申请一个作为user_key_payload,而kmem_cache_cpu已经耗光,因此向kmem_cache_node申请一条链表挂载到kmem_cache_cpu,而原有链表被移动到kmem_cache_node的full上。设新链表上面有d -> e -> f,那么取下d作为user_key_payload分配。释放a,而a属于的链表位于kmem_cache_node的full,因此将a作为链表头,将该链表移动到kmem_cache_node的partial上。申请一次
user_key_payload,e作为临时obj,f作为user_key_payload分配。释放e,此时kmem_cache_cpu仅剩e。申请一次
user_key_payload,e作为临时obj,此时需要再申请一个obj作为user_key_payload。此时,我们会从kmem_cache_node的partial链表中取下一条移动到kmem_cache_cpu。若恰好我们取了a所在的链表,而a是该链表头,因此我们就会取下a作为user_key_payload。如此一来,我们终于分配user_key_payload到了UAF obj。
现在,我们就明确了通过堆喷射,来保证user_key_payload分配到UAF obj的方法了。
利用思路
由于本题开启了KASLR,且没有给出读取和写入的接口,那么本题的利用方式大致可以分为如下两步:
- 使用有读取功能的结构体,泄露内核基地址
- 使用可以劫持程序控制流的结构体,劫持程序控制流
泄露内核基地址
总体上,我们这里采用user_key_payload越界读来获取到内核基地址。
首先通过题目功能申请一块空间,并通过题目功能释放,此时获得一块UAF的obj。通过堆喷射,使得user_key_payload分配到该obj。此时,由于我们不具有编辑的能力,我们再次将其释放。随后,我们再申请回大量obj,并往里面都填写user_key_payload的文件头,并将文件头写为特别大的数(0x2000),只有UAF obj的user_key_payload才会被写为0x2000的datalen。
随后,我们遍历所有的密钥id,并读取其内容:若其内容能读出特别长,说明其被改写了datalen,为我们的victim key;若长度没有变化,则说明是正常user_key_payload,我们调用key_revoke将其销毁,销毁时会写user_free_payload_rcu到头部。读到victim key时,就可以越界读到这个user_free_payload_rcu,从而泄露内核基地址。
劫持程序控制流
这里没有开启CONFIG_MEMCG_KMEM,不存在GFP_KERNEL和GFP_KERNEL_ACCOUNT的隔离。由此,我们这里采用pipe相关的数据结构来劫持程序控制流。此外,注意这里没有挂载pts,因此是无法打开tty数据结构的。
又由于,我们需要构造rop链,而题目开启了smep & smap等保护,因此将rop链放在哪里是一个需要思考的问题:rop链需要位于内核中可写的位置。这里,考虑如下因素:
pipe_buffer调用release时,其rsi为pipe_buffer自身的地址,或许我们可以利用gadget将栈迁移到pipe_bufferpipe_buffer中函数指针表我们需要控制,控制到自身是个不错的选择。而我们并不知道pipe_buffer的地址。pipe_inode_info中,存在一个pipe_buffer的指针可以获取pipe_buffer的地址。
那么,我们考虑使用user_key_payload越界读,读到pipe_inode_info的pipe_buffer指针。
题目可以分配两个obj,因此我们这里不再采用堆喷射,而是直接整两个UAF obj,然后申请user_key_payload,就可以让其中一个obj作为user_key_payload了。随后,我们再利用题目功能释放申请到的这个user_key_payload,并打开pipe,即可让pipe_inode_info和user_key_payload重叠(user_key_payload的大小是可控于任何kmalloc的,因此这里需要师傅构造堆风水)。重叠时,即可刚好让user_key_payload的datalen写为0xffff。那么,我们就可以利用user_key_payload越界读,读到pipe_buffer的地址了。
而对于pipe_buffer,我们通过UAF,将其完全滴控制即可~
漏洞利用
到这里,终于完结撒花~
下面是整个漏洞利用流程的exp,带有详细注释~
1 | |