标题 | 简介 | 类型 | 公开时间 | ||||||||||
|
|||||||||||||
|
|||||||||||||
详情 | |||||||||||||
[SAFE-ID: JIWO-2025-3052] 作者: 浩丶轩 发表于: [2022-03-25]
本文共 [348] 位读者顶过
其实网上关于脏牛的文章分析已经很多,本文算是对调试学习该漏洞过程的一个记录
[出自:jiwo.org] 前置知识
写时拷贝 竞态条件 页式内存管理 缺页中断处理
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长度的内容。 /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_mm_fault处理函数中。此时的调用链依旧像刚刚一样handle_mm_fault-> __handle_mm_fault -> handle_pte_fault 这个标志会作为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:
要点:
场景:首先一个进程将只读文件映射到进程的虚拟内存地址中,当mmap指定参数为map_private,尽管是只读的,仍然可以写入数据,只不过这时写到的是原始物理内存的副本。在写之前,会经历写时复制的三个过程,首先创建映射内存副本,然后更新页表,最后向副本写入数据。此时另一个线程在进行写时复制的进程的最后一步写入数据之前调用madvice(),那么进程的页表会重新指向原始的映射的内存物理块,那么此时进行写时复制的进程执行最后一部写入数据,此时写入的是原始内存,而不是副本中,那么就会只读文件写入数据。漏洞利用思路:这里的基本思路就是在一个进程中创建两个线程,一个线程向只读的映射内存通过write系统调用写入数据,这时候发生写时复制,另一个线程通过madvice来丢弃映射的私有副本,两个线程相互竞争从而向只读文件写入数据。主线程:普通用户身份以只读模式打开指定的只读文件 使用MAP_PRIVATE参数映射内存 找到目标文件映射的内存地址 创建两个线程procselfmem线程向文件映射的内存区域写数据 此时内核采用COW机制madvise线程使用MADV_DONTNEED参数调用madvise来释放文件映射内存区
可以看到我们已经将foo文件中的hello修改为了hacku |