
CVE-2022-0847 (Dirty Pipe) Linux 커널 권한 상승 취약점에 대한 개념 증명(PoC) 익스플로잇 및 상세 분석, 테스트와 디버깅을 위한 Docker 환경을 포함합니다.
[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 */