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_alloc
key_update
key_read
key_revoke
key_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_buffer
pipe_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 |
|