基本信息

readme 里提供了部分编译配置选项,可以看到使用 SLUB 分配器,同时开启 SLAB_FREELIST_RANDOM, SLAB_FREELIST_HARDENED 等保护:

1
2
3
4
5
6
7
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""
CONFIG_SLUB=y
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_HARDENED_USERCOPY=y

查看启动脚本,照例开启 smep, smap, kpti, kaslr 保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh
qemu-system-x86_64 \
-m 256M \
-cpu kvm64,+smep,+smap \
-smp cores=2,threads=2 \
-kernel bzImage \
-initrd ./rootfs.cpio \
-nographic \
-monitor /dev/null \
-snapshot \
-append "console=ttyS0 kaslr pti=on quiet oops=panic panic=1" \
-no-reboot

解包文件系统,提供了内核模块 d3kheap.ko ,同时初始化脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/sh
chown -R 0:0 /
mount -t tmpfs tmpfs /tmp
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev

echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/kptr_restrict

chown 0:0 /flag
chmod 400 /flag
chmod 777 /tmp

insmod d3kheap.ko
chmod 777 /dev/d3kheap

cat /root/banner
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh
poweroff -d 0 -f


题目漏洞分析

分析 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
2
3
4
5
6
7
8
9
10
11
static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;

#ifdef CONFIG_SLAB_FREELIST_HARDENED
BUG_ON(object == fp); /* naive detection of double free or corruption */
#endif

freeptr_addr = (unsigned long)kasan_reset_tag((void *)freeptr_addr);
*(freeptr_t *)freeptr_addr = freelist_ptr_encode(s, fp, freeptr_addr);
}

构造 UAF

首先很简单可以实现 UAF

  • 分配一个 1024 大小的 object
  • 释放该 object
  • 将其分配到别的结构体上
  • 释放该 object

解法1:利用 setxattr 多次劫持 msg_msg

Step1. setxattr 修改 object 内容

接下来思考如何对一个 free 状态的 object 写入数据,这里使用的是 setxattr 系统调用。
setxattr 系统调用可以在 kernel 利用中提供近乎任意大小的内核空间 object 分配。
在 setxatrr 函数中有如下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
//...
kvalue = kvmalloc(size, GFP_KERNEL);
if (!kvalue)
return -ENOMEM;
if (copy_from_user(kvalue, value, size)) {

//,..

kvfree(kvalue);

return error;
}

这里 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:向指定消息队列接收消息
    当创建消息队列时,在内核空间会分配以下结构体,表示消息队列:
    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;
    当我们调用 msgsnd 系统调用在指定消息队列上发送一条指定大小的 message 时,在内核空间中会创建一个结构体:
    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
    18
    static 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
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
30
31
32
33
struct msg_msg *load_msg(const void __user *src, size_t len)
{
struct msg_msg *msg;
struct msg_msgseg *seg;
int err = -EFAULT;
size_t alen;

msg = alloc_msg(len);
if (msg == NULL)
return ERR_PTR(-ENOMEM);

alen = min(len, DATALEN_MSG);
if (copy_from_user(msg + 1, src, alen))
goto out_err;

for (seg = msg->next; seg != NULL; seg = seg->next) {
len -= alen;
src = (char __user *)src + alen;
alen = min(len, DATALEN_SEG);
if (copy_from_user(seg + 1, src, alen))
goto out_err;
}

err = security_msg_msg_alloc(msg);
if (err)
goto out_err;

return msg;

out_err:
free_msg(msg);
return ERR_PTR(err);
}

alloc_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
30
31
32
33
34
35
36
37
static struct msg_msg *alloc_msg(size_t len)
{
struct msg_msg *msg;
struct msg_msgseg **pseg;
size_t alen;

alen = min(len, DATALEN_MSG);
msg = kmalloc(sizeof(*msg) + alen, GFP_KERNEL_ACCOUNT);
if (msg == NULL)
return NULL;

msg->next = NULL;
msg->security = NULL;

len -= alen;
pseg = &msg->next;
while (len > 0) {
struct msg_msgseg *seg;

cond_resched();

alen = min(len, DATALEN_SEG);
seg = kmalloc(sizeof(*seg) + alen, GFP_KERNEL_ACCOUNT);
if (seg == NULL)
goto out_err;
*pseg = seg;
seg->next = NULL;
pseg = &seg->next;
len -= alen;
}

return msg;

out_err:
free_msg(msg);
return NULL;
}

可以发现,其分配 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。
    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);
    ...
    }
    ...
    不过如果设置 MSG_COPY 标志就不会进行 unlink,从而绕过并且可以多次读取一个 msg_msg 结构体的数据。
    接下来通过 setxattr 修改 msg_msg 的 next 指针为 NULL, 同时将其 m_ts 成员修改为 0x1000-0x30
    然后即调用 copy_msg 函数搜索内存空间,copy_msg 函数在拷贝时会解引用 src->next 指针,如果 next 指针为 非法地址,则会导致 kernel panic。
    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
    struct 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;
    }
    因此我们需要提供 src 正常的堆上地址来保证 合法 next 指针。
    这里因为 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 劫持控制流

参考链接