[Kernel Exploit Tech] Dirty Pipe
리눅스의 pipe 구현에 대한 깊은 이해와, PTE에 대한 기본적인 이해가 필요합니다.
pipe의 구현에 대한 내용은 이 글의 두 번째 문단을,
PTE에 대한 내용은 이 글의 PTE 구조에 대한 그림을 참고하세요.
1. Introduction
Dirty Pipe는 앞선 글에서 분석했던 pipe buffer가 merge 가능한지 알려주는 PIPE_BUF_CAN_MERGE
가 splice()
호출 시에도 초기화되지 않는다는 점을 악용하는 공격 기법이다.
일반적으로 pipe_buffer
은 항상 merge 가능하기 때문에 이 flag는 pipe_buffer
를 pipe_write()
을 통해 생성할 시 다음과 같이 기본적으로 켜져 있다1.
// Def. in /fs/pipe.c, in function pipe_write(), line 523 (@linux-5.10.239)
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
pipe_buffer
에 속하는 페이지에 실제로 공간이 남아 있어 합치기가 가능한지 불가능한지는 다음과 같이 len
을 함께 비교해 최종적으로 merge 여부를 결정한다.
// Def. in /fs/pipe.c, in function pipe_write(), line 459 (@linux-5.10.239)
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);
2. splice()
2-1. 정의
splice()
에 대한 man page를 살펴보면 다음과 같다.

파일과 파이프를 이어 주는 system call임을 알 수 있다. 설명을 잘 읽어 보면 커널 공간에서 유저 공간으로의 copy 없이 데이터를 이어준다고 적혀 있는데,
splice()
가 구현되는 방식을 보면 이해할 수 있다.
2-2. 구현
splice()
의 기능을 정의하는 do_splice()
는 다음과 같다.
// Def. in /fs/splice.c, line 1011 (@linux-5.10.239)
long do_splice(struct file *in, loff_t *off_in, struct file *out,
loff_t *off_out, size_t len, unsigned int flags)
{
여기서 file을 pipe에 붙이냐, pipe를 file에 붙이냐에 따라 각각 do_splice_to()
와 do_splice_from()
으로 흐름이 나뉜다.
그러나 dirty pipe는 file을 pipe에 붙이는 do_splice_to()
를 사용하므로 이 함수를 들여다보면
// Def. in /fs/splice.c, line 773 (@linux-5.10.239)
static long do_splice_to(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsigned int flags)
{
...
return in->f_op->splice_read(in, ppos, pipe, len, flags);
}
위에서 몇 가지 검사를 한 후 splice_read()
를 호출하고 있음을 알 수 있다. 이때 이 함수는 f_op
라는 function table에 저장된 함수로,
generic_file_splice_read()
를 가리키고 있다. 이후 흐름은 길어 호출되는 함수들만 정리하면
generic_file_splice_read()
→
call_read_iter()
→
generic_file_read_iter()
→
generic_file_buffered_read()
→
copy_page_to_iter()
→
copy_page_to_iter_pipe()
를 통해 최종적으로 copy_page_to_iter_pipe()
에 도착한다. 이 함수가 하는 주요 동작을 간단하게 살펴보면
// Def. in /lib/iov-iter.c, in function copy_page_to_iter_pipe(), line 409 (@linux-5.10.239)
buf->ops = &page_cache_pipe_buf_ops;
buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;
여기서 buf
는 pipe_buffer
구조체이다. 이 점을 고려하면 원래 pipe_buffer
에 들어가 있던 page 대신, 파일에서 읽어온 내용이 들어가 있는 page로 교체하는 것을 볼 수 있다2.
이때 여기서는 buf->flags = 0
으로 올바르게 flags를 초기화하지만, 이전 버전은 다음과 같았다.
// Def. in /lib/iov-iter.c, in function copy_page_to_iter_pipe(), line 409 (@linux-5.10.101)
buf->ops = &page_cache_pipe_buf_ops;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;
위와 같이 buf->flags
에 대한 초기화가 없는 것을 볼 수 있고, 이로 인해 buf
에 설정된 PIPE_BUF_CAN_MERGE
가 유지되었다.
3. 공격 방법
1
우선 공격자는 파이프를 만든 후 버퍼 전체를 꽉 채운다. 이 시점에서 버퍼의 상태는 다음과 같다. (간단히 예시를 들기 위해 PAGE_SIZE
를 8바이트라고 가정하고,
PIPE_DEF_BUFFERS
는 4로 4개의 페이지만을 쓴다고 하자. PIPE_BUF_CAN_MERGE
플래그는 M으로 표시했다.)
[AAAAAAAA](M) [BBBBBBBB](M) [CCCCCCCC](M) [DDDDDDDD](M)
2
이제 쓴 데이터를 전부 읽어 파이프를 비운다.
[________](M) [________](M) [________](M) [________](M)
3
다음으로 splice()
를 통해 수정하고 싶은 파일을 열고, 1바이트만 읽어 온다. 예를 들어 파일에 “1234”가 써 있다고 하자.
[1234____](M) [________](M) [________](M) [________](M)
^
이때 splice()
는 파일에서 1 page를 읽어오지만, 사용자가 요청한 건 1바이트이기 때문에 버퍼의 len
을 1로 설정해 마치 1바이트만 읽어온 것처럼 동작한다.
4
마지막으로 파이프에 내용을 쓴다. 예를 들어 “AB”를 쓴다고 하면, 앞서 pipe를 분석하며 알아봤듯 pipe_write()
는 첫 번째 버퍼와 merge를 시도하고, 다음과 같은 상태가 된다.
[1AB4____](M) [________](M) [________](M) [________](M)
^
이때 dirty bit이 활성화되며 바뀐 페이지 내용이 실제 파일에도 영향을 미치게 된다.
실제 파일에 writeback하는 과정은 커널이 수행하기 때문에, 일반 사용자가 파일을 읽을 수만 있다면 어떤 파일이든 쓰는 것이 가능해진다. 이를 활용하면 /etc/passwd
등의 파일을 수정해
LPE가 가능하다.
4. 한계점
4-1. 맨 앞 바이트
splice()
가 실제로 파일로부터 읽어오는 건 page단위지만, len
을 조정해 실제로 사용자가 원하는 만큼의 바이트만 읽어온 것처럼 만든다.
즉 실제로는 4KB를 읽어왔지만 사용자가 5바이트만을 요청했다면 pipe_buffer
의 page를 파일이 mapping된 page로 바꾼 후 pipe_buffer->len
을 5로 설정해
마치 5바이트만 읽어온 듯한 행동을 하는 것이다. 이때, splice()
가 정상적으로 파일을 읽어와 page에 mapping하기 위해서는 파일로부터 최소한 1바이트는 읽어와야 한다.
즉 file이 mapping된 pipe_buffer->len
의 최솟값은 1이기 때문에, merge가 일어나도 맨 앞 1바이트는 쓸 수 없게 된다.
4-2. 파일 길이
Dirty Pipe를 통해 수정하는 파일은 원래 파일의 길이를 넘을 수 없다. 이는 파일을 읽어올 때 inode
의 i_size
는 변함이 없기 때문이다. 즉 writeback을 할 때
커널은 i_size
만큼만을 writeback하기 때문에 파일 길이를 넘겨 버퍼에 쓰더라도 그 이후는 모두 잘리게 된다.
5. 참고
5-1. 파이프 버퍼 전체를 채웠다 비우는 이유
굳이 버퍼 전체를 채우고 비우는 까닭은 pipe가 링 버퍼를 사용한다는 사실을 잘 고려하면 된다. 만약 하나의 버퍼만 채우고 비운다면, 다음 그림과 같은 일이 일어나
splice()
가 할당받은 페이지에 PIPE_BUF_CAN_MERGE
flag가 설정되어있지 않을 것이다. (pipe_write()
로 생긴 pipe_buffer
만 PIPE_BUF_CAN_MERGE
가 기본적으로 설정되어있다고 위에서 언급했다.)
이 상황을 그림으로 표현해 보면 다음과 같다.

모든 버퍼를 채웠다 비우면 다음과 같을 것이다.

따라서 splice()
가 할당받은 page가 들어간 버퍼에 PIPE_BUF_CAN_MERGE
가 제대로 설정되어 있을 것이다.
5-2. Dirty Bit 설정
splice()
가 할당받은 page는 실제 파일을 읽어온 page이기 때문에, 메모리 상에서 수정이 일어나면 이 패이지의 dirty bit을 설정해 나중에 page의 내용이
물리 저장장치(HDD, SSD)등에 쓰이는 writeback이 일어나야 한다. 이는 커널이 소프트웨어적으로 하지는 않고, MMU가 해당 페이지에 수정이 일어날 때 자동으로 설정해준다.
Reference
[1] https://www.hackthebox.com/blog/Dirty-Pipe-Explained-CVE-2022-0847
[2] https://dirtypipe.cm4all.com/
Leave a comment