• 北京网安在线

    北京网安在线云安全

    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.参考链接

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

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

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

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