本文以 Linux Kernel 5.15(commit 8bb7eca)为例进行分析。

Tldr

CVE-2022-0847 是一个因文件子系统中的零拷贝机制在处理 pipe_buffer 时未初始化 flags,结合 splice 系统调用导致的漏洞。用户可以构造大量带有 PIPE_BUF_FLAG_CAN_MERGE 标志位的 pipe_buffer 并释放,接着调用 splice 函数从文件向管道填充数据,由于 flags 未正确初始化,将沿用之前的 pipe_buffer 的标志位。此时向管道中写入则可修改文件的 page cache。

代码分析

首先来看一下本次分析用到的结构体和重要的字段,这里可以先略过,等下文遇到的时候再作为字典查阅。可以直接跳转到函数部分。

pipe_inode_info

include/linux/pipe_fs_i.h
struct pipe_inode_info { // 存放了 pipe 机制所要用到的字段
	...
	// 标注队列首部的索引,这里的索引单位是 pipe_buffer。head 为接下来要写入的位置
	unsigned int head;
	// 标注队列尾部的索引。tail 为接下来要读取的位置
	unsigned int tail;
	// 最大可用的 pipe_buffer 个数,这个字段约束了整个 pipe 所能容纳的数据大小
	unsigned int max_usage;
	// 当前已分配的 pipe_buffer 个数,注意该值必须为2的幂
	unsigned int ring_size;
    ...
	// 结构体 file 引用至该管道的个数。这个有点类似某个管道被 dup 出多个 fd 一样
	unsigned int files;
	...
	// 缓存先前被释放的 page,这个 page 可以被重用以降低重分配开销
	struct page *tmp_page;
	...
	// 实际存放多个 pipe_buffer 的数组,在设计上我们需要将该一维数组看作一个环
	struct pipe_buffer *bufs;
	...
};

其中,head 和 tail 的关系类似于

low addr                                 high addr
+--------------------------------------------+
|  |  |  |  |  |  |  | >|//|//|//|> |  |  |  |
+--------------------------------------------+
                       A   ---->   A
                       |           |
                     tail         head

它们都指向没写满的 buffer。

pipe_buffer

include/linux/pipe_fs_i.h
struct pipe_buffer { // 存放着实际管道中存放的数据
	struct page *page;	// 对应的 page
	// offset: 有效数据在 page 中的起始偏移
	// len: 尚未被读取的有效数据的长度
	unsigned int offset, len;
	const struct pipe_buf_operations *ops;
	unsigned int flags;	// 标志位
	unsigned long private;
};

可能的 flag 包含

include/linux/pipe_fs_i.h
#define PIPE_BUF_FLAG_LRU	0x01	/* page is on the LRU */
#define PIPE_BUF_FLAG_ATOMIC	0x02	/* was atomically mapped */
#define PIPE_BUF_FLAG_GIFT	0x04	/* page is a gift */
#define PIPE_BUF_FLAG_PACKET	0x08	/* read() as a packet */
#define PIPE_BUF_FLAG_CAN_MERGE	0x10	/* can merge buffers */
#define PIPE_BUF_FLAG_WHOLE	0x20	/* read() must return entire buffer or error */

这里我们只需要关注 PIPE_BUF_FLAG_CAN_MERGE 标志。

iov_iter

include/linux/uio.h
struct iov_iter {	// 用于迭代那种被分为多个页的数据
	u8 iter_type;	// 表示当前迭代的数据是来自于什么结构
	bool data_source;
	size_t iov_offset;	// 当前所迭代到 page 的相对偏移,读写将从该 page 的这个相对偏移开始
	size_t count;	// 可读写的数组字节大小
	union {
		...
		struct pipe_inode_info *pipe;
	};
	...
};

iter_type 可能包含

include/linux/uio.h
enum iter_type {
	/* iter types */
	ITER_IOVEC,
	ITER_KVEC,
	ITER_BVEC,
	ITER_PIPE,	// 表示正在迭代的数据是位于 pipe 中的
	ITER_XARRAY,
	ITER_DISCARD,	// 表示写入当前 iov_iter 的数据全部丢弃
};

pipe_read

Tldr

pipe_read 函数会循环从管道中读取数据,它会调用 copy_page_to_iter 将管道中的数据写入 iov_iter to 结构中。

pipe_read 函数用于从管道中读取数据,它的大概流程为:

pipe_read
  -> copy_page_to_iter

当内核需要从某个管道中读取数据时会调用 pipe_read 函数

  • iocb: 存放着获取当前 pipe 结构体的指针
  • to: 从管道读出来的数据将要写入的地方
fs/pipe.c
 
static ssize_t
pipe_read(struct kiocb *iocb, struct iov_iter *to)
{

从 to 中获取需要读入的数据大小:

	size_t total_len = iov_iter_count(to);

从 iocb 中获取 pipe_inode_info 结构体:

	struct file *filp = iocb->ki_filp;
	struct pipe_inode_info *pipe = filp->private_data;
	bool was_full, wake_next_reader = false;
	ssize_t ret;

如果读取大小为 0 则直接返回

	/* Null read succeeds. */
	if (unlikely(total_len == 0))
		return 0;
 
	ret = 0;
	__pipe_lock(pipe);

判断 pipe 是否已满,如果已满则设置 was_full 标志

    was_full = pipe_full(pipe->head, pipe->tail, pipe->max_usage);

循环读取 pipe 中的数据

	for (;;) {
		unsigned int head = pipe->head; // pipe 的头,指向的是 pipe_buffer
		unsigned int tail = pipe->tail; // pipe 的尾
		// 因为 pipe 是环形的,且 head 和 tail 可能大于 ring_size,所以需要计算 mask
		unsigned int mask = pipe->ring_size - 1;
		...

如果管道中存在数据

		if (!pipe_empty(head, tail)) {

获取 tail 对应的 pipe_buffer。tail 可以大于 max_usage

			struct pipe_buffer *buf = &pipe->bufs[tail & mask];

chars 是当前管道中已有的数据大小

			size_t chars = buf->len;
			size_t written;
			int error;

如果当前管道已有数据大小大于用户需要读入的大小,则截断

            if (chars > total_len) {
				if (buf->flags & PIPE_BUF_FLAG_WHOLE) {
					if (ret == 0)
						ret = -ENOBUFS;
					break;
				}
				chars = total_len;
			}

调用 pipe_buffer 的 confirm 方法,确认 pipe_buffer 中的数据有效

            error = pipe_buf_confirm(pipe, buf);
			...

将当前 pipe buffer 所对应的内存页写入 to 中

			written = copy_page_to_iter(buf->page, buf->offset, chars, to);

如果写入大小小于可读的大小,说明在写入数据时出现不可恢复的错误,直接返回

			if (unlikely(written < chars)) {
				if (!ret)
					ret = -EFAULT;
				break;
			}

一轮读取完成

			ret += chars;	// 成功读取的字节数
			buf->offset += chars;	// 有效数据的偏移增长(因为被消耗了)
			buf->len -= chars;	// 还剩下的可读的数据减少

如果是 packet buffer 的话,剩下的内容都扔掉

			/* Was it a packet buffer? Clean up and exit */
			if (buf->flags & PIPE_BUF_FLAG_PACKET) {
				total_len = chars;
				buf->len = 0;
			}

如果当前 pipe_buffer 中没有数据了,更新 tail 至下一个 pipe_buffer

			if (!buf->len) {
				pipe_buf_release(pipe, buf);
				spin_lock_irq(&pipe->rd_wait.lock);
				...
				tail++;
				pipe->tail = tail;
				spin_unlock_irq(&pipe->rd_wait.lock);
			}
			total_len -= chars;

如果读取完毕,跳出循环

			if (!total_len)
				break;	/* common path: read succeeded */

如果还需要读取数据,且管道中还有数据,继续循环

			if (!pipe_empty(head, tail))	/* More to do? */
				continue;
		}
	...
}

接下来我们来看 copy_page_to_iter 相关函数。

copy_page_to_iter 相关

Tldr

copy_page_to_iter 相关函数为“零拷贝”机制实现的部分

大体流程为:

copy_page_to_iter ->
  __copy_page_to_iter ->
    copy_page_to_iter_pipe

首先来看 copy_page_to_iter 函数:

lib/iov_iter.c
// 零复制引用
size_t copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
			 struct iov_iter *i)
{
	size_t res = 0;
	// 判断数据读写是否越界
	if (unlikely(!page_copy_sane(page, offset, bytes)))
		return 0;
	page += offset / PAGE_SIZE; // first subpage
	offset %= PAGE_SIZE;
	while (1) {
		size_t n = __copy_page_to_iter(page, offset,
				min(bytes, (size_t)PAGE_SIZE - offset), i);
		res += n;
		bytes -= n;
		if (!bytes || !n)
			break;
		offset += n;
		if (offset == PAGE_SIZE) {
			page++;
			offset = 0;
		}
	}
	return res;
}

它会调用 __copy_page_to_iter 函数,这个函数会根据 iov_iter 的类型选择不同的函数:

lib/iov_iter.c
static size_t __copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
			 struct iov_iter *i)
{
	// 根据 iov_iter 的类型选择不同的复制方式
	if (likely(iter_is_iovec(i)))
		return copy_page_to_iter_iovec(page, offset, bytes, i);
	if (iov_iter_is_bvec(i) || iov_iter_is_kvec(i) || iov_iter_is_xarray(i)) {
		...
	}
	if (iov_iter_is_pipe(i))
		return copy_page_to_iter_pipe(page, offset, bytes, i);
	if (unlikely(iov_iter_is_discard(i))) {
		...
	}
	WARN_ON(1);
	return 0;
}

如果传入的 iov_iter 是 pipe 类型(也就是我们在研究的从管道中读取),会调用 copy_page_to_iter_pipe 函数:

lib/iov_iter.c
static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes,
			 struct iov_iter *i)
{

获取待写入的 pipe 结构体

	struct pipe_inode_info *pipe = i->pipe;
	struct pipe_buffer *buf;

获取待写入的 pipe 结构体的一些信息,例如 head、tail等等

	unsigned int p_tail = pipe->tail;
	unsigned int p_mask = pipe->ring_size - 1;
	unsigned int i_head = i->head;
	size_t off;
	... ...

获取待写入的相对偏移位置

	off = i->iov_offset;

获取待写入数据的 pipe_buffer

	buf = &pipe->bufs[i_head & p_mask];
	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;

增加该页的 refcount

	get_page(page);

直接引用已有的页,记录当前复制的 offset、len 等,以降低性能开销

	buf->page = page;
	buf->offset = offset;
	buf->len = bytes;

CVE-2022-0847: 未初始化 buf 中的 flags 字段

由于 pipe_buf 直接引用了已有的页,需要在 pipe_write 中保证新传来的数据不会写入这样的页面中。这种保证依赖于 flags 中的 PIPE_BUF_FLAG_CAN_MERGE 字段,但这里并未对 flags 做初始化,因此它会使用之前的 flags 的值。

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

Danger

在零拷贝实现的过程中,如果页存在则只会对该页的 refcount 加一(get_page)。但是在这里我们看见,新的 pipe_buffer 字段中,大部分内容都正确地初始化了,唯有 buf->flags 没有被初始化!

copy_to_iter 相关

Tldr

copy_page_to_iter 不同,copy_to_iter 被设计为从内核任意地址复制数据,因此该复制是 deep copy。

大体流程为

copy_to_iter
  -> _copy_to_iter
    -> copy_pipe_to_iter
      -> push_pipe
include/linux/uio.h
// 数据复制,源数据类型可以是任意内核虚拟地址
static __always_inline __must_check
size_t copy_to_iter(const void *addr, size_t bytes, struct iov_iter *i)
{
	if (unlikely(!check_copy_size(addr, bytes, true)))
		return 0;
	else
		return _copy_to_iter(addr, bytes, i);
}

_copy_to_iter 函数中,也存在不同的 iov_iter 处理方式:

lib/iov_iter.c
size_t _copy_to_iter(const void *addr, size_t bytes, struct iov_iter *i)
{
	if (unlikely(iov_iter_is_pipe(i)))
		return copy_pipe_to_iter(addr, bytes, i);
	...
	return bytes;
}

我们这里还是只关注对 pipe 的处理方式。进入 iov_iter_is_pipe,它会从内核中指定的地址复制任意类型的数据到 pipe 中:

lib/iov_iter.c
static size_t copy_pipe_to_iter(const void *addr, size_t bytes,
				struct iov_iter *i)
{

获取要写入的 pipe 结构体:

	struct pipe_inode_info *pipe = i->pipe;
	unsigned int p_mask = pipe->ring_size - 1;
	unsigned int i_head;
	size_t n, off;
 
	if (!sanity(i))
		return 0;
  1. 准备空间,n 为待写入数据字节大小
	bytes = n = push_pipe(i, bytes, &i_head, &off);

如果没有数据需要写入,则直接返回

	if (unlikely(!n))
		return 0;
  1. 复制数据,这里会循环写入管道,直到待写入的数据全部写完。每写一次时,要么写完一整页,要么没写完一页就直接退出
	do {
		size_t chunk = min_t(size_t, n, PAGE_SIZE - off);
		memcpy_to_page(pipe->bufs[i_head & p_mask].page, off, addr, chunk);
		i->head = i_head;
		i->iov_offset = off + chunk;
		n -= chunk;
		addr += chunk;
		off = 0;
		i_head++;
	} while (n);

最后修改当前 iov_iter 待写入的大小:

	i->count -= bytes;
	return bytes;
}

其中 push_pipe 是我们要关注的重点,它会获取要写入的大小并准备对应的页:

lib/iov_iter.c
static size_t push_pipe(struct iov_iter *i, size_t size,
			int *iter_headp, size_t *offp)
{

获取接收数据的 pipe

	struct pipe_inode_info *pipe = i->pipe;
	unsigned int p_tail = pipe->tail;
	unsigned int p_mask = pipe->ring_size - 1;
	unsigned int iter_head;
	size_t off;
	ssize_t left;
	...
	left = size;

data_start 获取 pipe 的 head & 起始 offset。这个函数用于过滤 head 指向上一个未被分配的 pipe_buffer 或者 offset == PAGE_SIZE 的情况

	data_start(i, &iter_head, &off);
	*iter_headp = iter_head;
	*offp = off;

如果当前是从某个页的中间位置开始写

	if (off) {
		// 判断这剩余的页够不够写
		left -= PAGE_SIZE - off;
		// 要是够写则直接返回
		if (left <= 0) {
			pipe->bufs[iter_head & p_mask].len += size;
			return size;
		}
		// 如果不够写则先把该可写的半页,扩充为可写的整页
		pipe->bufs[iter_head & p_mask].len = PAGE_SIZE;
		iter_head++;
	}

到这里时循环扩充页

	while (!pipe_full(iter_head, p_tail, pipe->max_usage)) {
		// 循环获取 pipe_buffer,并初始化 pipe_buffer 结构体上的数据
		struct pipe_buffer *buf = &pipe->bufs[iter_head & p_mask];
		struct page *page = alloc_page(GFP_USER);
		if (!page)
			break;

这里 buf 引用的页是上面申请的新页,会在后面通过 memcpy_to_page 填充

		buf->ops = &default_pipe_buf_ops;
		buf->page = page;
		buf->offset = 0;
		buf->len = min_t(ssize_t, left, PAGE_SIZE);

CVE-2022-0847

和上文一样,这里也未对 buf->flag 初始化,buf->flag 将沿用旧的 pipe_buffer 的值

		left -= buf->len;
		iter_head++;
		pipe->head = iter_head;
 
		if (left == 0)
			return size;
	}
	return size - left;
}

简单总结一下当目标为 pipe 时,copy_page_to_itercopy_to_iter 这两个函数:

copy_to_itercopy_page_to_iter
源数据类型const void *addr (任意内核虚拟地址)struct page *page
核心函数copy_pipe_to_itercopy_page_to_iter_pipe
内存操作分配新页引用现有页
数据移动是(物理复制)否(零复制)
主要函数调用push_pipe alloc_page memcpy_to_pageget_page
pipe_buffer->opsdefault_pipe_buf_opspage_cache_pipe_buf_ops

pipe_write

Tldr

pipe_write 函数依靠 PIPE_BUF_FLAG_CAN_MERGE 实现页的合并。如果一页之内就能写下会直接合并,否则会循环写入 pipe_buffer

pipe_write 函数用于向管道中写数据:

fs/pipe.c
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
	struct file *filp = iocb->ki_filp;
	struct pipe_inode_info *pipe = filp->private_data;
	unsigned int head;
	ssize_t ret = 0;
	size_t total_len = iov_iter_count(from);
	ssize_t chars;
	bool was_empty = false;
	bool wake_next_writer = false;
	...
	__pipe_lock(pipe);
	...
	head = pipe->head;
	was_empty = pipe_empty(head, pipe->tail);

chars 保存了要写入的数据大小和页帧大小(PAGE_SIZE)的余数

	chars = total_len & (PAGE_SIZE-1);

如果 chars 不为 0 且 pipe_buffer 非空

	if (chars && !was_empty) {
		unsigned int mask = pipe->ring_size - 1;
		struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
		int offset = buf->offset + buf->len;

如果本次写入的数据可以 pipe_buffer 中剩余的空间容纳,且 buf 设置了 PIPE_BUF_FLAG_CAN_MERGE 标志位

		if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
		    offset + chars <= PAGE_SIZE) {
			ret = pipe_buf_confirm(pipe, buf);
			if (ret)
				goto out;

通过零拷贝的方式将数据写入 pipe_buffer

			ret = copy_page_from_iter(buf->page, offset, chars, from);
			if (unlikely(ret < chars)) {
				ret = -EFAULT;
				goto out;
			}
 
			buf->len += ret;
			if (!iov_iter_count(from))
				goto out;
		}
	}

如果本次的数据无法被单页容纳

	for (;;) {

如果 pipe 没有读者,说明管道已被破坏,发送 SIGPIPE 并退出

		if (!pipe->readers) {
			send_sig(SIGPIPE, current, 0);
			if (!ret)
				ret = -EPIPE;
			break;
		}
 
		head = pipe->head;

如果 pipe 没有被填满

		if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
			unsigned int mask = pipe->ring_size - 1;
			struct pipe_buffer *buf = &pipe->bufs[head & mask];
			struct page *page = pipe->tmp_page;
			int copied;

如果还没有为缓冲区分配页帧,调用 alloc_page() 函数分配一个

			if (!page) {
				page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
				if (unlikely(!page)) {
					ret = ret ? : -ENOMEM;
					break;
				}
				pipe->tmp_page = page;
			}

使用自旋锁锁住 pipe 的读者等待队列

			/* Allocate a slot in the ring in advance and attach an
			 * empty buffer.  If we fault or otherwise fail to use
			 * it, either the reader will consume it or it'll still
			 * be there for the next write.
			 */
			spin_lock_irq(&pipe->rd_wait.lock);
 
			head = pipe->head;

在加锁后检测 pipe 是否被填满(可能在加锁前被填满),如果被填满则进行下一次循环

			if (pipe_full(head, pipe->tail, pipe->max_usage)) {
				spin_unlock_irq(&pipe->rd_wait.lock);
				continue;
			}

这个 pipe_buffer 要被写入了,因此将 pipe_buffer 中的 header 指向下一个,以避免并发写入同一个 pipe_buffer

			pipe->head = head + 1;
			spin_unlock_irq(&pipe->rd_wait.lock);

向新的 pipe_buffer 中写入数据

			/* Insert it into the buffer array */
			buf = &pipe->bufs[head & mask];
			buf->page = page;

设置匿名管道操作

			buf->ops = &anon_pipe_buf_ops;
			buf->offset = 0;
			buf->len = 0;

如果 fd 设置了 O_DIRECT,则每次写入时都会占用新的一页,而不会合并

			if (is_packetized(filp))
				buf->flags = PIPE_BUF_FLAG_PACKET;
			else
				buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
			pipe->tmp_page = NULL;

复制页数据

			copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
			...
			ret += copied;
			buf->offset = 0;
			buf->len = copied;
 
			if (!iov_iter_count(from))
				break;
		}
 
		...
	}
out:
	if (pipe_full(pipe->head, pipe->tail, pipe->max_usage))
		wake_next_writer = false;
	__pipe_unlock(pipe);
	...
	return ret;
}
 

do_splice

Linux 库函数 splice 的作用是,将某个 fd 的数据不经过用户层,直接拷贝进另一个 fd 中。其函数声明如下:

#define _GNU_SOURCE         /* See feature_test_macros(7) */  
#include <fcntl.h>  
  
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

这里的 fd 只能有两种情况:pipe fd 或 file fd,因此在 do_splice 函数中,内核也会对 fd 的类型做特判,来执行不同的数据传递操作。

在内核中会调用到 do_splice 函数,它的签名为:

/*
 * Determine where to splice to/from.
 */
long do_splice(struct file *in, loff_t *off_in, struct file *out,
           loff_t *off_out, size_t len, unsigned int flags)

这里,我们只需关注 in-fd 为 file,out-fd 为 pipe ,即数据从文件传递至管道的情况:

在 5.15 版本中,调用链为

do_splice
  -> splice_file_to_pipe
    -> do_splice_to

do_splice_to 中,会根据文件系统类型调用对应的 splice_read 函数。

fs/splice.c
/*
 * Attempt to initiate a splice from a file to a pipe.
 */
static long do_splice_to(struct file *in, loff_t *ppos,
             struct pipe_inode_info *pipe, size_t len,
             unsigned int flags)
{
    // 根据文件系统类型,来调用对应的 splice_read 函数
    unsigned int p_space;
    int ret;
    ...
    /* Don't try to read more the pipe has space for. */
    // 获取待传递数据的大小
    p_space = pipe->max_usage - pipe_occupancy(pipe->head, pipe->tail);
    len = min_t(size_t, len, p_space << PAGE_SHIFT);
    ...
    return in->f_op->splice_read(in, ppos, pipe, len, flags);
}

我们接下来以 ext4 文件系统为例继续跟踪,ext4 的文件设置的调用类型为

fs/ext4/file.c
const struct file_operations ext4_file_operations = {
    ...
    .read_iter    = ext4_file_read_iter,
    ...
    .splice_read    = generic_file_splice_read,
    ...
};

generic_file_splice_read 函数的调用链为

generic_file_splice_read # fs/splice.c
  -> call_read_iter # include/linux/fs.h

call_read_iter 也会调用特定于文件系统类型的函数:

static inline ssize_t call_read_iter(struct file *file, struct kiocb *kio,
                     struct iov_iter *iter)
{
    return file->f_op->read_iter(kio, iter);
}

在 ext4 中会调用 ext4_file_read_iter 函数。接下来的调用链为

ext4_file_read_iter # fs/ext4/file.c
  -> generic_file_read_iter # mm/filemap.c
    -> filemap_read # mm/filemap.c
      -> copy_page_to_iter # lib/iov_iter.c

filemap_read 函数中,在有页缓存的情况下,会调用 copy_page_to_iter 通过零拷贝的方式将文件缓存页上的数据拷贝进 pipe 中。

利用流程

在有了上面的基础分析后,我们可以想一下已有的条件。

  1. pipe_write 依赖于 PIPE_BUF_FLAG_CAN_MERGE 标志位的设置;
  2. copy_page_to_iter_pipepush_pipe 等函数中,在初始化 pipe_buffer 时并没有初始化其中的 flags 字段,而是沿用旧的 flags 字段;

那么攻击流程大概是:

  1. 让所有的 pipe_buffer 带有 PIPE_BUF_FLAG_CAN_MERGE 标志位并释放;
  2. 将只读文件的部分数据(小于一页的长度)读入 pipe 中;
    • 这一步由 copy_page_to_iter_pipe 完成,flags 仍保留了 PIPE_BUF_FLAG_CAN_MERGE 标志,而 page 则指向了文件缓存页;
  3. 向 pipe 中写入数据
    • 由于 pipe 带有 PIPE_BUF_FLAG_CAN_MERGE 标志,因此 pipe_write 会数据写入 pipe_buffer 的 page 中

这样,此时的文件缓存页就已经被我们改写了。

漏洞验证

这里用 https://github.com/n3rada/DirtyPipe 进行验证。

我们主要要验证两点

  1. 在 pipe_buffer 创建时,所有的 flags 字段都被填充为 PIPE_BUF_FLAG_CAN_MERGE
  2. 在 splice 调用时,copy_page_to_iter 函数中,flags 字段都未被初始化,使用之前的值(即 PIPE_BUF_FLAG_CAN_MERGE

首先创建一个要覆写的文件并用随机字符串填充:

root@debian-vm:~# tr -dc A-Za-z0-9 < /dev/random | head -c 64 > /flag
root@debian-vm:~# cat /flag
SdKnIt6p0WpqYIwHDlmVGDeU1dMTS8JaxXMqdrwM244wZUFuluIhtFoGA3wwKyxv

在 Linux 中,pipe 的 ring_size 一般为 16(通过 fcntl(p[1], F_GETPIPE_SZ)),也就是说最多能填充 16 页一共 64K 大小的空间。

我们先用 write 填充满这些空间,再用 read 读,接着调用 slice,此时进入

由于此时还没有写数据,当前的 pipe 为空(head==tail):

又因为没有正确初始化 buf->flags 结构,其值仍为之前设置的 PIPE_BUF_FLAG_CAN_MERGE(0x10):

在这一次写入中,可以成功触发 pipe 的合并操作(因为我们写的内容不超过一页):

至此,漏洞验证完成。

漏洞成因

该漏洞由两个 commit 引入:

  1. new iov_iter flavour: pipe-backed - 241699c
    • 引入字段的未初始化漏洞
  2. pipe: merge anon_pipe_buf*_ops - f6dd975
    • 引入 PIPE_BUF_FLAG_CAN_MERGE 标志位判断 pipe_buffer 是否是可合并的

漏洞修复

lib/iov_iter: initialize “flags” in new pipe_buffer - 9d2231c 中进行了修复:将上述 copy_page_to_iter_pipepush_pipe 函数中未初始化的 flags 位置零。

参考资料


一篇早该写完的文章,断断续续拖到了现在……不过写完总比继续拖延要强,不是吗。