针对“Huge Dirty COW” (CVE-2017–1000405)的分析

阅读量    67727 | 评论 3   稿费 200

分享到: QQ空间 新浪微博 微信 QQ facebook twitter



译者:eridanus96

预估稿费:200RMB

投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿

 

摘要

众所周知,脏牛(Dirty COWCVE-2016-5195)是一个非常有名的公开漏洞。这个漏洞在过去10年中,存在于每一个Linux版本里,包括安卓手机、台式机和服务器都受这一漏洞的影响。该漏洞直接影响了数百万用户,使攻击者可以轻松地绕过常规防护。

该漏洞爆出之后,有大量相关新闻纷纷发布,但没有人详细分析过修补程序。我们对该补丁进行了深入研究,最终发现,该补丁对系统的修复并不完善。

 

“脏牛”漏洞回顾

首先,我们需要充分了解原始的“脏牛”漏洞的利用方式,前提是需要对Linux内存管理器有基本的了解。在这里,我们并不会完整讲解该漏洞的具体细节,因为在当时已经有很多文章进行了详尽说明,如有需要可以请大家自行检索参考。

该漏洞位于get_user_pages函数中,这一函数用于在用户进程中获取虚拟地址后面的物理页。调用方必须先指定在页上将要执行的操作,例如touchwritelock等,之后内存管理器就可以将相应的页提前准备好。具体来说,当准备在一个私有映射中的页执行写操作时,该页可能需要经过一个写时拷贝技术(Copy-On-Write,也就是所说的COW)循环,原始的只读页会被复制到一个可写的新内存页上。原来的页面也可能是私有的,这样就能将其映射到其他进程中,甚至可能会在修改后将其写回硬盘之中。

接下来,让我们看看__get_user_pages中的相关代码:

static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
         unsigned long start, unsigned long nr_pages,
         unsigned int gup_flags, struct page **pages,
         struct vm_area_struct **vmas, int *nonblocking)
 {
     // ...
     do {
         struct page *page;
         unsigned int foll_flags = gup_flags;
         // ...
         vma = find_extend_vma(mm, start);
         // ...  
         
 retry:
         // ...
         cond_resched();
         page = follow_page_mask(vma, start, foll_flags, &page_mask);
         if (!page) {
             int ret;
             ret = faultin_page(tsk, vma, start, &foll_flags,
                     nonblocking);
             switch (ret) {
             case 0:
                 goto retry;
             case -EFAULT:
             case -ENOMEM:
             case -EHWPOISON:
                 return i ? i : ret;
             case -EBUSY:
                 return i;
             case -ENOENT:
                 goto next_page;
             }
             BUG();
         }
         // ...
         
 next_page:
         // ...
         nr_pages -= page_increm;
     } while (nr_pages);
     return i;
 }

其中,while循环的目的是在请求的页范围内获取每一个内存页。重试(retry)标签的作用则在于,在符合要求的页出现前,要使每一个内存页都出现错误。

follow_page_mask的任务是扫描页表,以获取指定地址的物理页(同时需要考虑到页表条目的权限)。在follow_page_mask进行操作期间,页表条目会防止多处理器并发而进行spinlock锁定,这就保证了我们在得到一个引用之前,物理页不会被释放。

Faultin_page请求内存管理器使用指定的权限(同样基于页表条目的spinlock)来处理给定地址中的错误。在这里请特别注意,当成功调用faultin_page之后,锁定状态就会结束,因此不能保证follow_page_mask在下一个retry中能够成功,另一航代码很有可能打乱了我们的内存页。

漏洞涉及的代码在faultin_page的结尾部分:

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

之所以删除FOLL_WRITE标志,是考虑到在只读的虚拟内存空间上应用FOLL_FORCE标志的情况(当在虚拟内存空间中设置VM_MAYWRITE标志时)。在这种情况下,pte_maybe_mkwrite函数将不会设置写权限位,但faulted-in页确实可以写入。

如果在执行faultin_page时虚拟内存空间不能写入,在内存页经过一次COW循环时(使用VM_FAULT_WRITE来标记),就会在下一次尝试访问该页时删除FOLL_WRITE标志,以便请求只读权限。

如果该页是只读的,或者不存在,就会造成第一个follow_page_mask失败,我们会再次尝试。在这段时间里,也就是下一次尝试获取内存页之前,我们可以借助于madvise(MADV_DONTNEED)函数等方式来去掉COW

下一次对faultin_page的调用将不带FOLL_WRITE标志,因此我们从该页缓存(pcache)中获得只读的版本。在这种情况下,下一次对follow_page_mask的调用也会在没有FOLL_WRITE标志的情况下进行,因此它将会返回私有的的只读页,而不是调用方对可读内存页的原始请求。

以上就是脏牛漏洞的一个基本回顾,它允许我们写入到私有只读内存映射之中。作为对该漏洞的修复,在faultin_page中做了如下修改:

if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
 *flags |= FOLL_COW; // Instead of *flags &= ~FOLL_WRITE;

 并且,新增了一个由follow_page_mask调用的新函数:

/*
  * 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));
 }

该修复方式并不是减少所请求的权限,而是让get_user_pages记录是否经过了一次COW循环。在下个迭代中,只有当指定了FOLL_FORCEFOLL_COW标志并且该页表条目被标记为“Dirty”时,我们才能对只读页执行写操作。

这样的修复方式,需要建立在私有只读页副本不会有包含页面重写标志位(Dirty bit)的页表条目指向它的前提下。然而,这个前提有时是不能保证的。

 

透明大页

通常来说,Linux的默认内存页大小为4096字节。为了使系统能够管理大量的内存,我们可以增加页表条目的数量,也可以使用更大的内存页。我们重点关注第二种方式,在Linux中,该机制称为大内存页(Huge Page)。

一个大内存页的大小为2MB,这是通过透明大页(Transparent Huge PagesTHP的机制来实现的。除此之外,还有其他的方法来得到更大的内存页,我们在此先忽略。

内核将尝试使用大页面来满足相应的内存分配。透明大页具有可交换和可再分的特性。这里的“可再分”是指,它可以再分为正常的4096字节的内存页。同时它可以用于匿名(anonymous)、shmemtmpfs映射之中(后两个在较新的内核版本中出现)。

取决于编译标志和计算机配置,通常来说默认的透明大页只用于匿名映射,但也可以手动打开对shmemtmpfs的支持。一般来说,透明大页可以在系统运行时,通过像某些内核的特殊文件写入来实现启动和关闭。

其中,有一个重要的优化过程,就是将普通页合并成为大内存页。这是由守护进程khugepaged来完成的,改进成灰不断对可能合并的内存页进行扫描,其要求是虚拟内存空间必须要覆盖完整且对齐的2MB的内存范围。

透明大页是通过将PMD(一个高于PTE的级别)的_PAGE_PSE位置1来实现的。因此,PMD指向的是2MB的物理页,而并非PTE目录。在每次扫描页表时,都必须使用pmd_trans_huge函数来检查PMD,这样我们就可以确定PMD是指向PFN还是指向了PTE的一个目录。在一些结构上,巨大的PUD也可以存在,将会导致产生1GB的内存页。

从内核版本2.6.38开始,就支持透明大页的功能。但在大多数安卓设备上,透明大页的子系统并没有启用。

 

漏洞分析

在脏牛补丁代码中的处理THP的部分,我们可以看到,具有同样逻辑的can_follow_write_pte适用于巨大的PMD。补丁增加了一个名为can_follow_write_pmd的匹配函数:

static inline bool can_follow_write_pmd(pmd_t pmd, unsigned int flags)

{

    return pmd_write(pmd) ||

        ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pmd_dirty(pmd));

}

然而,在PMD较为巨大的情况下,如果使用touch_pmd函数,内存页则可以被标记为“Dirty”而不经过COW循环:

static void touch_pmd(struct vm_area_struct *vma, unsigned long addr,

        pmd_t *pmd)

{

    pmd_t _pmd;



    /*

     * We should set the dirty bit only for FOLL_WRITE but for now

     * the dirty bit in the pmd is meaningless.  And if the dirty

     * bit will become meaningful and we'll only set it with

     * FOLL_WRITE, an atomic set_bit will be required on the pmd to

     * set the young bit, instead of the current set_pmd_at.

     */

    _pmd = pmd_mkyoung(pmd_mkdirty(*pmd));

    if (pmdp_set_access_flags(vma, addr & HPAGE_PMD_MASK,

                pmd, _pmd,  1))

        update_mmu_cache_pmd(vma, addr, pmd);

}

在每次get_user_pages试图获取一个大页时,该函数会被调用。显然,上面的注释并不正确,此时的“Dirty bit”并非没有意义。特别是当使用get_user_pages来读取一个巨大的页,该页将被标记为“Dirty”同时不会经历COW周期,这就说明原来can_follow_write_pmd的逻辑已经被打破。

在这一点上,我们就能直接利用这个BUG,可以使用一个与原来的脏牛竞争条件漏洞类似的模式。这一次,我们去掉了内存页的复制版本,并让原始页故障两次,第一次让它到达当前,第二次是将Dirty bit置为1

那么问题来了,这个漏洞有多严重呢?

 

漏洞影响

为了利用该漏洞,我们必须要选择一个只读大内存页来作为要写入的目标。唯一的约束条件是,我们需要在获取该页之后,将它的madvise (MADV_DONTNEED)去掉。在fork之后由父进程继承的匿名大内存页是一个有价值的目标,但需要注意的是,一旦被丢弃,我们就无法再找回它们。

我们发现了两个目标,是不应该被写入的;巨大的零页面、封装(只读)大内存页。

零页面

在写入前,当匿名映射中发生读取错误时,我们会得到一个特殊的物理页,称为零页面。这一项优化主要为了防止系统分配多个零分页,尽管这种情况很少会发生。因此,完全相同的零页面会在许多不同的进程中被映射,它们具有不同的安全级别。

同样的原则也适用于大内存页。因为如果没有发生写错误,就不需要创建另一个巨大的页面。取而代之的是,被称为巨大零内存页(Huge zero page)的页面将被映射。此处需要注意,这个功能可以关闭。

透明大页、shmem和封装文件

shmemtmpfs文件同样可以用于透明大页的映射。可以使用memfd_create系统调用,或者mmaping匿名共享映射来创建shmem文件。也可以使用tmpfs(通常是在dev/shm)的挂载点来创建tmpfs文件。上述两种方法都可以用大页面来进行映射,具体要取决于系统配置。

shmem文件可以被封装,封装文件会限制该文件所允许的操作。这一机制让不互相信任的进程也可以铜鼓共享内存进行通信,而无需再采取额外措施来处理共享内存区域的意外操作。三种典型的封装是:

F_SEAL_SHRINK无法减小文件大小

F_SEAL_GROW:无法增加文件大小

F_SEAL_WRITE:无法修改文件内容

这些封装可以使用fcntl系统调用添加到shmem文件之中。

 

PoC

Poc演示中,我们覆盖了巨大零内存页。同理,也可以覆盖shmem

请注意,当第一个发生在零页的写页面错误出现后,它将会被换为一个全新清零的透明大页。借助这一点,我们成功使得几个进程崩溃。覆盖了巨大零内存页的后果,就是在大型非初始化数据段(BSS)中,可能会产生不正确的初始值。一个常见的攻击方法就是使用零值作为一个尚未初始化的全局变量的指示器。

下面的例子就演示了这种方式。在这个例子中,火狐的JS Helper线程创建了一个空deref,可能是由于被%rdx指向的布尔型变量误认为对象已经被初始化了。

Thread 10 "JS Helper" received signal SIGSEGV, Segmentation fault.

[Switching to Thread 0x7fffe2aee700 (LWP 14775)]

0x00007ffff13233d3 in ?? () from /opt/firefox/libxul.so

(gdb) i r

rax            0x7fffba7ef080 140736322269312

rbx            0x0 0

rcx            0x22 34

rdx            0x7fffba7ef080 140736322269312

rsi            0x400000000 17179869184

rdi            0x7fffe2aede10 140736996498960

rbp            0x0 0x0

rsp            0x7fffe2aede10 0x7fffe2aede10

r8             0x20000 131072

r9             0x7fffba900000 140736323387392

r10            0x7fffba700000 140736321290240

r11            0x7fffe2aede50 140736996499024

r12            0x1 1

r13            0x7fffba7ef090 140736322269328

r14            0x2 2

r15            0x7fffe2aee700 140736996501248

rip            0x7ffff13233d3 0x7ffff13233d3

eflags         0x10246 [ PF ZF IF RF ]

cs             0x33 51

ss             0x2b 43

ds             0x0 0

es             0x0 0

fs             0x0 0

gs             0x0 0

(gdb) x/10i $pc-0x10

   0x7ffff13233c3: mov    %rax,0x10(%rsp)

   0x7ffff13233c8: mov    0x8(%rdx),%rbx

   0x7ffff13233cc: mov    %rbx,%rbp

   0x7ffff13233cf: and    $0xfffffffffffffffe,%rbp

=> 0x7ffff13233d3: mov    0x0(%rbp),%eax

   0x7ffff13233d6: and    $0x28,%eax

   0x7ffff13233d9: cmp    $0x28,%eax

   0x7ffff13233dc: je     0x7ffff1323440

   0x7ffff13233de: mov    %rbx,%r13

   0x7ffff13233e1: and    $0xfffffffffff00000,%r13

(gdb) x/10w $rdx

0x7fffba7ef080: 0x41414141 0x00000000 0x00000000 0x00000000

0x7fffba7ef090: 0xeef93bba 0x00000000 0xda95dd80 0x00007fff

0x7fffba7ef0a0: 0x778513f1 0x00000000

另一个产生崩溃的例子,是在火狐调试模式中,加载符号而导致的gdb崩溃。

(gdb) r
Starting program: /opt/firefox/firefox
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Program received signal SIGSEGV, Segmentation fault.
0x0000555555825487 in eq_demangled_name_entry (a=0x4141414141414141, b=<optimized out>) at symtab.c:697
697   return strcmp (da->mangled, db->mangled) == 0;
(gdb) i s
#0  0x0000555555825487 in eq_demangled_name_entry (a=0x4141414141414141, b=<optimized out>) at symtab.c:697
#1  0x0000555555955203 in htab_find_slot_with_hash (htab=0x555557008e60, element=element@entry=0x7fffffffdb00, hash=4181413748, insert=insert@entry=INSERT) at ./hashtab.c:659
#2  0x0000555555955386 in htab_find_slot (htab=<optimized out>, element=element@entry=0x7fffffffdb00, insert=insert@entry=INSERT) at ./hashtab.c:703
#3  0x00005555558273e5 in symbol_set_names (gsymbol=gsymbol@entry=0x5555595b3778, linkage_name=linkage_name@entry=0x7ffff2ac5254 "_ZN7mozilla3dom16HTMLTableElement11CreateTHeadEv", len=len@entry=48,
copy_name=copy_name@entry=0, objfile=<optimized out>) at symtab.c:818
#4  0x00005555557d186f in minimal_symbol_reader::record_full (this=0x7fffffffdce0, this@entry=0x1768bd6, name=<optimized out>,
name@entry=0x7ffff2ac5254 "_ZN7mozilla3dom16HTMLTableElement11CreateTHeadEv", name_len=<optimized out>, copy_name=copy_name@entry=48, address=24546262, ms_type=ms_type@entry=mst_file_text,
section=13) at minsyms.c:1010
#5  0x00005555556959ec in record_minimal_symbol (reader=..., name=name@entry=0x7ffff2ac5254 "_ZN7mozilla3dom16HTMLTableElement11CreateTHeadEv", name_len=<optimized out>, copy_name=copy_name@entry=false,
address=<optimized out>, address@entry=24546262, ms_type=ms_type@entry=mst_file_text, bfd_section=<optimized out>, objfile=0x555557077860) at elfread.c:209
#6  0x0000555555696ac6 in elf_symtab_read (reader=..., objfile=objfile@entry=0x555557077860, type=type@entry=0, number_of_symbols=number_of_symbols@entry=365691,
symbol_table=symbol_table@entry=0x7ffff6a6d020, copy_names=copy_names@entry=false) at elfread.c:462
#7  0x00005555556970c4 in elf_read_minimal_symbols (symfile_flags=<optimized out>, ei=0x7fffffffdcd0, objfile=0x555557077860) at elfread.c:1084
#8  elf_symfile_read (objfile=0x555557077860, symfile_flags=...) at elfread.c:1194
#9  0x000055555581f559 in read_symbols (objfile=objfile@entry=0x555557077860, add_flags=...) at symfile.c:861
#10 0x000055555581f00b in syms_from_objfile_1 (add_flags=..., addrs=0x555557101b00, objfile=0x555557077860) at symfile.c:1062
#11 syms_from_objfile (add_flags=..., addrs=0x555557101b00, objfile=0x555557077860) at symfile.c:1078
#12 symbol_file_add_with_addrs (abfd=<optimized out>, name=name@entry=0x55555738c1d0 "/opt/firefox/libxul.so", add_flags=..., addrs=addrs@entry=0x555557101b00, flags=..., parent=parent@entry=0x0)
at symfile.c:1177
#13 0x000055555581f63d in symbol_file_add_from_bfd (abfd=<optimized out>, name=name@entry=0x55555738c1d0 "/opt/firefox/libxul.so", add_flags=..., addrs=addrs@entry=0x555557101b00, flags=...,
parent=parent@entry=0x0) at symfile.c:1268
#14 0x000055555580b256 in solib_read_symbols (so=so@entry=0x55555738bfc0, flags=...) at solib.c:712
#15 0x000055555580be9b in solib_add (pattern=pattern@entry=0x0, from_tty=from_tty@entry=0, readsyms=1) at solib.c:1016
#16 0x000055555580c678 in handle_solib_event () at solib.c:1301
#17 0x00005555556f9db4 in bpstat_stop_status (aspace=0x555555ff5670, bp_addr=bp_addr@entry=140737351961185, ptid=..., ws=ws@entry=0x7fffffffe1d0) at breakpoint.c:5712
#18 0x00005555557ad1ef in handle_signal_stop (ecs=0x7fffffffe1b0) at infrun.c:5963
#19 0x00005555557aec8a in handle_inferior_event_1 (ecs=0x7fffffffe1b0) at infrun.c:5392
#20 handle_inferior_event (ecs=ecs@entry=0x7fffffffe1b0) at infrun.c:5427
#21 0x00005555557afd57 in fetch_inferior_event (client_data=<optimized out>) at infrun.c:3932
#22 0x000055555576ade5 in gdb_wait_for_event (block=block@entry=0) at event-loop.c:859
#23 0x000055555576aef7 in gdb_do_one_event () at event-loop.c:322
#24 0x000055555576b095 in gdb_do_one_event () at ./common/common-exceptions.h:221
#25 start_event_loop () at event-loop.c:371
#26 0x00005555557c3938 in captured_command_loop (data=data@entry=0x0) at main.c:325
#27 0x000055555576d243 in catch_errors (func=func@entry=0x5555557c3910 <captured_command_loop(void*)>, func_args=func_args@entry=0x0, errstring=errstring@entry=0x555555a035da "",
mask=mask@entry=RETURN_MASK_ALL) at exceptions.c:236
#28 0x00005555557c49ae in captured_main (data=<optimized out>) at main.c:1150
#29 gdb_main (args=<optimized out>) at main.c:1160
#30 0x00005555555ed628 in main (argc=<optimized out>, argv=<optimized out>) at gdb.c:32
(gdb) list
692   const struct demangled_name_entry *da
693     = (const struct demangled_name_entry *) a;
694   const struct demangled_name_entry *db
695     = (const struct demangled_name_entry *) b;
696
697   return strcmp (da->mangled, db->mangled) == 0;
698 }
699
700 /* Create the hash table used for demangled names.  Each hash entry is
701    a pair of strings; one for the mangled name and one for the demangled
(gdb)

以上源代码请参考我们的Githubhttps://github.com/bindecy/HugeDirtyCowPOC

 

总结

这一漏洞,说明了在安全开发的生命周期中,对修复程序进行审计也是非常重要的。根据脏牛和其他一些漏洞的以往经验,有一些漏洞的官方补丁并没有完整修复。并且,这种情况不仅仅出现在闭源软件上,开源软件也出现过此类的问题。

 

时间线

最初的报告于20171122日提交,相应团队立即作出了响应,并在几天之内发布补丁,修复了touch_pmd函数,仅当调用方请求写访问权限时才会设置PMD条目的dirty位。

20171122日 将原始报告发送至 security@kernel.orglinux-distros@vs.openwall.org

20171122日 分配CVE-2017-1000405编号

20171127日 主内核补丁发布

20171129日 公开披露细节

 

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多