D3CTF2022-d3kheap
基本信息
readme 里提供了部分编译配置选项,可以看到使用 SLUB 分配器,同时开启 SLAB_FREELIST_RANDOM, SLAB_FREELIST_HARDENED 等保护:
1 | CONFIG_STATIC_USERMODEHELPER=y |
查看启动脚本,照例开启 smep, smap, kpti, kaslr 保护:
1 | !/bin/sh |
解包文件系统,提供了内核模块 d3kheap.ko ,同时初始化脚本如下:
1 | !/bin/sh |
题目漏洞分析
分析 d3kheap 模块,注册了字符设备 d3kheap,定义了 open, read, write, release, ioctl 等操作。
open, release, read, write 没啥好说的,只是打印了一个日志信息。
ioctl 定义一些操作:
- 0x1234:申请 0x400 大小的对象,flag 为 GFP_KERNEL,这里第一个参数 kmalloc_caches[10] 为 kmem_cache 池,即 kmalloc-*。这里会将对象赋值给全局变量,申请一次。
- 0xDEAD:释放 全局变量 指针,没有进行清空。这里有一个 ref_count 变量进行计数,如果不为0才能进行释放。(这里需要注意 ref_count 被 初始化 成了1,因此可以通过 Double Free),因此存在 UAF 以及 Double Free 漏洞
漏洞利用
在 slub_free 中有对 double free 的简单检查(类似于 glibc 中的 fastbin,会检查 freelist 指向的第一个 object),因此不能直接 double free,而是应该转化为 UAF 进行利用。
1 | static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp) |
构造 UAF
首先很简单可以实现 UAF
- 分配一个 1024 大小的 object
- 释放该 object
- 将其分配到别的结构体上
- 释放该 object
解法1:利用 setxattr 多次劫持 msg_msg
Step1. setxattr 修改 object 内容
接下来思考如何对一个 free 状态的 object 写入数据,这里使用的是 setxattr 系统调用。
setxattr 系统调用可以在 kernel 利用中提供近乎任意大小的内核空间 object 分配。
在 setxatrr 函数中有如下逻辑:
1 | static long |
这里 value 和 size 都是由用户指定。写入后会将其 kvfree 掉,因此我们可以通过 setxattr 多次修改 victim 的内容。
不够完美的一点是,slub 中 free 的 object 同样是连接成一个单向链表,因此无法控制该 object 中 kmem_cache->offset 偏移处 8 字节的内容。
Step2. msg_msg 搜索内存完成地址泄漏
以上 setxattr 为任意地址写原语,接下来实现任意地址读原语。利用 msg_msg 实现。msg_msg 使用 system V 消息队列:
- msgget:创建一个消息队列
- msgsnd:向指定消息队列发送消息
- msgrcv:向指定消息队列接收消息
当创建消息队列时,在内核空间会分配以下结构体,表示消息队列:当我们调用 msgsnd 系统调用在指定消息队列上发送一条指定大小的 message 时,在内核空间中会创建一个结构体:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/* one msq_queue structure for each present queue on the system */
struct msg_queue {
struct kern_ipc_perm q_perm;
time64_t q_stime; /* last msgsnd time */
time64_t q_rtime; /* last msgrcv time */
time64_t q_ctime; /* last change time */
unsigned long q_cbytes; /* current number of bytes on queue */
unsigned long q_qnum; /* number of messages in queue */
unsigned long q_qbytes; /* max number of bytes on queue */
struct pid *q_lspid; /* pid of last msgsnd */
struct pid *q_lrpid; /* last receive pid */
struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
} __randomize_layout;在内核中,这两个结构体形成一个循环双向链表:1
2
3
4
5
6
7
8
9/* one msg_msg structure for each message */
struct msg_msg {
struct list_head m_list;
long m_type;
size_t m_ts; /* message text size */
struct msg_msgseg *next;
void *security;
/* the actual message follows immediately */
};
如果消息队列中只有一个消息则是下图:
接下来深入阅读 msg_msg 的内部结构,阅读 msgsnd 源码可知,当发送一个消息1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18static long do_msgsnd(int msqid, long mtype, void __user *mtext,
size_t msgsz, int msgflg)
{
struct msg_queue *msq;
struct msg_msg *msg;
int err;
struct ipc_namespace *ns;
DEFINE_WAKE_Q(wake_q);
ns = current->nsproxy->ipc_ns;
if (msgsz > ns->msg_ctlmax || (long) msgsz < 0 || msqid < 0)
return -EINVAL;
if (mtype < 1)
return -EINVAL;
msg = load_msg(mtext, msgsz);
...
load_msg 函数调用 alloc_msg 函数分配对象,并调用 copy_from_user 拷贝用户输入内容。
1 | struct msg_msg *load_msg(const void __user *src, size_t len) |
alloc_msg 函数定义如下:
1 | static struct msg_msg *alloc_msg(size_t len) |
可以发现,其分配 msg_msg 生成的结构如下(kmalloc, GFP_KERNEL_ACCOUNT):
- 如果 size 小于 PAGE_SIZE-sizeof(msg_msg),则会分配一个 size+header 大小的对象,前 0x30 大小存放 msg_msg 的 header,剩余部分存放用户数据。
- 如果超出,则超出部分会生成 msg_msgseg 结构体,该结构体为一个单向链表,前8字节存放下一个 msg_msgseg 结构体,剩余为用户数据。
因此,可以分配一个大小为 1024 的 msg_msg 结构体作为 victim,利用 setxattr 系统调用修改 header 中的 m_ts (textsize)成员,从而实现堆上的数据越界,同时还能修改 msg_msg->next 实现任意地址读。
但是如果正常 msgrcv 接受消息,会调用 list_del() 将 msg_msg 结构体 unlink 掉,这会导致内核 panic。不过如果设置 MSG_COPY 标志就不会进行 unlink,从而绕过并且可以多次读取一个 msg_msg 结构体的数据。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static long do_msgrcv(int msqid, void __user *buf, size_t bufsz, long msgtyp, int msgflg,
long (*msg_handler)(void __user *, struct msg_msg *, size_t))
{
...
msg = find_msg(msq, &msgtyp, mode);
if (!IS_ERR(msg)) {
/*
* Found a suitable message.
* Unlink it from the queue.
*/
if ((bufsz < msg->m_ts) && !(msgflg & MSG_NOERROR)) {
msg = ERR_PTR(-E2BIG);
goto out_unlock0;
}
/*
* If we are copying, then do not unlink message and do
* not update queue parameters.
*/
if (msgflg & MSG_COPY) {
msg = copy_msg(msg, copy);
goto out_unlock0;
}
list_del(&msg->m_list);
...
}
...
接下来通过 setxattr 修改 msg_msg 的 next 指针为 NULL, 同时将其 m_ts 成员修改为 0x1000-0x30
然后即调用 copy_msg 函数搜索内存空间,copy_msg 函数在拷贝时会解引用 src->next 指针,如果 next 指针为 非法地址,则会导致 kernel panic。因此我们需要提供 src 正常的堆上地址来保证 合法 next 指针。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26struct msg_msg *copy_msg(struct msg_msg *src, struct msg_msg *dst)
{
struct msg_msgseg *dst_pseg, *src_pseg;
size_t len = src->m_ts;
size_t alen;
if (src->m_ts > dst->m_ts)
return ERR_PTR(-EINVAL);
alen = min(len, DATALEN_MSG);
memcpy(dst + 1, src + 1, alen);
for (dst_pseg = dst->next, src_pseg = src->next;
src_pseg != NULL;
dst_pseg = dst_pseg->next, src_pseg = src_pseg->next) {
len -= alen;
alen = min(len, DATALEN_SEG);
memcpy(dst_pseg + 1, src_pseg + 1, alen);
}
dst->m_type = src->m_type;
dst->m_ts = src->m_ts;
return dst;
}
这里因为 slub 会向 buddy system 申请一张或者多张连续内存页,将其分割为指定大小的 object 之后在返还给 kmalloc 的 caller,对于大小为 0x1000 的 object,其每次申请到的连续内存页为四张,分为16个object (sudo cat /proc/slabinfo 命令可以查看信息)
那么如果我们可以分配多个 0x1000 大小的 msg_msg 结构体,那么就很有可能落在地址连续的 4 张内存页上,此时如果从一个 msg_msg 向后进行越界读,则很容易读取到其他的 msg_msg 结构体上的数据,其 m_list 成员可以帮助我们泄漏一个堆上地址。
因此这里总结一下泄漏内核基址的流程(越界读+搜索):
- 分配多个 1024 大小的 msg_msg,可以修改其中一个为 0x1000-0x30 从而触发越界读;
- 修改其中一个 msg_msg 大小,即 m_ts 字段为 0x1000-0x30,同时 next 指针为 NULL,此时大概率可以越界读到后面的 msg_msg 结构体,从而读取到其 m_list 成员,m_list 成员会指向 msg_queue 的 qmessages 成员地址,该成员指针又会指向 msg_msg 的 m_list 地址。
- 修改 msg_msg 的 next 指针为 msg_queue->qmessages 地址,此时需要注意 msg_queue 不能 使用 msgrcv 接受消息,从而使 msg_queue->q_lrpid (qmessages 的前8字节,即可以指定为 next->next)为0,从而泄漏 msg_msg 的地址
- 然后根据 msg_msg 的地址一直向后搜索即可。(当然这里可以使用 page_offset_base + 0x9d000 的地址来进行泄漏)
Step3. 构造 A-> B -> A 式 freelist 劫持新的结构体
两种提权方法:修改 cred 结构体;劫持内核执行流
这里 userfaultd 已被弃用,而且能搜索的 内存空间有限,因此选择劫持内核执行流的方法。
这里如果要将其分配到别的地方,而且要进行修改,需要先将其放回 slub 中,因为此时该结构体虽然是 free,但是分配到别的结构体时,我们就无法控制其内容,因为此时 msg_msg 结构体内容被覆盖,无法正常释放。
所以要修复 msg_msg 的双向链表,将 原 msg_msg 释放掉,这里只需要将双向链表指向堆上一个合法地址,next 指针为 NULL 即可。可以直接使用 setxattr 进行修复。(这里有一个点就是 slub 中空闲的 object 是在 kmem_cache->offset 处存放下一个 free object 的指针,而且对于较大的 object 而言,其 offset 通常大于 msg_msg header 的大小。
接下来构造 A -> B -> A 的 freelist 即可实现 Double free
Step4. pipe_buffer 劫持 PIP
pipe_buffer 不修改 nr 的情况下,刚好从 kmalloc-1k 中取 object
当关闭 管道 两端时,会触发 pipe_buffer->pipe_buffer_operation->release 这一指针,因此我们需要劫持其函数表。
解法2:msg_msg + sk_buff 堆喷
构造布局如下 主消息(96 字节) -> 辅助消息(0x400 字节)
double free,使用 sk_buff 堆喷重新获取到该结构体,利用 MSG_COPY 读取失败不会 panic 的特性完成定位
然后 越界读 辅助消息的下一个消息 header 获取到其 prev 主消息地址以及 next msg_queue 地址。
修改 next 为 prev 主消息的地址,读出 其辅助消息地址,从而再减 0x400 即 UAF 对象地址
利用 sk_buff 修改消息,释放 msg_msg 辅助消息完成 unlink,再申请 pipe_buffer 劫持 pipe_buffer.
利用 sk_buff 泄露 pipe_buffer 内内容,从而泄露内核代码段基址
利用 sk_buff 伪造 pipe_operations 内容,构造 ROP 劫持控制流