Home
System Hacking
🐄

Dirty COW(CVE-2016-5195)란?

Type
CVE
날짜
2026/07/06
종류
Kernel
Linux
1 more property

여담

Dirty COW는 이름은 많이 들어봤는데 막상 정리하려고 보면 mmap, COW, get_user_pages, /proc/self/mem이 한 번에 엮여 있어서 처음 볼 때 꽤 헷갈린다. 그래서 해당 글에서는 exploit code를 먼저 보는 것이 아니라, 커널이 어떤 흐름에서 잘못된 page를 잡게 되는지 위주로 정리하겠다.

Dirty COW(CVE-2016-5195)란?

일단 Dirty COW를 간단하게 말하면 Linux Kernel의 Copy-On-Write 처리 과정에서 발생한 Race Condition 취약점이다.
COWCopy-On-Write의 약자이고, Dirty COW는 여기서 COW 처리가 깨져서 원래 수정되면 안 되는 page가 dirty 되는 취약점이라고 보면 된다.
CVE 번호는 CVE-2016-5195이고, local user가 kernel memory subsystem의 race를 이용해서 권한 상승까지 이어갈 수 있다.
중요한 점은 이 취약점이 단순히 파일 권한 체크가 빠져서 생긴 문제가 아니라는 점이다.
파일은 read-only로 열려 있고, mapping도 MAP_PRIVATE로 잡혀 있는데, 특정 race가 성공하면 원본 file-backed page 쪽에 write 효과가 생긴다.

영향 범위

Vulnerability : CVE-2016-5195
Component : Linux Kernel memory subsystem
Keyword : COW, mmap, MAP_PRIVATE, madvise, get_user_pages, /proc/self/mem
Impact : Local Privilege Escalation
Affected : Linux Kernel 2.6.22 이후부터 2016년 patch 전까지

Copy-On-Write Background

Dirty COW를 보기 전에 Copy-On-Write를 먼저 봐야 한다.
Copy-On-Write는 말 그대로 쓰기 작업이 발생하기 전까지 복사를 미루는 방식이다.
예를 들어 여러 process가 같은 file-backed page를 read-only로 공유하고 있을 때, read만 하는 동안에는 같은 물리 page를 바라보게 둔다. 이후 누군가 write를 시도하면 그 순간 새로운 page를 만들고, 원본이 아니라 새로 복사된 private page에 write를 수행한다.
정상적인 흐름은 아래와 같다.
1.
file을 MAP_PRIVATE로 mapping 한다.
2.
mapping된 page는 처음에는 file-backed page를 바라본다.
3.
해당 주소에 write가 발생한다.
4.
page fault가 발생하고 kernel이 COW를 수행한다.
5.
새 anonymous page를 만든 뒤 그 page에 write한다.
즉, private mapping에서 write가 발생하면 원본 file page가 아니라 복사본이 dirty 되어야 한다.

MAP_PRIVATE

char *map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
C
복사
위와 같이 파일을 PROT_READ | MAP_PRIVATE로 mapping하면 user 입장에서는 파일 내용을 읽을 수 있다.
하지만 이 mapping은 private mapping이기 때문에 여기에 write가 발생한다고 해서 원본 파일에 반영되면 안 된다. 만약 write가 허용되는 mapping이라면 kernel은 COW를 통해 private copy를 만들고, 그 copy에 데이터를 써야 한다.
정리하면 MAP_PRIVATE의 핵심은 원본을 공유하되, 수정 시점에는 원본과 분리한다는 것이다.

Dirty COW 분석

Dirty COW는 아래의 두 동작을 동시에 반복하면서 race를 만든다.
1.
madvise(MADV_DONTNEED)로 mapping된 page를 계속 버리게 만든다.
2.
/proc/self/mem을 통해 같은 virtual address에 write를 계속 시도한다.
구조만 보면 아래와 같다.
Thread 1 : madvise(addr, size, MADV_DONTNEED) 반복 Thread 2 : /proc/self/mem을 통해 addr에 write 반복
Plain Text
복사
여기서 중요한 점은 madvise/proc/self/mem 둘 중 하나가 혼자서 취약한 것이 아니라는 점이다.
madvise는 kernel에게 해당 memory range에 대한 advice를 주는 정상 syscall이고, /proc/self/mem도 process의 virtual memory에 접근하기 위한 정상 interface이다.
하지만 이 둘이 COW 처리와 get_user_pages() 흐름 사이에서 강하게 race 되면 문제가 생긴다.

전체 흐름

일단 exploit의 큰 흐름을 먼저 정리하면 아래와 같다.
1.
read-only file을 MAP_PRIVATEmmap한다.
2.
한 thread에서 madvise(MADV_DONTNEED)를 계속 호출한다.
3.
다른 thread에서 /proc/self/mem으로 mapping 주소에 write를 계속 시도한다.
4.
kernel 내부에서 get_user_pages()가 해당 주소의 page를 잡는다.
5.
COW가 끝나기 전후의 timing이 꼬이면 file-backed page가 잡힌다.
6.
결과적으로 private copy가 아니라 원본 page cache가 dirty 될 수 있다.
이 취약점은 한 번에 성공하는 종류가 아니라 race가 맞을 때까지 계속 반복하는 방식이다.

madvise(MADV_DONTNEED)

madvise(MADV_DONTNEED)는 해당 주소 범위의 page가 더 이상 필요 없다고 kernel에 알려주는 역할을 한다.
while (1) { madvise(map, size, MADV_DONTNEED); }
C
복사
해당 syscall이 호출되면 kernel은 해당 VMA의 page table entry를 drop할 수 있다. 이후 다시 같은 주소에 접근하면 page fault가 다시 발생하고, file-backed page를 다시 가져오거나 COW 처리를 다시 진행하게 된다.
즉, Dirty COW에서 madvise는 직접 write를 하는 역할이 아니라 COW와 page fault가 반복적으로 발생하도록 timing을 흔드는 역할을 한다.

/proc/self/mem

다른 thread에서는 /proc/self/mem을 열고, mapping된 virtual address로 이동한 뒤 write를 반복한다.
int mem = open("/proc/self/mem", O_RDWR); lseek(mem, (off_t)map, SEEK_SET); write(mem, data, len);
C
복사
일반적인 user space write와 달리 /proc/self/mem 경로는 kernel 내부에서 target virtual address에 대응되는 page를 가져오는 흐름을 탄다.
이때 등장하는 함수가 get_user_pages() 계열 함수이다.

get_user_pages()

get_user_pages()는 user virtual address에 대응되는 page를 kernel에서 사용할 수 있도록 pinning 하는 함수이다.
Dirty COW에서 중요한 부분은 write 목적의 접근이다. /proc/self/mem으로 write를 하려면 kernel은 해당 주소의 page를 write 가능한 page처럼 가져와야 한다.
취약한 흐름에서는 대략 아래와 같은 상황이 만들어진다.
1.
get_user_pages()FOLL_WRITE를 가지고 page를 가져오려고 한다.
2.
해당 mapping은 MAP_PRIVATE이고 write 권한이 없으므로 COW fault가 발생한다.
3.
COW로 private page가 만들어지는 흐름이 진행된다.
4.
동시에 다른 thread가 madvise(MADV_DONTNEED)로 page table entry를 drop한다.
5.
retry 과정에서 FOLL_WRITE 의미가 약해진 상태로 file-backed page를 다시 잡을 수 있다.
6.
caller는 write를 계속 진행하고, 결과적으로 원본 page cache가 dirty 된다.

취약했던 코드 흐름

__get_user_pages()의 취약했던 흐름을 보면 VM_FAULT_WRITE가 발생한 뒤 FOLL_WRITE를 제거하고 retry하는 부분이 있다.
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) foll_flags &= ~FOLL_WRITE;
C
복사
해당 코드는 private mapping에서 COW fault가 발생했으니 이후에는 write flag를 빼고 다시 page를 잡아도 된다는 의도에 가깝다.
그런데 이 사이에 madvise가 page table entry를 날려버리면, retry 시점에 방금 COW로 만든 private page가 아니라 file-backed page를 다시 잡는 상황이 생길 수 있다.
정리하면 문제는 FOLL_WRITE를 제거하는 것 자체라기보다는, COW가 완료되었다고 가정한 상태와 실제 page table 상태가 race로 어긋난 것이다.

COW가 깨지는 지점

이제 실제로 어디서 COW가 깨지는지 kernel code 기준으로 따라가보자.
앞에서 말한 것처럼 Dirty COW는 파일을 open(..., O_WRONLY)로 열어서 permission check를 우회하는 취약점이 아니다. write는 /proc/self/mem을 통해 process의 virtual address에 들어가고, kernel 내부에서는 그 virtual address가 가리키는 struct page를 잡은 뒤 그 page에 data를 복사한다.
따라서 핵심 질문은 아래와 같다.
/proc/self/mem write 시점에 kernel이 잡은 page가 private COW page인가, 아니면 원본 file-backed page인가?
정상이라면 무조건 private COW page가 잡혀야 한다. Dirty COW에서는 race 때문에 이 경계가 깨진다.

/proc/self/mem write path

먼저 /proc/self/mem에 write했을 때 어디로 들어가는지 보자.
fs/proc/base.c 기준으로 mem_write()mem_rw()write=1로 호출한다.
static ssize_t mem_write(...) { return mem_rw(..., 1); }
C
복사
mem_rw() 안에서는 user가 넘긴 data를 임시 kernel page로 복사한 뒤, target process의 virtual address에 대해 access_remote_vm()을 호출한다.
if (write && copy_from_user(page, buf, this_len)) break; this_len = access_remote_vm(mm, addr, page, this_len, write);
C
복사
여기서 addr은 exploit에서 lseek()로 맞춰둔 mmap address이다. 즉, 이 흐름은 원본 파일 fd에 write하는 것이 아니라 아래처럼 process memory에 write하는 형태이다.
/proc/self/mem write -> mem_write() -> mem_rw(write=1) -> access_remote_vm(mm, mmap_address, kernel_buffer, len, write=1)
Plain Text
복사
이 지점까지는 아직 file-backed page를 건드린다고 볼 수 없다. 아직은 단순히 특정 virtual address에 write하려는 요청이 kernel 내부로 들어온 것뿐이다.

access_remote_vm()

다음은 mm/memory.c__access_remote_vm() 흐름이다. 여기서 중요한 부분은 get_user_pages_remote()로 page를 가져온 뒤, 가져온 page에 실제 copy를 수행한다는 점이다.
ret = get_user_pages_remote(tsk, mm, addr, 1, write, 1, &page, &vma); copy_to_user_page(vma, page, addr, ...); set_page_dirty_lock(page);
C
복사
여기서 get_user_pages_remote()의 인자로 write=1, force=1이 들어간다. /proc/self/mem은 ptrace 계열 접근처럼 대상 mapping의 일반적인 user permission보다 강한 방식으로 page를 가져오려고 하기 때문에 FOLL_WRITEFOLL_FORCE 성격이 같이 들어간다고 보면 된다.
중요한 부분은 copy_to_user_page()가 어떤 page에 쓰는지 직접 판단하지 않는다는 점이다. 이미 get_user_pages_remote()가 반환한 page를 믿고 그 page에 data를 복사한다.
정상적인 경우라면 이 page는 COW로 새로 만들어진 anonymous page여야 한다.
MAP_PRIVATE file mapping -> write 목적 GUP -> COW fault -> anonymous private page 생성 -> GUP가 anonymous page pinning -> copy_to_user_page()는 anonymous page에 write
Plain Text
복사
그런데 race가 성공하면 get_user_pages_remote()가 anonymous page가 아니라 원래 file 내용을 담고 있던 page cache page를 반환할 수 있다. 그러면 뒤의 copy_to_user_page()set_page_dirty_lock()은 그대로 그 file-backed page를 수정하고 dirty로 표시한다.
Dirty COW에서 "write 효과가 원본 파일에 생긴다"는 말은, 파일 fd에 직접 write한다는 뜻이 아니라 get_user_pages()가 잘못 잡은 file-backed page에 access_remote_vm()의 write가 계속 진행된다는 뜻이다.

follow_page_pte()

그럼 get_user_pages()는 page를 어떻게 잡을까?
취약했던 흐름에서 먼저 봐야 하는 부분은 mm/gup.cfollow_page_pte()이다. 이 함수는 현재 page table entry를 보고 해당 virtual address가 어떤 struct page를 가리키는지 따라간다.
write 목적으로 page를 가져오는 경우에는 PTE가 writable인지 확인한다.
if ((flags & FOLL_WRITE) && !pte_write(pte)) return NULL;
C
복사
이 조건의 의미는 단순하다.
FOLL_WRITE가 있다.
그런데 현재 PTE가 writable 하지 않다.
그러면 이 page를 그냥 잡으면 안 된다.
일단 NULL을 반환해서 fault 처리 쪽으로 넘긴다.
MAP_PRIVATE | PROT_READ로 mapping된 file-backed page는 일반적으로 read-only PTE로 들어온다. 따라서 write 목적 GUP가 이 page를 바로 잡으면 안 되고, COW fault를 거쳐야 한다.
정상 흐름은 아래와 같다.
follow_page_pte(FOLL_WRITE) -> file-backed PTE는 writable 하지 않음 -> return NULL -> faultin_page() -> handle_mm_fault(FAULT_FLAG_WRITE) -> COW 발생
Plain Text
복사
여기까지는 문제가 없다. 오히려 여기서 바로 file-backed page를 잡지 않기 때문에 COW가 보장된다.

faultin_page()

follow_page_pte()가 page를 바로 잡지 못하면 GUP는 faultin_page()를 통해 page fault를 유도한다.
FOLL_WRITE가 설정되어 있으면 FAULT_FLAG_WRITE를 세우고 handle_mm_fault()로 들어간다.
if (*flags & FOLL_WRITE) fault_flags |= FAULT_FLAG_WRITE; ret = handle_mm_fault(vma, address, fault_flags);
C
복사
handle_mm_fault()는 해당 access가 write fault라는 것을 보고 COW를 처리한다. 즉, file-backed page를 그대로 writable하게 만드는 것이 아니라 private anonymous page를 만들고, 원본 내용을 복사한 뒤, PTE가 그 private page를 가리키도록 바꾸는 흐름이다.
문제는 그 다음에 있던 코드이다.
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) *flags &= ~FOLL_WRITE;
C
복사
이 코드는 COW fault가 발생했고, VMA 자체는 writable mapping이 아닐 때 FOLL_WRITE를 제거한다.
의도만 보면 이해는 된다. /proc/self/mem처럼 FOLL_FORCE로 read-only private mapping에 write하려는 상황에서는, COW가 한 번 끝났다면 이후 retry에서 writable PTE를 엄격히 요구하지 않아도 된다고 본 것이다.
하지만 이 판단은 방금 만들어진 COW page가 retry 시점에도 그대로 남아 있다는 전제를 깔고 있다.
Dirty COW에서는 바로 이 전제가 깨진다.

madvise가 끼어드는 위치

다른 thread는 계속 madvise(MADV_DONTNEED)를 호출한다.
while (1) { madvise(map, size, MADV_DONTNEED); }
C
복사
MADV_DONTNEED는 이 mapping 범위의 PTE를 버릴 수 있게 만든다. 그래서 /proc/self/mem thread가 COW fault를 일으켜 private page를 만들고 있는 사이, madvise thread가 그 PTE를 drop할 수 있다.
순서를 조금 더 구체적으로 쓰면 아래와 같다.
Thread A: /proc/self/mem write 1. get_user_pages_remote(write=1, force=1) 2. follow_page_pte(FOLL_WRITE) 3. read-only file-backed PTE라서 page를 바로 잡지 않음 4. faultin_page() 5. handle_mm_fault(FAULT_FLAG_WRITE)로 COW 진행 6. VM_FAULT_WRITE를 보고 FOLL_WRITE 제거 Thread B: madvise(MADV_DONTNEED) 7. 방금 COW로 만들어진 PTE를 drop Thread A: GUP retry 8. FOLL_WRITE가 제거된 상태로 다시 page table을 탐색 9. pte_write() 검사를 더 이상 강제하지 않음 10. file-backed page를 다시 fault-in하거나 그대로 따라감 11. get_user_pages가 file-backed page를 pinning 12. access_remote_vm()의 write가 그 page에 계속 진행
Plain Text
복사
여기서 핵심은 madvise가 직접 write를 하는 것이 아니라는 점이다. madvise는 COW로 만들어진 mapping 상태를 계속 흔들어서, GUP retry가 "COW가 끝난 private page"를 보는 대신 "다시 들어온 file-backed page"를 보게 만드는 역할을 한다.

왜 file-backed page가 dirty 되는가

file-backed page는 파일 내용이 page cache에 올라온 page이다.
파일을 mmap()하면 process의 virtual address가 디스크 파일을 직접 가리키는 것이 아니라, kernel의 page cache에 올라온 file page를 가리키게 된다.
user virtual address -> PTE -> struct page -> page cache page -> backing file offset
Plain Text
복사
MAP_PRIVATE에서는 이 page cache page를 처음에는 공유해서 읽다가, write가 발생하면 anonymous page로 분리해야 한다. 그래서 정상적인 COW에서는 write 대상이 아래처럼 바뀐다.
before write: virtual address -> file-backed page cache page after COW: virtual address -> anonymous private page
Plain Text
복사
Dirty COW에서는 GUP retry 시점에 FOLL_WRITE가 빠진 상태가 되면서, follow_page_pte()가 writable PTE를 요구하지 않고 page를 따라갈 수 있다. 이때 잡힌 page가 file-backed page라면 이후 write path는 이미 멈추지 않는다.
get_user_pages_remote() -> file-backed page 반환 access_remote_vm() -> copy_to_user_page(file-backed page, data) -> set_page_dirty_lock(file-backed page)
Plain Text
복사
결국 user process 입장에서는 private mapping에 write한 것처럼 보이지만, kernel 내부에서는 page cache에 있는 file-backed page가 dirty 된다. 이 상태가 되면 같은 file을 보는 다른 mapping에서도 바뀐 내용을 볼 수 있고, 조건이 맞으면 writeback을 통해 backing file에도 반영될 수 있다.
여기서 중요한 점은 filesystem permission check를 건너뛰고 파일을 직접 수정한 것이 아니라는 점이다. 이미 메모리에 올라온 file-backed page를 COW로 분리해야 했는데, GUP와 madvise의 race 때문에 그 분리 경계가 깨진 것이다.

한 줄로 보면

정상적인 COW는 아래처럼 끝나야 한다.
write fault -> allocate anonymous page -> copy original file page -> update PTE to anonymous page -> write to anonymous page
Plain Text
복사
Dirty COW에서는 취약한 kernel에서 아래 흐름이 가능했다.
write fault -> COW 처리 -> VM_FAULT_WRITE 이후 FOLL_WRITE 제거 -> madvise가 COW PTE drop -> GUP retry -> writable 검사가 약해진 상태로 file-backed page pinning -> access_remote_vm write continues -> page cache dirty
Plain Text
복사
즉, 깨지는 지점은 copy_to_user_page() 자체가 아니라, 그 전에 get_user_pages()가 어떤 page를 write 대상으로 잡았는지이다.

Patch

Dirty COW patch의 핵심은 madvise를 막거나 /proc/self/mem을 막는 것이 아니다.
문제는 GUP 내부에서 FOLL_WRITE를 제거해버린 뒤, retry 시점에도 "아까 COW가 끝난 page를 보고 있을 것"이라고 가정한 데 있었다. 그래서 patch는 이 racy한 FOLL_WRITE 제거 흐름을 없애고, COW가 실제로 유효한 상태인지 확인하는 방식으로 바뀐다.
관련 patch는 아래 흐름이다.
먼저 새로운 내부 flag가 추가된다.
#define FOLL_COW 0x4000
C
복사
이 flag의 의미는 "write 목적 자체를 없앤다"가 아니라, "COW fault를 한 번 통과했다"는 상태를 따로 표시하는 것이다.
그 다음 follow_page_pte()에서 writable PTE를 판단하는 조건이 바뀐다.
return pte_write(pte) || ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
C
복사
이 조건은 아래처럼 읽으면 된다.
1.
PTE가 실제로 writable이면 따라가도 된다.
2.
또는 FOLL_FORCE 접근이고,
3.
COW를 한 번 거쳤다는 FOLL_COW가 있고,
4.
현재 PTE가 dirty이면 따라가도 된다.
즉, 단순히 "COW를 한 번 했으니까 괜찮겠지"가 아니라 현재 보고 있는 PTE가 COW 이후 dirty 상태인지까지 확인한다.
그리고 기존에 FOLL_WRITE를 제거하던 부분은 FOLL_COW를 세우는 방식으로 바뀐다.
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) *flags |= FOLL_COW;
C
복사
이 변화가 중요한 이유는 FOLL_WRITE의 의미를 계속 유지하기 때문이다.
old : COW 이후 FOLL_WRITE를 제거한다.
new : FOLL_WRITE는 유지하고, COW를 거쳤다는 사실만 FOLL_COW로 추가 표시한다.
그래서 retry 중에 madvise가 PTE를 drop해서 file-backed page가 다시 보이더라도, 그 PTE가 COW 이후 dirty page라는 검증을 통과하지 못하면 GUP가 함부로 page를 pinning하지 못한다.

patch 이후 관점

patch 이후의 관점으로 다시 보면, Dirty COW의 본질은 아래 조건이 동시에 맞물린 것이다.
1.
MAP_PRIVATE file mapping이다.
2.
mapping은 user 관점에서는 read-only이다.
3.
/proc/self/memFOLL_FORCE | FOLL_WRITE 성격으로 해당 주소에 write를 시도한다.
4.
GUP가 COW fault를 일으킨다.
5.
취약한 kernel은 VM_FAULT_WRITE 이후 FOLL_WRITE를 제거한다.
6.
madvise(MADV_DONTNEED)가 그 사이 PTE를 drop한다.
7.
retry에서 file-backed page가 write 대상으로 잡힌다.
patch는 5번을 바꾼다. write 접근이라는 의미를 지우지 않고, COW가 일어났다는 사실만 따로 기록한 뒤, retry 시점의 PTE가 정말 COW 이후 page인지 dirty bit로 다시 확인한다.

정리

Dirty COW를 read-only 파일을 수정할 수 있는 취약점이라고만 보면 핵심이 흐려진다.
정확히는 MAP_PRIVATE에서 보장되어야 하는 COW 분리get_user_pages() retry race 때문에 깨진 취약점이다.
전체 흐름을 kernel code 기준으로 다시 줄이면 아래와 같다.
/proc/self/mem -> mem_write() -> mem_rw(write=1) -> access_remote_vm() -> get_user_pages_remote(write=1, force=1) -> follow_page_pte(FOLL_WRITE) -> faultin_page() -> handle_mm_fault(FAULT_FLAG_WRITE) -> COW -> vulnerable path removes FOLL_WRITE -> madvise drops PTE -> GUP retry pins file-backed page -> copy_to_user_page() -> set_page_dirty_lock()
Plain Text
복사
여기서 실제 write를 수행하는 쪽은 access_remote_vm()copy_to_user_page()이다. 하지만 취약점의 핵심은 그 함수가 아니라, 그 함수에 전달된 page가 private anonymous page가 아니라 file-backed page가 될 수 있었다는 점이다.
그래서 Dirty COW는 syscall 하나의 단순 permission bypass라기보다는 mmap, page fault, COW, GUP, madvise, page cache가 만나는 경계에서 생긴 race condition으로 보는 게 맞다.
일단 이번 글에서는 exploit payload보다는 왜 COW가 깨지는지에 초점을 맞춰서 정리하고 마치겠다.

Reference