이 문서는 xfx/LFS 설계(
arch.md)의 리포트 스키마를 검증하기 위한 스파이크 산출물이다. 인프라 없이 공개 자료만으로 손으로 작성했다. PoC는 실행하지 않고 정적 분석만 했다.
| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2022-0847 |
| 영향 버전 | Linux 5.8 ~ 5.10.102 미만, 5.15 ~ 5.15.25 미만, 5.16 ~ 5.16.11 미만 |
| CVSS v3.1 | 7.8 (High) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| 공개일 | 2022-02-19 커널 팀 확인 → 2022-02-23 안정 릴리스 패치 → 2022-03-10 NVD 공개 |
| 수정 커밋 | 9d2231c5d74e13b2a0546fee6737ee4446017903 |
Dirty Pipe는 Linux 커널의 파이프(pipe) 구현에서 pipe_buffer 구조체의 flags 멤버가
제대로 초기화되지 않아 발생하는 권한 상승 취약점이다. 권한 없는 로컬 사용자가 splice() 와
파이프의 병합(merge) 최적화 로직을 조합해, 읽기 전용으로 연 임의의 파일의 페이지 캐시
내용을 직접 덮어쓸 수 있다. 파일 자체의 권한 검사를 우회하는 것이 아니라, 커널이 "이 버퍼는
새로 만든 익명 버퍼라 마음대로 덮어써도 된다"고 잘못 판단하게 만드는 방식이다.
리눅스 커널에서 파이프는 pipe_buffer 구조체의 링(ring)으로 구현되며, 각 버퍼는 페이지 하나를
참조한다(include/linux/pipe_fs_i.h, v5.16 기준).
#define PIPE_BUF_FLAG_LRU 0x01 /* page is on the LRU */
#define PIPE_BUF_FLAG_ATOMIC 0x02 /* was atomically mapped */
#define PIPE_BUF_FLAG_GIFT 0x04 /* page is a gift */
#define PIPE_BUF_FLAG_PACKET 0x08 /* read() as a packet */
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
#define PIPE_BUF_FLAG_WHOLE 0x20 /* read() must return entire buffer or error */
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};ops 필드는 이 버퍼가 어떤 종류인지를 결정한다. 일반 write()로 채워진 버퍼는
anon_pipe_buf_ops(익명 버퍼)를 쓰고, splice()로 파일에서 들여온 버퍼는
page_cache_pipe_buf_ops(페이지 캐시 버퍼)를 쓴다.
splice() 시스템 콜은 파일 데이터를 파이프로 옮길 때, 데이터를 실제로 복사하지 않고 이미
페이지 캐시에 있는 페이지를 참조로 파이프 버퍼에 연결한다(zero-copy). 이 경로를 처리하는
함수가 lib/iov_iter.c 의 copy_page_to_iter_pipe() 다. 즉 파이프 버퍼의 page 포인터가
곧 그 파일의 페이지 캐시 페이지를 직접 가리키게 된다 — 이 사실이 취약점의 전제 조건이다.
이 플래그는 "새 데이터를 이 버퍼 뒤에 이어 붙여도(병합해도) 된다"는 뜻이다. fs/pipe.c 의
pipe_write() 는 익명 버퍼를 새로 만들 때만 이 플래그를 설정한다.
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; /* 오직 새 anon 버퍼에만 */작은 write() 여러 번을 하나의 페이지에 이어붙이는 최적화를 위한 것으로, 페이지 캐시
버퍼에는 절대 설정되어서는 안 되는 플래그다. 페이지 캐시 버퍼에 이 플래그가 잘못 설정되면,
그 페이지는 "파일 시스템이 아니라 파이프가 소유한, 마음대로 써도 되는 메모리"로 오인된다.
2016년 커밋 241699cd72a8("new iov_iter flavour: pipe-backed")에서 copy_page_to_iter_pipe()
와 push_pipe() 두 함수가 새 pipe_buffer 를 채우면서 flags 멤버 초기화를 빠뜨렸다.
패치 전(v5.16) 코드는 다음과 같다.
// lib/iov_iter.c — copy_page_to_iter_pipe() (패치 전)
buf = &pipe->bufs[i_head & p_mask];
...
buf->ops = &page_cache_pipe_buf_ops;
/* buf->flags 초기화 없음 — 이 슬롯을 이전에 쓰던 값이 그대로 남는다 */
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;// lib/iov_iter.c — push_pipe() (패치 전, 동일한 문제)
buf->ops = &default_pipe_buf_ops;
/* buf->flags 초기화 없음 */
buf->page = page;
buf->offset = 0;
buf->len = min_t(ssize_t, left, PAGE_SIZE);커널은 이 링 슬롯의 메모리를 매번 0으로 초기화하지 않는다. 따라서 같은 슬롯을 예전에
익명 버퍼(anon_pipe_buf_ops, flags = PIPE_BUF_FLAG_CAN_MERGE)로 썼던 적이 있다면, 그
잔여 flags 값이 새로 채워지는 페이지 캐시 버퍼에 그대로 남아있게 된다.
- 파이프를 만들고 일반
write()로 모든 ring 슬롯을 채운다 → 각 슬롯이anon_pipe_buf_ops+flags = PIPE_BUF_FLAG_CAN_MERGE상태가 된다. - 파이프를 전부
read()해서 비운다 → 슬롯은 재사용 가능해지지만flags값은 초기화되지 않은 채 남아 있다. - 대상 파일을 읽기 전용으로 열고, 원하는 오프셋 "직전"부터 1바이트를
splice()로 파이프에 흘려보낸다 →copy_page_to_iter_pipe()가 그 슬롯을 페이지 캐시 페이지로 채우면서ops는page_cache_pipe_buf_ops로 바꾸지만flags는 손대지 않아 이전CAN_MERGE값이 그대로 남는다. - 이 파이프에 공격자가 원하는 데이터로
write()를 호출한다 →pipe_write()의 병합 로직이 "마지막 버퍼가CAN_MERGE인가"만 확인하고ops종류(익명인지 페이지 캐시인지)는 확인하지 않는다. 조건이 참으로 판정되어copy_page_from_iter()로 페이지 캐시 페이지에 직접 새 데이터를 써버린다. - 이 페이지는 결국 디스크로 write-back 되어, 읽기 전용으로 열었던 파일의 실제 내용이 변조된다.
[일반 write() 여러 번]
└─ ring 슬롯: ops=anon_pipe_buf_ops, flags=CAN_MERGE
[read() 로 파이프 전부 비움]
└─ 슬롯 재사용 가능, flags 값은 그대로 잔존 (아직은 안전)
[splice(read-only 파일, 1 byte)]
└─ copy_page_to_iter_pipe() 실행
buf->ops = page_cache_pipe_buf_ops (교체됨)
buf->flags = (초기화 안 함) → CAN_MERGE 잔존 ← 버그 지점
[write(공격자 데이터)]
└─ pipe_write() 병합 검사: buf->flags & CAN_MERGE → 참 (오판)
copy_page_from_iter(buf->page, ...) 로 페이지 캐시에 직접 기록
│
▼
읽기 전용 파일의 내용이 변조됨
splice()를 실행하려면 최소한 파일 읽기 권한이 있어야 한다.- 덮어쓸 오프셋이 페이지 경계 자체여서는 안 된다(병합 대상이 되려면 그 앞에 최소 1바이트가 이미 있어야 한다).
- 한 번의 쓰기가 페이지 경계를 넘어갈 수 없다.
- 파일 크기 자체를 늘리거나 줄일 수는 없다(기존 내용을 덮어쓰는 것만 가능).
수정 커밋 9d2231c5d74e13b2a0546fee6737ee4446017903
("lib/iov_iter: initialize 'flags' in new pipe_buffer", 작성자 Max Kellermann, 커밋어 Al
Viro)은 두 함수 모두에 buf->flags = 0; 한 줄을 추가하는 것으로 근본 원인을 제거했다.
--- a/lib/iov_iter.c
+++ b/lib/iov_iter.c
@@ copy_page_to_iter_pipe()
buf->ops = &page_cache_pipe_buf_ops;
+ buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
@@ push_pipe()
buf->ops = &default_pipe_buf_ops;
+ buf->flags = 0;
buf->page = page;
buf->offset = 0;
buf->len = min_t(ssize_t, left, PAGE_SIZE);이로써 페이지 캐시 버퍼는 항상 flags = 0 으로 시작하므로, 이전 익명 버퍼가 남긴
CAN_MERGE 값을 물려받을 수 없다. 이 수정은 Linux 5.16.11, 5.15.25, 5.10.102 안정
릴리스에 백포트되었다.
앞서 그린 공격 흐름 그림을 패치 후 기준으로 다시 그리면, 문제의 그 지점에서 흐름이 끊긴다.
[splice(read-only 파일, 1 byte)]
└─ copy_page_to_iter_pipe() 실행
buf->ops = page_cache_pipe_buf_ops
buf->flags = 0 ← 패치로 추가된 한 줄, 항상 명시적으로 초기화
│
▼
[write(공격자 데이터)]
└─ pipe_write() 병합 검사: buf->flags & CAN_MERGE → 항상 거짓
(이전 슬롯이 무엇이었든 상관없이 0에서 시작하므로)
│
▼
병합 로직을 타지 않고 새 익명 버퍼를 따로 할당 → 페이지 캐시 페이지는 건드리지 않음
즉 패치 전후의 차이는 "이전 슬롯의 잔여값을 물려받느냐, 항상 0에서 시작하느냐" 단 한 줄이지만, 그 한 줄이 병합 검사의 전제 자체를 무너뜨려 공격 경로를 끊는다.
- The Dirty Pipe Vulnerability — 원저자 Max Kellermann 공개 writeup
- CVE-2022-0847 — NVD
- 수정 커밋 9d2231c5d74e13b2a0546fee6737ee4446017903 — torvalds/linux
- RHSB-2022-002 — Red Hat
include/linux/pipe_fs_i.h,lib/iov_iter.c,fs/pipe.c— torvalds/linux (v5.16)
- 리포트 스키마는 실제로 채울 수 있었다.
arch.md결론의 스키마(메타데이터 → 개요 → 사전 지식 → 취약점 분석 → 수정 방법 → PoC 언급 → 참고자료)를 그대로 따라가며 채우는 데 막힘이 없었다. 특히 "사전 지식을 설명 문단으로 풀어쓴다"는 지침이 실제로 리포트의 핵심 가치였다 — 코드만 보여주면 이해가 안 되고, 개념 설명이 먼저 있어야 다음 섹션이 읽힌다. - Tool 카탈로그 중 실제로 쓴 것은 3개뿐이었다:
fetch_cve_metadata(NVD),fetch_public_writeup(dirtypipe.cm4all.com),fetch_upstream_patch(GitHub commit API).search_public_poc/static_analyze_poc/clone_upstream_source는 이번엔 필요 없었다 — PoC 코드 자체를 열어보지 않고도 원저자 writeup + 실제 diff만으로 충분한 분석이 나왔다. search_public_poc의 설계를 바꿔야 한다. 실시간 GitHub 코드 검색 대신, CVE ID로 바로 조회 가능한 로컬 공개 저장소 색인을 1차 소스로 쓰는 게 훨씬 싸고 안정적이다.arch.md의 Sandbox 상세 설계(§"코어-Sandbox 통신")에서 가정했던 "매번 외부에 라이브 검색"은 최소한 1차 후보 목록 단계에서는 불필요하다.fetch_public_writeup이 실제로 핵심이었다. oss-security 메일링리스트가 아니라 원저자 개인 사이트가 1차 자료였다. 이 Tool 은 "정해진 몇 개 도메인"이 아니라 CVE advisory/커밋 메시지에 언급된 링크를 따라가는 방식이 되어야 할 수도 있다 — 이번엔 검색 없이 사전 지식(원저자가 dirtypipe.cm4all.com 을 운영한다는 사실)으로 바로 찾았지만, 자동화 하려면 "advisory 본문에서 외부 링크 추출" 같은 보조 로직이 필요해 보인다.