CVE-2018-18281 漏洞分析笔记

0x00 前言

这个CVE很早之前粗略的看了一下,当时是知其然,但不知其所以然,就转去看Linux内存管理了。。。。下面算是分析过程中的一些笔记。

0x01 漏洞相关代码逻辑分析

漏洞成因是由于两个系统调用之间存在竞争,导致地址空间复用。具体的漏洞成因就不举例分析了,这篇文章写的很容易理解CVE-2018-18281。这里主要分析漏洞相关代码逻辑。
该漏洞在linux-4.9之前都存在,在Linux4.9版本之后,在每次更新一个pmd的所有pte后,都会刷新TLB中old_pmd所对应的项,具体在后面进行分析。

mremap/ftruncate系统调用

存在竞争的两个系统调用如下:

1
2
mremap : 系统调用来改变虚拟内存的映射位置
ftruncate : 系统调用用来改变文件大小到指定大小

ftruncate系统调用在不同的Linux版本下有细微差别。Linux-3.3版本ftruncate 系统调用的具体调用流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//fs/open.c  line 178
SYSCALL_DEFINE2(ftruncate, unsigned int, fd, unsigned long, length)
-->do_sys_ftruncate(fd, length, 1);
-->do_truncate(dentry, length, ATTR_MTIME|ATTR_CTIME, file);
-->notify_change(dentry, &newattrs)
--> inode->i_op->setattr(dentry, attr)(实际调用虚函数shmem_setattr)//调用虚函数shmem_setattr,由inode->i_op_setattr指向 mm/shmem.c line 543
-->unmap_mapping_range(inode->i_mapping,holebegin, 0, 1); // mm/memory.c line 2630
-->unmap_mapping_range_tree(&mapping->i_mmap, &details);
-->unmap_mapping_range_vma(vma,((zba -vba)<< PAGE_SHIFT) + vma->vm_start,((zea - vba + 1) << PAGE_SHIFT) + vma->vm_start,details);
-->zap_page_range(vma, start_addr, end_addr - start_addr, details);
--> unmap_vmas(&tlb, vma, address, end, &nr_accounted, details);
-->unmap_page_range(tlb, vma, start, end, details);
--> zap_pud_range(tlb, vma, pgd, addr, next, details);
-->zap_pmd_range(tlb, vma, pud, addr, next, details);
--> zap_pte_range(tlb, vma, pmd, addr, next, details);// mm/memory.c line 1036

fruncate 系统调用的具体调用流程如下:(源码分析基于linux-4.20.rc3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//fs/open.c  line 202
SYSCALL_DEFINE2(ftruncate, unsigned int, fd, unsigned long, length)
-->do_sys_ftruncate(fd, length, 1);
-->do_truncate(dentry, length, ATTR_MTIME|ATTR_CTIME, file);
-->notify_change(dentry, &newattrs, NULL)
--> inode->i_op->setattr(dentry, attr)(实际调用虚函数shmem_setattr)//调用虚函数shmem_setattr,由inode->i_op_setattr指向 mm/shmem.c line 991
-->unmap_mapping_range(inode->i_mapping,holebegin, 0, 1); // mm/memory.c line 2630
-->unmap_mapping_pages(mapping, hba, hlen, even_cows);//
-->unmap_mapping_range_tree(&mapping->i_mmap, &details);
-->unmap_mapping_range_vma(vma,((zba -vba)<< PAGE_SHIFT) + vma->vm_start,((zea - vba + 1) << PAGE_SHIFT) + vma->vm_start,details);
-->zap_page_range_single(vma, start_addr, end_addr - start_addr, details);
-->unmap_single_vma(&tlb, vma, address, end, details);
-->unmap_page_range(tlb, vma, start, end, details);
-->zap_p4d_range(tlb, vma, pgd, addr, next, details);
-->zap_pud_range(tlb, vma, p4d, addr, next, details)
-->zap_pmd_range(tlb, vma, pud, addr, next, details);
--> zap_pte_range(tlb, vma, pmd, addr, next, details);// mm/memory.c line 1036

mremap系统调用具体流程如下,在linux-3.3和Linux4-20都是一样,只是move_ptes函数的具体实现有改变,后面会详细说明:

1
2
3
4
5
6
SYSCALL_DEFINE5(mremap, unsigned long, addr, unsigned long, old_len,
unsigned long, new_len, unsigned long, flags,
unsigned long, new_addr)//mm/mremap.c line 515
-->move_vma(vma, addr, old_len, new_len, new_addr,&locked, &uf, &uf_unmap);
-->move_page_tables(vma, old_addr, new_vma, new_addr, old_len,need_rmap_locks);
-->move_ptes(vma, old_pmd, old_addr, old_addr + extent, new_vma, new_pmd, new_addr, need_rmap_locks); // mm/mremap.c line 115

ftruncate 系统调用漏洞相关代码分析

上面已经列出了ftruncate系统调用调用流程,包括Linux-3.3和Linux-4.20两个版本。漏洞出现在Linux-4.9之前,这里主要分析linux-3.3和Linux-4.20两个版本中与TLB pte 刷新相关的zap_pte_range()函数,并比较其不同。
zap为zero all pages的缩写。该函数的作用是将在pmd中从虚拟地址address开始,长度为size的内存块通过循环调用ptep_get_and_clear_full()将其页表项清零,调用free_swap_and_cache()将所含空间中的物理内存或交换空间中的虚存页释放掉。在释放之前,必须检查从address开始长度为size的内存块有无越过PMD_SIZE.(溢出则可使指针逃出0~1023的区间)。

Linux-3.3 – zap_pte_range()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// mm/memory.c line 1116
static unsigned long zap_pte_range(struct mmu_gather *tlb,
struct vm_area_struct *vma, pmd_t *pmd,
unsigned long addr, unsigned long end,
struct zap_details *details)
do{
......
again:
init_rss_vec(rss);
//step 1 : 获取pmd的锁
start_pte = pte_offset_map_lock(mm, pmd, addr, &ptl);
pte = start_pte;
arch_enter_lazy_mmu_mode();
do {
......
//step 2 : 清空页表项
ptent = ptep_get_and_clear_full(mm, addr, pte,
tlb->fullmm);
tlb_remove_tlb_entry(tlb, pte, addr);
.......
//step 3 : 删除page cache 或 swap cache
if (unlikely(!free_swap_and_cache(entry)))
print_bad_pte(vma, addr, ptent, NULL);
}
pte_clear_not_present_full(mm, addr, pte, tlb->fullmm);
} while (pte++, addr += PAGE_SIZE, addr != end);
//while 循环将pmd中从虚拟地址address开始,长度为size的内存块通过循环调用ptep_get_and_clear_full将其页表项清零,调用free_swap_and_cache将pte对应的page cache 或 swap cache 清除
//step 4 : 释放pmd的锁
pte_unmap_unlock(start_pte, ptl);

/*
* mmu_gather ran out of room to batch pages, we break out of
* the PTE lock to avoid doing the potential expensive TLB invalidate
* and page-free while holding it.
*/
if (force_flush) {
force_flush = 0;
//执行两步操作:1. flush 与当前mm相关的所有 TLB pte;2. 想前一步对应的所有页面释放到buddy系统中。注意这都是在释放掉锁之后进行的。
tlb_flush_mmu(tlb);
if (addr != end)
goto again;
}

return addr;

在Linux-3.3中zap_pte_range()函数的大体流程为:

  1. 获取pmd锁。(pmd是指向页中级目录的指针,即最后一级页表).
  2. 循环遍历当前页表的所有pte,并调用ptep_get_and_clear_full()清除对应的pte.
  3. 释放pmd锁。
  4. 调用tlb_flush_mmu刷新与当前mm相关的TLB 条目,并将对应的page 释放到buddy系统中。

Linux-4.20 – zap_pte_range()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
static unsigned long zap_pte_range(struct mmu_gather *tlb,
struct vm_area_struct *vma, pmd_t *pmd,
unsigned long addr, unsigned long end,
struct zap_details *details)
{
......
tlb_remove_check_page_size_change(tlb, PAGE_SIZE);
again:
//获取pmd锁
start_pte = pte_offset_map_lock(mm, pmd, addr, &ptl);
pte = start_pte;
......
do {
pte_t ptent = *pte;
//pte没有映射页面
if (pte_none(ptent))
continue;
//pte对应的page在内存中
if (pte_present(ptent)) {
struct page *page;
//获取相应的page
page = _vm_normal_page(vma, addr, ptent, true);
......
//清空对应的pte条目,并返回原来的pte
ptent = ptep_get_and_clear_full(mm, addr, pte,
tlb->fullmm);
//将该pte添加到之后要进行flush的集合中
tlb_remove_tlb_entry(tlb, pte, addr);

if (!PageAnon(page)) {
//如果是dirty页的话 ,需要强制性刷新,并标记页为dirty
if (pte_dirty(ptent)) {
force_flush = 1;
set_page_dirty(page);
}
......

entry = pte_to_swp_entry(ptent);
......

//释放pte对应的page cache 或swap cache
if (unlikely(!free_swap_and_cache(entry)))
print_bad_pte(vma, addr, ptent, NULL);
pte_clear_not_present_full(mm, addr, pte, tlb->fullmm);
} while (pte++, addr += PAGE_SIZE, addr != end);
......
/* Do the actual TLB flush before dropping ptl
* 刷新当前mm相关的页表条目
* */
if (force_flush)
tlb_flush_mmu_tlbonly(tlb);
//释放pmd锁
pte_unmap_unlock(start_pte, ptl);

/*
* If we forced a TLB flush (either due to running out of
* batch buffers or because we needed to flush dirty TLB
* entries before releasing the ptl), free the batched
* memory too. Restart if we didn't do everything.
*/
if (force_flush) {
force_flush = 0;
//释放清空了pte条目对应的page到buddy系统中
tlb_flush_mmu_free(tlb);
if (addr != end)
goto again;
}

return addr;
}

在Linux-4.20中zap_pte_range()函数的大体流程为:

  1. 获取pmd锁。(pmd是指向页中级目录的指针,即最后一级页表).
  2. 循环遍历当前页表的所有pte,并调用ptep_get_and_clear_full()清除对应的pte.
  3. 调用tlb_flush_mmu刷新与当前mm相关的TLB 条目
  4. 释放pmd锁。
  5. 对应的page 释放到buddy系统中。

PS: 其实在Linux-3.14之前都是先释放pmd锁,再执行tlb 刷新。在linux-3.15开始,都是采用的先刷新TLB条目,再释放锁的做法,然后是批处理释放page到buddy系统。只是各个版本的处理有细微差别。

mremap系统调用漏洞相关代码分析

这里主要分析与CVE-2018-18281漏洞相关逻辑。其实在linux-4.9版本之前,TLB pte的刷新是在move_page_tables()中调用flush_tlb_range()函数来做的;但是在linux-4.9之后,刷新被放到了move_ptes()函数内部。

Linux-3.3

首先是move_page_tables()函数将虚拟地址空间中从old_addr开始的长度为len的虚拟内存移动到以new_addr为起始地点的的虚拟空间中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
unsigned long move_page_tables(struct vm_area_struct *vma,
unsigned long old_addr, struct vm_area_struct *new_vma,
unsigned long new_addr, unsigned long len)
{
......
for (; old_addr < old_end; old_addr += extent, new_addr += extent) {
......
//获取old pmd
old_pmd = get_old_pmd(vma->vm_mm, old_addr);
if (!old_pmd)
continue;
//分配new pmd,将用于保存old_pmd中的pte。
new_pmd = alloc_new_pmd(vma->vm_mm, vma, new_addr);
if (!new_pmd)
break;
......
//将old_pmd中的pte拷贝到new pmd中
move_ptes(vma, old_pmd, old_addr, old_addr + extent,
new_vma, new_pmd, new_addr);
need_flush = true;
}
//刷新old_pmd中在tlb 中对应的条目
if (likely(need_flush))
flush_tlb_range(vma, old_end-len, old_addr);

mmu_notifier_invalidate_range_end(vma->vm_mm, old_end-len, old_end);

return len + old_addr - old_end; /* how much done */

可以看到是先调用了move_ptes函数将旧页表中的条目拷贝到新页表中,然后调用flush_tlb_range(),flush_tlb_range接口是一个flush强度比flush_tlb_mm要弱的接口,flush_tlb_range不是invalidate整个地址空间的TBL,而是针对该地址空间中的一段虚拟内存(start到end-1)在TLB中的entry进行flush。
继续跟进move_ptes函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static void move_ptes(struct vm_area_struct *vma, pmd_t *old_pmd,
unsigned long old_addr, unsigned long old_end,
struct vm_area_struct *new_vma, pmd_t *new_pmd,
unsigned long new_addr)
{
.......
//step 2: 获取old pmd 锁
old_pte = pte_offset_map_lock(mm, old_pmd, old_addr, &old_ptl); new_pte = pte_offset_map(new_pmd, new_addr);
// 获取new pmd 锁
new_ptl = pte_lockptr(mm, new_pmd);
......
for (; old_addr < old_end; old_pte++, old_addr += PAGE_SIZE,
new_pte++, new_addr += PAGE_SIZE) {
if (pte_none(*old_pte))
continue;
//清空old pmd 对于的pte条目,并返回原来的pte值保存在变量pte中
pte = ptep_get_and_clear(mm, old_addr, old_pte);
// 将pte拷贝到新页表中new_addr处,并返回pte指针
pte = move_pte(pte, new_vma->vm_page_prot, old_addr, new_addr);
//将新页表中pte指针拷贝到new_pte
set_pte_at(mm, new_addr, new_pte, pte);
}

arch_leave_lazy_mmu_mode();
if (new_ptl != old_ptl)
spin_unlock(new_ptl);//释放新页表的锁
pte_unmap(new_pte - 1);
pte_unmap_unlock(old_pte - 1, old_ptl); //释放旧页表的锁
if (mapping)
mutex_unlock(&mapping->i_mmap_mutex);
}

mremap系统调用重新映射一块内存的大体流程如下:

  1. move_ptes函数将旧页表中pte拷贝到新页表中;
    1. 获取旧页表的锁;
    2. 获取新页表的锁;
    3. 遍历旧页表中的pte,并拷贝的新页表中;
    4. 释放新页表的锁;
    5. 释放旧页表的锁;
  2. flush_tlb_range():刷新旧页表在TLB中条目。

    Linux-4.20

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// mm/mremap.c line 115
static void move_ptes(struct vm_area_struct *vma, pmd_t *old_pmd,
unsigned long old_addr, unsigned long old_end,
struct vm_area_struct *new_vma, pmd_t *new_pmd,
unsigned long new_addr, bool need_rmap_locks)
{
......
//获取旧页表的锁
old_pte = pte_offset_map_lock(mm, old_pmd, old_addr, &old_ptl);
new_pte = pte_offset_map(new_pmd, new_addr);
//获取新页表的锁
new_ptl = pte_lockptr(mm, new_pmd);
......
//循环遍历旧页表中的pte,并调用move_pte()函数将其拷贝到新页表中
for (; old_addr < old_end; old_pte++, old_addr += PAGE_SIZE,
new_pte++, new_addr += PAGE_SIZE) {
if (pte_none(*old_pte))
continue;
// 清空旧页表中的pte
pte = ptep_get_and_clear(mm, old_addr, old_pte);
......
//将旧页表中的pte拷贝到新页表中
pte = move_pte(pte, new_vma->vm_page_prot, old_addr, new_addr);
pte = move_soft_dirty_pte(pte);
set_pte_at(mm, new_addr, new_pte, pte);
}
......
//刷新旧页表中在TLB中的条目
if (force_flush)
flush_tlb_range(vma, old_end - len, old_end);
if (new_ptl != old_ptl)
spin_unlock(new_ptl);//释放新页表的锁
pte_unmap(new_pte - 1);
pte_unmap_unlock(old_pte - 1, old_ptl);//释放旧页表的锁
if (need_rmap_locks)
drop_rmap_locks(vma);

mremap系统调用重新映射一块内存的大体流程如下:

  1. move_ptes函数将旧页表中pte拷贝到新页表中;
    1. 获取旧页表的锁;
    2. 获取新页表的锁;
    3. 遍历旧页表中的pte,并拷贝的新页表中;
    4. flush_tlb_range():刷新旧页表在TLB中条目。
    5. 释放新页表的锁;
    6. 释放旧页表的锁;

0x02 如何提高CPU的命中率

由于抢占发生在move_ptes函数和flush_tlb_range函数之间,所以只要增加这两个函数之间的间隔就可以稳定的触发漏洞。但是问题是怎么样才能增加它们之间的间隔呢?这里需要用到Linux内核抢占的知识点,Linux内核采用的是抢占式多任务模式。由于那个是可抢占的,那么如果让进程在执行flush_tlb_range函数之前被抢占,那么race的时间窗口就会增大。

POC思路

新建A、B、C、D、E 5个线程:

  1. A映射一个文件a到地址X,A绑定到核c1运行,A的调度策略设置为SCHED_IDLE。
  2. C绑定到c1,并将C阻塞在某个pipe,pipe返回时立即将A重新绑定到核c4,并调用ftruncate函数将文件的大小改为0。
  3. A调用mremap函数重新映射X到Y,会执行以下两个函数:
    1. move_ptes():该函数会引起内核状态变化,该变化可以通过用户态文件/proc/pid/status 文件获取。
    2. flush_tlb_range()
  4. D绑定到核c2,监控进程的内存映射情况,如果发生变化则通过写pipe唤醒C。
  5. B绑定到核c3,循环读取X的内容,并判断是否还是初始值。
  6. E绑定到核c4,并执行一个死循环。
    这里会提高命中率的原因个人的理解是:当A调用mremap函数重新映射X到Y,执行move_ptes函数导致内存状态改变,通过写pipe唤醒C,此时会将A重新绑定到核c4,由于A的调度策略被设置为最低优先级SCHED_IDLE,核c4会一直执行死循环,不会调度A,导致A被挂起,而此时C调用ftruncate将文件的大小改为0,会释放Y对应的页面,并将其释放到伙伴系统中,但是X页面中相应的在TLB中的pte还没有被刷新,所以此时通过X还是可以访问到这些内存,造成地址复用。
    核4的死循环加大了move_ptes()函数和flush_tlb_range()的间隔,可以增加命中率,稳定触发漏洞。

0x03 思考

在较新版本的linux kernel中,都是将TLB条目刷新放在释放页表锁前面,防止条件竞争发在。对于上面的利用方式,在linux-4.9之后,由于flush_tlb_range()在释放页表前执行,也就是说一个进行调用mremap将虚拟内存X重新映射到Y,在Y的页表释放后,可以保证的是X中的pte在TLB的缓存被清空,其他的线程再访问X的内存将会是非法的,有效的防止了由于条件竞争导致的地址复用。

0xFF Reference

https://googleprojectzero.blogspot.com/2019/01/taking-page-from-kernels-book-tlb-issue.html
https://www.iceswordlab.com/2019/03/08/cve-2018-18281/
https://xz.aliyun.com/t/4005#toc-9
https://www.kernel.org/doc/html/latest/x86/tlb.html
https://www.kernel.org/doc/html/v4.18/core-api/cachetlb.html