一、漏洞详情
漏洞描述
脏牛漏洞(CVE-2016–5195) 可能是公开后影响范围最广和最深的漏洞之一,这十年来的每一个Linux版本,包括Android、桌面版和服务器版都受到其影响。通过该漏洞可以轻易地绕过常用的漏洞防御方法,攻击到几百万的用户。目前已经有许多关于该漏洞的分析文章,但很少有对其补丁的深入研究。
我们(Bindecy)对该补丁和内容十分感兴趣,更重要的是,尽管漏洞的后果已经十分严重,但我们发现它的修复补丁仍存在缺陷,由于修复补丁不完善而产生的新利用方法(CVE-2017–1000405)。
影响范围
2.6.38后的内核版本都有可能受到影响。
已知影响版本:
回顾脏牛漏洞
首先我们需要完整地来理解一下原始的脏牛漏洞利用方式。考虑到已经有详细的解释(关于脏牛分析的链接)所以我们假设你已经有一定的Linux内存管理基础,不再具体讲述之前漏洞的分析。
之前的漏洞是在get_user_pages函数中。这个函数能够获取用户进程调用的虚拟地址之后的物理地址,调用者需要声明它想要执行的具体操作(例如写、锁等操作)所以内存管理可以准备相对应的内存页。具体来说,也就是当进行写入私有映射的内存页时,会经过一个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读取页表来获取指定地址的物理页(同时通过PTE允许)或获取不满足需求的请求内容。在follow_page_mask操作中会获取PTE的spinlock-用来保护我们试图获取内容的物理页不被泄露。
faultin_page函数申请内存管理的权限(同样有PTE的spinlock保护)来处理目标地址中的错误信息。注意在成功调用faultin_page后,锁会自动释放-从而保证follow_page_mask能够成功进行下一次尝试,下面是我们使用时可能涉及到的代码。
原始的漏洞代码在faultin_page底部:
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) *flags &= ~FOLL_WRITE;
移除FOLL_WRITE标志的原因是考虑到只读VMA(当VMA中有VM_MAYWRITE标志)使用FOLL_FORCE标志的情况,在我们的例子中,pte_maybe_mkwrite函数不会修改写入字节,然而faulted-in页是可以进行写入的。
当页在进行faultin_page时经过COW循环(有VM_FAULT_WRITE标志),同时VMA是不可写入的,那么FOLL_WRITE标志将会在下次尝试访问页时移除-只能进行只读权限的请求。
在最初的follow_page_mask因为页只读或不存在而失败后,我们会尝试进一步的研究。想象下直到下一次试图获取页的这段时间里,我们跳过COW版本(例如使用madvise(MADV_DONTNEED)。
下一次调用的faultin_page将不会有FOLL_WRITE标志,所以我们从缓存页中获取能够获取只读版本的页文件。现在因为下一次调用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_FORCE和FOLL_COW标志声明过且PTE标记为污染,就可以获取只读页的写入操作。
这个补丁假设只读权限的复制页永远不会有PTE指针调用dirty bit,这是不是一个靠谱的假设呢?
大页内存管理(THP)
一般来说,Linux通常使用4096字节的页。我们可以增加页表项,或使用更大的页来使系统管理更大的内存。我们使用Linux的大内存页,即后者方法来满足我们的需求。
一个大页一般是指2MB的长页,一种利用方式是通过大页管理机制,尽管还有其他的利用方式来管理大内存页,但本文不做更多的讨论。
内核会通过分配大页来满足相关的内存需求,THP是可以交换且不可破坏的(例如分割为普通的4096字节页),能够用于匿名,shmem和tmpfs映射(后两者只在新内核版本中使用)。
一般来说(根据编译标志头和机器设置)默认的THP设置只允许匿名映射,shmem和tmpfs支持都需要手动打开,而通常THP设置都会根据系统运行的内核具体文件来决定是打开还是关闭。
一个重要的优化方式是将普通页聚合为大内存页,一个叫做khugepaged的镜像会不断扫描可用于聚合为大页的普通页。显然一个VMA(虚拟内存空间)必须包含整个连续的2MB内存页面才能被用于聚合。
THP通过PMD(Pages Medium目录,PTE文件下一级)的_PAGE_PSE设置来打开,PMD指向一个2MB的内存页而非PTEs目录。PMDs在每一次扫描到页表时都会通过pmd_trans_huge函数进行检查,所以我们可以通过观察PMD指向pfn还是PTEs目录来判断是否可以聚合。在一些结构中,大PUDs(上一级目录)同样存在,这会导致产生1GB的页。
THP从2.6.38内核版本后都提供支持,在大多数Android设备中THP子系统都没有被启动。
漏洞
仔细查看脏牛补丁中关于THP的部分,我们可以发现大PMDs中用了和can_follow_write_pte同样的逻辑,其添加的对应函数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函数,无需COW循环就标记为dirty:
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); }
这个函数在follow_page_mask每次获取get_user_pages试图访问大页面时被调用。很明显这个注释有问题而现在dirty bit并非无意义的。尤其是在使用get_user_pages来读取大页时,这个页会无需经过COW循环而标记为dirty,使得can_follow_write_pmd的逻辑发生错误。
在此时,如何利用该漏洞就很明显了-我们可以使用类似脏牛的方法。这次在我们获取复制的内存页后,可以污染两次原始页-第一次创建它,第二次写入dirty bit。
那么一个不可避免的问题来了,究竟有多严重的影响?
漏洞说明
我们通过对一个只读大内存页进行写入操作来展示漏洞的利用,其中唯一约束是madvise(MADV_DONTNEED)限制我们的内存访问。在fork后继承自主进程的匿名大页是一个容易攻击的目标,但他们在销毁后就关闭了-也就是说我们无法再次访问它。
我们发现了两类不应写入内容的可能攻击目标:
-
大零内存页
-
封闭(只读)大内存页
零页
当匿名映射在写入前发生读取错误时,我们会获取叫做零页的物理地址。这个优化系统可以避免系统分配多个未被写入的新建零页。所以同一个零页可能映射在多个不同有不同安全等级的进程中。
同样的原理也应用在大页中-如果没有发生错误,那么就无需创建另一个大页-映射产生一个叫做大零页的特殊页,注意这个特性是可以手动关闭的。
THP,shmem和封闭文件
在使用THP时都会包含shmem和tmpfs文件,shmem文件可以通过memfd_create_syscall或通过共享映射来创建,tmpfs文件可以通过tmpfs(通常为/dev/shm)的指针来创建,两个都可以根据系统设置来映射大内存页。
shmem文件可以被封闭-封闭文件能够限制用户可以对文件进行的操作类型。这一机制允许相互不信任的进程通过无需额外操作的共享内存来交流信息从而处理共享内存区域的意外操作 (搜索man memfd_create()函数可以了解更多信息),一共有三类封闭类型:
-
F_SEAL_SHRINK:文件大小不可被缩小
-
F_SEAL_GROW:文件大小不可被增加
-
F_SEAL_WRITE:文件内容不可被修改
这些封闭方式都可以通过funtl syscall来添加到shmem文件中。
Poc
我们的poc展示了对零页的重新写入,重新写入shmem的利用方式可能产生其他类似的漏洞利用方式。
注意在最初地写入零页操作后,它会用一个新的(也是原始的)大页内存管理来替换掉。通过这一方法,我们成功地令多个进程崩溃。对零页区域的重新写入可能导致程序中BSS段的错误初始化,常见的漏洞利用方式包括利用它来声明未被初始化的全局变量。
下列崩溃案例展示了这一具体内容,在该案例中,火狐的JS Helper线程可能因为%rdx错误地使用了布尔指针,从而创建了一个使用NULL-deref的对象。
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=
Poc下载地址
https://github.com/bindecy/HugeDirtyCowPOC
// // The Huge Dirty Cow POC. This program overwrites the system's huge zero page. // Compile with "gcc -pthread main.c" // // November 2017 // Bindecy // #define _GNU_SOURCE #include
总结
这个漏洞展示了补丁跟踪在安全开发周期中的重要性,正如同脏牛和其他漏洞的例子一样,即便是修复过的漏洞也可能产生有风险的修复补丁,这不仅是闭源软件面临的问题,开源软件也有这样的风险。
欢迎在下面提出和交流更多的想法和问题。
公开时间
最初的报道是17年11月22号,几天后发布了相应的修复补丁,补丁修复了当使用者请求写入操作时touch_pmd函数PMD入口的dirty bit。
感谢安全团队对该高安全漏洞的长期关注和修复所做出的贡献。
17年11月22日-漏洞内容提交到security@kernel.org和linux-distros@vs.openwall.org。
17年11月22日-获得CVE-2017-1000405编号。
17年11月27日-发布修复补丁。
17年11月29日-公开漏洞内容。
参考
https://bbs.pediy.com/thread-223056.htm
https://medium.com/bindecy/huge-dirty-cow-cve-2017-1000405-110eca132de0