Linux高危漏洞Dirtycow复现
介绍
漏洞编号:CVE-2016-5195
漏洞详情:Linux内核的内存子系统在处理写时拷贝(Copy-on-Write) 时存在条件竞争漏洞
产生影响:可以破坏私有只读内存映射,获取低权限的本地用户后,利用此漏洞获取其他只读内存映射的写权限,进一步获取 root 权限。
前置知识
条件竞争
Race Condition,是指两个或多个进程或线程同时处理一个资源 (比如:全局变量、文件) 产生了非预期的效果,从而使程序执行流改变,进而达到攻击的目的。
需要的条件:
- 并发,即存在至少两个处于进行状态的执行流。
- 共享对象,即多个并发流会访问同一对象。常见的有共享内存,文件系统,信号。
- 改变对象,即至少有一个执行流会改变竞争对象的状态。因为如果程序只是对对象进行读操作,那么并不会产生条件竞争。
所以当竞争的共享对象是内存中变量时,条件竞争可以算作内存破坏漏洞。分析条件竞争就是分析两个并发的线程/进程在不断的对共享对象做什么。
Linux 写时拷贝技术(copy-on-write)
传统的 fork 函数会通过系统调用直接把所有的资源复制给新进程, 但这样效率低下。
所以出于效率,Linux 引用了“写时拷贝”的技术,它可以推迟甚至免除拷贝数据。
推迟:通过让父进程和子进程以只读的方式共享同一个拷贝,再在需要写入的时候父进程才给子进程复制数据。
免除:fork 后立即调用exec()
页式内存管理
把虚拟内存和物理内存都划分为长度大小固定的页,虚拟的内存只在逻辑上存在,物理的页(也称之为页框,页帧)真实的存在于内存条上,把虚拟内存页和真实的物理内存页的对应关系存储成一张表,就是页表,可以把页表想象成存放在内存中的一个大数组。当 CPU 进行寻址时,会将逻辑地址经过分段机制,变成线性地址,线性地址再通过分页机制,变成物理地址。
缺页中断处理
malloc() 和 mmap() 等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。
具体一点说就是,因为物理内存是有限的,当一个程序执行时,会将暂时不需要的页面从物理页框换出到磁盘上。但是进程看到的自己的内存空间是完整的。所以当进程访问到一个逻辑页面时,操作系统去查页表,发现这个逻辑页面不在内存中,那么则会去磁盘上找到刚才换出的页面,重新加载到内存,然后修好页表,然后重新去用逻辑地址查找这个物理地址,这个过程就是缺页中断。不过这也只是缺页中断的其中一种情况。
Page cache
页面缓存,内核会为每个文件单独维护一个 Page cache,是一段真正的物理内存,其中会保存用户进程访问过的该文件的内容,这些内容以页为单位保存在内存中。用户进程对于文件的大多数读写操作会直接作用到 Page cache 上,内核会选择在适当的时候将 Page cache 中的内容写到磁盘上(当然我们可以手工fsync控制回写),这样可以大大减少磁盘的访问次数,从而提高性能。
函数
mmap
1 | void mmap(void start, size_t length, int prot, int flags, int fd, off_t offsize); |
这个函数的一个很重要的用处就是将磁盘上的文件映射到虚拟内存中,对于这个函数唯一要说的就是当 flags 的 MAP_PRIVATE
被置为 1 时,对 mmap 得到内存映射进行的写操作会使内核触发 COW 操作,写的是 COW 后的内存,不会同步到磁盘的文件中。
madvice
1 | madvice(caddr_t addr, size_t len, int advice) |
这个函数的主要用处是告诉内核内存 addr~(addr+len)
在接下来的使用状况,以便内核进行一些进一步的内存管理操作。当 advice 为 MADV_DONTNEED
时,此系统调用相当于通知内核 addr~(addr+len)
的内存在接下来不再使用,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。
write
向打开的文件描述符中,写相应的内容。
/proc/self/mem
这个文件是一个指向当前进程的虚拟内存文件的文件,当前进程可以通过对这个文件进行读写以直接读写虚拟内存空间,并无视内存映射时的权限设置。也就是说我们可以利用写 /proc/self/mem 来改写不具有写权限的虚拟内存。可以这么做的原因是 /proc/self/mem 是一个文件,只要进程对该文件具有写权限,那就可以随便写这个文件了,只不过对这个文件进行读写的时候需要一遍访问内存地址所需要寻页的流程。因为这个文件指向的是虚拟内存。
触发原理
当调用 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 获取这块内存的页表项的时候,不仅可以成功获取,而且获取之后强制的写入的内容也会同步到映射的只读文件中。从而导致了只读文件的越权写。
POC 代码分析
1 | /* |
- lseek: 按照偏移更改文件描述符的指针,SEEK_SET 表示将指针指向 map
- fstat: 获得文件描述符指向的文件的更多信息,如文件大小等
- madvise: 将自己的主动控制内存的行为告知操作系统内核
- pthread_join:表示主线程”main”会一直等待直到 ph1、ph2 这个线程执行完毕自己才结束
main 函数首先以只读的方式打开要修改的文件;以只读和私有方式映射到内存中;再启动两个线程,procselfmemThread
线程不断写那段内存,madviseThread
线程不断释放那段内存。
源码及漏洞分析
这个节选了部分会用到的源代码
1 | get_user_pages{//这是一个Wrap |
step1
再对照着 POC 可以知道,我们 mmap 了一段区域( map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0)
),先看 procselfmemThread 线程,往 /proc/self/mem
进行读写操作,本质是调用了一系列函数:mem_write
-> mem_rw
-> access_remote_vm
-> __access_remote_vm
,__access_remote_vm 中的 get_user_pages 获取需要写入的内存页,get_user_pages **函数会调用 **follow_page_mask 函数寻找内存页对应的页表项。
因为这是 mmap 后第一次对 map 进行操作,所以 map 所对应的页表为空,接着经过 if 判断,调用 faultin_page 函数。
对应的函数简化如下:
1 | get_user_pages{//这是一个Wrap |
faultin_page **函数会调用 **handle_mm_fault 进行缺页处理。缺页处理时,如果页表为空,内核会调用 do_fault 函数调页,这个函数会检查是否是因为内存写造成的缺页以及该内存是否是以 private 方式 mmap 的内存,如果是,则会进行 COW 操作,更新页表为 COW 后的页表。并将返回值的 FAULT_FLAG_WRITE 位 置为1,也就是触发缺页中断,并将页面标记为脏。
对应的函数简化如下:
1 | faultin_page(vma,){ |
对应的流程图如下(这里是直接拿网上师傅的图):
这幅图表示第一次 mmap,此时文件已经从磁盘上加载到了内存中(即文件对应的 page cache) ,但是进程相应的页表还没有建立。
之后尝试访问这个页,但发现页表项为空,所以触发一个缺页中断,因为映射的属性是只读并且私有,而我们要写,所以会触发COW。并标记页表为只读(RO)和脏(DIRTY)。
step2
返回后,因为现在页表已经设置好了,所以第二次 get_user_pages **会调用 **follow_page_mask 寻找页表项,follow_page_mask 会调用 follow_page_pte 函数。这个函数会通过 flag 参数的 FOLL_WRITE 位是否为 1 来判断该页是否被要求具有写权限,通过页表项的 VM_WRITE 位是否为 1 来判断该页是否可写。
因为此时我们 mmap 出来的 map 带的属性是 PROT_READ 和 MAP_PRIVATE,所以此时的 VM_WRITE 为 0 ,即该页不可写,而我们要求页表是可写的,对应 FOLL_WRITE 为 1,所以会返回 0 ,再一次触发中断。
对应的函数简化如下:
1 | get_user_pages{//这是一个Wrap |
follow_page_pte 函数返回 0 ,使得 page 为 0 ,再一次进入 faultin_page 函数,faultin_page 函数调用 handle_mm_fault 函数,然后到 handle_pte_fault 函数,因为这一次页表不为空,所以会检查是否是因为页没有写权限导致的中断,如果是因为没有写权限中断,就会调用 do_wp_page 函数,do_wp_page 函数会检查 COW 操作是否已经进行过(整合的函数没有这一步,具体要去看源码),因为在 step1 就已经 COW 过了,所以这里会直接利用之前 COW 得到的页表项,之后 handle_mm_fault 的返回值的 VM_FAULT_WRITE 位会被置为1。
接着 faultin_page 会通过判断 handle_mm_fault 返回值的 VM_FAULT_WRITE 位是否为1来判断COW是否顺利完成,以及通过页表项 VM_WRITE **位是否为1来判断该内存是否可写。如果判断出来 COW 已完成并且页表项不可写,就会将 **FOLL_WRITE 置 0 ,从而使获取的页表项不再要求具有写权限,并返回 get_user_pages。
对应函数简化如下:
1 | __get_user_pages(vma,...,int flag,...){ |
对应流程图如下:
step3
get_user_pages 第三次调用 follow_page_mask 进行寻页,注意此时的 FOLL_WRITE 已被置为 0,也就是在寻页的时候不再需要页具有写权限。正常来说,程序会直接返回找到的页表项 map,然后执行写操作(注意这里写的地方是 COW 出来的副本,也就是图中对应的 anonymous page,并不是 page cache)。但是因为此时我们通过 pthread_join 函数加入了 madviseThread 线程,cond_resched() 函数使其执行,里面的 madvise 函数 advice 位被置为 MADV_DONTNEED,所以 map 页表项 COW 出来的副本(anonymous page)会被置空。
对应流程图:
回到 procselfmemThread 线程,因为页表已被置空,所以会再一次触发中断,进入 faultin_page 等一系列函数,然后进到 do_fault,因为此时页表已经不被要求具有写权限,所以不会执行 COW 操作,而是直接映射文件,也就是直接返回了 page cache 这个物理内存。
对应函数如下:
1 | do_fault(fe){ |
对应流程图如下:
此时我们再进入 procselfmemThread 线程,通过向 /proc/self/mem 强制执行写操作,写 /proc/self/mem 的原理就是调用 kmap ,kmap 也是指向 map 的,也就会直接将内容写入 map 物理内存。
最后由于page cache的写回机制,最终会覆盖磁盘上的文件,攻击完成。
参考博客
https://blog.csdn.net/qq_39153421/article/details/116742488
https://xuanxuanblingbling.github.io/ctf/pwn/2019/11/18/race/
https://zhuanlan.zhihu.com/p/25918300
https://www.bilibili.com/read/cv11057658
https://atum.li/2016/10/25/dirtycow/