Skip to content

Latest commit

 

History

History
232 lines (190 loc) · 11.4 KB

File metadata and controls

232 lines (190 loc) · 11.4 KB

Dirty Pipe (CVE-2022-0847)

이 문서는 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

리눅스 커널에서 파이프는 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

splice() 시스템 콜은 파일 데이터를 파이프로 옮길 때, 데이터를 실제로 복사하지 않고 이미 페이지 캐시에 있는 페이지를 참조로 파이프 버퍼에 연결한다(zero-copy). 이 경로를 처리하는 함수가 lib/iov_iter.ccopy_page_to_iter_pipe() 다. 즉 파이프 버퍼의 page 포인터가 곧 그 파일의 페이지 캐시 페이지를 직접 가리키게 된다 — 이 사실이 취약점의 전제 조건이다.

PIPE_BUF_FLAG_CAN_MERGE

이 플래그는 "새 데이터를 이 버퍼 뒤에 이어 붙여도(병합해도) 된다"는 뜻이다. fs/pipe.cpipe_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() 여러 번을 하나의 페이지에 이어붙이는 최적화를 위한 것으로, 페이지 캐시 버퍼에는 절대 설정되어서는 안 되는 플래그다. 페이지 캐시 버퍼에 이 플래그가 잘못 설정되면, 그 페이지는 "파일 시스템이 아니라 파이프가 소유한, 마음대로 써도 되는 메모리"로 오인된다.

취약점 분석

근본 원인: 초기화되지 않은 flags

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 값이 새로 채워지는 페이지 캐시 버퍼에 그대로 남아있게 된다.

공격이 성립하는 경로

  1. 파이프를 만들고 일반 write() 로 모든 ring 슬롯을 채운다 → 각 슬롯이 anon_pipe_buf_ops + flags = PIPE_BUF_FLAG_CAN_MERGE 상태가 된다.
  2. 파이프를 전부 read() 해서 비운다 → 슬롯은 재사용 가능해지지만 flags 값은 초기화되지 않은 채 남아 있다.
  3. 대상 파일을 읽기 전용으로 열고, 원하는 오프셋 "직전"부터 1바이트를 splice() 로 파이프에 흘려보낸다 → copy_page_to_iter_pipe() 가 그 슬롯을 페이지 캐시 페이지로 채우면서 opspage_cache_pipe_buf_ops 로 바꾸지만 flags 는 손대지 않아 이전 CAN_MERGE 값이 그대로 남는다.
  4. 이 파이프에 공격자가 원하는 데이터로 write() 를 호출한다 → pipe_write() 의 병합 로직이 "마지막 버퍼가 CAN_MERGE 인가"만 확인하고 ops 종류(익명인지 페이지 캐시인지)는 확인하지 않는다. 조건이 참으로 판정되어 copy_page_from_iter()페이지 캐시 페이지에 직접 새 데이터를 써버린다.
  5. 이 페이지는 결국 디스크로 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에서 시작하느냐" 단 한 줄이지만, 그 한 줄이 병합 검사의 전제 자체를 무너뜨려 공격 경로를 끊는다.

참고 자료


스파이크 결론 (설계 검증 메모)

  • 리포트 스키마는 실제로 채울 수 있었다. 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 본문에서 외부 링크 추출" 같은 보조 로직이 필요해 보인다.