【翻译】The Evolution of Dirty COW (1)
-
第一部分:Dirty COW 的演进 (1) -
第二部分:Dirty COW 的演进 (2)
多年来,Linux 内核一直存在与内存管理 (mm) 相关的漏洞。其中最著名的就是 Dirty COW。自那以后,研究人员发现了许多源于相同问题的类似漏洞,这些漏洞出现在 huge pages 和 shared memory 等领域。
尽管 mm 子系统随着时间的推移经历了许多变化和改进,但重新审视这些经典漏洞仍然非常有用。
在这篇文章中,我将深入探讨 Dirty COW 的根本原因,并分享我在研究过程中的思考。在接下来的文章中,我将进一步详细分析两个相关漏洞:Huge Page Dirty COW 和 Shared Memory Dirty COW。
参考资料:
https://web.git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=19be0eaffa3ac7d8eb6784ad9bdbc7d67ed8e619 https://spectralops.io/blog/what-is-the-dirty-cow-exploit-and-how-to-prevent-it/ https://github.com/dirtycow/dirtycow.github.io/wiki/VulnerabilityDetails
1. 概述
以下是 Dirty COW (CVE-2016-5195) 漏洞被触发的大致过程:
-
当通过 /proc/self/mem
访问内存时,错误会带有FOLL_FORCE
标志。这个标志基本上告诉内核忽略读/写权限。 -
加载页面可以分为三个步骤: -
[1] 加载 PTE, -
[2] 检查是否需要 COW, -
[3] 获取实际页面。 -
为了绕过只读文件的 COW 映射的写保护,内核会清除 FOLL_WRITE
标志 - 这个标志用于检查 PTE 的写权限。 -
现在,在清除 FOLL_WRITE
和获取页面之间,如果 PTE 同时被清零,内核将重试整个序列。但在重试时,FOLL_WRITE
已经被清除。 -
没有 FOLL_WRITE
,当内核再次加载 PTE 时,它会直接映射到文件内容 - 即使内存操作实际上是写入操作。 -
结果,写入操作会直接进入文件,即使它最初是以只读权限映射的。
2. 内存管理
2.1. 访问 /proc/self/mem
当尝试写入 /proc/self/mem
时,内核通过 mem_write()
函数 [1] 处理,然后调用 mem_rw()
[2]。
staticconststructpid_entrytgid_base_stuff[] = {// [...] REG("mem", S_IRUSR|S_IWUSR, proc_mem_operations),// [...]};staticconststructfile_operationsproc_mem_operations = {// [...] .write = mem_write, // [1]// [...]};staticssize_tmem_write(struct file *file, constchar __user *buf,size_t count, loff_t *ppos){return mem_rw(file, (char __user*)buf, count, ppos, 1/* write */);}
在 mem_rw()
函数中,关键点在于它设置了 FOLL_FORCE
标志[2]。这告诉内核:"让我访问这块内存,无论常规权限如何"。这个标志通常在使用 ptrace 或 /proc/self/mem
访问内存时使用。
由于内存可能属于另一个进程,内核使用 access_remote_vm()
[3] 来处理它。
staticssize_tmem_rw(struct file *file, char __user *buf,size_t count, loff_t *ppos, int write){structmm_struct *mm = file->private_data;// [...] flags = FOLL_FORCE | (write ? FOLL_WRITE : 0); // [2]while (count > 0) {size_t this_len = min_t(size_t, count, PAGE_SIZE); this_len = access_remote_vm(mm, addr, page, this_len, flags); // [3]// [...] }// [...]}
实际获取页面的工作发生在 get_user_pages_remote()
[4] 中,该函数最终会调用 __get_user_pages()
[5]。
需要注意的是,get_user_pages_remote()
函数是在持有 mm
的读锁 [6] 的情况下被调用的。由于读锁可以被多次获取,这可能会引入潜在的条件竞争。
intaccess_remote_vm(struct mm_struct *mm, unsignedlong addr,void *buf, int len, unsignedint gup_flags){return __access_remote_vm(mm, addr, buf, len, gup_flags); // <-----------------}int __access_remote_vm(struct mm_struct *mm, unsignedlong addr, void *buf,int len, unsignedint gup_flags){// [...] down_read(&mm->mmap_sem); // [6]// [...]while (len) {// [...] ret = get_user_pages_remote(tsk, mm, addr, 1/* write */, // [4] write, 1/* force */, &page, &vma);// [...] }// [...] up_read(&mm->mmap_sem);}longget_user_pages_remote(struct task_struct *tsk, struct mm_struct *mm,unsignedlong start, unsignedlong nr_pages,int write, int force, struct page **pages, struct vm_area_struct **vmas){return __get_user_pages_locked(tsk, mm, start, nr_pages, write, force, // <----------------- pages, vmas, NULL, false, FOLL_TOUCH | FOLL_REMOTE);}static __always_inline long __get_user_pages_locked(struct task_struct *tsk, struct mm_struct *mm,unsignedlong start,unsignedlong nr_pages,int write, int force, struct page **pages, struct vm_area_struct **vmas,int *locked, bool notify_drop,unsignedint flags){// [...]if (pages) flags |= FOLL_GET;if (write) flags |= FOLL_WRITE; // setif (force) flags |= FOLL_FORCE; // set// [...]for (;;) { ret = __get_user_pages(tsk, mm, start, nr_pages, flags, pages, // [5] vmas, locked);// [...] }// [...]}
在 __get_user_pages()
函数中,内核首先会找到对应的 VMA [7],然后使用 check_vma_flags()
检查请求的访问是否被允许 [8]。这里有一个技巧:如果你试图写入一个没有写权限(VM_WRITE
)的内存区域,访问通常会失败。但如果你使用了 FOLL_FORCE
并且该区域被标记为 COW,则访问会被允许 [9, 10]。
long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,unsignedlong start, unsignedlong nr_pages,unsignedint gup_flags, struct page **pages, struct vm_area_struct **vmas, int *nonblocking){// [...]do {unsignedint foll_flags = gup_flags;// [...]if (!vma || start >= vma->vm_end) { vma = find_extend_vma(mm, start); // [7], find the corresponding VMA// [...]if (!vma || check_vma_flags(vma, gup_flags)) // [8]return i ? : -EFAULT;// [...] }retry:// [...] page = follow_page_mask(vma, start, foll_flags, &page_mask);if (!page) {int ret; ret = faultin_page(tsk, vma, start, &foll_flags, nonblocking);switch (ret) {case0:goto retry; }// [...] } }// [...]}staticintcheck_vma_flags(struct vm_area_struct *vma, unsignedlong gup_flags){vm_flags_t vm_flags = vma->vm_flags;int write = (gup_flags & FOLL_WRITE);int foreign = (gup_flags & FOLL_REMOTE);// [...]if (write) {if (!(vm_flags & VM_WRITE)) {if (!(gup_flags & FOLL_FORCE)) // [9]return -EFAULT;if (!is_cow_mapping(vm_flags)) // [10]return -EFAULT; } }}staticinlineboolis_cow_mapping(vm_flags_t flags){return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;}
最后,follow_page_mask()
负责遍历页表以查找给定地址对应的 struct page
。
但如果该页面尚未被 fault 处理,它会返回 NULL
,此时内核会回退到 faultin_page()
来将该页面调入内存。
2.2. 首次页面访问
当内核需要首次加载一个页面时,它会调用 faultin_page()
,该函数最终会进入 handle_pte_fault()
[1] 来处理 PTE 的设置。
staticintfaultin_page(struct task_struct *tsk, struct vm_area_struct *vma,unsignedlong address, unsignedint *flags, int *nonblocking){unsignedint fault_flags = 0;vm_fault_t ret;// [...] ret = handle_mm_fault(vma, address, fault_flags /* FAULT_FLAG_WRITE | FAULT_FLAG_REMOTE */); // <----------------// [...]if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) *flags &= ~FOLL_WRITE;return0;}inthandle_mm_fault(struct vm_area_struct *vma, unsignedlong address,unsignedint flags){// [...] ret = __handle_mm_fault(vma, address, flags); // <----------------// [...]}staticint __handle_mm_fault(struct vm_area_struct *vma, unsignedlong address,unsignedint flags){structfault_envfe = { .vma = vma, .address = address, .flags = flags, };// ... handle pgd, pud and pmdreturn handle_pte_fault(&fe); // [1]}
在 handle_pte_fault()
中,如果页表尚未为该地址设置有效的 PTE [2],则会进入 do_fault()
以实际调入该页面 [3]。
staticinthandle_pte_fault(struct fault_env *fe){if (unlikely(pmd_none(*fe->pmd))) { // [2] fe->pte = NULL; } else {// [...] }if (!fe->pte) {// [...]elsereturn do_fault(fe); // [3] }// [...]}
如果内存是 COW 映射,do_fault()
将会调用 do_cow_fault()
,而 do_cow_fault()
又会调用 __do_fault()
[4] 来加载实际的页面。之后,原始内存内容会被复制到一个新创建的页面 [5],该页面将作为 COW 页面。最后,PTE 会被更新以指向新页面 [6]。
staticintdo_fault(struct fault_env *fe){structvm_area_struct *vma = fe->vma;// [...]if (!(vma->vm_flags & VM_SHARED))return do_cow_fault(fe, pgoff); // <----------------// [...]}staticintdo_cow_fault(struct fault_env *fe, pgoff_t pgoff){structvm_area_struct *vma = fe->vma;// [...] new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, fe->address);// [...] ret = __do_fault(fe, pgoff, new_page, &fault_page, &fault_entry); // [4] copy_user_highpage(new_page, fault_page, fe->address, vma); // [5]// [...] ret |= alloc_set_pte(fe, memcg, new_page); // [6]// [...]return ret;}
如果这是一个文件映射,内核将会调用文件的 fault handler,filemap_fault()
[7],来获取映射文件的 memory page 用于 COW。
staticint __do_fault(struct fault_env *fe, pgoff_t pgoff, struct page *cow_page, struct page **page, void **entry){structvm_area_struct *vma = fe->vma;// [...] ret = vma->vm_ops->fault(vma, &vmf); // [7], `filemap_fault()`// [...] *page = vmf.page;return ret;}
简而言之,当第一次调用 faultin_page()
时,内核实际上并不会检查访问是读操作还是写操作。它只是设置 PTE 指向 COW 页面。
2.3. 第二次页面访问
由于在第一次调用 faultin_page()
时没有进行权限检查,它直接返回 0,然后流程跳转回重试 follow_page_mask()
。
long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,unsignedlong start, unsignedlong nr_pages,unsignedint gup_flags, struct page **pages, struct vm_area_struct **vmas, int *nonblocking){// [...]retry:// [...] page = follow_page_mask(vma, start, foll_flags, &page_mask);if (!page) {int ret; ret = faultin_page(tsk, vma, start, &foll_flags, nonblocking);switch (ret) {case0:goto retry; }// [...] }}
这一次,虽然存在一个有效的 PTE,但由于请求包含写操作(FOLL_WRITE
)且 PTE 未被标记为可写 [1],follow_page_mask()
仍然返回 NULL
[2]。
struct page *follow_page_mask(struct vm_area_struct *vma,unsignedlong address, unsignedint flags,unsignedint *page_mask){// [...]return follow_page_pte(vma, address, pmd, flags); // <----------------}static struct page *follow_page_pte(struct vm_area_struct *vma,unsignedlong address, pmd_t *pmd, unsignedint flags){// [...] ptep = pte_offset_map_lock(mm, pmd, address, &ptl); pte = *ptep;// [...]if ((flags & FOLL_WRITE) && !pte_write(pte)) { // [1] pte_unmap_unlock(ptep, ptl);returnNULL; // [2] }}
因此内核再次重试 faultin_page()
。但这一次,由于已经存在一个有效的 PTE,内核跳过了创建 PTE 的步骤,转而检查访问权限。
如果这是一个写错误 [3] 且 PTE 不允许写入 [4],它就会通过调用 do_wp_page()
来触发 COW 机制,以处理写保护页面。
staticinthandle_pte_fault(struct fault_env *fe){if (unlikely(pmd_none(*fe->pmd))) {// [...] } else { fe->pte = pte_offset_map(fe->pmd, fe->address); entry = *fe->pte; } fe->ptl = pte_lockptr(fe->vma->vm_mm, fe->pmd); spin_lock(fe->ptl);// [...]if (fe->flags & FAULT_FLAG_WRITE) { // [3]if (!pte_write(entry)) // [4]return do_wp_page(fe, entry);// [...] }// [...]}
在 do_wp_page()
函数中,如果这是一个匿名映射(即不是共享内存或文件支持的映射),它会调用 wp_page_reuse()
来处理该错误。该函数只是将页面标记为 dirty 和 accessed,并返回 VM_FAULT_WRITE
[5]。
staticintdo_wp_page(struct fault_env *fe, pte_t orig_pte) __releases(fe->ptl){// [...] old_page = vm_normal_page(vma, fe->address, orig_pte);if (PageAnon(old_page) && !PageKsm(old_page)) {// [...] unlock_page(old_page);return wp_page_reuse(fe, orig_pte, old_page, 0, 0); // <---------------- }}staticinlineintwp_page_reuse(struct fault_env *fe, pte_t orig_pte, struct page *page, int page_mkwrite, int dirty_shared) __releases(fe->ptl){// [...] entry = pte_mkyoung(orig_pte); // set `_PAGE_ACCESSED` pte_mkdirty(entry); // set `_PAGE_DIRTY` | `_PAGE_SOFT_DIRTY`// [...] pte_unmap_unlock(fe->pte, fe->ptl);return VM_FAULT_WRITE; // [5]}
当 faultin_page()
完成并看到 handle_mm_fault()
返回 VM_FAULT_WRITE
时,它就知道写操作是有效的。
然而,如果 VMA 没有 VM_WRITE
权限 [6],内核会简单地清除 FOLL_WRITE
标志 [7],从而允许后续流程将其视为读取操作。
staticintfaultin_page(struct task_struct *tsk, struct vm_area_struct *vma,unsignedlong address, unsignedint *flags, int *nonblocking){unsignedint fault_flags = 0;vm_fault_t ret;// [...] ret = handle_mm_fault(vma, address, fault_flags /* FAULT_FLAG_WRITE | FAULT_FLAG_REMOTE */);// [...]if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) // [6] *flags &= ~FOLL_WRITE; // [7]return0;}
Dirty COW 问题的核心在于:尽管这是一个写操作,但内核通过清除 FOLL_WRITE
标志将其降级为读操作,从而为条件竞争的发生创造了条件。
2.4. 第三次页面访问
由于 faultin_page()
再次返回 0,内核继续进行对 follow_page_mask()
的第三次调用。流程与之前相同 - 但这一次,由于 FOLL_WRITE
标志已被清除,follow_page_pte()
不再检查 PTE 是否可写 [1]。它只是直接获取页面并返回 [2]。
static struct page *follow_page_pte(struct vm_area_struct *vma,unsignedlong address, pmd_t *pmd, unsignedint flags){// [...] ptep = pte_offset_map_lock(mm, pmd, address, &ptl); pte = *ptep;// [...]if ((flags & FOLL_WRITE) && !pte_write(pte)) { // [1]// [...] }// [...] page = vm_normal_page(vma, address, pte);// [...] pte_unmap_unlock(ptep, ptl);return page; // [2]}
3. 漏洞分析
3.1. 内存建议
SYS_madvise
系统调用允许用户进程向内核提供关于如何使用内存的提示。根据提供的建议类型,内核会采取不同的操作。在系统调用开始时,内核会根据建议类型获取 current->mm
的读锁或写锁 [1]。
当建议类型为 MADV_DONTNEED
时,它告诉内核:"我暂时不会使用这段内存。" 这个提示由 madvise_dontneed()
函数处理 [2]。
SYSCALL_DEFINE3(madvise, unsignedlong, start, size_t, len_in, int, behavior){// [...] write = madvise_need_mmap_write(behavior); // [1], return 0 if `MADV_DONTNEED`if (write) {if (down_write_killable(¤t->mm->mmap_sem))return -EINTR; } else { down_read(¤t->mm->mmap_sem); }// [...] vma = find_vma_prev(current->mm, start, &prev);// [...]for (;;) {// [...] error = madvise_vma(vma, &prev, start, tmp, behavior); // <----------------// [...] }}staticlongmadvise_vma(struct vm_area_struct *vma, struct vm_area_struct **prev,unsignedlong start, unsignedlong end, int behavior){switch (behavior) {// [...]case MADV_DONTNEED:return madvise_dontneed(vma, prev, start, end); // [2]// [...] }}
在 madvise_dontneed()
函数中,内核使用 zap_page_range()
[3] 清除指定内存范围内的页面。如果进程稍后尝试再次访问这些页面,它们将根据需要被重新调入。
staticlongmadvise_dontneed(struct vm_area_struct *vma, struct vm_area_struct **prev,unsignedlong start, unsignedlong end){ *prev = vma;// [...] zap_page_range(vma, start, end - start, NULL); // [3]return0;}
Function zap_page_range()
walks through the VMAs that overlap with the given address range [4]. For each one, it eventually calls unmap_page_range()
[5] to zero out the page table entries.
voidzap_page_range(struct vm_area_struct *vma, unsignedlong start,unsignedlong size, struct zap_details *details){structmm_struct *mm = vma->vm_mm;structmmu_gathertlb;unsignedlong end = start + size;// [...]for ( ; vma && vma->vm_start < end; vma = vma->vm_next) unmap_single_vma(&tlb, vma, start, end, details); // [4]// [...]}staticvoidunmap_single_vma(struct mmu_gather *tlb, struct vm_area_struct *vma, unsignedlong start_addr,unsignedlong end_addr, struct zap_details *details){if (start != end) {// [...] unmap_page_range(tlb, vma, start, end, details); // [5], zero out page table }}
由于 sys_madvise(MADV_DONTNEED)
仅获取 mm
的读锁,因此可以并发地将这些 COW 页面清零。
3.2. 条件竞争
如果另一个线程在第二次和第三次页面访问之间清除了 PTE,操作将重新开始且没有 FOLL_WRITE
标志,因此它的行为将类似于读取访问而不是写入访问。
触发条件竞争的预期执行流程如下:
[Thread-1] [Thread-2]__get_user_pages follow_page_mask -> no PTE faultin_page -> faults in a new COW page with file content cond_resched -> context switch [...] follow_page_mask -> page lacks write permission faultin_page -> clears FOLL_WRITE flag (treat as read) cond_resched -> context switch [...] sys_madvise(MADV_DONTNEED) -> zeroes out the COW PTE [...] follow_page_mask -> no PTE faultin_page (w/o FOLL_WRITE) do_read_fault -> faults in a direct file mapping cond_resched -> context switch [...] follow_page_mask -> returns file-backed page
3.3. 读取错误
当 FOLL_WRITE
未设置时,内核将页面错误视为读取操作。在这种情况下,do_fault()
会将错误处理分派给 do_read_fault()
[1]。
staticintdo_fault(struct fault_env *fe){structvm_area_struct *vma = fe->vma;// [...]if (!(fe->flags & FAULT_FLAG_WRITE))return do_read_fault(fe, pgoff); // [1]// [...]}
在 do_read_fault()
函数内部,内核最终会调用文件系统的页面映射处理程序 [2]。虽然不同的文件系统可能以不同的方式实现这一点,但对于大多数常见的文件系统来说,vm_ops->map_pages
函数指向 filemap_map_pages()
。
该函数从 pagecache 中检索 映射页面,pagecache 是在挂载文件系统时设置的。
staticintdo_read_fault(struct fault_env *fe, pgoff_t pgoff){structvm_area_struct *vma = fe->vma;structpage *fault_page;int ret = 0;// [...]if (vma->vm_ops->map_pages && /* ... */) { ret = do_fault_around(fe, pgoff); // <----------------if (ret)return ret; }// [...]}staticintdo_fault_around(struct fault_env *fe, pgoff_t start_pgoff){// [...]if (pmd_none(*fe->pmd)) { fe->prealloc_pte = pte_alloc_one(fe->vma->vm_mm, fe->address);// [...] }// [...] fe->vma->vm_ops->map_pages(fe, start_pgoff, end_pgoff); // [2], `filemap_map_pages()`if (!pte_none(*fe->pte)) ret = VM_FAULT_NOPAGE; // 0x100// [...] fe->address = address; fe->pte = NULL;return ret;}
最终,PTE 指向的是文件映射页面而非 COW 页面,因此任何修改都会直接更新底层文件内容。
4. 补丁
在应用补丁后,内核在发生 COW 时不再清除 FOLL_WRITE
标志。相反,它引入了一个新的内部标志 FOLL_COW
。
+#define FOLL_COW 0x4000 /* internal GUP flag */@@ -412,7 +422,7 @@ staticintfaultin_page(struct task_struct *tsk, struct vm_area_struct *vma, * reCOWed by userspace write). */if((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))- *flags &= ~FOLL_WRITE;+ *flags |= FOLL_COW;return0;
在映射页面时,内核现在会执行更严格的检查。除了验证是否为 COW 场景外,它还会检查目标 PTE 是否被标记为脏,然后才允许写访问。
+staticinlineboolcan_follow_write_pte(pte_t pte, unsignedint flags)+{+ return pte_write(pte) ||+ ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));+}static struct page *follow_page_pte(struct vm_area_struct *vma,unsignedlong address, pmd_t *pmd, unsignedint flags){@@ -95,7 +105,7 @@ retry: }if ((flags & FOLL_NUMA) && pte_protnone(pte))goto no_page;- if ((flags & FOLL_WRITE) && !pte_write(pte)) {+ if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) { pte_unmap_unlock(ptep, ptl);returnNULL; }
5. 其他
在正常情况下,内核如何防止 private file mapping 中的 dirty pages 被写回文件?
查看 SYS_msync
的实现,我们可以看到只有当 VMA 设置了 VM_SHARED
标志时,数据才会被同步回文件[1]。换句话说,只有共享映射才会触发写回操作。
如果映射是使用 MAP_PRIVATE
创建的,VMA 上不会设置 VM_SHARED
标志 - 这意味着任何修改都只会保留在内存中,而不会触及底层文件。
SYSCALL_DEFINE3(msync, unsignedlong, start, size_t, len, int, flags){// [...]if ((flags & MS_SYNC) && file && (vma->vm_flags & VM_SHARED)) { // [1] get_file(file); mmap_read_unlock(mm); error = vfs_fsync_range(file, fstart, fend, 1); fput(file);// [...] mmap_read_lock(mm); vma = find_vma(mm, start); }// [...]}
原文始发于微信公众号(securitainment):Dirty COW 的演进 (1)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论