CVE-2022-2602 Linux内核io-uring框架漏洞分析

0rb1t Lv2

0x00.前言

分析这个漏洞的原因在于该漏洞的产生与gc机制有关,并且漏洞原理十分有趣,再加上默师傅详细的分析文章辅助,可以更快的理解。本文不会进行特别详细的分析,主要还是对大致原理进行讲解。

0x01.前置知识

因为整体框架代码繁杂,这里我就不详细结合源码分析了,使用的linux源码版本为5.9.10

飞行计数产生缘由

进程之间的fd通信

知周所众,进程之间的数据都是相互隔离的,包括内存、文件描述符等。

而进程之间想要进行ipc通信,可以使用共享内存或者消息队列等通信机制。而文件描述符的传递则是通过sendmsg+recvmsg的socket通信完成。

构造方法也比较简单,大致如下:

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
// 发送方

void send_fd(int socket_fd, int fd_to_send) {
struct msghdr msg = {0};
char cmsg_buf[CMSG_SPACE(sizeof(int))];
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);

struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
*(int *)CMSG_DATA(cmsg) = fd_to_send;

sendmsg(socket_fd, &msg, 0);
}

// 接收方
int receive_fd(int socket_fd) {
struct msghdr msg = {0};
char cmsg_buf[CMSG_SPACE(sizeof(int))];
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);

recvmsg(socket_fd, &msg, 0);

struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
int received_fd;
memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int));
return received_fd;
}
return -1;
}

//发送方main函数
int main() {
int server_fd, client_fd;
struct sockaddr_un addr;

// 创建UNIX域流式套接字
server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}

// 绑定到本地socket文件
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/fd_passing.sock", sizeof(addr.sun_path)-1);

// 确保文件不存在
unlink(addr.sun_path);

if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}

// 监听连接
if (listen(server_fd, 1) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}

printf("等待接收方连接...\n");
client_fd = accept(server_fd, NULL, NULL);
if (client_fd == -1) {
perror("accept");
exit(EXIT_FAILURE);
}

// 打开要传递的文件(示例文件)
int fd_to_send = open("test.txt", O_RDONLY);
if (fd_to_send == -1) {
perror("open");
exit(EXIT_FAILURE);
}

// 发送文件描述符
send_fd(client_fd, fd_to_send);
printf("文件描述符 %d 已发送\n", fd_to_send);

// 清理资源
close(fd_to_send);
close(client_fd);
close(server_fd);
unlink(addr.sun_path); // 删除socket文件
return 0;
}


//接收方main函数
int main() {
int sock_fd;
struct sockaddr_un addr;

// 创建UNIX域流式套接字
sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sock_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}

// 连接到发送方
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/fd_passing.sock", sizeof(addr.sun_path)-1);

if (connect(sock_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("connect");
exit(EXIT_FAILURE);
}

// 接收文件描述符
int received_fd = receive_fd(sock_fd);
if (received_fd == -1) {
fprintf(stderr, "接收文件描述符失败\n");
exit(EXIT_FAILURE);
}
printf("接收到文件描述符 %d\n", received_fd);

// 验证文件可读(示例)
char buf[1024];
ssize_t n = read(received_fd, buf, sizeof(buf));
if (n == -1) {
perror("read");
} else {
printf("读取文件内容(前%d字节): %.*s\n", (int)n, (int)n, buf);
}

// 清理资源
close(received_fd);
close(sock_fd);
return 0;
}

sendmsg调用链大致如下:

1
2
3
4
5
6
7
8
9
用户态 sendmsg()
→ __sys_sendmsg()
→ ___sys_sendmsg()
→ sock_sendmsg()
→ unix_stream_sendmsg() # UNIX 域套接字处理
→ scm_send() # 处理控制消息
→ scm_fp_copy() # 复制文件描述符
→ get_file() # 增加引用计数
→ unix_inflight() # 标记飞行计数

recvmsg调用链大致如下:

1
2
3
4
5
6
7
8
9
用户态 recvmsg()
→ __sys_recvmsg()
→ ___sys_recvmsg()
→ sock_recvmsg()
→ unix_stream_read_generic() # UNIX 域套接字处理
→ scm_recv() # 解析控制消息
→ scm_fp_copy() # 分配新文件描述符
→ get_file() # 增加引用计数
→ unix_notinflight() # 移除飞行计数

大概就是这么个流程图

image-20250401130757995

正常情况下是不会有任何问题的,进程a将一个文件描述符放到sockb的sk_receive_queue中,进程b通过sockb获取文件描述符,只需要正常的引用计数即可保证稳定。

针对于正常情况,进程b就算不读取sk_receive_queue中的文件描述符,而直接关闭sockb触发release,sockb也会正常释放sk_receive_queue中的文件描述符(指减引用),不会产生任何问题。

死锁的产生

但存在一种情况,会使得socka和sockb出现和线程mutex一样的死锁。

image-20250401131743718

这里socka和sockb的sk_receive_queue都拥有对方的fd引用,同时进程也不去调用recvmsg获取文件描述符。

这个时候close掉socka和sockb,因为两个sock的sk_receive_queue中都还保留着对方的引用,socka会一直等待sockb释放自己的引用,sockb会一直等待socka释放自己的引用,而进程也不再持有任何关于两者的引用,由此导致死锁。

这里的问题核心在于sk_receive_queue会持有文件引用,但是只有当sock的引用计数为0时才会释放

sk_receive_queue,当两个sock都揣着对方的引用时,就会产生死锁(和线程死锁一样,互相等待对方的资源)。

unix_gc回收机制

为了预防死锁的产生,我们需要对产生死锁的sock进行回收释放,由此unix_gc机制产生,每当一个sock被关闭时(close即可而无需release),就会触发unix_gc,对所有符合要求的死锁进行释放。而标记这些死锁的就是我们的飞行计数。

飞行计数

飞行计数的功能很简单,就是当一个fd被传输时,会进行飞行计数的加一,不过因为死锁产生只发生在sock(还有后文的io_uring),故也只会对这些特殊的fd进行飞行计数。

io_uring

这里就不详细介绍io_uring的功能,只简单介绍以下几点。

异步执行系统io任务

正常情况下,用户进程执行一些io请求时会进行堵塞直到请求完成,这使得程序执行效率变得很低。io_uring则允许用户创建一个提交队列以及完成队列,将io任务放入提交队列,再等合适的时间去完成队列获取结果,这样就可以在不堵塞的情况下执行一些io操作。

文件注册

执行io任务会涉及到文件,所以在执行io任务前需要调用io_uring_register对文件进行注册(加引用之类的操作),同时会对socket文件进行飞行计数加一。

飞行计数

当CONFIG_UNIX参数开启时,io_uring会直接使用unix_socket作为文件结构体。

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
static int io_uring_get_fd(struct io_ring_ctx *ctx)
{
struct file *file;
int ret;

#if defined(CONFIG_UNIX)
ret = sock_create_kern(&init_net, PF_UNIX, SOCK_RAW, IPPROTO_IP,
&ctx->ring_sock);
if (ret)
return ret;
#endif

ret = get_unused_fd_flags(O_RDWR | O_CLOEXEC);
if (ret < 0)
goto err;

file = anon_inode_getfile("[io_uring]", &io_uring_fops, ctx,
O_RDWR | O_CLOEXEC);
if (IS_ERR(file)) {
err_fd:
put_unused_fd(ret);
ret = PTR_ERR(file);
goto err;
}
...

这也就意味着io_uring也会和sock一样使用飞行计数,并且io_uring使用skb_receive_queue存放先前注册的文件。

0x02.漏洞原理

io_uring不该使用传统飞行计数以及被unix_gc机制回收。

因为io_uring整体是异步的,它在传输过程中仍然会进行异步的工作,即iouring_fd在socka传输给sockb->sk_receive_queue后,即使不被recvmsg接收也仍然在异步处理任务。这个时候若是被unix_gc机制回收就会导致io_uring的sk_receive_queue中的文件被释放(减引用),但是异步工作仍在进行,从而触发uaf漏洞。

0x03.漏洞触发

由于本文重点只是分析漏洞产生的究极原因,因此这里就不去复现漏洞了,只阐述一遍漏洞触发流程。

根据漏洞原理,可以了解到,我们只需要调用sendmsg把io_uring进行socket传输,并不进行recvmsg,即可让io_uring保持飞行计数,之后触发unix_gc即可触发漏洞。

步骤如下:

  • 创建unix类型的套接字socka和sockb。
  • 以可读可写方式打开一个普通文件fd。
  • 创建io_uring_fd,并注册将sockb和fd放入io_uring的sk_receive_queue中,这里会把sockb进行飞行计数加一。
  • sendmsg通过socka传输io_uring给sockb,但不调用recvmsg接收。
  • 关闭socka和sockb,此时socka引用和飞行计数都为0被释放,sockb的引用为1,飞行计数为1,但是并没有构成循环引用,因为io_uring引用次数和飞行计数不相等。
  • 提交针对fd的writev任务,并在异步执行期间利用userfaultfd堵塞异步任务。
  • close关闭fd, io_uring_queue_exit关闭io_uring_fd,此时io_uring和普通文件的引用为1,io_uring飞行计数为1,但不会触发unix_gc(io_uring_queue_exit不会触发unix_gc)。
  • 最后随便关闭一个socket,unix_gc检查发现sockb引用和飞行计数相等为1,而io_uring的引用和飞行计数也相等为1,构成循环引用条件。分别释放sockb和io_uring。
  • 恢复异步任务导致uaf。

0x04.总结

这个漏洞感觉更像是类型混淆之类的漏洞,因为io_uring使用unix_socket作为结构体,从而把io_uring和unix_socket混为一谈,导致针对unix_socket的unix_gc回收机制会处理不该被处理的io_uring。

0x05.参考文献

默师傅的分析文章

360漏洞研究院文章

  • Title: CVE-2022-2602 Linux内核io-uring框架漏洞分析
  • Author: 0rb1t
  • Created at : 2025-04-01 12:27:50
  • Updated at : 2025-04-01 16:09:58
  • Link: https://redefine.ohevan.com/2025/04/01/CVE-2022-2602-Linux内核io-uring框架漏洞分析/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments