6月1日,我报告了一个在开启了home目录加密的ubuntu上的本地用户可触发的linux内核任意递归漏洞。如果您想查看poc以及exploit代码还有简报,请查阅 https://bugs.chromium.org/p/project-zero/issues/detail?id=836 。
Linux给每一个用户进程分配了8M大小的栈,如果程序耗尽了这个栈的话,比如用了无限递归,就会触发栈后面的页保护。
但是Linux的内核栈就很不一样了,尤其是处理系统调用的时候。内核栈相对较短,32位系统上4096bytes,64位系统上16384bytes。(内核栈大小由 THREAD_SIZE_ORDER
和 THREAD_SIZE
指定。)内核栈由linux的伙伴系统(buddy allocator)来分配的,这也是linux系统中常规的页分配机制,并且伙伴系统并不提供页保护。这就意味着内核栈溢出的时候可写入正常数据。由于这个原因,内核代码必须(通常也是)非常小心,尽量不要申请大块内存,尽量避免过多的递归。
Linux系统中的绝大部分文件系统不会使用基础设备(underlying device,即伪文件系统pseudo-filesystems,比如sysfs,procfs,tmpfs等等)或者块设备(block device,典型的比如硬盘上的一个分区)作为后备设备(backing device)。但有两种文件系统,ecryptfs和overlayfs是例外:它们复用文件系统(stacking filesystems),即利用一个(overlayfs利用了其他多个文件系统中的多个文件夹)存在于其他文件系统里的文件夹来作为后备设备。(作为后被设备的文件系统叫做底层文件系统lower filesystem,存在于底层文件系统里的文件叫做底层文件lower files。)复用文件系统的作用是在转接对于底层文件系统的访问的时候,对交换的数据做一些修改。这样做的结果,overlayfs文件系统将多个文件系统整合到一起,而ecryptfs被用来做透明加密。
stacking filesystems的一个潜在问题是,因为虚拟文件系统的句柄经常调用其下层实际文件系统的句柄,这样比单纯和真实的文件系统交互麻烦很多,增加了内核栈的负荷。如果可能的话,将上层的虚拟系统当做另外一个虚拟系统的实际的底层系统,以此类推,无限递归,内核栈就会被耗尽。当然,这可以通过设定FILESYSTEM_MAX_STACK_DEPTH变量的值来限定嵌套的层数——在一个没有被嵌套的文件系统上只可以嵌套两层。
procfs伪文件系统给每一个正在运行的进程准备了一个目录,每一个目录里面都包含了这个进程的描述文件。我们这里感兴趣的只是“mem”,“environ”和“cmdline”这三个文件,因为访问它们就是同步访问这个进程的虚拟内存。这些文件暴露了虚拟内存地址范围:
如果可以mmap()这个"mem"文件的话(不要把它想得太难),你可以这样设置页:
假设/proc/$pid/mem页面需要被载入,这个在进程C里的缺页异常需要从进程B里载入页面,这可以造成进程B的缺页异常,需要从进程A里面载入页面——递归缺页
这种情况实际上不会发生,mem、environ和cmdline文件只有VFS句柄,和正常的读写权限,并不能mmap:
#!cpp static const struct file_operations proc_pid_cmdline_ops = { .read = proc_pid_cmdline_read, .llseek = generic_file_llseek, }; [...] static const struct file_operations proc_mem_operations = { .llseek = mem_lseek, .read = mem_read, .write = mem_write, .open = mem_open, .release = mem_release, }; [...] static const struct file_operations proc_environ_operations = { .open = environ_open, .read = environ_read, .llseek = generic_file_llseek, .release = mem_release, };
有趣的是,ecryptfs文件系统支持mmap()。因为底层实际系统加密的页面,展示给用户的时候需要解密,ecryptfs不能把mmap()操作直接交给底层实际文件系统的mmap()。所以ecryptfs需要有自己的页面缓存。
当ecryptfs处理一个缺页异常的时候,它必须从底层文件系统中读取一个加密的页面。这可以通过读取底层文件系统的页面缓存(用底层文件系统的mmap句柄)实现,但这样做会浪费内存。于是,ecryptfs只是简单地利用底层文件系统的VFS句柄(通过kernel_read())。这高效又直接多了,但是也有副作用——有可能mmap()到原本不可能被分页到的解密后的文件(因为ecryptfs的mmap句柄有效时间和底层文件系统中包含了有效加密数据的文件读取句柄一致)。
于是,我们可以把前面提到的这些点串联起来。首先创建进程A,PID是$A。然后在ecryptfs文件系统里创建了/tmp/$A,在真实的底层文件系统创建了/proc/$A。(ecryptfs只会有一个key所以文件名不会被加密)现在,如果对应的/proc/$A包含了有效的ecryptfs数据结构头部的话,/tmp/$A/mem,/tmp/$A/environ和/tmp/$A/cmdline都是可被分页到的。除非有root权限,否则无法在进程A中分配0地址,对应着/proc/$A/mem的偏移0处。所以访问/proc/$A/mem的开头永远返回-EIO,并且/proc/$A/mem也永远不可能有一个有效的加密数据头。因此,environ和cmdline的潜在威胁更大一些。
在那些用参数CONFIG_CHECKPOINT_RESTORE编译的linux内核上(至少Ubuntu发行版的内核是这样),mm_struct结构的成员arg_start,arg_end,env_start和env_end可以轻易地被非特权进程利用prctl(PR_SET_MM, PR_SET_MM_MAP, struct prctl_mm_map *, size)修改。这可以在任意虚拟机制上映射/proc/$A/environ和/proc/$A/cmdline。(在没有checkpoint-restore支持的kernel上,攻击执行的稍有些麻烦,设置好参数和环境重新安排栈内存的话还是有可能执行成功的)
如果一个有效的加密过的ecryptfs文件被载入了进程A的内存空间,然后这个进程的运行环境被配置成指向这块内存,这个运行环境里解密后的数据/tmp/$A/environ就变得可访问了。然后,这个文件可以被映射进另一个进程,进程B。为确保上面的过程能够重复进行,一些数据需要被ecryptfs反复加密,在进程A里面创建了ecryptfs套娃。现在,一个互相指向对方解密后的运行环境内存的进程链表如下:
如果在进程C和进程B相应的内存页面没有数据的话,C进程里产生的缺页(用户态产生的缺页或者用户态的内核访问copy_from_user())引起ecryptfs文件系统去读取/proc/$B/environ,又引发了进程B的缺页,又通过ecryptfs去读/proc/$A/environ,再引发进程A的缺页。这个过程可以任意循环下去,造成内核栈溢出:
#!bash [...] [<ffffffff811bfb5b>] handle_mm_fault+0xf8b/0x1820 [<ffffffff811bac05>] __get_user_pages+0x135/0x620 [<ffffffff811bb4f2>] get_user_pages+0x52/0x60 [<ffffffff811bba06>] __access_remote_vm+0xe6/0x2d0 [<ffffffff811e084c>] ? alloc_pages_current+0x8c/0x110 [<ffffffff811c1ebf>] access_remote_vm+0x1f/0x30 [<ffffffff8127a892>] environ_read+0x122/0x1a0 [<ffffffff8133ca80>] ? security_file_permission+0xa0/0xc0 [<ffffffff8120c1a8>] __vfs_read+0x18/0x40 [<ffffffff8120c776>] vfs_read+0x86/0x130 [<ffffffff812126b0>] kernel_read+0x50/0x80 [<ffffffff81304d53>] ecryptfs_read_lower+0x23/0x30 [<ffffffff81305df2>] ecryptfs_decrypt_page+0x82/0x130 [<ffffffff813040fd>] ecryptfs_readpage+0xcd/0x110 [<ffffffff8118f99b>] filemap_fault+0x23b/0x3f0 [<ffffffff811bc120>] __do_fault+0x50/0xe0 [<ffffffff811bfb5b>] handle_mm_fault+0xf8b/0x1820 [<ffffffff811bac05>] __get_user_pages+0x135/0x620 [<ffffffff811bb4f2>] get_user_pages+0x52/0x60 [<ffffffff811bba06>] __access_remote_vm+0xe6/0x2d0 [<ffffffff811e084c>] ? alloc_pages_current+0x8c/0x110 [<ffffffff811c1ebf>] access_remote_vm+0x1f/0x30 [<ffffffff8127a892>] environ_read+0x122/0x1a0 [...]
有关怎么触发这个洞:需要ecryptfs文件系统作为源挂载/proc/$pid。ecryptfs软件包安装好之后(如果在安装的时候选择加密home目录的话,ubuntu会自动安装好),通过/sbin/mount.ecryptfs_private来完成挂载。(用户有源目录的权限,但这不是问题,因为这是用户拥有他自己的进程的目录权限)
下面的测试只针对amd64平台。
曾经攻击这样的漏洞很简单: Jon Oberheide's "The Stack is Back" slides 讨论过,曾经还是可以溢出覆盖到栈底的thread_info结构,用适当的值覆盖restart_block或者addr_limit,取决于你选择攻击哪个,利用用户态可执行页面的代码或者利用copy_from_user()和copy_to_user()读写内核数据。
然而,restart_block被从thread_info结构中移除,而且触发栈溢出漏洞的时候有包含kernel_read()的栈帧,所以addr_limit已经是KERNEL_DS,并且在返回时被置成USER_DS。另外,Ubuntu Xenial发行版内核开启了CONFIG_SCHED_STACK_END_CHECK配置选项,导致不论是否被调用,thread_info结构前面的canary都会被检查;如果canary不正确的话,内核会抛出panic(在 29d6455178a0 中修复为立即panic)。
由于在thread_info结构中找不到任何有价值的目标(而且从thread_info结构里移除内容并不是一个很好的缓解措施),我选择了一个不同的策略:仅仅溢出覆盖栈的前部,即攻击本栈帧本身和其他的栈数据。这种方法的问题是,canary和一些其他的thread_info结构内容不能被覆盖。栈看起来如下(绿色的是可以覆盖的,红色的是不能覆盖的,黄色的根据数据的不同而不同对待):
幸运的是,栈帧里有洞——如果递归的底部用cmdline而不是environ的话,会有一个5qword的洞在递归中没有被用到,可以避开从STACK_END_MAGIC到flags之间的所有数据。这些洞可以通过安全递归和内核调试模块观察到:
#!bash [...] 0xffff88015d115030: 0x0000000000000020 0xffff880077494640 0xffff88015d115040: 0xffffea000531feb0 0xffff88015d115118 0xffff88015d115050: 0xffffffff811bfc2b 0xdead505cdead5058 0xffff88015d115060: 0xdead5064dead5060 0xdead506cdead5068 0xffff88015d115070: 0xffff88014e3dff70 0xffff88015d1150d8 [...] 0xffff88015d115120: 0xffffffff811bacd5 0xdead512cdead5128 0xffff88015d115130: 0xdead5134dead5130 0xdead513cdead5138 0xffff88015d115140: 0xdead5144dead5140 0xdead514cdead5148 0xffff88015d115150: 0xffff8800d8364b00 0xffff88015d118000 [...] 0xffff88015d1154d0: 0xffffffff811bfc2b 0xdead54dcdead54d8 [...] 0xffff88015d1155a0: 0xffffffff811bacd5 0xdead55acdead55a8 0xffff88015d1155b0: 0xdead55b4dead55b0 0xdead55bcdead55b8 0xffff88015d1155c0: 0xdead55c4dead55c0 0xdead55ccdead55c8 [...] 0xffff88015d115950: 0xffffffff811bfc2b 0xdead595cdead5958 0xffff88015d115960: 0xdead5964dead5960 0xdead596cdead5968 [...] 0xffff88015d115a20: 0xffffffff811bacd5 0xdead5a2cdead5a28 0xffff88015d115a30: 0xdead5a34dead5a30 0xdead5a3cdead5a38 0xffff88015d115a40: 0xdead5a44dead5a40 0xdead5a4cdead5a48 [...]
下一个问题是这些洞只出现在特定深的栈帧中,要攻击成功,必须让它们出现在恰当的位置。这里有几个技巧可以利用:
最后,我把environ文件,cmdline文件,write()系统调用和uid_map的VFS句柄结合起来。
现在我们可以递归覆盖到栈的前部并且不影响任何危险的栈内区域。当被溢出的栈返回的时候,内核线程必须被暂停,返回地址需要被覆盖,并且指向一个新的构造出来的栈,然后再恢复内核线程的运行。
为了在递归中终止内核线程,设定好前面的页链表之后,这个链表的最后一页可设置为FUSE页面(userfaultfd不行,它不会响应远程内存访问)
有关栈前部分填充的内容,我用了pipe。向一个新分配的空pipe写入数据的时候,linux的伙伴系统就会分配一个页面给它。exploit只是简单地用clone()创建进程然后用pipe垃圾数据填充内存,这会触发缺页异常。用clone()而不是fork()因为需要更少的设置参数,减少了递归中的噪音。在clone()的时候,所有的pipe页面都被填满,但除了在FUSE暂停递归进程的时候,一开始保存的在RSP之后的RIP没有被改写。写入得更少会导致第二个pipe在RIP被控制之前改写栈里边的内容,这可能使内核崩溃。一旦递归在FUSE里面停下,二次写入所有pipe,覆盖RIP和其后面的数据,并用新的攻击者控制的假栈帧代替。
最后一道防线就是ASLR了。正如ubuntu的官方描述 Security Features page ,x86和amd64上都支持ASLR,但是需要用户手动开启。这个bug已经被很快 修复 ,现在所有的发行版应该默认开启了ASLR。由于绝大多数的机器上的系统并没有加入任何特殊的内核参数,所以这里假设KASLR并没有被编入内核,攻击者知道内核text和静态数据的地址。
然后你可以选择利用ROP来完成commit_creds(prepare_kernel_cred(NULL)),而我选择了另一条路。请注意下,在栈溢出发生的时候,addr_limit结构里面的KERNEL_DS变量的值,在最后返回的时候被设置成了USER_DS,但在这里我们是直接返回到的用户态地址空间,addr_limit结构里仍然是KERNEL_DS,所以我的exploit填充了一个新的栈:
#!cpp unsigned long new_stack[] = { 0xffffffff818252f2, /* return pointer of syscall handler */ /* 16 useless registers */ 0x1515151515151515, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (unsigned long) post_corruption_user_code, /* user RIP */ 0x33, /* user CS */ 0x246, /* EFLAGS: most importantly, turn interrupts on */ /* user RSP */ (unsigned long) (post_corruption_user_stack + sizeof(post_corruption_user_stack)), 0x2b /* user SS */ };
结束掉FUSE服务进程之后,递归进程会在post_corruption_user_code函数处恢复,这个函数可以通过pipe写任意内核地址,因为检查函数copy_to_user()被禁掉了:
#!cpp void kernel_write(unsigned long addr, char *buf, size_t len) { int pipefds[2]; if (pipe(pipefds)) err(1, "pipe"); if (write(pipefds[1], buf, len) != len) errx(1, "pipe write"); close(pipefds[1]); if (read(pipefds[0], (char*)addr, len) != len) errx(1, "pipe read to kernelspace"); close(pipefds[0]); }
现在你可以在用户态任意读写地址了,如果想要一个root权限的shell,可以覆盖coredump句柄,它的位置处在一个固定地址,然后触发SIGSEGV信号:
#!cpp char *core_handler = "|/tmp/crash_to_root"; kernel_write(0xffffffff81e87a60, core_handler, strlen(core_handler)+1);
这个bug在两个独立的补丁中修复: 2f36db710093 禁止cryptfs在不用mmap句柄的时候打开文件, e54ad7f1ee26 禁止procfs上面嵌套任何东西,因为还有其他很多黑魔法来利用procfs,并且在procfs上嵌套任何东西都是不必要的。
我写这篇完整的exploit攻击文章只是想演示下linux栈溢出可以发生在很隐蔽的地方,就算现有的防护全部开启,仍然是可以攻击成功的。在我的报告中,问过linux security list,为什么不像其他操作系统一样在内核栈中增加页保护并且在栈底移除thread_info结构,Andy Lutomirski已经开始着手这项工作了 https://lkml.org/lkml/2016/6/15/1064 。