Dirty COW(CVE-2016-5195)漏洞分析

CVE-2016-5195是一个几乎通杀全版本linux的本地提权的神洞,最近学习了Linux内核的一些调试方法和漏洞利用技术,但到目前为止做过的漏洞还都是比赛中的题目,都是驱动上的漏洞,没有做过实际漏洞的分析,这篇文章作为第一次对实际Linux Kernel漏洞的分析学习。

漏洞复现

为了增加学习的动力和乐趣,下面先对漏洞进行复现,看看它的威力:
我的环境:VirtualBox + Ubuntu 12.04 x86_64
这里的Ubuntu虚拟机是之前介绍VirtualBox调试内核时用到的,自己编译了linux-3.13的内核,因为据说一些发行版已经修复了该漏洞,后面的分析也是基于3.13进行的,不同版本的内核函数名和执行流程可能稍有不同,但原理相同。
先运行两个PoCdirtyc0w.ccowroot.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ su
# echo hellodirtycow > foo
# chmod 0404 foo
$ cat foo
hellodirtycow
$ echo howtoexploit > foo
bash: foo: Permission denied
$ gcc -pthread dirtyc0w.c -o dirtyc0w
$ ./dirtyc0w foo howtoexploit
mmap 7f0e2d982000
madvise 0
procselfmem 1200000000
$ cat foo
howtoexploitw

dirtyc0w.c这个PoC可以实现向任意可读文件写任意内容。

1
$ gcc -pthread cowroot.c -o cowroot

cowroot.c可以实现提权。

漏洞分析

CVE-2016-5195是一个内核竞态条件漏洞,影响范围:Linux Kernel > 2.6.22。
学习漏洞需要了解以下知识:

  • 写时拷贝(Copy on Write, COW)
  • 竞态条件
  • 页式内存管理
  • 缺页中断处理

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

dirtyc0w.c简要分析

前面用到的第一个PoC关键部分伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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(map, 100, MADV_DONTNEED)

首先打开需要修改的只读文件并使用MAP_PRIVATE标志映射文件到内存区域,然后启动两个线程:
线程1:向文件映射的内存区域写数据,这时内核采用COW机制。
线程2:使用带MADV_DONTNEED参数的madvise系统调用将文件映射内存区域释放,达到干扰线程1的COW过程,产生竞态条件,当竞态条件发生时就能成功写入文件。

漏洞原理分析

PoC代码中有一个写/proc/self/mem的线程,关键操作是lseekwrite函数。对应内核中的代码base.c

1
2
3
4
5
6
7
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
};

mem_write函数关键代码流程如下:

1
mem_write -> mem_rw -> access_remote_vm -> __access_remote_vm

__access_remote_vm函数中完成了数据的写操作,将应用层的数据写到目标位置,代码如下:

1
2
3
4
5
6
7
8
9
maddr = kmap(page);
if (write) {
copy_to_user_page(vma, page, addr,
maddr + offset, buf, bytes);
set_page_dirty_lock(page);
} else {
copy_from_user_page(vma, page, addr,
buf, maddr + offset, bytes);
}

可以看到,写操作由两步完成:
1. 将应用层传进的数据写到目标page中
2. 将page设置为脏页
很显然,这个page如何获取是这个漏洞成因的关键。page获取的代码也是在__access_remote_vm函数中:

1
2
ret = get_user_pages(tsk, mm, addr, 1,
write, 1, &page, &vma);

下面分析一下page获取代码get_user_pages函数的关键代码流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 {
cond_resched(); // 线程调度,产生多线程竞态条件的可能
while (!(page = follow_page_mask(vma, start,
foll_flags, &page_mask))) {
ret = handle_mm_fault(mm, vma, start, fault_flags);
}
} while (nr_pages && start < vma->vm_end);
...
}

__get_user_pages函数中有两个最关键的函数:follow_page_maskhandle_mm_fault函数。
follow_page_mask函数的作用是根据给的应用层地址addr(虚拟地址)去查找该addr映射到的内核内存页,如果查找到就返回该内存页的描述符struct page*。当应用层地址addr还没有映射到内核内存页时,该函数返回NULL。
handle_mm_fault函数的作用是使应用层地址addr与内核内存页之间建立联系。
PoC中用mmap去映射文件到内存区域时,使用了MAP_PRIVATE标志,写文件时会写到COW机制产生的内存区域中,原文件不受影响。其中获取用户进程内存页的过程如下:

  • 第一次调用follow_page_mask查找虚拟地址对应的page,带有FOLL_WRITE标志。因为所在page不在内存中,follow_page_mask返回NULL,会调用handle_mm_fault进行缺页处理,最终调用__do_fault函数,分配只读匿名内存页并建立映射关系。
  • 第二次循环,调用follow_page_mask,带有FOLL_WRITE标志。在权限检查时出错,代码如下。第二次进入handle_mm_fault函数,最终调用do_wp_page函数分配COW页,并在上级函数中去掉FOLL_WRITE标志。

    1
    2
    3
    4
    5
    6
    struct page *follow_page_mask(...)
    {

    ...
    if ((flags & FOLL_WRITE) && !pte_write(pte))
    goto unlock;
    }

  • 第三次循环,调用follow_page_mask,不带FOLL_WRITE标志,成功得到page,到这里复制过程就算完成了。

产生竞态条件
我们再来梳理一下写时复制的过程中调页的过程:

  • 第一次follow_page_mask(FOLL_WRITE),因为page不在内存中,进行缺页处理。
  • 第二次follow_page_mask(FOLL_WRITE),因为page没有写权限,并去掉FOLL_WRITE
  • 第三次follow_page_mask(无FOLL_WRITE),成功。

__get_user_pages函数中每次查找page前会先调用cond_resched()线程调度一下,这样就引入了竞态条件的可能性。在第二次分配COW页成功后,FOLL_WRITE标记已经去掉,如果此时,另一个线程把page释放了,那么第三次由于page不在内存中,又会进行调页处理,由于不带FOLL_WRITE标记,不会进行COW操作,此时get_user_pages得到的page带__PAGE_DIRTY,竞态条件就是这样产生的,流程如下:

  • 第一次follow_page_mask(FOLL_WRITE),page不在内存中,进行缺页处理。
  • 第二次follow_page_mask(FOLL_WRITE),page没有写权限,并去掉FOLL_WRITE
  • 另一个线程释放上一步分配的COW页
  • 第三次follow_page_mask(无FOLL_WRITE),page不在内存中,进行缺页处理。
  • 第四次follow_page_mask(无FOLL_WRITE),成功返回page,但没有使用COW机制。

漏洞修复

该漏洞的patch。现在不再是把FOLL_WRITE标记去掉,而是添加了一个FOLL_COW标志来表示获取一个COW分配的页。即使是竞态条件破坏了一次完整的获取页的过程,但是因为FOLL_WRITE标志还在,所以会重头开始分配一个COW页,从而保证该过程的完整性。
reference
【漏洞分析】11月4日:深入解读脏牛Linux本地提权漏洞(CVE-2016-5195)
【漏洞分析】CVE-2016-5195 Dirtycow: Linux内核提权漏洞分析
庖丁解牛