跳转至

Dirty COW

约 1863 个字 4 张图片 预计阅读时间 9 分钟

Background

Copy On Write

我们知道,一个父进程,如果它使用 fork() 创建了一个子进程,那么父进程会将它的内存空间复制一份给子进程。即父进程和子进程在虚拟内存方面是完全相同的,但是它们的物理内存空间是不同的,操作系统会将两块内存映射到物理内存空间的不同位置,从物理内存的角度来看,这样的操作似乎像是操作系统复制了一份内存空间并写到了另一片内存空间。

但是,我们知道,内存空间的写操作是非常耗时的,所以操作系统并不会真的复制一份内存空间,而是使用了一个叫做 Copy On Write 的技术。这样的技术让父进程和子进程映射到同一份物理内存空间,只有当父进程或子进程尝试写入内存时,操作系统才会复制一份需要写的内存空间给它们(并非将共享的内存空间全部复制一遍,需要写哪个部分就复制哪个部分,一般以页为单位,那么该页就变成了脏页)。这样就避免了不必要的内存复制,提高了性能。


Map File to Memory

我们有如下程序:

其中,最核心的操作是 mmap() 函数,它将一个文件映射到内存空间。在我们的程序当中,我们将文件的所有内容都映射到内存空间,并赋予其读写权限(需要注意的是,这里的读写权限必须和前面的 open() 函数对应,只有 open() 函数有读写权限我们才能在 mmap() 函数中赋予读写权限),这样,当我们运行程序的时候(也就是启动了一个进程),我们有自己的虚拟内存映射到文件从磁盘映射到的物理内存,同时操作系统会帮我们同步物理内存和磁盘当中的内容,我们就可以像操作内存一样操作文件内容了,同理如果文件内容改变,物理内存当中的内容也会改变。

mmap() 函数还有一个重要的参数 MAP_SHARED,它对应的是 MAP_PRIVATEMAP_SHARED 表示映射的内存空间是共享的,多个进程可以共享同一份内存空间,当某个进程修改了内存空间的内容,其他进程也会看到这个修改。而 MAP_PRIVATE 则会在内存空间复制一份一模一样的内容,但是并不会与磁盘文件同步,也不会影响其他进程的内存空间。此时,Copy On Write 技术就会起作用了,只有当使用 MAP_PRIVATE 的进程尝试写入内存空间时,操作系统才会复制一份需要写的内存空间给它。

当进程不再需要自己的这份 Private copy 时,可以使用 madvise() 函数来告诉操作系统不再需要这份内存空间,操作系统就会将这份内存空间释放掉,并将其映射到原来的物理内存空间上。


Map Read-Only File to Memory

如果我们将一个只读文件映射到内存空间,那么这个文件的内容是不能被修改的。因为操作系统会在映射的时候将文件的内容设置为只读权限,如果我们尝试修改这个文件的内容,操作系统就会拒绝我们的请求。

但是,如果我们使用 mmap() 函数将一个只读文件映射到内存空间,并且使用 MAP_PRIVATE 参数,那么操作系统会将这个文件的内容复制一份到内存空间,并且将这份内存空间设置为可写权限。这样,我们就可以修改这份内存空间的内容了,但是这并不会影响原来的文件内容。

Dirty COW 漏洞的目的就是尝试去修改只读文件的内容,这也就要涉及写内存的方法,我们知道一种最简单的方法是通过 memcpy 函数,提供地址就能写入内容,CPU 会首先检查这个地址是否是可写的,如果是可写的,就会将内容写入到这个地址对应的内存空间中。但是如果这个地址是只读的,那么 CPU 就会拒绝这个请求。CPU 的检查是非常严格的,它也不允许当前进程去修改其他进程的内存空间。

但是,当我们进行调试的时候,我们正是要通过当前进程(调试程序)去获取其他进程(运行程序)的内存,对于这种情况,操作系统提供了另一种方法,那就是通过 /proc 文件系统来访问其他进程的内存空间。我们可以通过读取 /proc/<pid>/mem 文件来获取其他进程的内存空间内容,我们可以将这个内存空间当作一个文件来处理,使用 read()write()lseek() 等操作系统的函数来操作它。

如上图,我们利用 MAP_PRIVATE 将只读文件映射到内存空间,然后通过 /proc/<pid>/mem 文件来访问这个内存空间。我们可以使用 lseek() 函数移动指针的位置,用 write() 函数将内容写入到指针所指的位置中,这样就可以修改只读文件的内容了。


The Dirty COW Vulnerability

如上面所描述的,我们已经成功可以修改只读文件的内容了,但是这并没有影响到最终原来的位于磁盘的文件内容,因为我们仅仅只是在修改通过 MAP_PRIVATE 得到的文件副本而已。在修改的过程当中,实现了 Copy On Write 的机制,操作系统并没有将修改的内容同步到原来的文件内容上。

但是,我们可以发现,修改的操作事实上分为 3 个步骤,复制需要修改的部分的内容、更改 Page Table 的映射以及最后的写操作。整个修改操作并不是原子性的,因此这里就出现了竞态条件漏洞。如果我们在更改 Page Table 的映射之后,插入一个将映射还原的操作(madvise 函数),那么我们就可以在写操作之前就指向原来的文件,从而修改源文件的内容了。

以上就是代码,我们启动两个线程,一个线程不断地写入内容,另一个线程不断地将映射还原。这样两者形成竞争条件,在某个点就有可能实现内容的修改,这使得我们可以修改 /etc/passwd 等敏感文件的内容从而获得 root 权限。

Question

或许有人会问,为什么我们启动的是两个线程(Thread)而不是两个进程(Process)?

答案也很简单,两个线程之间是可以共享同一份内存空间的,这样才能实现 Copy On Write 的机制。两个进程之间是无法共享内存空间的,它们各自有自己的虚拟内存空间,操作系统会将它们映射到不同的物理内存空间上。

评论