CVE-2022-2602
1 | Linux version 5.13.1 (yhellow@yhellow-virtual-machine) (gcc (Ubuntu 11.4.0-2ubuntu1~20.04) 11.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1 SMP Wed Mar 13 11:24:24 CST 2024 |
1 | qemu-system-x86_64 \ |
- smap,smep,pti,kaslr
1 | mount -t proc proc /proc |
io_uring 模块
io_uring 会把要内核做的 io 操作都放在一个队列里,内核空闲的时候就会从任务队列里拿你给它的 io 任务去完成,等你觉得内核做完了你给它的 io 任务的时候,你就去结果队列里取结果就行了
提交任务的环叫 SQ,里面的每个任务叫 SQE,获取结果的环叫 CQ,里面的每个结果叫 CQE
io_uring 的具体实现是通过下面三个系统调用:
- io_uring_setup:初始化 io_uring
- 初始化 io_uring 的两个环形队列(SQ,CQ)
- 为 io_uring 创建一个文件对象(后续使用这个文件描述符映射出内存,来访问两个队列和创建相关资源)
- io_uring_enter:通知内核任务已经提交或获取任务结果
- 任务发送和结果接收需要使用 io_uring_enter
- io_uring 提供了一个轮训模式 IORING_SETUP_SQPOLL,在该模式下,内核会自动取检查任务队列里是否有新任务并去完成,而不需要我们去调用 io_uring_enter 系统调用(底层使用了内核线程)
- io_uring_register:注册共享缓冲区
- 将文件描述符或内存区域与 io_uring 关联起来
安装 liburing 生成 liburing.a / liburing.so.2.2:
1 | wget https://github.com/axboe/liburing/archive/liburing-2.2.zip |
liburing 中会提供一些 io_uring API:
1 |
|
- 从属于 ring 参数的提交队列中获取下一个可用的提交队列条目
- 成功时返回一个指向提交队列条目的指针,失败时返回 NULL
1 |
|
- 将下一个事件提交到属于 ring 的提交队列
- 成功时返回提交的提交队列条目数,失败时返回
-errno
调用者先使用 io_uring_get_sqe
检索提交队列条目,然后初始化 SQE(可以通过 API 辅助填写),最后使用 io_uring_submit
提交
用于提交请求的 io_uring_enter 函数:
1 | SYSCALL_DEFINE6(io_uring_enter, unsigned int, fd, u32, to_submit, u32, |
1 | static int io_submit_sqes(struct io_ring_ctx *ctx, unsigned int nr) |
1 | static int io_submit_sqe(struct io_ring_ctx *ctx, struct io_kiocb *req, |
- 这里先检查了文件的权限,然后调用
io_queue_sqe
执行如下的调用链
1 | io_queue_sqe->__io_queue_sqe->io_issue_sqe |
- 在 io_issue_sqe 中会根据
req->opcode
来调用不同的函数进行处理,在这些函数中可能会因为 inode 锁而陷入阻塞 - 由于之前已经完成的权限检查,如果在阻塞时 file 结构体被非法释放,就可能存在 DirtyFile 的风险
初始化提交任务的 io_init_req 函数源码如下:
1 | static int io_init_req(struct io_ring_ctx *ctx, struct io_kiocb *req, |
- 当用户态传入
io_uring_sqe->flags = IOSQE_FIXED_FILE
时,此时的io_uring_sqe->fd
不再是 io_uring 需要处理的文件描述符,而是代表了skb->fp->fp
中对应文件描述符的下标
1 | static struct file *io_file_get(struct io_submit_state *state, |
用于注册的 io_uring_register 函数:
1 | SYSCALL_DEFINE4(io_uring_register, unsigned int, fd, unsigned int, opcode, |
1 | static int __io_uring_register(struct io_ring_ctx *ctx, unsigned opcode, |
1 | static int io_sqe_files_register(struct io_ring_ctx *ctx, void __user *arg, |
1 | static int io_sqe_files_scm(struct io_ring_ctx *ctx) |
1 | static int __io_sqe_files_scm(struct io_ring_ctx *ctx, int nr, int offset) |
- 用户传入的文件描述符都会保存在
skb->fp->fp
中,如果目标 skb 被销毁,则存储在skb->fp->fp
中所有的 file 结构体都会被执行 fput 操作 sk_receive_queue
代表一个 socket 还未接收的消息队列
引用计数与飞行计数
在 linux 中 file
结构体用于描述一个打开的文件,其中的 file->f_count
成员用于记录其引用数目
- 可能存在多个文件描述符对应同一个
file
结构体的情况(多个进程打开同一个文件,或者使用dup()
函数拷贝文件描述符) - 函数 open dup fork 会使
file->f_count
增加,函数 close exit 会使file->f_count
减小,当file->f_count
为“0”时则释放file
结构体
实际能引起文件引用计数变化的内核函数有:
fget()
:通过文件描述符获取struct file
,并把文件引用计数 +1get_file()
:传入是struct file
,返回struct file
,该函数单纯的把文件引用计数 +1fput()
:减少一次文件引用计数,如果减少到 0 则会释放文件的struct file
结构
SCM_RIGHTS 消息拥有传递文件描述符信息的能力,linux 内核可以通过 sendmsg 系统调用来传递 SCM_RIGHTS 消息,也就是在两个不相关的进程间传递文件描述符信息
- 该功能的本意是有权限打开文件的进程打开文件,然后传递给没权限打开的进程使用
- 当 sender 进程将文件描述符传递给另一个 receiver 进程时,SCM_RIGHTS 将创建一个对
file
结构的引用 - 当 receiver 进程确定接收到文件描述符时,SCM_RIGHTS 创建的引用将会被消除
使用 SCM_RIGHTS 可能会造成内存泄露问题:
1 | (1)该进程创建socket A 和 B (fileA->f_count=1, fileA->f_count=1) |
- 由于 socket A 和 socket B 互相发给彼此的 SCM_RIGHTS 消息并没有被接收,导致
fileA->f_count
和fileB->f_count
都为“1”,并且没有办法将其释放掉
函数 unix_inflight 用于增加飞行计数:
1 | void unix_inflight(struct user_struct *user, struct file *fp) |
内核垃圾回收系统
Linux 内核垃圾回收系统就是为了防止这种情况下的内存耗尽,引入 inflight 飞行计数是为了识别潜在的垃圾
- 当采用 SCM_RIGHTS 数据报发送文件描述符时,Linux 内核将其
unix_sock
放入全局列表gc_inflight_list
中,并递增unix_tot_inflight
(表示飞行中的 socket 总数) - 然后,内核递增
u->unix_inflight
以记录每个文件描述符的飞行计数(表示正在被传递的数目)
引用飞行计数后,还是会出现不可破循环的现象:
1 | (1)该进程创建socket A 和 B (ref=1 inflight=0, ref=1 inflight=0) |
- 当 A 和 B 的引用计数都等于每个 socket 文件描述符的飞行计数,这是可能存在垃圾的迹象
linux 垃圾处理的核心函数如下:
1 | void unix_gc(void) |
1 | static inline void __skb_queue_purge(struct sk_buff_head *list) |
- 垃圾收集会释放掉引用计数等于飞行计数的所有 skb,并会对 skb 中的所有 file 调用 fput
漏洞分析
影响版本:Linux Kernel < v6.0.3(v6.0.3 已修复)
漏洞效果就是在 io_uring 执行 IO 任务之前非法把文件释放掉,核心思路类似于 DirtyFile:
- 利用另一个线程提前打开 io_uring 需要写入的文件
- 在 io_uring 陷入阻塞的时候将该文件的 file 结构体释放掉
- 堆喷另一个文件的 file 结构体来占位
- 另一个线程释放 inode 锁,io_uring 拿到锁后就会写入目标文件了
如何在 io_uring 阻塞时释放其将要操作的 file 结构体,理论上来说 io_uring 始终会占用一个文件计数器,目标 file 结构体的文件计数器是不可能为 “0” 的
但在 unix_gc 释放 skb 的过程中会对 skb 中的所有 file 调用 fput,这里没有考虑 io_uring file 可能会阻塞的问题(逻辑漏洞),导致该 file 在任务阻塞完毕之前被释放,从而造成 UAF
入侵思路
漏洞的触发过程比较复杂,分析了网上很多的 wp 和 exp 后,提取出如下的关键代码:
1 | socketpair(AF_UNIX, SOCK_DGRAM, 0, s); /* 准备一对socket(s[0],s[1]),准备好之后默认的引用计数均为1 */ |
触发流程如下:
线程A | 线程B |
---|---|
进行准备工作 | |
启动线程B | 打开"/tmp/rwA" 文件,写入大量数据(0x80000 * 0x1000 字节) |
打开"/tmp/rwA" 文件,尝试写入恶意数据(新的 root 账号和密码),提交写任务到 io_uring |
通过文件权限校验,并获取 inode 文件锁 |
通过文件权限校验,等待获取 inode 文件锁(io_uring 阻塞) | 长时间写入…(持有锁) |
触发垃圾回收 unix_gc(在获取 inode 文件锁之前,释放目标 file 结构体) | 长时间写入…(持有锁) |
打开大量"/etc/passwd" 文件,堆喷占位刚刚释放的 file 结构体 |
释放 inode 文件锁 |
获得 inode 文件锁,但实际会写入"/etc/passwd" 文件(因为 file 结构体被替换) |
- io_uring 会因为线程B占用
"/tmp/rwA"
文件而阻塞(等待 inode 文件锁) - 利用阻塞的时间来触发垃圾回收释放阻塞的 file 结构体,造成 UAF
- 大量打开
"/etc/passwd"
文件的 file 结构体来填充 UAF - 最后获取 inode 文件锁时,实际上就是往
"/etc/passwd"
文件中写入数据了
完整 exp 如下:(最好使用 gcc-9 来编译,测试发现 gcc-9 的打通率要显著高于其它版本)
1 |
|
最终效果如下:
1 | / $ ./exp |