DirtyCow 漏洞分析2

上一篇关于DirtyCow的分析过于细节,写的越多反而越不知所云。本篇从流程角度做个简易分析,力求通俗易懂:

先了解两个内核函数及其主要功能:

follow_page_mask: 寻页
faultin_page:缺页异常,或请求调页,或写实复制(COW)

当一个进程P企图对一个只读文件F进行写操作的时候,最终是不能改写文件内容的。但是它是可以用某种方法改变该文件在进程虚拟内存空间的内容,比如:

1 进程P以只读+MAP_PRIVATE的方式将该文件映射到自己的虚拟内存空间;
2 进程P通过强制写/proc/self/mem,来改变映射到本进程空间内的文件内容;
3 强行写入的内容被写到内核COW出来的拷贝页中,供进程使用;
4 进程退出时拷贝页回收,原文件F内容不受影响。

这个具体的过程在内核的实现如下:
1 进程调用MMAP映射一个只读文件进入内存后,得到一个线性的虚拟内存地址;
2 进程写这个虚拟地址,在内核态首先触发第一次寻页,继而发生第一次缺页异常;
3 第一次缺页异常中,页表为空,内核根据进程的写请求标志及所映射内存页面的只读属性判断,进行COW,生成一个新的可写COW页面,并标记为脏页面,同时保留其只读属性;
4 第一次缺页异常结束后,返回并进入第二次寻页重试;
5 内核在第二次寻页的时候判断,进程仍然是写请求标志,且页面属性不可写,于是返回NULL,触发第二次缺页异常:

  if ((flags & FOLL_WRITE) && !pte_write(pte)) {
    pte_unmap_unlock(ptep, ptl);
    return NULL;
}

6 第二次缺页异常中,判断已经是COW页且根据其结束标志(VM_FAULT_WRITE)将进程的写请求标志移除(便于后续直接绕过权限检查而获取COW页进行写):

   if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
    *flags &= ~FOLL_WRITE;
     return 0;
}

7 第二次缺页异常结束后,返回并进入第三次寻页重试;
8 第三次寻页重试的时候,因为进程的写请求标志被移除,因此步骤5中的条件判断将得不到满足,不会返回NULL,而是进入vm_normal_page获取到COW页面;
9 进程可对COW页面进行任意读写,不会影响原文件内容。

DirtyCow漏洞发生在步骤7-8之间,当内核进行第三次寻页重试之前,控制CPU调度执行madvise回收所映射内存,于是:
8 第三次寻页重试,首先判断到页表为空不存在,再次进入第三次缺页异常;
9 第三次缺页异常中,再次进入do_fault,因为之前已经移除了进程的写请求标志,于是这次直接进入了do_read_fault,不会进行COW;
10 进程进入第四次寻页重试,成功获取到原文件所映射内存并任意读写,并能同步至物理文件中。

所以问题的关键在于两点:
1 因为竞争回收的影响,导致第8步中内核走不到vm_normal_page获取COW页面,而是提前返回进入缺页异常;
2 缺页异常中因为进程的写请求标志被移除,于是被错误的转换到了读请求中,直接获取到原文件的映射内存

补丁中增加了FOLL_COW标志用以标识所映射页面是否已进行过COW,即第6步中不是用移除写请求标志来标识COW,而是或上新的标识位,这样进程的写请求标志
属性依然存在,避免了后面错误地转换成读请求处理。并且增加pte_dirty来结合判断该标志是否过期。
另外,在判断进程请求标识与页面属性是否匹配时(决定是否进入新的缺页异常),会综合考虑FOLL_FORCE标志位与COW是否匹配,即只有COW后的页面才能强行读写。

 +#define FOLL_COW    0x4000    /* internal GUP flag */ 
 typedef int (*pte_fn_t)(pte_t *pte, pgtable_t token, unsigned long addr,
         void *data);
 diff --git a/mm/gup.c b/mm/gup.c
 index 377a5a7..3cec4df 100644
 -- a/mm/gup.c
 ++ b/mm/gup.c
 @@ -32,6 +32,16 @@ static struct page *no_page_table(struct vm_area_struct *vma,
   return NULL;
 }

+/*
+ * FOLL_FORCE can write to even unwritable pte's, but only
+ * after we've gone through a COW cycle and they are dirty.
+ */
+static inline bool can_follow_write_pte(pte_t pte, unsigned int flags)
+{
+    return pte_write(pte) ||
+        ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
+}
+
 static struct page *follow_page_pte(struct vm_area_struct *vma,
         unsigned long address, pmd_t *pmd, unsigned int flags)
 {
@@ -66,7 +76,7 @@ retry:
     }
     if ((flags & FOLL_NUMA) && pte_numa(pte))
         goto no_page;
-    if ((flags & FOLL_WRITE) && !pte_write(pte)) {
+    if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) {
         pte_unmap_unlock(ptep, ptl);
         return NULL;
     }
@@ -315,7 +325,7 @@ static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
      * reCOWed by userspace write).
      */
     if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
-        *flags &= ~FOLL_WRITE;
+            *flags |= FOLL_COW;
     return 0;
 }。

弄清楚原理后想做动态检测其实就很容易了。简单一点的做法是在第8步发现页表被回收时检查COW标志即可:

if (!pte_present(pte)) {
    swp_entry_t entry;
    /*
     * KSM's break_ksm() relies upon recognizing a ksm page
     * even while it is being migrated, so for that case we
     * need migration_entry_wait().
     */
  +  if(flags & FOLL_COW)
  +     printk("DirtyCow detected!\");

    if (likely(!(flags & FOLL_MIGRATION)))
        goto no_page;