infiltr8 Lab
Draft · 검수 전 · 심층판
Home/ CVE/ 커널·로우레벨/ 리눅스 파이프 버퍼 플래그 미초기화 권한상승 (Dirty Pipe)
CVE · 커널·로우레벨 · LPE · CVE-2022-0847
심층 분석 연구 분석

파이프를 비워도, '덧써도 된다'는 표식은 지워지지 않았다

CVE-2022-0847, 이름하여 'Dirty Pipe'는 리눅스 커널의 파이프 버퍼 구조체가 재사용될 때 flags 필드를 초기화하지 않아, 읽기전용 파일의 페이지캐시를 일반 사용자가 직접 덮어쓸 수 있게 된 권한상승 취약점입니다. 이 글은 발견자 본인의 공개 기술 문서를 재구성하고, 실제 커널 소스와 딱 두 줄짜리 패치 커밋을 라인 단위로 직접 대조해 근본원인과 흐름을 분석합니다.

이 글의 코드 발췌(lib/iov_iter.c의 copy_page_to_iter_pipe()·push_pipe(), fs/pipe.c의 pipe_write() 병합 검사·플래그 대입부, include/linux/pipe_fs_i.h의 PIPE_BUF_FLAG_CAN_MERGE 정의)는 torvalds/linux 오픈소스의 실제 소스를 raw.githubusercontent.com에서 패치 커밋(9d2231c5d74e13b2a0546fee6737ee4446017903)의 부모 커밋(e783362eb54cd99b2cac8b3a9aeac942e6f6ac07, 패치 직전 상태) 기준으로 직접 받아 라인 번호까지 대조했습니다. 패치 diff는 GitHub의 실제 .patch 엔드포인트에서 원문 그대로 받았고, kernel.org stable 트리(linux-5.16.y/5.15.y/5.10.y)의 공식 ChangeLog에서 같은 커밋이 5.16.11·5.15.25·5.10.102에 그대로 백포트됐음을 직접 확인했습니다. 5.8 도입 시점의 근거로 든 커밋(f6dd975583bd8ce088400648fd9819e4691c8958, 'pipe: merge anon_pipe_buf*_ops')도 GitHub API로 존재와 날짜(2020-05-20)를 직접 확인했습니다. CVSS·CWE·발행일·CISA KEV 등재일은 NVD API(JSON) 응답에서 직접 가져왔습니다. 다만 '공격 과정'(파이프를 채우고 비운 뒤 splice로 특정 파일 페이지를 편입시키는 정확한 순서, 오프셋·EOF 관련 제약조건)은 커널 소스를 저희가 직접 추적한 것이 아니라 발견자 Max Kellermann이 공개한 기술 문서(dirtypipe.cm4all.com)의 서술을 근거로 재구성한 것입니다 — 그 부분(공격 시나리오의 구체적 syscall 순서·제약조건 서술)은 원 발견자의 1차 공개 자료에 의존했음을 밝힙니다. pipe_write()의 병합 검사·flags 대입 코드(§7 W2·W3, §8.2 병합 지점)는 커널 소스에서 저희가 직접 확인했습니다.

infiltr8 team 직접 분석 #uninitialized-memory#pipe-buffer#kernel-lpe
무엇리눅스 커널의 파이프(pipe) 버퍼를 새로 만드는 두 함수 copy_page_to_iter_pipe()·push_pipe()가 버퍼 구조체의 flags 필드를 초기화하지 않아, 일반 사용자가 읽기전용 파일의 페이지캐시를 직접 덮어쓸 수 있는 취약점입니다.
원리파이프에 데이터를 쓰면 커널은 나중에 이어 써도 되는지 표시하려고 그 버퍼에 PIPE_BUF_FLAG_CAN_MERGE 플래그를 켭니다. 그 파이프를 비운 뒤에도 이 플래그 값은 지워지지 않고 메모리에 남고, 이후 다른 목적(읽기전용 파일의 페이지를 파이프로 옮기는 splice())으로 같은 자리를 재사용하면 그 값을 그대로 물려받습니다.
핵심패치는 단 두 줄, buf->flags = 0;을 두 함수에 각각 추가한 것입니다. CVSS 7.8(High), CWE-665(초기화 미흡). 발견 즉시 실제 악용이 확인돼 CISA KEV(악용 확인 취약점 목록)에 등재됐습니다.

01개요

리눅스 커널의 파이프(pipe)는 두 프로세스(또는 커널 내부 두 단계) 사이에서 데이터를 주고받는 단방향 통로입니다. 내부적으로는 고정 개수의 슬롯을 가진 링 버퍼로 구현되어 있고, 각 슬롯은 struct pipe_buffer 하나가 차지하며 실제 데이터가 담긴 메모리 페이지를 가리킵니다. CVE-2022-0847('Dirty Pipe'라는 이름으로 더 널리 알려짐)은 이 pipe_buffer 구조체의 flags 필드가 새로 만들어질 때 초기화되지 않아, 이전 사용의 흔적이 완전히 무관한 문맥에서 살아남는 취약점입니다.

그 흔적이 우연히 'CAN_MERGE'(이어 써도 됨)라는 값이고, 그 값이 하필 읽기전용 파일의 페이지캐시를 가리키는 새 버퍼로 넘어가면, 일반 권한의 사용자가 write() 시스템 콜 하나로 그 파일의 캐시된 내용을 직접 덮어쓸 수 있습니다. 이 취약점은 2016년의 Dirty COW(CVE-2016-5195)와 결과는 비슷하지만 훨씬 쉽게 재현됩니다 — Dirty COW는 정교한 레이스 컨디션(경쟁 상태)을 맞춰야 했던 반면, Dirty Pipe는 타이밍 경쟁이 필요 없는 순차적 시스템 콜 호출만으로 성립합니다. 확인

CVSS: 7.8 (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H, High, NVD) · CWE-665(Improper Initialization)

이 CVE와 같은 공지에서 함께 발표된 형제 취약점은 없습니다(단일 결함, 단일 CVE). CISA는 실제 악용이 확인된 뒤 2022-04-25 Known Exploited Vulnerabilities(KEV) 목록에 'Linux Kernel Privilege Escalation Vulnerability'로 등재했습니다(조치 기한 2022-05-16). 확인

02영향받는 버전

대상취약 버전안전 버전
Linux 커널(메인라인/stable)5.8 이상 ~ 5.16.10 · 5.15.24 · 5.10.101 이하 각 계열5.16.11 · 5.15.25 · 5.10.102 이상(각 stable 계열, kernel.org 공식 ChangeLog로 직접 확인)
Android(커널 5.8+ 탑재 기기, 예: Google Pixel 6)패치 반영 전 AOSP 커널2022-02-24 AOSP 커널 병합 이후 벤더가 배포한 보안 업데이트 분석 — 발견자 공개 문서의 타임라인 서술 기반, AOSP 저장소를 직접 대조하지는 않음

03배경 — 파이프가 '이어 써도 된다'는 표식을 남기는 이유

파이프에 데이터를 쓰는 방법은 하나가 아닙니다. 일반적인 write() 시스템 콜은 fs/pipe.cpipe_write()가 처리하는데, 이 함수는 커널이 직접 할당한 새 페이지에 사용자 데이터를 복사해 넣습니다. 반면 splice()·vmsplice() 계열은 lib/iov_iter.ccopy_page_to_iter_pipe()·push_pipe()를 거쳐, 파일의 페이지캐시 페이지를 복사 없이 파이프 버퍼가 그대로 가리키게 만듭니다(제로카피 최적화) — 파이프가 페이지를 '소유'하는 게 아니라 '빌려 보는' 경우가 생기는 지점입니다.

pipe_write()는 작은 데이터를 연속으로 쓸 때마다 매번 새 페이지를 할당하지 않도록 최적화를 합니다. 방금 쓴 버퍼의 페이지에 아직 여유 공간이 있으면, 새 페이지를 만드는 대신 그 자리에 이어 씁니다. 이 '이어 써도 되는 버퍼'라는 상태를 표시하는 것이 PIPE_BUF_FLAG_CAN_MERGE 플래그(include/linux/pipe_fs_i.h 11행, #define PIPE_BUF_FLAG_CAN_MERGE 0x10)입니다 — 커널이 직접 소유하고 통제하는 페이지에 대해서만 의미가 있는 표식입니다.

여기서 짚어야 할 것은 이 플래그가 살아 있는 자리입니다. 파이프의 링 버퍼는 고정 슬롯 배열이라, 한 슬롯을 다 쓰고 비우면 그 배열 인덱스는 다음 버퍼를 위해 재사용됩니다. 그 슬롯의 struct pipe_buffer는 메모리에서 사라지는 게 아니라 그 자리에 그대로 남아, 다음에 그 자리를 차지하는 코드가 각 필드를 새로 채워 넣기를 기다립니다 — 다음 절의 근본원인은 정확히 이 '채워 넣기'가 한 필드만 빠뜨렸다는 데서 시작합니다.

04취약점 발생 원인

이 취약점은 서로 다른 계층의 세 가지 사실이 겹쳐야 완성됩니다. 근본원인(W1)은 명확히 한 자리 — 새 버퍼를 만드는 두 함수의 초기화 누락 — 이지만, 그게 실제로 위험해지려면 슬롯이 재사용된다는 사실(W2)과 병합 검사가 버퍼의 출처를 확인하지 않는다는 사실(W3)이 함께 있어야 합니다.

1"새 파이프 버퍼를 만들면서, flags 필드는 채우지 않았다"

핵심

copy_page_to_iter_pipe()push_pipe() 둘 다 buf->ops·buf->page·buf->offset·buf->len은 새로 대입하면서, buf->flags만 이 초기화 목록에서 빠져 있습니다.

lib/iov_iter.ccopy_page_to_iter_pipe()(패치 전, 382행부터 시작)는 splice()로 파일의 페이지캐시 페이지를 파이프에 편입시킬 때 쓰이는 함수입니다. 416행에서 buf->ops = &page_cache_pipe_buf_ops;로 이 버퍼가 '파일 페이지캐시를 가리키는' 종류임을 표시한 뒤, 곧바로 페이지 참조·오프셋·길이를 채웁니다 — 그런데 flags는 이 목록에 없습니다. 그 결과 이 슬롯이 직전에 어떤 값을 갖고 있었든, 그 값이 그대로 남습니다.

if (pipe_full(i_head, p_tail, pipe->max_usage)) return 0; buf->ops = &page_cache_pipe_buf_ops; /* buf->flags 초기화가 바로 이 자리에 없습니다 — 패치가 추가하는 지점 */ get_page(page); buf->page = page; buf->offset = offset;

torvalds/linux 실제 소스 — lib/iov_iter.c · copy_page_to_iter_pipe() (패치 전, 커밋 e783362e = 패치 커밋 9d2231c5의 부모, 413~420행). 416행에서 버퍼 종류만 표시하고 flags 초기화 없이 바로 페이지 정보를 채웁니다.

같은 파일의 push_pipe()(패치 전, 545행부터 시작)도 정확히 같은 패턴입니다. vmsplice() 등에서 파이프에 새 익명 페이지를 채울 때 쓰이는데, 579행 buf->ops = &default_pipe_buf_ops; 다음에도 flags 초기화가 없습니다. 두 함수 모두 '새 버퍼를 만든다'는 같은 책임을 지면서, 같은 필드 하나를 똑같이 빠뜨렸습니다.

왜 이렇게 됐나제로카피 splice 경로는 성능이 핵심 관심사라, '이 버퍼가 무엇을 가리키는지'(ops·page·offset·len)만 채우면 충분하다고 여겨졌을 가능성이 높습니다. flags는 병합 최적화를 위한 부가 상태일 뿐 이 함수의 핵심 책임(페이지를 파이프에 연결하는 것)과 무관해 보였을 것입니다 — 하지만 이 필드가 재사용되는 메모리 위에 놓인다는 사실이 그 부가 상태를 위험하게 만들었습니다. 분석

2"버퍼가 쓰던 CAN_MERGE 값은, 슬롯이 재사용돼도 스스로 지워지지 않는다"

핵심

정상적인 write()pipe_write()가 새 익명 버퍼를 만들 때마다 의도적으로 PIPE_BUF_FLAG_CAN_MERGE를 켭니다. 이건 버그가 아니라 설계입니다 — 문제는 그 값이 남아 있는 슬롯을 나중에 다른 함수(W1)가 재사용한다는 점입니다.

fs/pipe.cpipe_write()(414행부터)는 파이프가 가득 차지 않았으면 새 슬롯에 익명 페이지를 할당해 넣습니다. 522행에서 buf->ops = &anon_pipe_buf_ops;로 '커널이 직접 소유한 페이지'임을 표시하고, 528행에서 buf->flags = PIPE_BUF_FLAG_CAN_MERGE;를 명시적으로 켭니다 — 다음 작은 쓰기가 같은 페이지에 이어 붙을 수 있도록 하기 위한 의도된 최적화입니다.

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;

torvalds/linux 실제 소스 — fs/pipe.c · pipe_write() 내부 새 버퍼 생성부(패치 전, 커밋 e783362e, 520~528행). 528행이 CAN_MERGE를 의도적으로 켜는 지점입니다(이 자체는 정상 동작).

이 데이터를 read()로 전부 읽어 파이프를 비워도, 그 슬롯의 struct pipe_buffer 메모리 자체는 초기화되지 않습니다. 읽기는 슬롯을 '소비됨'으로 표시해 다음 재사용을 위해 넘길 뿐, 그 안의 flags 값을 지우는 코드는 어디에도 없습니다. 그 슬롯 인덱스를 나중에 W1의 두 함수가 다시 차지할 때, 이 켜진 채로 남은 값을 그대로 물려받습니다.

왜 이렇게 됐나이 코드 자체는 결함이 아닙니다 — pipe_write()는 자신이 만든 버퍼의 flags를 매번 명시적으로 채웁니다. 문제는 이 함수가 책임지지 않는 영역, 즉 '이 슬롯을 나중에 완전히 다른 함수가 재사용할 때'까지는 이 함수가 관여할 수 없다는 것입니다. 커널 메모리 재사용에서 흔히 나오는 경계 — 한 코드가 만든 상태를, 그 코드를 전혀 모르는 다른 코드가 이어받는 지점 — 이 여기서도 그대로 나타납니다.

3"병합 여부를 판단할 때, 이 버퍼가 정말 커널 소유 페이지인지는 확인하지 않는다"

핵심

pipe_write()의 병합 검사는 buf->flags 비트 하나만 보고, buf->ops가 여전히 anon_pipe_buf_ops(커널 소유)인지 page_cache_pipe_buf_ops(파일 페이지캐시)로 바뀌었는지는 묻지 않습니다.

461행의 병합 검사 if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) && offset + chars <= PAGE_SIZE)는 마지막 버퍼에 이어 써도 되는지를 오직 이 플래그 비트로만 판단합니다. 이 버퍼가 애초에 누가(어떤 ops로) 만든 것인지, 그 페이지가 커널이 자유롭게 써도 되는 페이지인지 파일 시스템의 캐시 페이지인지는 이 조건문 어디에도 없습니다.

조건을 통과하면 467행 copy_page_from_iter(buf->page, offset, chars, from)가 사용자 데이터를 buf->page에 직접 복사합니다. W1·W2가 겹쳐 이 buf->page가 사실은 읽기전용 파일의 페이지캐시 페이지라면, 이 한 줄이 그 파일의 캐시된 내용을 그대로 덮어씁니다 — 커널 입장에서는 '병합 가능하다고 표시된 버퍼에 정상적으로 이어 쓴 것'과 구분되지 않습니다.

왜 이렇게 됐나플래그 하나로 상태를 압축해 표현하는 것 자체는 흔한 최적화지만, 그 플래그가 '이 버퍼의 출처'라는 더 근본적인 사실(ops)과 독립적으로 취급되면서 값과 문맥이 분리됐습니다. flags는 재사용되는 메모리 위에 남고, ops는 W1에서 매번 새로 정확히 설정되는데, 병합 검사는 둘 중 재사용 가능한(그래서 신뢰할 수 없는) 쪽만 확인합니다. 분석

분석 결론

무게 중심은 W1(copy_page_to_iter_pipe()·push_pipe()의 flags 미초기화)입니다 — 실제 패치도 이 두 지점에 각 한 줄씩만 추가했습니다.

W2(pipe_write()가 자기 버퍼에 CAN_MERGE를 켜는 것)와 W3(병합 검사가 ops를 확인하지 않는 것)는 그 자체로는 결함이 아닙니다. W2는 의도된 최적화이고, W3의 단순한 플래그 검사도 '이 슬롯은 항상 새로 초기화된다'는 전제가 지켜지는 한 안전합니다.

한 문장으로 묶으면 이렇습니다. 재사용되는 메모리 구조체의 필드 하나를, 그 구조체를 새로 채우는 두 함수가 나란히 빠뜨렸습니다. 그 필드에 남은 이전 값이, 원래 그 값이 의미를 가졌던 문맥(커널 소유 익명 페이지)과 전혀 다른 문맥(읽기전용 파일의 페이지캐시)으로 그대로 흘러 들어갔습니다.

05공격 과정

5.1 공격 과정 요약

파이프 생성 + write()로 채우기 pipe_write()가 각 새 버퍼에 CAN_MERGE 플래그를 켬(정상) ① 비워도 슬롯 구조체는 재사용 대기 상태로 남음 read()로 파이프를 완전히 비움 논리적으로는 비었지만 링 슬롯 구조체의 flags 값은 그대로 남음 ② splice가 같은 슬롯 재사용, flags 초기화 누락(패치 전) 핵심 지점 splice()로 읽기전용 파일의 페이지를 파이프에 편입 copy_page_to_iter_pipe()가 슬롯 재사용, flags 미초기화(패치 전) ③ 병합 가능 플래그가 파일 페이지 버퍼에 잘못 상속됨 write()로 다시 데이터 주입 pipe_write()의 병합 검사가 CAN_MERGE 비트만 확인(ops는 미확인) ④ ops(버퍼 출처) 확인 없이 flags만 보고 직접 복사 파일의 페이지캐시가 직접 덮어써짐 읽기전용 파일 변조 · 로컬 권한상승(LPE)
공격 흐름도 (직접 작성)

5.2 상세 공격 과정

①·②단계는 순전히 파이프 하나로만 진행됩니다. 공격자는 자신이 소유한 파이프에 임의 데이터를 write()로 채운 뒤 read()로 비웁니다 — 이 시점까지는 다른 프로세스나 파일에 전혀 손대지 않습니다. 파이프의 기본 슬롯 수(현대 커널에서 보통 16개)만큼 채워야 링 버퍼 전체의 상태를 예측 가능하게 만들 수 있습니다. 분석 — 이 세션에서 파이프 슬롯 개수·초기화 로직을 커널 소스로 직접 재확인하지는 않았습니다

③단계에서 공격자는 자신이 읽기 권한만 가진 파일을 대상으로 합니다. splice(fd, &offset, pipe_write_fd, NULL, 1, 0)처럼 오프셋을 지정해 딱 1바이트만 옮기는 것이 핵심입니다 — 이 오프셋은 페이지 경계여서는 안 됩니다(그 페이지의 적어도 1바이트가 이미 파이프에 들어와 있어야 하기 때문입니다). 여기에 '쓰기가 페이지 경계를 넘을 수 없다', '파일 크기 자체를 늘릴 수는 없다'는 제약이 함께 작동합니다. 분석 — 2차 소스(발견자 1차 공개문서) 기반, 이 세션에서 fs/splice.c를 직접 대조하지 않았습니다

④단계의 write()가 실제로 도달하는 코드는 §7 W3에서 직접 확인한 pipe_write()의 병합 분기(461~467행)입니다. 이 write가 성공하면, 공격자가 보낸 바이트가 커널 메모리 어딘가가 아니라 그 파일의 실제 페이지캐시 페이지에 그대로 들어갑니다 — write() 시스템 콜의 반환값은 정상적인 성공을 가리키므로, 공격자 입장에서는 파이프에 쓴 것처럼 보이지만 실제로는 파일을 수정한 것입니다.

이 취약점은 임의 코드 실행 익스플로잇을 필요로 하지 않습니다 — 순수하게 문서화된 시스템 콜(open·pipe·write·splice·read)의 순서만으로 성립하는 논리적 결함입니다. 이 글에서는 실제 오프셋 계산이나 완성된 익스플로잇 코드는 싣지 않습니다.

5.3 미흡점 ↔ 공격 흐름 매핑

미흡점(§4)발화 지점(§5.2)여는 프리미티브→ 다음으로 잇는 것
W1 — flags 미초기화(copy_page_to_iter_pipe/push_pipe)8.2 ③ splice가 슬롯을 재사용하는 지점이전 write()가 남긴 CAN_MERGE 비트가 새 page_cache 버퍼로 상속읽기전용 파일의 페이지가 '병합 가능'으로 잘못 표시됨
W2 — 링 슬롯 재사용, 이전 값 미소거8.2 ①→② 사이(비우기 후 재사용 전 대기 구간)flags 필드의 이전 값이 물리 메모리에 그대로 존재W1이 이 값을 그대로 읽어 새 버퍼에 물려줌
W3 — 병합검사가 버퍼 출처(ops)를 확인하지 않음8.2 ④ pipe_write()의 병합 분기(461~467행)buf->page가 page_cache 소유인지 확인 없이 copy_page_from_iter()로 직접 복사읽기전용 파일의 페이지캐시가 그대로 덮어써짐 — 권한상승 경로 성립(8.2 ⑤)

06취약점 패치

lib/iov_iter.c·copy_page_to_iter_pipe() / push_pipe()·커밋 9d2231c5d74e13b2a0546fee6737ee4446017903 · 부모 e783362eb54cd99b2cac8b3a9aeac942e6f6ac07 · 2022-02-21, Max Kellermann
if (pipe_full(i_head, p_tail, pipe->max_usage)) return 0; buf->ops = &page_cache_pipe_buf_ops; buf->flags = 0; get_page(page); ── 같은 파일의 두 번째 지점: push_pipe() ── if (!page) break; buf->ops = &default_pipe_buf_ops; buf->flags = 0; buf->page = page;

§7 W1이 지목한 자리를 정확히 겨냥합니다 — 두 함수 다 슬롯을 재사용해 새 버퍼를 만들면서 ops·page·offset·len은 채우지만 flags만 빠뜨리고 있었습니다.

어떻게각 함수의 buf->ops 대입 바로 다음 줄에 buf->flags = 0; 한 줄씩만 추가했습니다. 새 기능이 아니라 원래 있어야 했던 초기화를 채운 것입니다 — 커밋 diff 전체가 1개 파일, +2/-0줄입니다. 커밋 메시지의 Fixes: 태그는 이 초기화 누락의 기원을 2016년 커밋 241699cd72a8('new iov_iter flavour: pipe-backed', 파이프 전용 iov_iter를 처음 도입한 커밋)로 지목합니다 — 다만 실제로 공격 가능해진 것은 이후 2020년 커밋 f6dd975583bd8ce088400648fd9819e4691c8958('pipe: merge anon_pipe_buf*_ops', Linux 5.8에 반영)가 익명 버퍼와 페이지캐시 버퍼의 병합 검사 로직을 통합하면서부터입니다. 확인 — 두 커밋 모두 GitHub에서 존재·날짜 직접 확인

비고'Dirty Pipe'라는 이름과 CVSS 7.8·CISA KEV 등재가 주는 무게에 비해, 패치 자체의 물리적 규모는 극히 작습니다(1개 파일, 2줄 추가) — 저희가 이전에 다룬 대형 리팩터형 패치들과 대비되는, '작은 초기화 누락 하나가 큰 취약점이 된다'는 사례를 그대로 보여줍니다.

07대응방안

§영향받는 버전 표에 따라 커널을 5.16.11·5.15.25·5.10.102 이상(또는 이후 릴리스, 배포판이 백포트한 커널 패키지)으로 올리는 것이 유일한 근본 해결입니다. kernel.org의 공식 ChangeLog에서 세 계열 모두 이 패치가 반영됐음을 직접 확인했습니다. 확인

08왜 이게 반복되는가

이 사건의 핵심은 재사용되는 메모리 구조체의 필드 하나를 새로 채울 때, '이전에 뭐가 들어있었는지'를 신경 쓰지 않았다는 것입니다. 파이프의 링 버퍼 슬롯은 계속 재사용되는 자리이고, 그 자리 위에 놓인 flags 필드는 이전 사용자가 남긴 값을 그대로 들고 있습니다. 이건 저희가 Jenkins CLI 분석(CVE-2024-23897)에서 본 '기본값이 어떤 신뢰 경계를 전제로 만들어졌는지 다시 묻지 않은 것'과 결이 비슷합니다 — 거기서는 라이브러리 기본값이 원격 노출 컨텍스트로 옮겨갔고, 여기서는 커널 소유 페이지를 위한 상태 표식이 파일 페이지캐시라는 전혀 다른 컨텍스트로 흘러 들어갔습니다. 둘 다 '값 자체'는 멀쩡한데 '그 값이 놓인 문맥'이 바뀌면서 안전 전제가 깨졌습니다.

2016년 Dirty COW(CVE-2016-5195, CVSS2 7.2)와도 결과는 같습니다 — 둘 다 일반 사용자가 읽기전용 파일을 덮어써 권한을 얻습니다. 다만 근본원인은 다릅니다. Dirty COW는 메모리 관리 코드(mm/gup.c)의 레이스 컨디션(경쟁 상태)이 원인이라 정교한 타이밍 제어가 필요했지만, Dirty Pipe는 순차적인 시스템 콜 호출만으로 재현되는 논리적 결함입니다 — '같은 결과, 다른 난이도'를 보여주는 좋은 대조군입니다.

패치 완전성 평가. 이 CVE에 저희 3질문 틀을 적용해 봅니다. ① 근본원인을 막았나: 확인 — 패치는 정확히 §7 W1이 지목한 두 초기화 누락 지점에 buf->flags = 0;을 추가했습니다. 증상(잘못된 쓰기 자체)이 아니라 원인(잔존값)을 직접 지우는 수정입니다. ② variant(다른 코드경로의 같은 결함)가 남았나: 공개된 조사에서 Dirty Pipe 자체를 우회하는 후속 취약점은 확인하지 못했습니다. 다만 '커널 구조체 필드의 미초기화·재사용 잔존값'이라는 결함 계열 자체는 이 CVE 하나로 끝나는 문제가 아니라, 커널처럼 메모리를 적극적으로 재사용하는 환경에서 반복될 수 있는 일반 패턴이라는 점은 짚어둘 가치가 있습니다. 분석 ③ silent patch(조용히 넘어간 패치)인가: 확인 — 아니다. CVE-2022-0847로 정식 등록되고 발견자가 상세 기술문서를 직접 공개했으며, 실제 악용이 확인돼 CISA KEV에 등재(2022-04-25)까지 됐습니다 — 오히려 공개 이후 가장 널리 알려진 커널 LPE 취약점 중 하나가 됐습니다.

이 패턴이 헌팅 신호로 남는 지점은, 파이프 외에도 커널 안에 '고정 크기 슬롯을 재사용하는 구조'가 여럿 있다는 사실입니다 — 소켓 버퍼, 페이지 할당자의 캐시 등. 그런 구조를 새로 채우는 함수마다 '모든 필드를 빠짐없이 초기화하는가'를 정적으로 훑어보는 것은, 저희가 이전 글([[patch-diff-flywheel-blog-to-cve]] 사고방식)에서 말한 '패치갭에서 파생하는 다음 취약점 후보'와 같은 결의 작업입니다.

이 틈은 두 줄로 닫혔지만, '재사용되는 메모리 위의 필드를 빠짐없이 초기화했는가'라는 질문은 파이프가 아닌 다른 자료구조에서도 똑같이 반복해서 물어야 합니다 — 그래서 저희는 이 CVE 번호가 아니라 이 질문 자체를 남깁니다.

09결론

CVE-2022-0847(Dirty Pipe)은 리눅스 커널의 파이프 버퍼를 새로 만드는 두 함수가 flags 필드를 초기화하지 않아, 이전 write()가 남긴 'CAN_MERGE' 표식이 splice()로 편입된 읽기전용 파일의 페이지캐시 버퍼로 그대로 상속되는 취약점입니다. CVSS 7.8(High), CWE-665로 등록됐고 커널 5.16.11·5.15.25·5.10.102로 패치됐습니다. 패치는 근본원인(초기화 누락)을 정확히 겨냥한 두 줄짜리 수정이었으며, 실제 악용이 확인돼 CISA KEV에 등재됐습니다.

참고 자료

이 글의 코드 발췌(lib/iov_iter.c의 copy_page_to_iter_pipe()·push_pipe(), fs/pipe.c의 pipe_write() 병합 검사·플래그 대입부, include/linux/pipe_fs_i.h의 PIPE_BUF_FLAG_CAN_MERGE 정의)는 torvalds/linux 오픈소스의 실제 소스를 raw.githubusercontent.com에서 패치 커밋(9d2231c5d74e13b2a0546fee6737ee4446017903)의 부모 커밋(e783362eb54cd99b2cac8b3a9aeac942e6f6ac07, 패치 직전 상태) 기준으로 직접 받아 라인 번호까지 대조했습니다. 패치 diff는 GitHub의 실제 .patch 엔드포인트에서 원문 그대로 받았고, kernel.org stable 트리(linux-5.16.y/5.15.y/5.10.y)의 공식 ChangeLog에서 같은 커밋이 5.16.11·5.15.25·5.10.102에 그대로 백포트됐음을 직접 확인했습니다. 5.8 도입 시점의 근거로 든 커밋(f6dd975583bd8ce088400648fd9819e4691c8958, 'pipe: merge anon_pipe_buf*_ops')도 GitHub API로 존재와 날짜(2020-05-20)를 직접 확인했습니다. CVSS·CWE·발행일·CISA KEV 등재일은 NVD API(JSON) 응답에서 직접 가져왔습니다. 다만 '공격 과정'(파이프를 채우고 비운 뒤 splice로 특정 파일 페이지를 편입시키는 정확한 순서, 오프셋·EOF 관련 제약조건)은 커널 소스를 저희가 직접 추적한 것이 아니라 발견자 Max Kellermann이 공개한 기술 문서(dirtypipe.cm4all.com)의 서술을 근거로 재구성한 것입니다 — 그 부분(공격 시나리오의 구체적 syscall 순서·제약조건 서술)은 원 발견자의 1차 공개 자료에 의존했음을 밝힙니다. pipe_write()의 병합 검사·flags 대입 코드(§7 W2·W3, §8.2 병합 지점)는 커널 소스에서 저희가 직접 확인했습니다.