大白话图解 Linux 脏管道(Dirty Pipe) 漏洞(CVE-2022-0847)
漏洞披露链接:https://dirtypipe.cm4all.com/
背景
今天(2022年3月8日),开源社区爆出了Linux内核新的一级安全漏洞, Dirty Pipe Vulnerability. 漏洞点于Linux内核5.8版本引入,可以’通杀’后续多个版本内核,以及所有使用了相关Linux内核的下游厂商产品。当然,有必要说明的是,这里的通杀不是针对任意系统的远程命令执行,而是面向内核的利用攻击,最终达到的效果是对任意可读文件写不超过一页的内容!
那么什么时候我们能够直接面向内核进行攻击呢?这种场景其实非常多!比如说在我们平常的开发/构建服务器里,我们基于ssh shell连接的前提下,可以直接进行权限提升(获取root 权限)。
下图是一个常见的面向用户的云服务器环境:
典型的提权就是执行一段恶意程序,将uid提升至root权限。
还有一种场景是,攻击者在远程攻破系统并夺取到业务进程控制权后,进一步提权至root 权限,完整掌控系统。
漏洞原理
为了避免陷入细节(其实主要是怕读者没有耐心看完),在详细代码分析之前,还是先用大白话解释一下本次漏洞的核心思路。
first of all,我们需要先对齐一些基本术语。第一个是pipe管道机制, 因为既然漏洞名字叫”管道漏洞“,那一定和pipe有关。
Pipe 机制
在Linux中,Pipe管道分为命名管道和匿名管道,我们先直观感受一下什么是命名管道,见下图:
命名管道是一个有名字的实体文件。
再看看匿名管道:
我们经常使用的管道符命令 | 其实就是创建了一个匿名管道。将进程1的输出 重定向给 进程2. 【避免复杂化,这里面忽略shell进程中转的处理描述】
虽然Pipe有多种形态,但是本质上来说,管道(pipe)就是一种进程间通信的手段,让两个进程可以通过pipe 发送和接收数据。
Page Cache机制
第二个需要知道的前置知识是,内核的Page Cache机制 (缓存管理机制)。
我们知道,磁盘的IO读写速度是很慢的,所以一般当我们访问一个磁盘文件的时候,首先会将其内容装载到物理内存中,后续的访问都是直接取内存中的副本来读取数据。因为一个文件的内存副本,后续可能会被很多进程打开使用。(想想是不是,平常我们的微信软件可能打开本地的一个文本,word软件也可能打开同一个文本)。为了保证大家都能快速的访问,Linux设计了这样一个Page Cache机制管理起物理内存中映射的页框。
如果用户进程使用read/write读写文件,那么内核会先将载入数据的物理内存映射到内核虚拟内存buffer。然后再将内核的buffer数据拷贝到用户态。
如果追求效率,内核也提供一种零拷贝模式(不发生系统调用,跨越用户和内核的边界做上下文切换)。用户进程可以使用mmap直接将用户态的buffer 映射到 物理内存,不需要进行系统调用,直接访问自己的mmap区域即可访问到那段物理内存内容。
你以为只有文件有Page Cache?那就错了。不光是文件使用了Page cache, 前面提到的Pipe也用到了Page Cache。在内核中,pipe的数据结构管理如下图,
pipe有一个大小为16的ring buffer数组,里面存了16个pipe_buf结构,每个pipe_buf结构又有一个指针指向一个表示物理内存页Page的结构体。每个Page大小为4KB,且不连续存放。【这也说明管道一般大小为4kb*16】
splice接口
最后的最后,还需要再知道一个C 的api接口,叫做splice()。这个接口可以将数据从一个文件"零拷贝"到一个pipe管道。
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <fcntl.h>
ssize_t splice(int fd_in, off64_t *off_in, int fd_out,
off64_t *off_out, size_t len, unsigned int flags);
/*
splice() moves data between two file descriptors without copying
between kernel address space and user address space. It
transfers up to len bytes of data from the file descriptor fd_in
to the file descriptor fd_out, where one of the file descriptors
must refer to a pipe.
*/
核心思路
有了上述的背景知识,我们可以来看看这个漏洞的利用姿势了。
现在假设我们的目标是篡改一个只读,不可写的关键资产文件,如/etc/passwd.
调用splice() 拷贝数据
当使用splice将文件内容拷贝到pipe时,会触发前面所说的零拷贝。这个零拷贝本质上就是用文件的缓存页来替换pipe的缓存页,让pipe_buf的page指针直接指向文件所关联的Page.
调用pipe_write() 篡改文件
在splice已经零拷贝数据到pipe后,攻击者write(pipeFd)向管道写入篡改的数据。由于此时,pipe_buf->page 跟 文件的page是同一个引用,所以写pipe就等于写目标文件。这样,我们就完成了恶意数据的篡改注入。
到这里,我们已经可以改/etc/passwd的话,随便把/etc/passwd里用户的uid 改成0,即可完成提权。
源码分析
前面是从大的层面了解这个漏洞。接下来,会适当分析源码,印证前面的说法。
splice实现
fs/splice.c
调用链比较复杂,但是最终会走到copy_page_to_iter_pipe,也是漏洞点所在位置。
SYSCALL_DEFINE6(splice...)->__do_splice->do_splice
...->copy_page_to_iter-> copy_page_to_iter_pipe
copy_page_to_iter_pipe函数会将文件的缓存页 拷贝替换给 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)
{
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;
//获取当前bufs数组中 写入index的pipe_buf
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->flag的默认值是 PIPE_BUF_FLAG_CAN_MERGE
//这里没有将 buf->flag 设置为 0,漏洞点!后面会提到
buf->ops = &page_cache_pipe_buf_ops;
//获取文件的缓存页,存放到page里。
get_page(page);
//将pipe的page指向文件的缓存page
buf->page = 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的page设置成文件的page cache,并且遗漏了flag的初始化0操作。
(如果晕了可以拖到上面再复习一下大的流程图)
接下来看利用的第二步操作,pipe_write
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;
...
/*
* If it wasn't empty we try to merge new data into
* the last buffer.
*
* That naturally merges small writes, but it also
* page-aligns the rest of the writes for large writes
* spanning multiple pages.
*/
head = pipe->head;
was_empty = pipe_empty(head, pipe->tail);
chars = total_len & (PAGE_SIZE-1);
if (chars && !was_empty) {
//如果缓存页不为空 (要走进这个分支,说明我们得提前把pipe申请满一遍,然后再将内容都读完,保留buf结构体)
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
int offset = buf->offset + buf->len;//接着写的位置
//看这里看这里看这里!! 如果pipe_buf的Flag为 PIPE_BUF_FLAG_CAN_MERGE(初始化时设置的), 则允许在当前页面续写
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) && offset + chars <= PAGE_SIZE) {
//如果续写不会引发写跨页,则写入。否则goto out, 那里会分配一个新的内存页来装数据。
ret = pipe_buf_confirm(pipe, buf);
if (ret)
goto out;
//将数据从用户传来的from, 拷贝到 pipe_buf->page
//这里的pipe_buf->page 和 攻击目标的只读文件 共享 缓存页!
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;
}
}
...
}
可以看到,漏洞的关键在于pipe_buf 带有PIPE_BUF_FLAG_CAN_MERGE,允许我们继续往一个"没写完"的pipe页面续写数据. 这个"没写完"的pipe页面,就是咱们提到的/etc/passwd对应的缓存页。
排查建议:
如果是个人使用的虚拟机镜像,可以直接查看版本号判断是否涉及该漏洞。
如果是企业或者组织,单纯看版本号可能存在误报。(因为企业的内核往往自己维护,可能会回合高版本补丁到自己版本的内核)。可以搜索查看自己内核源码是否初始化buf->flag 为0,以及相关宏定义是否存在。