io_uring 新代码、新漏洞与新型内核利用技术

本文深入探讨了Linux内核io_uring子系统中的CVE-2021-41073漏洞,详细分析了其成因、利用方法,并介绍了一种基于simple_xattr结构的新型内核利用技术,同时分享了在io_uring新特性中发现的两个竞态条件漏洞。

io_uring - 新代码、新漏洞与新型利用技术

目录

在过去的几周里,我在导师Billy和Ramdhan的指导下,对Linux内核的io_uring子系统进行了N-day分析和漏洞挖掘。本文将简要讨论io_uring子系统,以及我在分析CVE-2021-41073期间发现和开发新型内核利用技术的方法。我还将讨论在分析io_uring新特性时发现的两个漏洞。

什么是io_uring?

io_uring子系统由Jens Axboe创建,旨在提高I/O操作(文件读/写、套接字发送/接收)的性能。传统上,此类需要与内核交互的I/O操作通过系统调用(syscalls)进行,由于需要从用户模式切换到内核模式再切换回来,会产生显著的开销。这对执行大量此类I/O操作的程序(如Web服务器)有重大影响。目前计划将其集成到NGINX Unit中。io_uring包括内核子系统(主要位于fs/io_uring.c)和用户态库(liburing)。

io_uring不是对每个请求都使用系统调用,而是通过两个在内核和用户态之间共享的环形缓冲区实现用户模式与内核模式之间的通信:提交队列(SQ)和完成队列(CQ)。顾名思义,用户态程序将I/O请求放在SQ上,由内核出队并处理。完成的请求被放在CQ上,允许用户态程序检索操作结果。

SQ和CQ操作是异步的:向SQ添加请求永远不会阻塞,除非队列已满,此时会返回错误。

io_uring可以配置为持续检查(轮询)SQ以获取新请求,或者可以使用系统调用(io_uring_enter)通知内核存在新请求。内核然后可以在当前线程中处理请求,或将其委托给其他内核工作线程。

io_uring是Linux内核中增长最快的子系统之一,不断添加对新类型I/O操作的支持。fs/io_uring.c是fs目录中最大的文件之一,超过13,000行和300,000字节(截至内核v5.19-rc3)。

拥有如此庞大且活跃的代码库,不断在此子系统中发现诸如CVE-2021-20226和CVE-2021-3491(由我的导师Billy发现)之类的漏洞也就不足为奇了。

提供的缓冲区

当执行同步I/O时,几乎立即需要存储结果的内存空间。然而,在异步I/O中,请求可能很长时间都不会被处理。因此,为每个请求分配缓冲区会消耗不必要的内存量。更高效的方法是分配一个缓冲区池,并将其交给io_uring,让它选择要为某个请求使用哪个缓冲区。所选缓冲区的ID返回给用户态应用程序。这被称为传统提供的缓冲区,或简称为提供的缓冲区。

不幸的是,直到最近(内核版本5.18-rc1),提供的缓冲区都是单次使用的,应用程序必须在原始缓冲区用完后提供(或重新注册)新缓冲区。最近(2022年5月中旬,内核版本5.19-rc1),环形映射提供的缓冲区在io_uring中落地。提供的缓冲区现在可以在环形队列中跟踪,最后一个缓冲区使用后第一个缓冲区被重用。虽然两者都是环形缓冲区,但此机制与提交队列或完成队列是分开的。

第一种提供的缓冲区将与本文的第一部分相关,而第二种(环形提供的缓冲区)将在第二部分讨论。

CVE-2021-41073

CVE-2021-41073是Valentina Palmiotti发现的io_uring中的一个错误释放。该漏洞在使用提供的缓冲区时发生在loop_rw_iter中。

 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 ssize_t loop_rw_iter(int rw, struct io_kiocb *req, struct iov_iter *iter)
{
    ...

    while (iov_iter_count(iter)) {
        struct iovec iovec;
        ssize_t nr;
        ... 
        if (rw == READ) {
            nr = file->f_op->read(file, iovec.iov_base,
                          iovec.iov_len, io_kiocb_ppos(kiocb));
        } else {
            // 写入文件
        }
        ...
        ret += nr;
        if (nr != iovec.iov_len)
            break;
        req->rw.len -= nr;
        req->rw.addr += nr; // 漏洞在这里!
        iov_iter_advance(iter, nr);
    }

    return ret;
}

如果使用提供的缓冲区,req->rw.addr是一个内核指针(指向管理提供的缓冲区的io_buffer对象),而不是用户态指针。由于错误地递增此指针,本应释放的io_buffer没有被释放,而是释放了它之后的对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static unsigned int io_put_kbuf(struct io_kiocb *req, struct io_buffer *kbuf)
{
    unsigned int cflags;

    cflags = kbuf->bid << IORING_CQE_BUFFER_SHIFT;
    cflags |= IORING_CQE_F_BUFFER;
    req->flags &= ~REQ_F_BUFFER_SELECTED;
    kfree(kbuf); // req->rw.addr在这里被释放
    return cflags;
}

static inline unsigned int io_put_rw_kbuf(struct io_kiocb *req)
{
    struct io_buffer *kbuf;

    kbuf = (struct io_buffer *) (unsigned long) req->rw.addr;
    return io_put_kbuf(req, kbuf);
}

我们如何利用这个错误释放?一种典型的技术是操纵内核将另一个对象分配到被错误释放的内存中。现在两个内核对象占用同一内存,导致类似释放后使用的内存覆盖。

选择合适的内核对象

但我们应该使用什么内核对象?内核内存以类似大小对象的组分配,称为kmalloc缓存。每个缓存由页面组成,这些页面被划分为许多特定大小的对象。io_buffer被分配到kmalloc-32,因为它的大小为32字节,尽管16到32字节之间的对象也可以分配在那里。

已经对哪些内核对象可以分配到不同的kmalloc缓存以及它们对寻求权限提升的攻击者的价值进行了大量研究(这里这里)。例如,shm_file_data,一个分配到kmalloc-32的对象,可用于泄漏内核堆和文本段地址。

然而,可以被操纵分配到任何kmalloc缓存的对象,一种称为通用堆喷的技术,非常稀少。也许最流行和最著名的是msg_msg,其next指针在被覆盖时允许攻击者写入内存中的任意位置。不幸的是,msg_msg对象只能分配到kmalloc-64及以上。必须为kmalloc-32使用不同的对象。

在她的原始文章中,Valentina使用sk_filter,它包含一个指向扩展伯克利包过滤器(eBPF)程序的指针。通过将此指针覆盖为攻击者控制的程序,可以实现本地权限提升。然而,此技术需要另一个子系统(eBPF)、另一个内核对象(攻击者的eBPF程序)以及另一个泄漏(攻击者的eBPF程序地址)。此外,eBPF在Ubuntu 21.10中默认禁用。必须有其他结构体用于利用kmalloc-32。

搜索内核结构体

我使用pahole获取Linux内核中所有结构体的大小:

1
pahole --sizes vmlinux > all_structs.txt

然后我使用一个简单的Python脚本将结构体列表过滤到大小为16-32字节的结构体。从内核代码中提取每个感兴趣结构体的定义后,我使用正则表达式识别包含一些有趣特性的结构体:

  • 函数指针
  • 指向名称中包含op的结构体的指针(可能是指向函数指针结构体的指针)
  • list_head结构体:包含可操纵的next和prev指针

在所有结果结构体中,我发现simple_xattr结构体特别有趣,因为我一直在使用setxattr进行漏洞利用的其他部分,并且知道xattrs可能可以从用户空间控制。

用pahole检查结构体的大小,它确实是32字节,并包含一个list_head结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct simple_xattr {
        struct list_head           list;                 /*     0    16 */
        char *                     name;                 /*    16     8 */
        size_t                     size;                 /*    24     8 */
        char                       value[];              /*    32     0 */

        /* size: 32, cachelines: 1, members: 4 */
        /* last cacheline: 32 bytes */
};
struct list_head {
	struct list_head *next, *prev;
};

通过更多研究,我发现simple_xattr用于为内存文件系统(如tmpfs)存储扩展属性(xattrs)。某个文件的simple_xattr对象通过list_head指针存储在链表中。

当在tmpfs上设置xattr时,simple_xattr对象在simple_xattr_alloc中分配。由于xattr的值存储在此对象中,分配了攻击者控制的内存量。这允许simple_xattr被分配到从kmalloc-32及以上的kmalloc缓存中。

对攻击者来说不幸的是,当编辑xattr时,simple_xattr对象不会被修改。相反,旧的simple_xattr被取消链接,并分配一个新对象并添加到链表中。因此,通过直接覆盖大小或next指针导致越界/任意写入是不可行的。

最后,tmpfs的手册页指出:

tmpfs文件系统支持扩展属性(参见xattr(7)),但不允许用户扩展属性。

这有点问题,因为非特权用户通常不能在其他命名空间(security和trusted)中设置xattrs。然而,这可以通过使用用户命名空间来克服。在这样的命名空间中,用户可以在security命名空间中设置xattrs,从而启用非特权的simple_xattr分配。

解除链接攻击

虽然攻击者不能直接操纵next指针进行任意写入,但next和prev指针可用于执行解除链接攻击,在删除xattr时导致更有限的任意写入。

以下是__list_del中的相关代码:

1
2
3
4
5
static inline void __list_del(struct list_head * prev, struct list_head * next)
{
	next->prev = prev; // 1
	WRITE_ONCE(prev->next, next); // 2
}

由于我们完全控制prev和next指针,next指针可以设置为感兴趣的地址,例如modprobe_path。prev的值将在第1行写入next。

不幸的是,next在第2行写入prev。这意味着prev也必须是一个有效的指针。这对我们可以写入next的值施加了重大限制。然而,我们可以利用physmap提供有效的prev值。

physmap是内核虚拟内存的一个区域,其中物理内存页面被连续映射。例如,如果机器有4GiB(2^32字节)内存,则需要32位(4字节)来寻址系统中可用的每个字节的物理内存。假设physmap从0xffffffff00000000开始,从0xffffffff00000000到0xffffffffffffffff的任何地址都是有效的,因为每个值(从0x00000000-0xffffffff)的低4字节都需要寻址内存。

因此,假设系统至少有4GiB内存,攻击者可以为prev的低4字节选择任何值,只要高4字节对应于physmap地址。

由于我们以modprobe_path为目标,我们将使用0xffffxxxx2f706d74作为prev的值。如果next是modprobe_path+1,modprobe_path将被覆盖为/tmp/xxxxprobe,其中xxxx是prev的高4字节。这是一个攻击者控制的路径,可以触发以实现root用户的用户空间代码执行。

simple_xattr利用技术总结

通过触发simple_xattr分配到从kmalloc-32及以上的kmalloc缓存,并利用解除链接攻击,攻击者可以将内核堆中的溢出或错误释放升级为有限的任意写入。虽然攻击者只能写入4个受控字节和4个非受控字节,但这足以实现Linux内核LPE。

此技术也比其他类似技术(如使用msg_msg结构体)具有优势,因为list_head指针之前没有任何元数据。此技术需要攻击者泄漏指向physmap中某处的指针。然而,许多结构体,包括shm_file_data,都包含指向文本段和physmap的指针,因此这不太可能是一个主要问题。还应注意,所选的physmap地址必须是可写的,并且位于那里的任何内容都将被覆盖。

完整的漏洞利用代码可以在我们的github仓库中找到。

演示

演示时间到了。

io_uring漏洞挖掘

在N-day分析之后,我继续查看与io_uring相关的最近提交,并寻找其中引入的任何漏洞。这个提交引起了我的注意,因为它引入了相当多的更改,包括一个新的缓冲区选择机制,环形映射提供的缓冲区。此机制已在前面部分描述,因此我不在此解释。

该提交添加了io_ring_buffer_select,一个从提供的缓冲区环中选择缓冲区的函数。以下是一个简短的片段:

1
2
3
4
5
6
7
8
9
head &= bl->mask;
if (head < IO_BUFFER_LIST_BUF_PER_PAGE) {
    buf = &br->bufs[head];
} else {
    int off = head & (IO_BUFFER_LIST_BUF_PER_PAGE - 1);
    int index = head / IO_BUFFER_LIST_BUF_PER_PAGE - 1;
    buf = page_address(bl->buf_pages[index]);
    buf += off;
}

head变量用于索引br->bufs数组,该数组包含指向提供的缓冲区的指针。在数组访问之前,head被设置为head & bl->mask,这确保它回绕,而不是超出数组的边界。

else块中的数学似乎有些有趣。由于缓冲区的数量可能非常大,可能无法将所有包含指向提供的缓冲区的io_uring_buf结构体的指针放在单个页面中。因此,分配给存储io_uring_buf对象的页面存储在bl->buf_pages中。br->bufs指向第一页。

在大多数具有4KiB(2^12字节)页面的机器上,IO_BUFFER_LIST_BUF_PER_PAGE定义为256。因此offset设置为head & 255,相当于head % 256,这是合理的。但为什么index = head / 256 - 1?如果head是256,index将是0,这意味着buf将指向第一页,该页被缓冲区0-255占用。这可能导致两个请求使用相同的缓冲区,导致该缓冲区中任何数据损坏。这个漏洞在我报告之前就被修复了。

接下来,我决定查看head在哪里递增。似乎head在io_ring_buffer_select中大多数情况下不会递增。相反,它在__io_puts_kbuf中递增,这似乎只在请求完成时调用。

如果同时提交许多请求会怎样?它们都会获得相同的缓冲区吗,因为head只在请求完成时递增?我决定测试一下:

当16个请求同时提交时,7个请求被分配缓冲区0,导致读取损坏。这是一个相当明显的竞态条件。由于head没有正确递增,请求的数量可能超过可用缓冲区的数量。因此,当这些请求完成并递增head时,head可能超过可用缓冲区的数量。然而,由于head & bl->mask确保head不超过数组的长度,不会发生越界漏洞。

由于我无法确定此漏洞的任何安全影响,我决定在项目的GitHub页面上报告它。项目的维护者非常有助于验证漏洞,并在24小时内将修复推送到内核。

不幸的是,我仍然不完全理解修复是如何工作的,而且这个漏洞似乎比我预期的要复杂得多。

结论

由于我之前没有Linux内核的经验,这次实习对我来说是一次全新的学习体验。我看到了从学校操作系统模块中学到的概念,如内存管理和分页,如何应用到现实世界中。漏洞挖掘使我能够加强和实践代码审查和利用技术,如释放后使用,这些技术可以应用到其他程序中。通过N-day分析,我学会了如何编写简洁传达漏洞和利用的漏洞报告,确保所有各方都知道漏洞是什么以及如何修复它。最后,我要感谢我的导师Billy和Ramdhan,感谢他们在我实习期间的指导和宝贵反馈。在STAR Labs与员工和实习生一起工作和互动是一次惊人的体验,我期待未来再次回来!

参考文献

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计