Dirty Cow 漏洞成因
Dirty COW 漏洞是一种发生在写时复制 Copy-On-Write 的条件竞争漏洞
- 自2007年9月
linux kernel-2.6.22
被引入,直到2018年linux kernel 4.8.3, 4.7.9, 4.4.26
之后才被彻底修复
写时复制:
- 使用
fork
系统调用创建子进程,那么这个子进程通过使页表条目指向相同的物理内存来共享父进程内存 - 当任何进程试图写入内存时,会引发异常,OS 将为子进程分配新的物理内存,从父进程复制内容,更改每个进程的页表使它指向自己的私有内存副本
- 其基本原理为:修改父页面为不可写(不管之前是什么权限),当程序尝试往该页中写入数据时,程序就会触发页中断,然后根据标志位来判断页中断原因为 COW,之后就调用对应的函数来分配 COW 页,并建立 COW 页表项
条件竞争:
- 一个系统或者进程的输出,依赖于不受控制的事件出现顺序,或者出现时机
- 在多个进程(线程)同时访问和操作相同的数据时,访问的顺序可能与预期有差别,这将造成较为严重的安全问题
COW 执行过程执行三个重要步骤:
- 制作映射内存的副本
- 更新页表,使虚拟内存指向新创建的物理地址
- 写入内存
由于三个步骤不是原子性的,一个线程在执行这三个步骤过程中可能被其他线程中断,从而产生潜在的竞态条件
Dirty Cow 漏洞利用
Dirty Cow 的利用需要两个系统调用:
mmap
:将一个文件或者其它对象映射到进程的地址空间madvise
:建议内核如何使用指定段的内存(当参数设置为MADV_WILLNEED
时,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空)
1 | git clone https://github.com/dirtycow/dirtycow.github.io |
伪代码如下:
1 | Main: |
- 主线程打开目标文件,并且把该文件映射到内存(非匿名映射)
- Thread1 循环向写入
map
中写入数据 - Thread2 循环对
map
解除映射
接下来就详细分析一下这个过程:(内核版本为:4.1.4)
用户态 mmap
在内核中对应的函数为 sys_mmap
,其核心函数为 do_mmap_pgoff
:
1 | unsigned long do_mmap_pgoff(struct file *file, unsigned long addr, |
- 前面就是根据
flags
中的标志位进行一些设置 - 后面进入
mmap_region
函数完成映射 - 值得注意的一点是:如果没有设置
flag=MAP_POPULATE
(提前建立页表的标志位),是不会建立页表项的 - 也就是说,当后续的
write
读取该 VMA 对应的地址空间时,会触发缺页异常page_fault
page_fault
可能有多种原因:
- 访问地址不在虚拟地址空间
- 访问地址在虚拟地址空间中,但没有访问权限
- 访问地址在虚拟地址空间中,但没有与物理地址间建立映射关系
Linux 内核关于 page_fault
的处理是通过一系列系统调用函数实现的:
1 | do_page_fault() -> handle_mm_fault() -> handle_pte_fault() -> do_fault() |
do_page_fault
:会检查多种异常原因,比如缺页异常的地址在内核空间还是用户空间,是内核态还是用户态触发的异常等等情况handle_mm_fault
:为发生page_fault
的地址分配各级页表目录handle_pte_fault
:从上层函数得到了缺页异常的pte
(页表项),然后做多层检查
1 | static int handle_pte_fault(struct mm_struct *mm, |
- 如果该
pte
不在物理内存中- 如果
pte
为空,说明进程第一次访问该页面- 如果
vma
属性是匿名映射(没有真实的磁盘文件与该地址对应) - 如果
vma
不是匿名,说明是文件映射,进入do_fault
函数
- 如果
- 如果
pte
不为空,说明该页此前访问过,但是被换出了,只需要再换入即可 - PS:
pte
既可以索引到内存中的物理页,也可以索引交换区描述符swap_info_struct
在swap_info
数组的下标,以及在swap_map
中的偏移量
- 如果
- 如果该
pte
在物理内存中,说明此次page_fault
不是页面缺失引起,检查是否由写操作引起- 页面不可写,进入
do_wp_page
进行 COW 操作 - 页面可写,标记
dirty
位
- 页面不可写,进入
do_fault
:检查各种标志位,根据不同的情况调用不同的函数
1 | static int do_fault(struct mm_struct *mm, struct vm_area_struct *vma, |
Dirty Cow 漏洞就源自于内核对 Cow 的处理不当,这里我们重点关注以下函数:
do_wp_page
:不缺页的 COW 处理函数
1 | static int do_wp_page(struct mm_struct *mm, struct vm_area_struct *vma, |
do_cow_fault
:缺页的 COW 处理函数
1 | static int do_cow_fault(struct mm_struct *mm, struct vm_area_struct *vma, |
wp_page_reuse
:重用由do_cow_fault
分配好的内存页副本
1 | static inline int wp_page_reuse(struct mm_struct *mm, |
接下来就重点分析一下 write
函数写入 VMA 对应地址的过程:
- 前面分析到,程序执行
mmap
生成 VMA 结构体是只读的,并且没有设置页表项 - 接下来
write
的调用链如下:
1 | sys_write -> ... -> mem_rw -> ... -> copy_to_user -> __get_user_pages |
- 这里我们只需要关注最后一个函数
__get_user_pages
:
1 | long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm, |
- 如果页面 page 不存在则会调用
faultin_page
处理异常页:
1 | static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma, |
- 设置完标志
flag
后,进入handle_mm_fault
,分配各级页表项
第一次处理 page_fault
的流程如下:
- 用户态函数
write
执行到内核中的__get_user_pages
时,会因为mmap
没有为 VMA 设置页表而调用faultin_page
来处理page_fault
- 然后依次调用
handle_mm_fault
和handle_pte_fault
- 此时
pte
索引的物理地址不存在,pte
中的内容为空(没有设置页表项),VMA 地址是非匿名映射,所以会调用do_fault
- 由于触发
page_fault
的原因为write
函数的“写缺页”,因此会调用do_cow_fault
进行写时复制:分配一个新页面作为文件映射内存的副本页(只读),并建立 COW 页的页表项 - 跳转回到
retry
第二次处理 page_fault
的流程如下:
- 程序第二次执行
follow_page_mask
时因为分配了属性为不可写的 COW 页而,从而继续调用faultin_page
来处理page_fault
- 然后依次调用
handle_mm_fault
和handle_pte_fault
- 此时
pte
所指向的物理地址存在(COW 分配了页表),对应的 COW 页不可写,因此程序会调用do_wp_page
进行写时复制 - 由于第一次
faultin_page
执行do_cow_fault
时已经分配了副本页,因此会直接调用wp_page_reuse
重用这个页(COW 分配的页面属于匿名页),并返回 VM_FAULT_WRITE - 返回到
faultin_page
中时,由于返回了 VM_FAULT_WRITE 标志,表示已经完成了 COW 页的分配,但是注意到此时 COW 页时只读的,如果我们再次进入follow_page_mask
,那么写和只读会冲突,又会返回 NULL,这样就会陷入retry
的循环 - Linux 为了防止该冲突,选择在
faultin_page
函数的最后,检查VM_FAULT_WRITE
标志以确定完成了 COW 操作,然后去掉了 COW 页的FOLL_WRITE
标志使其可以写入
1 | if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) |
- 跳转回到
retry
第三次处理 page_fault
的正常流程如下:
- 由于之前去掉了
FOLL_WRITE
标志,因此不会检查PTE
有没有写入权限,因此第三次执行follow_page_mask
将会返回 COW 页 - 退出
retry
循环,在 COW 页上执行write
的剩余工作
第三次处理 page_fault
的异常流程如下:(触发 COW)
- 如果我们在第二次
faultin_page
函数去掉flags
里的FOLL_WRITE
标志之后,通过竞争条件,执行madvice
函数清空页表项(在get_user_page
里有一步cond_resched
线程调度操作,提供了条件竞争的机会) - 线程调度结束,返回
write
线程,又会重新进入follow_page_mask
然后因为pte
被清空导致缺页,函数返回 NULL,再次进入faultin_page
,然后会一直运行到do_fault
,此时不再要求写入权限,因此程序就会认为触发页中断的原因是“读缺页”,所以会执行do_read_fault
函数,建立文件映射页的页表项 - 注意:此时的可写页为 COW 页,但是该 COW 页的页表项却索引到了文件映射页所在的物理内存地址,操纵该 COW 页其实就相当于直接控制文件映射页
- 接着从
do_fault
返回到handle_pte_fault
中,检查到页面可写,从而给页面标记dirty
- 随后回到
retry
第四次处理 page_fault
的流程如下:
- 第四次进入
follow_page_mask
,不要求写权限,从而成功返回映射到文件的 COW 页(已经标记为dirty
,表示该页可以写入磁盘) - 在文件映射页上执行
write
的剩余工作(此时虽然该映射页只读,但是内核还是可以强制写入,完成越权写操作)
参考:脏牛(Dirty COW)漏洞攻击实验(SEED-Lab:Dirty-COW Attack Lab)
Dirty Cow 漏洞复现
1.修改服务器上 flag 文件的值:
1 |
|
- 结果如下:
1 | / $ cat flag |
调试内核选用32位的 linux-4.1.4
(quem 对64位 linux-4.x
的内核兼容性不是很好,尝试了好几个版本都失败了)
- 断点到
do_page_fault
:
1 | 0xc1041600 <do_page_fault> push ebp |
- 由于本人不会调试内核多线程,后面的操作就没法展示了
2.修改 /etc/passwd
中用户的 uid 和组 id 来实现提权:
1 |
|