漏洞描述

漏洞编号:CVE-2016-5195

漏洞名称:脏牛(Dirty COW)

漏洞危害:低权限用户利用该漏洞技术可以在全版本Linux系统上实现本地提权

影响范围:Linux内核>=2.6.22(2007年发行)开始就受影响了,直到2016年10月18日才修复。

360 Vulpecker Team:Android 7.0最新的10月补丁安全级别的系统上测试过漏洞POC,确认Android受影响

 

Dirty COW的发现可谓是轰动,只要得到一个低权限帐号,就能轻松root,可谓是神洞.最近在研究条件竞争,特意学习了一下.

POCs

漏洞分析

脏牛(Dirty COW)名字来源于Linux的Copy on Write机制,该机制存在条件竞争漏洞,导致可以破坏私有只读内存映射。

一个低权限的本地用户能够利用此漏洞获取其他只读内存映射的写权限,有可能进一步导致某些Linux版本提权漏洞。

低权限用户可以利用该漏洞修改只读内存,进而执行任意代码获取Root权限。

get_user_page内核函数在处理Copy-on-Write(以下使用COW表示)的过程中,可能产出竞态条件造成COW过程被破坏,导致出现写数据到进程地址空间内只读内存区域的机会。当我们向带有MAP_PRIVATE标记的只读文件映射区域写数据时,会产生一个映射文件的复制(COW),对此区域的任何修改都不会写回原来的文件,如果上述的竞态条件发生,就能成功的写回原来的文件。比如我们修改su或者passwd程序就可以达到root的目的。

 

POC分析

POC的地址如下:[https://github.com/dirtycow/dirtycow.github.io/blob/master/dirtyc0w.c], 下面是POC关键部分的伪代码:

Main:
    fd = open(filename, O_RDONLY)
    fstat(fd, &st)
    map = mmap(NULL, st.st_size , PROT_READ, MAP_PRIVATE, fd, 0)
    start Thread1
    start Thread2
    
Thread1:
    f = open("/proc/self/mem", O_RDWR)
    while (1):
        lseek(f, map, SEEK_SET)
        write(f, shellcode, strlen(shellcode))
        
Thread2:
    while (1):
        madvise(map100, MADV_DONTNEED)

首先打开我们需要修改的只读文件并使用MAP_PRIVATE标记映射文件到内存区域,然后启动两个线程:

其中一个线程向文件映射的内存区域写数据,这时内核采用COW机制。

另一个线程使用带MADV_DONTNEED参数的madvise系统调用将文件映射内存区域释放,达到干扰另一个线程的COW过程,产生竞态条件,当竞态条件发生时就能写入文件成功。

/proc/{pid}/mem是一个假的文件,它提供了一些Out-of-band的访问内存的方法。另一个类似的访问是调用ptrace(2),同样的,也可称为Dirty COW的另一个可选的攻击点。

在内核层面中,文件系统的操作的实现是利用面向对象的思想设计的(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,此时才是开始真正地访问远程进程内存。

//进行写操作
mem_write 
	    mem_rw  
     access_remote_vm
            __access_remote_vm        
                //用于获取页
                get_user_pages
                    __get_user_pages
                        retry:
                            follow_page_mask(...,flag,...);
                                //通过内存地址来找到内存页
                                follow_page_pte(...,flag,...);
                                     //如果获取页表项时要求页表项所指向的内存映射具有写权限,但是页表项所指向的内存并没有写权限。则会返回空
                                    if ((flags & FOLL_WRITE) && !pte_write(pte)) 
                                        return NULL
                                    ////获取页表项的请求不要求内存映射具有写权限的话会返回页表项
                                    return page
                            if (foll_flags & FOLL_WRITE)//要求页表项要具有写权限,所以FOLL_WRITE为1
                                fault_flags |= FAULT_FLAG_WRITE;
                            //获取页表项
                            if (!page) {
                                faultin_page(vma,...); //获取失败时会调用这个函数
                                     handle_mm_fault();
                                        __handle_mm_fault()
                                            handle_pte_fault()
                                                if (!fe->pte) 
                                                    do_fault(fe);
                                                        ////如果不要求目标内存具有写权限时导致缺页,内核不会执行COW操作产生副本,ers
                                                        if (!(fe->flags & FAULT_FLAG_WRITE))
                                                            do_read_fault(fe, pgoff);
                                                                __do_fault(fe, pgoff, NULL, &fault_page, NULL);
                                                                ret |= alloc_set_pte(fe, NULL, fault_page)
                                                                    //如果执行了COW,设置页表时会将页面标记为脏,但是不会标记为可写。
                                                                    if (fe->flags & FAULT_FLAG_WRITE)
                                                                        entry = maybe_mkwrite(pte_mkdirty(entry), vma);
                                                        //如果要求目标内存具有写权限时导致缺页,目标内存映射是一个VM_PRIVATE的映射,内核会执行COW操作产生副本
                                                        if (!(vma->vm_flags & VM_SHARED))
                                                            do_cow_fault(fe, pgoff);
                                                                new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, fe->address);
                                                                ret = __do_fault(fe, pgoff, new_page, &fault_page, &fault_entry);
                                                                copy_user_highpage(new_page, fault_page, fe->address, vma);
                                                                ret |= alloc_set_pte(fe, memcg, new_page);
                                                if (fe->flags & FAULT_FLAG_WRITE)
                                                    if (!pte_write(entry))
                                                        do_wp_page(fe, entry)//VM_FAULT_WRITE置1
                                                            if ((flags & FAULT_FLAG_WRITE) && reuse_swap_page(page))
                                                                maybe_mkwrite(pte_mkdirty(entry), vma);
                                                                if (likely(vma->vm_flags & VM_WRITE))
                                                                    pte_mkwrite(pte);
                                                                flags &= ~FAULT_FLAG_WRITE;
                                                                ret |= VM_FAULT_WRITE;
                                if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
                                    *flags &= ~FOLL_WRITE;
                          ,==0 goto retry        
        if (write)
          copy_to_user_page

Memory Map原理

内存映射就是把物理内存映射到进程的地址空间之内,这些应用程序就可以直接使用输入输出的地址空间.由此可以看出,使用内存映射文件处理存储于磁盘上的文件时,将不需要由应用程序对文件执行I/O操作,这意味着在对文件进行处理时将不必再为文件申请并分配缓存,所有的文件缓存操作均由系统直接管理,由于取消了将文件数据加载到内存、数据从内存到文件的回写以及释放内存块等步骤,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。

Linux下mmap的实现过程与普通文件io操作

mmap映射原理与过程

一般文件io操作方式

通过内存映射的方法访问硬盘上的文件,效率要比read和write系统调用高, read()是系统调用,其中进行了数据拷贝,它首先将文件内容从硬盘拷贝到内核空间的一个缓冲区,然后再将这些数据拷贝到用户空间,在这个过程中,实际上完成了 两次数据拷贝 ;而mmap()也是系统调用,如前所述,mmap()中没有进行数据拷贝,真正的数据拷贝是在缺页中断处理时进行的,由于mmap()将文件直接映射到用户空间,所以中断处理函数根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了 一次数据拷贝 。因此,内存映射的效率要比 read/write效率高。

 

Copy-on-Write(COW)

当我们用mmap去映射文件到内存区域时使用了MAP_PRIVATE标记,我们写文件时会写到COW机制产生的内存区域中,原文件不受影响。

第一次查找页

follow_page_mask

该函数用来通过进程虚拟地址沿着pgd、gud、gmd、pte一路查找page。因为是第一次访问映射的内存区域,此时页表是空的,返回NULL,然后外层函数进入faultin_page过程去调页。

static 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)
{
    // ...
    do {
        struct page *page;
        unsigned int foll_flags = gup_flags;
        // ...
        vma = find_extend_vma(mm, start);
        // ...  
         
retry:
        // ...
        cond_resched();
        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();
        }
        // ...
         
next_page:
        // ...
        nr_pages -= page_increm;
    } while (nr_pages);
    return i;
}

 

整个while循环的目的是获取请求页队列中的每个页,反复操作直到满足构建所有内存映射的需求,这也是retry标签的作用。

follow_page_mask读取页表来获取指定地址的物理页(同时通过PTE允许)或获取不满足需求的请求内容。在follow_page_mask操作中会获取PTE的spinlock-用来保护我们试图获取内容的物理页不被泄露。

faultin_page函数申请内存管理的权限(同样有PTE的spinlock保护)来处理目标地址中的错误信息。注意在成功调用faultin_page后,锁会自动释放-从而保证follow_page_mask能够成功进行下一次尝试,下面是我们使用时可能涉及到的代码。

faultin_page

该函数完成follow_page_mask找不到page的处理。第一次查找时页还不在内存中,首先设置FAULT_FLAG_WRITE标记,然后沿着handle_mm_fault -> __handle_mm_fault -> handle_pte_fault -> do_fault -> do_cow_fault分配页。

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;

    if (*flags & FOLL_WRITE)
        fault_flags |= FAULT_FLAG_WRITE; /* 标记失败的原因 WRITE */
    ...
    ret = handle_mm_fault(mm, vma, address, fault_flags); /* 第一次分配page并返回 0 */
    ...
    return 0;

}

static int handle_pte_fault(struct mm_struct *mm,
         struct vm_area_struct *vma, unsigned long address,
         pte_t *pte, pmd_t *pmd, unsigned int flags)
{
    if (!pte_present(entry))
        if (pte_none(entry))
            return do_fault(mm, vma, address, pte, pmd, flags, entry); /* page不在内存中,调页 */
}

static int do_fault(struct mm_struct *mm, struct vm_area_struct *vma,
    unsigned long address, pte_t *page_table, pmd_t *pmd,
    unsigned int flags, pte_t orig_pte)
{
    if (!(vma->vm_flags & VM_SHARED)) /* VM_PRIVATE模式,使用写时复制(COW)分配页 */
        return do_cow_fault(mm, vma, address, pmd, pgoff, flags,
                orig_pte);
}

static int do_cow_fault(struct mm_struct *mm, struct vm_area_struct *vma,
    unsigned long address, pmd_t *pmd,
    pgoff_t pgoff, unsigned int flags, pte_t orig_pte)
{
    new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address); /* 分配一个page */

    ret = __do_fault(vma, address, pgoff, flags, new_page, &fault_page,
         &fault_entry);

    do_set_pte(vma, address, new_page, pte, true, true); /* 设置new_page的PTE */
}

static int __do_fault(struct vm_area_struct *vma, unsigned long address,
        pgoff_t pgoff, unsigned int flags,
        struct page *cow_page, struct page **page,
        void **entry)
{
    ret = vma->vm_ops->fault(vma, &vmf);
}

void do_set_pte(struct vm_area_struct *vma, unsigned long address,
    struct page *page, pte_t *pte, bool write, bool anon)
{
    pte_t entry;

    flush_icache_page(vma, page);
    entry = mk_pte(page, vma->vm_page_prot);
    if (write)
        entry = maybe_mkwrite(pte_mkdirty(entry), vma); /* 带_RW_DIRTY,不带_PAGE_RW */
    if (anon) { /* anon = 1 */
        page_add_new_anon_rmap(page, vma, address, false);
    } else {
        inc_mm_counter_fast(vma->vm_mm, mm_counter_file(page));
        page_add_file_rmap(page);
    }

    set_pte_at(vma->vm_mm, address, pte, entry);
}

static inline pte_t maybe_mkwrite(pte_t pte, struct vm_area_struct *vma)
{
    if (likely(vma->vm_flags & VM_WRITE)) /* 因为是只读的,所以pte不带_PAGE_RW标记 */
        pte = pte_mkwrite(pte);
    return pte;
}

第二次查找

follow_page_mask会通过flag参数的FOLL_WRITE位是否为1判断要是否需要该页具有写权限,以及通过页表项的VM_WRITE位是否为1来判断该页是否可写。由于Mappedmem是以PROT_READ和MAP_PRIVATE的的形式进行映射的。所以VM_WRITE为0,而FOLL_WRITE为1,返回null,进而调用faultin_page函数,此时由于已经找到了页表,不再调用_do_fault,而是调用了do_wp_page,在do_wp_page中,将FAULT_FLAG_WRITE置0,同时,将ret的VM_FAULT_WRITE置1,表示已经执行过COW,在faultin_page之后的判断中,由于ret中VM_FAULT_WRITE置1,则flag的FOLL_WRITE置0,而FOLL_WRITE置0代表着也页表项不需要写权限。

进入faultin_page

struct page *follow_page_mask(...)
{
    return follow_page_pte(vma, address, pmd, flags);
}

static struct page *follow_page_pte(...)
{
    if ((flags & FOLL_WRITE) && !pte_write(pte)) { /* 查找可写的页,但是该页是只读的 */
        pte_unmap_unlock(ptep, ptl);
        return NULL;
    }
}

沿着函数调用路径faultin_page -> handle_mm_fault -> __handle_mm_fault -> handle_pte_fault一路找来,在handle_pte_fault中因为没有写访问权限,会进入do_wp_page函数中

static int handle_pte_fault(...)
{
    if (flags & FAULT_FLAG_WRITE) /* faultin_page函数开头设置了该标志 */
        if (!pte_write(entry))
            return do_wp_page(mm, vma, address, pte, pmd, ptl, entry);
}

FOLL_WRITE置0

static int faultin_page(...)
{
    ret = handle_mm_fault(mm, vma, address, fault_flags); /* 返回 VM_FAULT_WRITE */

    /* 去掉FOLL_WRITE标记, */
    if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
        *flags &= ~FOLL_WRITE;
    return 0;
}

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

第三次查找

 此时FOLL_WRITE flag已经被移除,follow_page_mask在下一次retry时,该访问将被视为只读的,尽管我们的目标是要写。现在,假如我们在同一时刻,COWed page被抛弃了通过另一个线程调用madvice(MADV_DONTNEED)会怎样?

madvise(MADV_DONTNEED)

madvise系统调用的作用是给系统对于内存使用的一些建议,MADV_DONTNEED参数告诉系统未来不访问该内存了,内核可以释放内存页了。内核函数madvise_dontneed中会移除指定范围内的用户空间page。

static long madvise_dontneed(struct vm_area_struct *vma,
                 struct vm_area_struct **prev,
                 unsigned long start, unsigned long end)
{
    ...
    zap_page_range(vma, start, end - start, NULL);
    return 0;
}

void zap_page_range(struct vm_area_struct *vma, unsigned long start,
    unsigned long size, struct zap_details *details)
{
    ...
    for ( ; vma && vma->vm_start < end; vma = vma->vm_next)
        unmap_single_vma(&tlb, vma, start, end, details);
    ...
}

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在这次尝试中会失败,进入dofault_page函数,此时的调用流程会和第一次有一定的区别,由于FAULT_FLAG_WRITE置0,所以直接执行do_read_fault。而do_read_fault函数调用了__do_fault,由于标志位的改变,此时直接与文件内存进行映射。

__do_fault部分代码如下

if ((flags & FAULT_FLAG_WRITE) && !(vma->vm_flags & VM_SHARED)) {
                 if (unlikely(anon_vma_prepare(vma)))
                       return VM_FAULT_OOM;
                 cow_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
                if (!cow_page)
                         return VM_FAULT_OOM;
                 if (mem_cgroup_newpage_charge(cow_page, mm, GFP_KERNEL)) {
                         page_cache_release(cow_page);
                         return VM_FAULT_OOM;
                 }
         } else
                cow_page = NULL;

if (flags & FAULT_FLAG_WRITE) {

     if (!(vma->vm_flags & VM_SHARED)) {
           page = cow_page;
                 anon = 1;
                 copy_user_highpage(page, vmf.page, address, vma);
                         __SetPageUptodate(page);
                 } else 
            ...
}

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来进行更多的处理。

机敏的读者可能已经注意到了,如果我们直接访问一个基于文件的只读映射,一个段错误将会产生。但是,为什么我们使用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 ... */}

为什么内核采用如此多步骤来提供这种Out-of-band的内存访问呢?为什么内核支持这种侵入式的访问,从一个进程来访问另一个进程的地址空间?
答案很简单,即使每个进程的地址空间是神圣的,私有性很强,等等。但是仍然需要调试器或别的侵入式的程序来有方法访问和获取一个进程的数据。这是一个了不起的实现,不然调试器从一个bug程序中如何设置断点和观察变量。

引用资料

  1. 11月4日:深入解读脏牛Linux本地提权漏洞(CVE-2016-5195)
  2. Dirty COW(CVE-2016-5195)漏洞分析

  3. 从内核角度分析Dirty Cow原理

  4. 深入分析CVE-2016-5195 Dirty Cow

  5. LMDB中的mmap、Copy On Write、MVCC深入理解——讲得非常好,常来看看!