本文首发于华为安全应急响应中心公众号:
https://mp.weixin.qq.com/s/w_u0FoiFdU0KM397UXJojw
文章目录
- 漏洞简介
- 环境搭建
- 漏洞原理
- 文件引用计数与飞行计数
- 引用计数
- 飞行计数
- 发送过程scm_send
- 接收过程
- unix_gc垃圾处理机制
- io_uring原理(仅限漏洞)
- io_uring_setup
- io_uring_register
- io_submit_sqe
- 漏洞的触发
- 漏洞修复
- 漏洞利用
- Dirty Cred(File)
- 参考
漏洞简介
漏洞编号: CVE-2022-2602
漏洞产品: linux kernel - io_uring & unix_gc
影响范围: linux kernel 5.x~6.1-rc1
利用条件: 无
利用效果: 本地提权
环境搭建
exp编译环境需要安装的一些东西:
apt-get install git
git clone git://git.kernel.dk/liburing
apt-get install g++
cd liburing
make
make install
复现成功与ubuntu-21.10,内核版本5.13.0-16-generic
另,直接编译5.13版本内核无需特殊编译选项也可复现成功,qemu启动需要指定内存大于等于4GB
漏洞原理
漏洞是一个UAF,主要原因是io_uring提供的IORING_REGISTER_FILES功能中将文件放入io_uring 的sock的receive_queue中,导致触发linux 的垃圾回收机制的时候会将io_uring中的文件当成垃圾释放,导致io_uring下次使用文件的时候造成UAF。
虽然大体过程看起来比较容易,但涉及两个模块与文件系统的引用计数机制等细节,总体逻辑还是比较复杂的,下面分别从文件引用计数、unix_gc垃圾回收机制和io_uring分析漏洞的原理。
文件引用计数与飞行计数
文件引用计数和飞行计数可以查看下面两篇文章:
io_uring, SCM_RIGHTS, and reference-count cycles
The quantum state of Linux kernel garbage collection CVE-2021-0920 (Part I)
下面简单说一下:
引用计数
打开一个文件就会在内核创建一个struct file
结构体,并返回给用户一个文件描述符fd用于对应这个struct file
,同时struct file
中有一个非常重要的字段就是struct file->f_count
,也就是文件的引用计数。我们可以通俗的理解为,每一个使用这个文件的地方都会增加这个文件的引用计数,当他使用完的时候就减少引用计数。这个"使用这个文件的地方",包括很多场景,不一定是打开这个文件或使用这个文件描述符,比如你对这个文件进行读写,那么就会进入内核来完成IO操作,那么内核在读写这个文件之前会加一这个文件的引用计数,然后读写完成之后会减一这个文件的引用次数。除此之外,调用dup2()复制这个文件描述符、进程分叉等操作都会增加引用次数(很容易理解);如果调用close关闭一个文件描述符其实也就是引用计数-1,如果一个文件的引用计数降为了0,则会将文件的struct file结构体释放。
文件引用计数是一个原理简单,但实际场景很复杂的功能,包括后面介绍的通过sock的scm功能发送文件给另一端/io_uring的注册文件等,都涉及文件引用计数的变化情况,并且该漏洞的核心就是文件引用计数的变化。能引起文件引用计数变化的内核函数包括:
-
fget:通过问价描述符获取struct file,并把文件引用计数+1
-
get_file:传入是struct file,返回时struct file,该函数单纯的把文件引用计数+1
-
fput:减少一次文件引用计数,如果减少到0则会释放文件的struct file结构
一般内核使用文件的功能都会在使用之前调用fget或get_file,然后使用完之后调用fput。
飞行计数
Linux sock中比较重要的功能就是进程间通信,他允许建立一个双向socket来传递消息,通常会用在两个进程中的进程见通信,这其中比较重要的就是SCM_RIGHTS消息。SCM_RIGHTS消息允许发送一个文件描述符到对端,对端接收之后就可以使用,这个功能的本意是有权限打开文件的进程打开文件然后传递给没权限打开的进程使用,实际的实现类似dup2而不单单是单纯的传递文件描述符过去(传递过去对端的描述符数字甚至和本端是不一样的)。
如果文件发送过去之后对面还没接收文件就close了,那么就会出问题,所以在发送状态就需要给文件的引用计数+1,这样即便文件发送后立马被关闭,也还保留至少1的引用计数而不会被释放;如果对端还没来得及接收,socket就被close了,那么会在sock_release环节对发送中还没接收的文件进行fput引用计数-1,来去掉飞行过程中增加的引用计数;这没问题。
但问题是,如果发送的文件是socket fd本身,比如下面这张图:
- 打开两个sock A和B,那么初始引用计数是1
- 然后A将自己发送给B,B将自己发送给A,发送过程中内核会将两个文件的引用计数+1
- 但A和B都不接受发送的文件描述符,而直接关闭A和B
- 关闭之后,A和B的引用计数都会-1,这样就变回了1,由于没有变成0,所以不会真的释放
- 由于用户态已经没有引用A和B的文件描述符了,而内核态由于引用计数没有到0,所以不会调用到close流程中的sock_release,自然也无法清理发送状态中的文件。
- 那么A和B将永远无法释放
为了解决上诉问题,unix_gc 即垃圾处理机制就诞生了。但在介绍垃圾处理机制之前,我们先来讲解一下飞行计数。这需要从SCM_RIGHTS的源码分析比较好理解,相关代码不多
发送过程scm_send
由于文件的引用计数贯穿整个漏洞利用的全部阶段,所以这里我们从源码层面看一下发送SCM_RIGHTS类型消息的过程,重点关注过程中文件引用计数与飞行计数的变化情况
主要操作差不多是申请SOCK_DGRAM 类型的socket,然后sendmsg发送SCM_RIGHTS类型的消息,sendmsg会走到dgram的sendmsg也就是unix_dgram_sendmsg函数:
net\unix\af_unix.c : unix_dgram_sendmsg
static int unix_dgram_sendmsg(struct socket *sock, struct msghdr *msg,
size_t len)
{
struct scm_cookie scm;
··· ···
err = scm_send(sock, msg, &scm, false);//[1]先调用scm_send获取用户传入的文件描述符对应的文件,然后初始化scm_cookie 结构
··· ···
··· ···
skb = sock_alloc_send_pskb(sk, len - data_len, data_len,
msg->msg_flags & MSG_DONTWAIT, &err,
PAGE_ALLOC_COSTLY_ORDER);
if (skb == NULL)
goto out;
err = unix_scm_to_skb(&scm, skb, true);//[2]调用unix_scm_to_skb
··· ···
scm_destroy(&scm);//[3]最后释放scm,也会对scm中的文件进行fput减少引用计数
··· ···
skb_queue_tail(&other->sk_receive_queue, skb);//[4] 将skb添加到对端的接收队列
}
[1] 这里会在unix_dgram_sendmsg的一开始调用scm_send,主要工作是获取用户传入的文件描述符对应的文件,并初始化局部变量struct scm_cookie scm
,主要是初始化这里面的文件列表,这个过程会对涉及到的文件进行引用计数增加。
struct scm_fp_list {
short count;
short max;
struct user_struct *user;
struct file *fp[SCM_MAX_FD];//文件数组
};
struct scm_cookie {
struct pid *pid; /* Skb credentials */
struct scm_fp_list *fp; //文件列表
struct scm_creds creds; /* Skb credentials */
#ifdef CONFIG_SECURITY_NETWORK
u32 secid; /* Passed security ID */
#endif
};
[2] 重要的发送操作,会将scm_cookie中的文件绑定到skb中,并且还会对文件引用计数与飞行计数增加操作等。
[3] 发送过程已经结束,将刚刚初始化scm时增加的文件引用计数减少。
net\core\scm.c : __scm_destroy
void __scm_destroy(struct scm_cookie *scm)
{
··· ···
if (fpl) {
scm->fp = NULL;
for (i=fpl->count-1; i>=0; i--)
fput(fpl->fp[i]);//依次调用fput
··· ···
}
}
[4] 最后将skb添加到对端的接收队列,用于接收逻辑
先来看看scm_send,在scm_send中直接调用__scm_send
,所以直接看__scm_send
:
net\core\scm.c : __scm_send
int __scm_send(struct socket *sock, struct msghdr *msg, struct scm_cookie *p)
{
··· ···
for_each_cmsghdr(cmsg, msg) {//对用户传入的所有消息操作:
··· ···
switch (cmsg->cmsg_type)
{
case SCM_RIGHTS: //处理SCM_RIGHTS类型消息
if (!sock->ops || sock->ops->family != PF_UNIX)
goto error;
err=scm_fp_copy(cmsg, &p->fp);//进一步处理,从用户消息中获取文件来完成初始化
if (err<0)
goto error;
break;
··· ···
··· ···
}
对于SCM_RIGHTS类型消息会调用scm_fp_copy来初始化scm_cookie 的fp字段,也就是struct scm_fp_list,用于存放所有从用户空间发送过来的文件列表:
net\core\scm.c : scm_fp_copy
static int scm_fp_copy(struct cmsghdr *cmsg, struct scm_fp_list **fplp)
{
··· ···
if (!fpl)
{//初始化fpl一些基本信息
fpl = kmalloc(sizeof(struct scm_fp_list), GFP_KERNEL);
if (!fpl)
return -ENOMEM;
*fplp = fpl;
fpl->count = 0;//文件数量
fpl->max = SCM_MAX_FD;//最大数量
fpl->user = NULL;
}
fpp = &fpl->fp[fpl->count];
if (fpl->count + num > fpl->max)
return -EINVAL;
/*
* Verify the descriptors and increment the usage count.
*/
for (i=0; i< num; i++)//[1] 依次获取从用户空间发送的所有文件描述符:
{
int fd = fdp[i];
struct file *file;
if (fd < 0 || !(file = fget_raw(fd)))//[1] fget_raw根据描述符获取file结构体,并且引用计数+1
return -EBADF;
*fpp++ = file;
fpl->count++;//文件数量增加
}
··· ···
}
[1] 会循环从用户消息中获取文教描述符,并调用fget_raw 函数获取对应的struct file
结构体,在调用fget_raw的过程中文件的引用计数会+1。这里的引用+1只是代表scm正在处理这个文件,会在unix_dgram_sendmsg函数后面调用的scm_destroy中减少回去。
随后看unix_dgram_sendmsg后面的unix_scm_to_skb,进行主要的文件发送工作,其实没啥工作:
net\unix\af_unix.c : unix_scm_to_skb
static int unix_scm_to_skb(struct scm_cookie *scm, struct sk_buff *skb, bool send_fds)
{
··· ···
UNIXCB(skb).pid = get_pid(scm->pid);//初始化skb->cb的一些信息
··· ···
if (scm->fp && send_fds)//如果scm文件列表中有文件则
err = unix_attach_fds(scm, skb);//调用unix_attach_fds进一步处理
··· ···
}
设置一些基本信息之后调用unix_attach_fds进一步处理:
net\unix\scm.c : unix_attach_fds
int unix_attach_fds(struct scm_cookie *scm, struct sk_buff *skb)
{
int i;
if (too_many_unix_fds(current))
return -ETOOMANYREFS;
/*
* Need to duplicate file references for the sake of garbage
* collection. Otherwise a socket in the fps might become a
* candidate for GC while the skb is not yet queued.
*/
UNIXCB(skb).fp = scm_fp_dup(scm->fp);//[1] 先调用scm_fp_dup,对每一个文件增加引用计数
if (!UNIXCB(skb).fp)
return -ENOMEM;
for (i = scm->fp->count - 1; i >= 0; i--)
unix_inflight(scm->fp->user, scm->fp->fp[i]);//[2] 对每一个文件调用unix_inflight进行inflight
return 0;
}
EXPORT_SYMBOL(unix_attach_fds);
[1] 先调用scm_fp_dup,对每一个文件增加引用计数,代表文件正在发送中,这里的增加才是真正"代表文件正在发生中,所以引用+1"的增加,而之前scm_fp_copy 中的引用增加只是代表scm正在处理这个文件
net\core\scm.c : scm_fp_dup
struct scm_fp_list *scm_fp_dup(struct scm_fp_list *fpl)
{
··· ···
if (new_fpl) {
for (i = 0; i < fpl->count; i++)//对每一个文件:
get_file(fpl->fp[i]);//调用get_file,引用+1
new_fpl->max = new_fpl->count;
new_fpl->user = get_uid(fpl->user);
}
return new_fpl;
}
[2] 然后通过unix_inflight 函数设置文件的飞行计数:
unix_inflight函数如下:
net\unix\scm.c : unix_inflight
void unix_inflight(struct user_struct *user, struct file *fp)
{
struct sock *s = unix_get_socket(fp);//[1] 只有socket 和io_uring的fd才能找到sock
spin_lock(&unix_gc_lock);
if (s) {//[2] 对于sock类型文件则增加飞行计数
struct unix_sock *u = unix_sk(s);
if (atomic_long_inc_return(&u->inflight) == 1) {
BUG_ON(!list_empty(&u->link));
list_add_tail(&u->link, &gc_inflight_list);//[2] 添加到gc_inflight_list全局飞行列表
} else {
BUG_ON(list_empty(&u->link));
}
unix_tot_inflight++;//[2] 全局飞行文件数+1
}
user->unix_inflight++;//用户统计飞行计数增加
spin_unlock(&unix_gc_lock);
}
[1] 首先,经过上面的描述,我们知道,"文件循环发送导致无法释放"的场景仅限socket发送socket fd自己的情况,而对于普通文件发送不接受是可以在sock_release中得到释放的。所以这里需要判断发送的文件类型,调用unix_get_socket函数判断是否是socket类型文件:
net\unix\scm.c : unix_get_socket
struct sock *unix_get_socket(struct file *filp)
{
struct sock *u_sock = NULL;
struct inode *inode = file_inode(filp);
/* Socket ? */
if (S_ISSOCK(inode->i_mode) && !(filp->f_mode & FMODE_PATH)) {//socket情况
struct socket *sock = SOCKET_I(inode);
struct sock *s = sock->sk;
/* PF_UNIX ? */
if (s && sock->ops && sock->ops->family == PF_UNIX)
u_sock = s;
} else {
/* Could be an io_uring instance */
u_sock = io_uring_get_socket(filp);//获取io_uring的sock
}
return u_sock;//如果不是socket fd也不是io_uring fd那么返回0
}
unix_get_socket 函数会对传入文件类型做判断,socket类型文件和io_uring类型文件,则会返回对应的struct sock
结构,而普通文件就返回0。由于io_uring在此处的场景和socket比较类似,所以也需要特殊处理,总而言之吗,我们只要知道,对于传输的文件是socket类型和io_uring类型的文件,需要进行额外的飞行计数处理。
[2] 如果传输的文件是socket或io_uring文件的情况下,会:
- 先将文件的飞行计数(
u->inflight
)+1,飞行计数不存放在struct file
中,而存放在文件对应的sock结构中 - 然后将文件对应的sock 结构加入gc_inflight_list列表,这个列表是"全局飞行列表"意思是所有"正在发送"中的文件。
- 然后会对全局的飞行计数和用户的飞行计数增加
看到这里我们就知道了,对于有sock结构的文件在被传输过程中的时候,会增加文件的飞行计数,并且会把对应的sock插入到全局飞行列表中。这样如果两边的socket被关闭了,那么还可以通过全局飞行列表找到正在传输的文件,然后通过一些判断规则判断出是否是"无法释放"的文件即可。
整个过程中引用计数变化如果图所示,箭头代表函数调用关系,上下代表函数的执行时间先后。
接收过程
由于漏洞利用不涉及接收过程,这里不做详细分析,接收过程中会给文件安装新的文件描述符,这一过程会增加文件的引用计数,但接收完毕之后还会减少因为发送过程而增加的引用计数和飞行计数,并从飞行列表中删除,等于引用计数没有变化,飞行计数减少。感兴趣的同学可以自己研究。
我们只需要知道接收端还未接收的消息(不管是文件还是报文啥的)会以sk_buff的形势存放在自己的sk_receive_queue 中。下面看一下unix_gc垃圾处理机制
unix_gc垃圾处理机制
垃圾处理的触发在一个socket关闭的时候,主要函数是unix_gc函数,调用栈如下:
回到上面的那张图:
增加了飞行计数的情况下,如果在传输过程中关闭A和B,那么A和B还会剩下一个引用计数导致无法真正释放,而由于在传输过程中,他们还有一个飞行计数,并且被放入了gc_inflight_list全局飞行列表中,而垃圾处理机制就是遍历全局飞行列表中的文件,查看他们是否"飞行计数等于引用计数",当然还有一些其他条件,然后识别垃圾并释放他们。
net\unix\garbage.c : unix_gc
void unix_gc(void)
{
struct unix_sock *u;
struct unix_sock *next;
struct sk_buff_head hitlist;
struct list_head cursor;
LIST_HEAD(not_cycle_list);
··· ···
list_for_each_entry_safe(u, next, &gc_inflight_list, link) {//[1] 对于gc_inflight_list全局飞行列表中的每一个成员:
long total_refs;
long inflight_refs;
total_refs = file_count(u->sk.sk_socket->file);//获取文件的引用计数
inflight_refs = atomic_long_read(&u->inflight);//获取文件的飞行计数
BUG_ON(inflight_refs < 1);
BUG_ON(total_refs < inflight_refs);
if (total_refs == inflight_refs) {//[1] 如果引用计数和飞行计数相等
list_move_tail(&u->link, &gc_candidates); //把u->link 添加到 gc_candidates备选垃圾列表中
__set_bit(UNIX_GC_CANDIDATE, &u->gc_flags);//设置两个flag
__set_bit(UNIX_GC_MAYBE_CYCLE, &u->gc_flags);
}
}
/* Now remove all internal in-flight reference to children of
* the candidates.
*/
list_for_each_entry(u, &gc_candidates, link)//[2] 初步过滤一遍垃圾列表
scan_children(&u->sk, dec_inflight, NULL);
/* Restore the references for children of all candidates,
* which have remaining references. Do this recursively, so
* only those remain, which form cyclic references.
*
* Use a "cursor" link, to make the list traversal safe, even
* though elements might be moved about.
*/
list_add(&cursor, &gc_candidates);
while (cursor.next != &gc_candidates) {//[3] 恢复合法的文件
u = list_entry(cursor.next, struct unix_sock, link);
/* Move cursor to after the current position. */
list_move(&cursor, &u->link);
if (atomic_long_read(&u->inflight) > 0) {//[3] 如果还有飞行计数非0,则代表被正常socket引用,不是垃圾
list_move_tail(&u->link, ¬_cycle_list);//添加到not_cycle_list中
__clear_bit(UNIX_GC_MAYBE_CYCLE, &u->gc_flags);//清除flag
scan_children(&u->sk, inc_inflight_move_tail, NULL); //inc_inflight_move_tail是增加飞行计数
}
}
list_del(&cursor);
/* Now gc_candidates contains only garbage. Restore original
* inflight counters for these as well, and remove the skbuffs
* which are creating the cycle(s).
*/
skb_queue_head_init(&hitlist);//[4] 再过滤一遍垃圾列表,满足的就是真垃圾,添加到hitlist中
list_for_each_entry(u, &gc_candidates, link)
scan_children(&u->sk, inc_inflight, &hitlist);
/* not_cycle_list contains those sockets which do not make up a
* cycle. Restore these to the inflight list.
*/
while (!list_empty(¬_cycle_list)) {//[5] 正常的在放回gc_inflight_list之中
u = list_entry(not_cycle_list.next, struct unix_sock, link);
__clear_bit(UNIX_GC_CANDIDATE, &u->gc_flags);
list_move_tail(&u->link, &gc_inflight_list);
}
spin_unlock(&unix_gc_lock);
/* Here we are. Hitlist is filled. Die. */
__skb_queue_purge(&hitlist);//清理垃圾
··· ···
}
[1] 首先遍历gc_inflight_list 中的成员,获取每个成员的引用计数和飞行计数,如果相等,则该成员可能陷入了上述"无法释放"的引用死循环中。将其添加到gc_candidates"备选垃圾队列",并设置俩flag,UNIX_GC_CANDIDATE 和 UNIX_GC_MAYBE_CYCLE。
[2] 有一点值得注意,由于gc_inflight_list 中的成员肯定都是socket文件类型的成员(io_uring也是类似socket),那么就说明他们自己的sk_receive_queue 之中可能还是有没接收的东西,换言之就是他们还有children 引用的其他file 类型。我们需要过滤一遍。遍历备选垃圾队列中的每一个成员,调用scan_children函数,scan_children中直接调用scan_inflight,代码分析在下面,建议先看一下。这里会寻找gc_candidates备选垃圾队列中的"垃圾"引用(sk_receive_queue中有还没接收的其他socket fd)了其他备选垃圾队列中的"垃圾",调用dec_inflight减少飞行计数,此举是减少垃圾的引用计数,方便统计是否有非垃圾socket引用了该目标。
[3] 现在垃圾队列中已经没有"垃圾引用垃圾"的情况了,重新遍历一遍垃圾队列,如果还有成员的飞行计数不为0,那么一定是被不是垃圾的(也就是还存活并正常工作的)socket 引用(还没接收)。那么将他们放入not_cycle_list,并取消UNIX_GC_MAYBE_CYCLE flag,并重新调用scan_children 传入inc_inflight_move_tail 将刚刚减少的飞行计数添加回来。
[4] 再进行一遍同[2]中一样的过滤操作,只不过这次指定了一个hitlist列表,满足条件的被引用垃圾会从sk_receive_queue中解引用并添加到hitlist中。
[5] not_cycle_list中的就是正常的socket了,要放回到gc_inflight_list 飞行列表中,并取消相应的flag
[6] 调用__skb_queue_purge
清理真正的垃圾队列。会遍历垃圾队列中的skb,并把skb->cb.fp 中的文件调用scm_destroy
释放:
net\unix\scm.c : unix_destruct_scm
void unix_destruct_scm(struct sk_buff *skb)
{
struct scm_cookie scm;
memset(&scm, 0, sizeof(scm));
scm.pid = UNIXCB(skb).pid;
if (UNIXCB(skb).fp)
unix_detach_fds(&scm, skb);//将skb->cb.fp中的文件转移成scm格式
/* Alas, it calls VFS */
/* So fscking what? fput() had been SMP-safe since the last Summer */
scm_destroy(&scm);//调用scm_destroy fput他们
sock_wfree(skb);
}
然后让我们分析一下比较关键的scan_inflight 函数:
net\unix\garbage.c : scan_inflight
static void scan_inflight(struct sock *x, void (*func)(struct unix_sock *),
struct sk_buff_head *hitlist)
{
struct sk_buff *skb;
struct sk_buff *next;
spin_lock(&x->sk_receive_queue.lock);
skb_queue_walk_safe(&x->sk_receive_queue, skb, next) {//[1] 遍历这个sock的sk_receive_queue 查看还没接收的消息
/* Do we have file descriptors ? */
if (UNIXCB(skb).fp) {//[2] 如果有没接收的文件
bool hit = false;
/* Process the descriptors of this socket */
int nfd = UNIXCB(skb).fp->count;
struct file **fp = UNIXCB(skb).fp->fp;
while (nfd--) {//[2] 则遍历文件列表,查找是否有sock类型的文件
/* Get the socket the fd matches if it indeed does so */
struct sock *sk = unix_get_socket(*fp++);
if (sk) {//[2] 如果发现sock类型的文件
struct unix_sock *u = unix_sk(sk);
/* Ignore non-candidates, they could
* have been added to the queues after
* starting the garbage collection
*/
if (test_bit(UNIX_GC_CANDIDATE, &u->gc_flags)) {//[2.1] 这个flag意味着这个文件引用等于飞行已经被添加到备选垃圾
hit = true;
func(u);//[3] 的函数操作,可能是dec_inflight 减少飞行计数,也可能是增加
}
}
}
if (hit && hitlist != NULL) {//如果指定了目标list则
__skb_unlink(skb, &x->sk_receive_queue);//[4] sk_receive_queue 取下
__skb_queue_tail(hitlist, skb);//[4] 添加到上层传入的列表中,可能没有指定列表就什么也不干
}
}
}
spin_unlock(&x->sk_receive_queue.lock);
}
[1] 传入的x是一个在gc_candidates 备选垃圾列表中的,备选垃圾成员,并且它一定是一个socket类型文件对应的sock,那么它就也会有自己的sk_receive_queue"未接收队列"。这个函数的功能就是遍历未接收队列中的文件
[2] 遍历它的未接收队列中的skb,如果真的有未接收的文件,并且是socket类型文件
[2.1] 并且具备UNIX_GC_CANDIDATE flag,这个flag在上面unix_gc函数刚开始,对引用计数等于飞行计数的所有socket文件都设置了,设置这个flag的文件一定在备选垃圾列表中。也就是说,存在"垃圾引用垃圾"的情况
[3] 对"垃圾引用垃圾"中被引用的垃圾调用上层传递的func,一般是增减飞行计数。
[4] 然后将"垃圾引用垃圾"中被引用的垃圾从sk_receive_queue中取下,并添加到上层传入的目标列表中,可能没指定目标列表就什么也不干。
也就是说scan_children -> scan_inflight要解决的就是在垃圾列表中的socket "引用"了其他垃圾列表中的socket,将被引用的socket卸下,放入一个指定的队列中,如果不指定队列,则只会进行增加/减少飞行计数的操作(通过传入的func)。类似下图的一个关系:
除此之外,可能各种结构体之间的关系有点绕,这边也有一个图方便理解:
io_uring原理(仅限漏洞)
io_uring可以参考这篇文章:Put an io_uring on it: Exploiting the Linux Kernel
这里简单描述一下漏洞相关的io_uring功能,首先io_uring提供的是一个移步进行io类系统调用的机制,中心思想是不需要进行系统调用操作来完成系统调用功能。打个比方,传统的io系统调用,比如你要读写文件,那么你进行read write系统调用之后,你要等待内核给你进行读写操作。如果内核那边因为一些原因阻塞,那么你也要继续等着。io_uring做的事情就是,你可以把你要内核做的io操作都放在一个队列里,然后你就去做其他的事情,内核(空闲的时候)就会从任务队列里拿你给它的io任务去完成,等你觉得内核做完了你给它的io任务的时候,你就去结果队列里取结果就行了。
很多介绍io_uring的文章都会列出这两个环形结构,其实就可以表示io_uring的主要工作原理了:
提交任务的环叫SQ,里面的每个任务叫SQE,获取结果的环叫CQ,里面的每个结果叫CQE。
io_uring的具体实现是通过下面三个系统调用:
- io_uring_setup: 初始化io_uring,创建上面两个队列什么的。
- io_uring_enter:通知内核任务已经提交或获取任务结果。
- io_uring_register:注册共享缓冲区。
虽然说io_uring是"不需要系统调用操作来完成系统调用功能",但实际使用中还是需要其中三个系统调用,尤其是任务发送和结果接收需要使用io_uring_enter,但io_uring提供了一个轮训模式IORING_SETUP_SQPOLL,在该模式下,内核会自动取检查任务队列里是否有新任务并去完成,而不需要我们去调用io_uring_enter系统调用。利用该模式甚至可以实现无系统调用的server。
漏洞相关不会涉及io_uring的一些任务下发和回收的功能,下面只简单讲解一下漏洞相关的io_uring功能:
io_uring_setup
io_uring_setup 主要是初始化io_uring的两个环形队列之类的,然后为io_uring创建一个文件对象,将文件描述符返回给用户用于后续使用,用户后续使用这个文件描述符来映射出内存来访问两个队列和创建相关资源,没有什么特别复杂的逻辑,需要注意的就是在io_uring_setup->io_uring_create->io_uring_get_file中,初始化了一个sock结构体:
fs\io_uring.c : io_uring_get_file
static struct file *io_uring_get_file(struct io_ring_ctx *ctx)
{
struct file *file;
#if defined(CONFIG_UNIX)
int ret;
ret = sock_create_kern(&init_net, PF_UNIX, SOCK_RAW, IPPROTO_IP,
&ctx->ring_sock);//给ctx->ring_sock 初始化一个sock结构体
if (ret)
return ERR_PTR(ret);
#endif
file = anon_inode_getfile("[io_uring]", &io_uring_fops, ctx,
O_RDWR | O_CLOEXEC);//初始化一个file对象,其fd后续给用户使用
#if defined(CONFIG_UNIX)
if (IS_ERR(file)) {
sock_release(ctx->ring_sock);
ctx->ring_sock = NULL;
} else {
ctx->ring_sock->file = file;
}
#endif
return file;
}
也就是记住,io_uring初始化完,还会同步存在一个io_uring的文件对象和sock对象即可,这在后面漏洞触发中很重要。如果初始化io_uring携带IORING_SETUP_SQPOLL flag的话,则会在io_sq_offload_create 中初始化一个内核线程轮训io_uring的任务队列,就不用我们主动调用io_uring_enter通知io_uring了,这里不详细分析了。
io_uring_register
漏洞重要逻辑所在的io_uring功能为io_uring_register的IORING_REGISTER_FILES功能,该功能允许将若干文件描述符注册进入io_uring,方便后续的io操作,我们暂时不用理解的这么详细,先看一下该功能的重点函数,首先是入口:
fs\io_uring.c : SYSCALL_DEFINE4(io_uring_register…)
SYSCALL_DEFINE4(io_uring_register, unsigned int, fd, unsigned int, opcode,
void __user *, arg, unsigned int, nr_args)
{
··· ···
ret = __io_uring_register(ctx, opcode, arg, nr_args);
··· ···
}
直接调用了__io_uring_register
,在__io_uring_register
中会根据opcode类型调用到io_sqe_files_register
函数:
static int io_sqe_files_register(struct io_ring_ctx *ctx, void __user *arg,//2602
unsigned nr_args, u64 __user *tags)
{
··· ···
//[1] nr_user_files 使用户绑定的文件数量,遍历所有用户传进来的文件描述符
for (i = 0; i < nr_args; i++, ctx->nr_user_files++) {
u64 tag = 0;
if ((tags && copy_from_user(&tag, &tags[i], sizeof(tag))) ||
copy_from_user(&fd, &fds[i], sizeof(fd))) {//[1] 获取用户传递的文件描述符fd
ret = -EFAULT;
goto out_fput;
}
··· ···
file = fget(fd);//[1] 获取文件结构体,fget会对文件引用次数+1
··· ···
if (file->f_op == &io_uring_fops) {//不能io_uring 注册自己
fput(file);
goto out_fput;
}
ctx->file_data->tags[i] = tag;
io_fixed_file_set(io_fixed_file_slot(&ctx->file_table, i), file);
}
ret = io_sqe_files_scm(ctx);//[2]关键函数,进一步逻辑
··· ···
··· ···
}
[1] 会遍历用户传递的所有需要注册的文件描述符,找到对应的struct file
结构体,这其中会调研fget函数,该操作会对文件的引用次数+1
[2] 接下来会调用关键函数io_sqe_files_scm对文件进行下一步注册:
static int io_sqe_files_scm(struct io_ring_ctx *ctx)
{
unsigned left, total;
int ret = 0;
total = 0;
left = ctx->nr_user_files;
while (left) {//对所有注册文件
unsigned this_files = min_t(unsigned, left, SCM_MAX_FD);
ret = __io_sqe_files_scm(ctx, this_files, total);//调用__io_sqe_files_scm
if (ret)
break;
left -= this_files;
total += this_files;
}
if (!ret)
return 0;
··· ···
}
约等于直接调用__io_sqe_files_scm,
static int __io_sqe_files_scm(struct io_ring_ctx *ctx, int nr, int offset)//ctx,thisfiles total
{
struct sock *sk = ctx->ring_sock->sk;//[1] sock 是最开始io_uring初始化的时候创建的
struct scm_fp_list *fpl;
struct sk_buff *skb;
int i, nr_files;
fpl = kzalloc(sizeof(*fpl), GFP_KERNEL);
··· ···
skb = alloc_skb(0, GFP_KERNEL);//[2] 申请一个sk_buff
··· ···
skb->sk = sk;//skb的sk指向io_uring的sk
nr_files = 0;
fpl->user = get_uid(current_user());
for (i = 0; i < nr; i++) {//[2] 遍历所有文件
struct file *file = io_file_from_index(ctx, i + offset);//获得文件结构体
if (!file)
continue;
fpl->fp[nr_files] = get_file(file);//[2] get_file同样会使file引用次数+1,把文件注册到fpl中
unix_inflight(fpl->user, fpl->fp[nr_files]);//[2] 把文件添加到发送队列,会增加sock类型文件的inflight飞行计数
nr_files++;
}
if (nr_files) {
fpl->max = SCM_MAX_FD;
fpl->count = nr_files;
UNIXCB(skb).fp = fpl;//[2] fpl 给skb
skb->destructor = unix_destruct_scm;
refcount_add(skb->truesize, &sk->sk_wmem_alloc);
skb_queue_head(&sk->sk_receive_queue, skb);//[2] skb添加到sk_receive_queue中
for (i = 0; i < nr_files; i++)
fput(fpl->fp[i]);//[3] 对这些文件使用fput,平衡刚刚使用的get_file
} else {
kfree_skb(skb);
kfree(fpl);
}
return 0;
}
[1] sk是io_uring在初始化时候创建的struct sock结构
[2] 先申请一个skb,这很重要;然后遍历所有文件,对他们使用get_file引用计数会+1;然后将这些文件使用unix_inflight发送,如果发送的文件是sock类型则会增加飞行计数和添加到全局飞行列表什么的,普通文件不受影响;最后将文件列表fpl赋值给skb->cb.fp,并把skb添加到io_uring sock的接收队列之中。
[3] 最后处理完之后会对所有文件使用fput,文件引用计数会-1,相当于平衡了最开始的fget。
上面我们分析过unix_inflight和sk_receive_queue等,那么这里的逻辑很容易就可以看出有点奇怪(用法奇怪,而不是问题),这里将注册到io_uring的文件通过unix_inflight 增加飞行计数,这看起来没啥问题,后面又给这个文件添加到自己的sk_receive_queue 之中,经过上面的分析,我们知道,sk_receive_queue代表一个socket还未接收的消息队列,socket总是成对出现,而io_uring只有一个,这里是要把它理解成"自己给自己发送文件"吗。总之这个使用也导致了利用垃圾处理机制的漏洞(但修复不在这里)。
io_submit_sqe
这个函数是在我们向io_uring中提交任务之后(不管是不是IORING_SETUP_SQPOLL模式都会走到这里),io_uring准备完成这个sqe的任务的时候会触发,这里简单看一下io_uring如何完成IORING_OP_WRITEV 类型任务,也就是writev,io_uring任务的主要处理函数可以从io_submit_sqe看起:
static int io_submit_sqe(struct io_ring_ctx *ctx, struct io_kiocb *req,
const struct io_uring_sqe *sqe)
{
struct io_submit_link *link = &ctx->submit_state.link;
int ret;
ret = io_init_req(ctx, req, sqe);//初始化调用任务
··· ···
ret = io_req_prep(req, sqe);//准备调用任务,这里会进行文件权限的判断
··· ···
··· ···
} else {
io_queue_sqe(req);//尝试执行
··· ···
}
主要分为三步,在io_init_req中进行初始化,然后调用io_req_prep函数准备调用任务,对于writev任务会在这里调用io_write_prep进行文件权限校验:
static int io_write_prep(struct io_kiocb *req, const struct io_uring_sqe *sqe)
{
if (unlikely(!(req->file->f_mode & FMODE_WRITE)))
return -EBADF;
return io_prep_rw(req, sqe);
}
最后会在io_queue_sqe->__io_queue_sqe->io_issue_sqe->io_write
中完成实际写入工作。
所以虽然是writev任务,但并没有直接去调用系统调用writev相关内容,而且整个过程中也没有改变文件的引用计数。
漏洞的触发
按照https://github.com/LukeGix/CVE-2022-2602 中的exp 和http://web.archive.org/web/20221201205031/https://seclists.org/oss-sec/2022/q4/57 中的poc 的思路来介绍漏洞触发的流程,这个过程中我们需要重点关注相关文件的引用计数变化和飞行计数变化:
- 准备一对sock,文件描述符记为s[0] 与s[1],准备好之后默认的引用计数均为1
- 初始化io_uring,获取一个文件描述符记为fd,初始状态引用计数为1
- 打开一个普通可读写文件,文件描述符记为rfd[1],初始状态引用计数为1
- 使用io_uring_register 的注册功能注册s[1]和rfd[1],在这期间会给文件的引用计数+1,并且io_uring_register 中会对注册的文件调用unix_inflight 函数,sock类型的s[1]的飞行计数+1,并会将s[1]和rfd[1]放入同一个skb中
- 也就是说现在s[1]: 引用计数2; 飞行计数1。
- rfd[1]: 引用计数2; 不涉及飞行计数
- 关闭rfd[1],引用计数-1,变为1
- 将fd使用s[0]发送给s[1],这期间会将他们的引用计数+1,并且由于发送过程中调用unix_inflight 函数,io_uring类型的fd 同样会增加一个飞行计数
- fd: 引用计数2; 飞行计数1
- 分别关闭s[0]和s[1],他们的引用计数都会减1
- s[0]: 引用计数0,被释放。
- s[1]: 引用计数1,飞行计数1,暂时不会被释放
- 提交一个向文件写(writev)的任务,并同时使用inode锁将文件锁住(参考Dirty Cred分析中的方法),这个任务就会阻塞。
- 调用io_uring_queue_exit 关闭fd,引用计数-1
- fd: 引用计数1; 飞行计数1,暂时不会被释放
- 创建一个socket,并且关闭,触发unix_gc(在socket 关闭过程中触发)
- s[1] 和fd(io_uring) 都满足引用计数==飞行计数的条件,并且他们互相都在对方的sk_receive_queue中
- unix_gc会将他们从对方的sk_receive_queue 中取下并且加入hitlist中调用__skb_queue_purge 完成释放(释放的是skb),并且减少飞行计数。
- 释放skb过程中会对skb中的file 调用fput
- fd所在的skb中只有fd自己,对fd(io_uring)使用fput,文件引用计数-1,变为0成功释放。
- s[1]所在的skb中有s[1]自己和rfd[1],对这两个文件使用fput
- s[1] 经过fput之后引用计数变为0,被释放,没问题
- rfd[1]经过fput之后引用计数变为0,被释放,但上面还有因阻塞而没有完成的任务,所以导致任务阻塞完毕之后尝试写入时访问了已经被释放的文件结构体,造成UAF。
说到这里其实对这个漏洞的全景已经有了大概的了解了。我们只需要再非法释放之后,阻塞结束之前,使用堆喷射喷射其他文件结构体覆盖这个被释放的结构体所在内存,就可以写到其他文件了。
漏洞修复
根据补丁信息,漏洞的修复主要是在unix_gc函数中:
如果发现hitlist中有io_uring的sock文件,那么将其从hitlist中卸下,也就是不处理io_uring,简单粗暴,后续io_uring自己会关闭的。
漏洞利用
漏洞的原理虽然很复杂,但本质上漏洞效果就是在io_uring执行IO任务之前非法把文件释放掉,这和前些日志刚刚公布的Dirty Cred中的Dirty File利用依赖的漏洞条件非常符合,可以直接套用Dirty File利用方法。
io_uring虽然是无系统调用的IO操作,但本质上还是完成的对应系统调用的功能,比如可以给io_uring下发writev系统调用的任务,那么就可以直接套用Dirty File完成利用。
Dirty Cred(File)
漏洞和传统Dirty Cred相关利用的漏洞不同的点在于,该漏洞的io_uring自带了写功能,并且逻辑和writev类似。我们无需自己在writev,关于Dirty Cred(File)利用原理可以看下面两个文章,比较简单,这里不多说了,这个漏洞的精髓在于漏洞的触发,看完上面的触发原理之后,基本利用手段已经了然与胸了吧。
[kernel exploit] Dirty Cred: 一种新的无地址依赖漏洞利用方案
[漏洞分析] CVE-2022-2588 route4 double free内核提权
参考
CVE-2022-2602: DirtyCred File Exploitation applied on an io_uring UAF
DirtyCred Remastered: how to turn an UAF into Privilege Escalation
Put an io_uring on it: Exploiting the Linux Kernel
io_uring, SCM_RIGHTS, and reference-count cycles
The quantum state of Linux kernel garbage collection CVE-2021-0920 (Part I)
io_uring简单了解