Dirty COW 的演进 (1)

admin 2025年3月31日23:27:06评论4 views字数 15519阅读51分43秒阅读模式

【翻译】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

参考资料:

  1. https://web.git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=19be0eaffa3ac7d8eb6784ad9bdbc7d67ed8e619
  2. https://spectralops.io/blog/what-is-the-dirty-cow-exploit-and-how-to-prevent-it/
  3. https://github.com/dirtycow/dirtycow.github.io/wiki/VulnerabilityDetails

1. 概述

以下是 Dirty COW (CVE-2016-5195) 漏洞被触发的大致过程:

  1. 当通过 /proc/self/mem 访问内存时,错误会带有 FOLL_FORCE 标志。这个标志基本上告诉内核忽略读/写权限。
  2. 加载页面可以分为三个步骤:
    • [1] 加载 PTE,
    • [2] 检查是否需要 COW,
    • [3] 获取实际页面。
  3. 为了绕过只读文件的 COW 映射的写保护,内核会清除 FOLL_WRITE 标志 - 这个标志用于检查 PTE 的写权限。
  4. 现在,在清除 FOLL_WRITE 和获取页面之间,如果 PTE 同时被清零,内核将重试整个序列。但在重试时,FOLL_WRITE 已经被清除。
  5. 没有 FOLL_WRITE,当内核再次加载 PTE 时,它会直接映射到文件内容 - 即使内存操作实际上是写入操作。
  6. 结果,写入操作会直接进入文件,即使它最初是以只读权限映射的。

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, NULLfalse,                       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, 00); // <----------------    }}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(&current->mm->mmap_sem))return -EINTR;    } else {        down_read(&current->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)

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年3月31日23:27:06
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Dirty COW 的演进 (1)https://cn-sec.com/archives/3899954.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息