其实网上关于脏牛的文章分析已经很多,本文算是对调试学习该漏洞过程的一个记录
前置知识
写时拷贝
竞态条件
页式内存管理
缺页中断处理
基础知识
mmap函数
mmap(void* start, size_t length, int prot,int flags,int fd, off_t offset)
这个函数其实比较常用,它有一个很重要的用处就算将磁盘上的文件映射到虚拟内存中,对于这个函数唯一要说的就是当flags的MAP_PRIVATE被置为1时,对mmap得到内存映射进行的写操作会使内核触发COW操作,写的是COW后的内存,不会同步到磁盘的文件中
madvice函数
madvice(caddr_t addr, size_t len, int advice)
这个函数的主要用处是告诉内核内存addr~addr+len
在接下来的使用状况,以便内核进行一些进一步的内存管理操作。当advice为MADV_DONTNEED
时,此系统调用相当于通知内核addr~addr+len
的内存在接下来不再使用,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。
write函数
ssize_t write(int fd, const void* buf, size_t count)
这个函数也是一个常见函数,主要作用就是向fd描述符所指向的文件写入buf中最多count长度的内容。
mem文件
/proc/self/mem
该文件是一个指向当前进程的虚拟内存文件的文件,当前进程可以通过对这个文件进行读写以直接读写虚拟内存空间,并无视内存映射时的权限设置,也就是说我们可以利用写/proc/self/mem来改写不具有写权限的虚拟内存。可以这么做的原因是/proc/self/mem是一个文件,只要进程对该文件具有写权限,那就可以随便写这个文件,只不过对这个文件进行读写的时候需要一遍访问内存地址所需要的寻页的流程。因为这个文件指向的是虚拟内存。
环境准备
这里使用4.7.0的内核版本来复现
在环境中有一个run.sh
脚本来创建一个root用户,并且创建一个只读的文件foo
,并且往其中写入hello
的内容
漏洞分析
对于COW中的写操作,我们调用mem_write
函数来实现,下面就是该函数的调用链:
mem_write
-> mem_rw
-> access_remote_vm
-> __access_remote_vm
首先我们看到__access_remote_vm
函数中的这部分,如果是write
操作,就执行拷贝数据到page页中,并且设置脏页的操作,这里我们关心的是它的page是如何获得的。我们关注下执行这个操作前的上面的get_user_pages_remote
这个函数,这个函数就是获取接下来要写入数据的目标页。我们跟入这个函数分析,我们可以看到这个函数是对__get_user_pages_locked
这个函数的封装,继续跟入到__get_user_pages_locked
中。
可以如果是write的设置对应的flags位,接下来调用__get_user_pages
函数,我们跟入这个函数中。由于代码量过大,我们只需要关注重点需要关注的地方,也就是下面一部分中
首先我们看到一个cond_resched
函数,这个函数是一个线程调度的函数,正因为这个函数,才引入了我们条件竞争的可能。然后回调用follow_page_mask
函数来获得一个page
,如果没有正常返回一个page
的话,就会调用faultin_page
来进行缺页处理的操作。follow_page_mask
简单来说就是一个一级目录,二级目录等到页表的这么一个寻找的过程。
因为多线程调试较麻烦,所以我们修改下exp,用一个阉割版的exp来调试,这里删除掉了madvice
函数的操作。下图为多线程竞争的exp和阉割版的对比。
在阉割版exp中,在worker_write
函数前面加入了一个getchar
函数,相当于在触发COW操作前打了一个断点,方便我们调试。
首先这里我们先将地址随机化给关闭
echo 0 > /proc/sys/kernel/randomize_va_space
我们使用阉割版的exp来调试看看我们的COW机制到底发生了什么。
COW
我们在follow_pages_mask
函数前也就是mm/gup.c:573
处下一个断点后,remote上qemu,然后运行我们的阉割版exp。
可以看到我们已经在follow_pages_mask
函数前断了下来,这里我们重点关注当第一次调用follow_pages_mask
的时候发生了什么
当我们执行完follow_pages_mask
后,发现我们的page
此时是0,并且我们没法访问我们映射的虚拟地址
我们回到源码去看看这个0到底是如何返回的。我们直接看最后查找页表项的函数follow_page_pte
可以看到我们返回空是因为我们还没有给虚拟内存分配物理内存,所以跳到no_page
,在no_page
处就会调用no_page_table
函数返回空。所以这里我们知道了第一次调用follow_pages_mask
函数返回0的原因是因为我们的映射还是在虚拟内存中,并没有分配实际的物理内存,所以这里我们找不到page。
然后此时找不到page
就会进入缺页处理函数faultin_page
中
在上面的缺页处理函数中,我们可以看到这里有些进行错误标记的操作,就是比如说如果上面是因为没有写权限而来到了缺页处理函数,那么就会加上一个FAULT_FLAG_WRITE
标记,其他也是同样道理。然后在下方主要通过handle_mm_fault
函数来实现他的功能。此时,我们在这个函数下个断点来看下执行完缺页处理函数之后会发生什么。
可以看到此时我们的位置是在执行完了handle_mm_fault
之后,然后我们可以看到刚刚映射的没法访问的地址,现在已经可以访问了,说明此时我们已经分配到了实际的物理内存,并且里面的内容是就是foo
文件中的内容。我们再回到源码中看看handle_mm_fault
函数到底做了什么,在handle_mm_fault
函数中主要调用__handle_mm_fault
来实现功能,我们继续跟入,在里面主要的就是调用了handle_pte_fault
继续跟入。
这里因为没有分配实际的物理内存,所以我们会进入上面的if分支中,并且执行do_fault
这里我们之前已经设置好了要进行COW操作的标志,所以接下来就会调用do_cow_fault
来进行一个COW副本页的分配。
我们继续执行第二次follow_page_mask
函数的后,可以看到下图我们的物理内存已经分配了,但是我们的page还是0。
我们回到follow_page_mask
中,继续进入到follow_page_pte
中
我们可以知道此时我们已经分配了物理内存,所以最上面的if语句是不会进入的,我们看下面if 语句,就是判断内存是否具有写权限,如果没有的话,此时依旧是会返回空。我们前面说过了,我们要写的这个foo
文件此时是只有读权限的,所以这里就能够解释为什么我们分配了实际的物理内存之后,在第二次调用follow_page_mask
之后page
依旧是会返回空。接下来我们看第二次进入缺页处理函数faultin_page
的时候,它做了什么。
进入缺页处理函数,这里就会做一个标记,表示因为没有写权限而错误。做完了标记之后就会进入到handle_mm_fault
处理函数中。此时的调用链依旧像刚刚一样handle_mm_fault
-> __handle_mm_fault
-> handle_pte_fault
在handle_pte_fault
函数中,我们就会在上图的位置中做一个检测,因为我们前面已经做了一个因为没有写全写而错误的标志,然后就会调用do_wp_page
函数,这个函数前部分会判断是否已经通过COW分配到了一个副本页,然后会调用wp_page_reuse
函数来使用上一步分配好的副本页,这个函数调用后会返回一个标志,如下图
这个标志会作为handle_mm_fault
的返回给ret,然后会去做一个操作,如下图
这个操作就是去掉我们的FOLL_WRITE
标志,我们知道我们第二次进入缺页处理函数的原因是我们没有写权限,也就是FOLL_WRITE
这个标志导致我们出现错误,然后我们在第二次缺页处理的过程中,将这个标志去掉了。好了,我们接下来看第三次调用follow_page_mask
函数会发生什么。
可以看到当我们执行完第三次后,我们成功返回了一个page
,到此我们就完成了一个COW的流程。
总结
正常:
- 第一次调用
follow_page_mask(FOLL_WRITE)
函数,因为page
不在内存中,进行缺页处理 - 第一次调用
follow_page_mask(FOLL_WRITE)
函数,因为page
不具有写权限,并去掉FOLL_WRITE
- 第一次调用
follow_page_mask(无FOLL_WRITE)
函数,此时已经分配的真实的物理内存,并且无FOLL_WRITE
,成功
POC:
- 第一次调用
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机制,此时因为没有使用COW机制,所以会影响到原文件。
要点
- mmap函数的第四个参数指定映射的方式,包括map_shared和map_private,map_shared是指当多个线程将同一个文件映射到自己的虚拟地址中,它们共享同一物理内存块,而map_private则是将文件映射到进程的私有内存。这里重点讲解下map_private,这个参数指定将文件映射到进程的私有内存,假设此时有两个进程将同一文件映射到自己的虚拟内存地址,如果都是只读的,那么虚拟内存地址都将指向同一个物理内存块。如果一个进程试图写入数据,就会发生写时复制,将物理内存块复制一个副本,然后更新该进程的页表指向新的内存块,最后向物理内存块的副本中写入数据。
这里要注意的是,即使程序是以只读的方式来做内存映射,map_private允许程序通过write系统调用往物理内存块的副本中写入数据,这其实也为我们的利用创造了条件
- madvise函数的第三个参数为MADV_DONOTNEED告诉内核不在需要声明地址部分的内存,内核将释放该地址的资源,进程的页表会重新指向原始的物理内存。
场景
首先一个进程将只读文件映射到进程的虚拟内存地址中,当mmap指定参数为map_private
,尽管是只读的,仍然可以写入数据,只不过这时写到的是原始物理内存的副本。在写之前,会经历写时复制的三个过程,首先创建映射内存副本,然后更新页表,最后向副本写入数据。此时另一个线程在进行写时复制的进程的最后一步写入数据之前调用madvice(),那么进程的页表会重新指向原始的映射的内存物理块,那么此时进行写时复制的进程执行最后一部写入数据,此时写入的是原始内存,而不是副本中,那么就会只读文件写入数据。
漏洞利用思路
这里的基本思路就是在一个进程中创建两个线程,一个线程向只读的映射内存通过write系统调用写入数据,这时候发生写时复制,另一个线程通过madvice来丢弃映射的私有副本,两个线程相互竞争从而向只读文件写入数据。
主线程
- 普通用户身份以只读模式打开指定的只读文件
- 使用MAP_PRIVATE参数映射内存
- 找到目标文件映射的内存地址
- 创建两个线程
procselfmem线程
- 向文件映射的内存区域写数据
- 此时内核采用COW机制
madvise线程
- 使用MADV_DONTNEED参数调用madvise来释放文件映射内存区
- 干扰procselfmem线程的COW过程,产生竞争条件
- 当竞争条件发生时就能成功将数据写入文件
下面是利用成功的截图
可以看到我们已经将foo
文件中的hello
修改为了hacku