Linux内核中的递归漏洞利用

6月1号,我提交了一个linux内核中的任意递归漏洞。如果安装Ubuntu系统时选择了home目录加密的话,该漏洞即可由本地用户触发。如果想了解漏洞利用代码和短一点的漏洞报告的话,请访问https://bugs.chromium.org/p/project-zero/issues/detail?id=836。

背景知识

在Linux系统中,用户态的栈空间通常大约是8MB。如果有程序发生了栈溢出的话(比如无限递归),栈所在的内存保护页一般会捕捉到。

Linux内核栈(可以用来处理系统调用)和用户态的栈很不一样。内核栈相对来说更短:32位x86架构平台为4096byte , 64位系统则有16384byte(内核栈大小由THREAD_SIZE_ORDER 和 THREAD_SIZE 确定)。

它们是由内核的伙伴内存分配器分配,伙伴内存分配器是内核常用来分配页大小(以及页大小倍数)内存的分配器,它不创建内存保护页。也就是说,如果内核栈溢出的话,它将直接覆盖正常的数据。

正因如此,内核代码必须(通常也是)在栈上分配大内存的时候非常小心,并且必须阻止过多的递归。

Linux上的大多数文件系统既不用底层设备(伪文件系统,比如sysfs, procfs, tmpfs等),也不用块设备(一般是硬盘上的一块)作为备用存储设备。然而, ecryptfs 和overlayfs是例外。

这两者是堆叠的文件系统,这种文件系统会使用其他文件系统上的文件夹作为备用存储设备(overlayfs则使用多个不同文件系统上的多个文件作为备用存储设备)。被用作备用存储设备的文件系统称为底层文件系统,其上的文件称为底层文件。

这种层叠文件系统的特点是它或多或少的会访问底层文件系统,并对访问的数据做一些修改。 Overlayfs融合多个文件系统,ecryptfs则进行了相应的加密。

层叠文件系统实际上存在潜在风险,因为其访问虚拟文件系统的函数常会访问到底层文件系统的函数,相较直接访问底层文件系统的句柄,这会增大栈空间。

考虑这样一个场景:如果用层叠文件系统作为另外一个层叠系统的备用存储设备,由于每一层文件系统的堆叠都增大了栈空间,内核栈就会在某些情况下溢出。但是,设置FILESYSTEM_MAX_STACK_DEPTH 限制文件系统的层数,只允许最多两层层叠文件系统放在非层叠文件系统上,就可以避免这个问题。

在Procfs伪文件系统上,系统中运行的每一个进程都有一个文件夹,每个文件夹包含一些描述该进程的文件。值得注意的是每个进程的“mem”,“ environ”和“cmdline”文件,因为访问这些文件会同步访问目标进程的虚拟内存。这些文件显示了不同的虚拟内存地址范围:

1.“mem”文件显示了整个虚拟内存地址范围(需要PTRACE_MODE_ATTACH 权限) 2.“environ”文件显示了mm->env_start 到mm->env_end的内存范围(需要PTRACE_MODE_READ权限) 3.“cmdline”文件显示了mm->arg_start 到mm->arg_end的地址范围(如果mm->arg_end的前一个字符是null 的话)

如果可以用mmap()函数映射“mem”文件的话(啥意义也没有,别想太多),就可以映射成如下图所示的样子:

接下来,假设/proc/$pid/mem的映射有一些错误,那么在进程C里的内存读取错误,将会导致从进程B中映射的内存出错,进而导致进程B里出现其它的内存错误,进而导致从A进程映射的内存出错,这就是一个递归内存错误。

可是,现实中这是不可行的,“mem”,“environ”,“cmdline ”文件只能用VFS函数读写,mmap无法使用:

staticconst struct file_operations proc_pid_cmdline_ops = { .read = proc_pid_cmdline_read, .llseek = generic_file_llseek, }; [...] staticconst struct file_operations proc_mem_operations = { .llseek = mem_lseek, .read = mem_read, .write = mem_write, .open = mem_open, .release = mem_release, }; [...] staticconst struct file_operations proc_environ_operations = { .open = environ_open, .read = environ_read, .llseek = generic_file_llseek, .release = mem_release, };

相关ecryptfs文件系统,比较有趣的一个细节在于它支持mmap()。用户看到的内存映射必须是解密的,而底层文件系统的内存映射是加密的,因而ecryptfs 文件系统不能将mmap()函数直接映射到底层文件系统的mmap()函数上。Ecrypt 文件系统在内存映射时使用了自己的页缓存。

ecryptfs文件系统处理页错误的时候,必须以某种方式读取底层文件系统上加密的页。这可以通过读取底层文件文件系统的页缓存(使用底层文件系统的mmap函数)来实现,但是这样比较消耗内存。

于是它直接使用底层文件系统的 VFS读取函数(通过kernel_read()),这样做更加直接有效,但是这个做法有副作用,就是有可能会mmap() 到通常不能映射的解密后的文件(因为只要底层文件有读权限并且包含合法的加密数据, ecryptfs文件系统的mmap函数就能工作)。

漏洞分析

在此,我们就能描绘完整的攻击方式了。首先创建一个进程A,进程号为$A。然后创建一个ecrypptfs 挂载/tmp/$A,使/proc/$A作为它的底层文件系统(ecryptfs 应该只有一个 key,这样文件名才不会被加密)。

现在,如果/proc/$A下相应的文件有合法的ecryptfs 文件头的话,那么 /tmp/$A/mem, /tmp/$A/environ 和 /tmp/$A/cmdline就可以被映射。除非有 root 权限,否则无法将内存映射到进程 A的0×0处,也就是 /proc/$A/mem的开头。

因此从开始读取 /proc/$/A 总是会返回-EIO,而且 /proc/$A/mem 不会有一个合法的 ecryptfs 文件头。如此,environ 和 cmdline 文件才有攻击的可能性。

在使用CONFIG_CHECKPOINT_RESTORE编译的内核(至少是Ubuntu的 distro 内核)中,非特权用户可以通过prctl(PR_SET_MM, PR_SET_MM_MAP, &mm_map,sizeof(mm_map), 0)设置mm_struct 中的 arg_start, arg_end, env_start 和 env_end值。

这使得映射 /proc/$A/environ 和 /proc/$A/cmdline到任意虚拟内存范围成为可能。(不支持checkpoint-restore的内核中,攻击过程就稍微有点麻烦,但使用所需的参数区域和环境变量的长度重新执行,然后取代部分栈空间的映射,还是有可能的。)

如果一个有效加密的ecryptfs文件被加载到进程A的内存中,并且它的环境变量也被配置为指向这块区域,那么环境变量区域里的解密形式的数据就可以在 /tmp/$A/environ文件中获取。

这个文件也可以被映射到进程B的内存中。为了能够重复该进程,某些数据需要反复加密,进而创建一个加密的matroska 文件,并将这个文件加载到进程 A的内存中。这样一来,映射互相进程解密环境变量区域的进程链就建立起来了:

如果映射到进程C和进程B的内存相应范围内没有数据,进程C 中的内存错误(这个内存错误可能是用户空间产生也可能是由于用户空间访问内核空间,比如通过copy_from_user()函数)将会导致ecryptfs读取 /proc/$B/environ ,进而导致进程B中的内存错误。

接下来导致ecryptfs读取 /proc/$A/environ ,最后导致进程A中的进程错误。如此循环往复,最终溢出内核栈,使内核崩溃。内核栈如下:

[...] [<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 [...]

关于这个漏洞的可利用性:利用该漏洞,需要能够挂载/proc/$pid为ecryptfs文件系统。安装完ecryptfs-utils包之后(如果安装Ubuntu时选择了home目录加密, Ubuntu 会自动安装),使用 /sbin/mount.ecryptfs_私有的setuid辅助函数就可以做到这一点。

漏洞利用

接下来的描述是平台相关的,这里指amd64。

以前要利用这一类漏洞还是相当简单的,可以直接覆盖栈底的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 16.04以后的内核都打开了CONFIG_SCHED_STACHK_END_CHECK 内核配置选项。打开这个选项以后,每次调度到这个线程时, thread_info 结构体上方的金丝雀值都会被检查;如果金丝雀值不正确的话,内核递归就会出错然后崩溃。

由于thread_info结构体中很难照到有价值的攻击目标(同时移除thread_info中的数据并非有效的缓解措施),我就选择了其它方式:溢出栈到栈之前的空间,然后利用栈和其它内存空间之间会重合这一点。

这种方式的问题就是一定要保证金丝雀值和 thread_info结构中的其它成员不被覆盖。栈溢出的内存布局如下所示(绿色表示可以覆盖,红色表示不能覆盖,黄色表示覆盖后可能会有问题):

幸运的是,有些栈帧中存在空洞(如果递归的最底部采用cmdline而不是environ),递归的过程中就会有一个5个QWORD空洞没有被访问到。

这些空洞足够用来存放从SRACK_END_MAIC到flags的所有数据。这一点可以通过一个安全递归和一个内核调试模块来实现,这个内核调试模块将栈中的所有空洞标绿便于观察:

接下来的问题是空洞只会出现在特定的位置,而漏洞利用就需要空洞在准确的位置出现。下面有一些技巧可以用来对齐栈空间:

1.在每个递归层上都可以选择“environ”文件或者“cmdline”文件,它们的栈帧大小和空洞模式都不一样。 2.任何调用copy_from_user()都会导致内存错误。甚至可以将写入系统调用和VFS写入句柄结合起来,所以每一个写入系统调用和 VFS写入句柄都会影响深度(合并深度可以计算出来,而不用测试每个变量)。

在测试了各种组合之后,我找到一组environ文件和cmdline文件, 还有write ()系统调用和进程的VFS写句柄的组合。

随后,就可以递归到之前分配的空间,而不会覆盖任何危险数据了。然后暂停内核线程的执行,此时栈指针指向之前分配的内存空间,这些内存空间应该用新的栈来覆盖,然后继续内核线程的执行。

为了暂停递归中内核线程的执行,在建立起映射链后,映射链最后的annonymous映射可以用FUSE映射取代( userfaultfd 函数并不适用,它不能捕捉远程的内存访问)。

对于先前分配的内存,我的exp使用管道(Pipes)。当写入数据到新分配的空管道时,伙伴内存分配器会分配一个内存页,来存放这些数据。我的exp通过管道内存页分配来填充大量内存,所以使用clone()创建新进程时就会触发内存错误。

这里使用clone() 而非fork(),因为调用clone()时只要控制好参数,系统就会复制较少的信息,可以减少内存分配的干扰。 Clone( ) 函数调用过程中,所有的管道内存页都被填充满,除了第一次保存的 RIP值——递归进程暂停在FUSE中时,它保存在期望的 RSP 值之后。

写入较少的数据就能致使第二个管道写入目标栈数据,这些数据在 RIP控制实现之前就被使用,可能会导致内核崩溃。随后,递归进程在FUSE 中暂停时,第二次向所有管道写入数据,会覆盖保存的 RIP值和其后的数据,攻击者也就能够完全控制全新的栈了。

此时,最后一道防线就是KASLR了。Ubuntu支持KASLR ,只不过KASLR需要手动开启。这个b最近该BUG已经修复了,现在distros内核应该是默认就开启KASLR的。虽说这项安全特性帮不上太大的忙,但毕竟KASLR不需要占用太多资源,开启这项特性就显得相当理所当然了。

由于大多数的设备并不支持向内核命令行传输特殊参数,所以这里假设KASLR虽然编译进了内核,但仍处于未激活状态,攻击者也知道内核代码和静态数据的地址。

然后就可以用ROP在内核里做各种事情了,漏洞利用具体有两个方向可以继续。可以使用ROP进行 commit_creds 类似操作。不过我用了另一个方法。在栈溢出过程中,原来addr_limit的KERNEL_DS 值保存了起来。

栈一次次返回,最终将会把 addr_limit 重置为USER_DS。但如果我们直接返回到用户空间, addr_limit 将保持 KERNEL_DS 。所以我这样构造新栈,或多或少复制了栈顶的数据:

unsigned longnew_stack[] = { 0xffffffff818252f2,/* return pointer of syscall handler */ /* 16 uselessregisters */ 0x1515151515151515,0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (unsignedlong) post_corruption_user_code, /* user RIP */ 0x33, /* userCS */ 0x246, /*EFLAGS: most importantly, turn interrupts on */ /* user RSP*/ (unsignedlong) (post_corruption_user_stack + sizeof(post_corruption_user_stack)), 0x2b /* userSS */ };

杀掉FUSE服务进程后,递归进程继续运行到post_corruption_user_code函数上。这个函数可以使用管道向任意内核地址写数据,因为 copy_to_user()中的地址检查已经失效。

voidkernel_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 tokernelspace"); close(pipefds[0]); }

现在你就可以在用户态舒服地执行任意读写操作了。如果你想要root shell,可以覆盖coredump函数,它存储在一个静态变量里,然后触发一个 SIGSEGV,就可以以root权限执行coredump函数:

char*core_handler = "|/tmp/crash_to_root"; kernel_write(0xffffffff81e87a60,core_handler, strlen(core_handler)+1);

漏洞修复

有两个独立的补丁可用于修复该BUG:其中,2f36db710093 禁止通过ecryptfs打开没有mmap函数的文件, e54ad7f1ee26 禁止在procfs 上层叠任何东西,因为的确没什么道理要在其上层叠任何东西。

不过,我还是写了一个完整的root提权漏洞利用程序。我主要想说明linux栈溢出可能会以非常隐蔽的方式出现,即便开启了一些现有的漏洞缓解措施,它们仍然可利用。

在我写的漏洞报告中,我有提到给内核增加内存保护页,移除栈底部的 thread_info结构体,这样缓解这类漏洞的利用,有其他操作系统就是这么干的。Andy Lutomirski已经开始着手这方面的工作,并发布了增加了内存保护页的补丁包: https://lkml.org/lkml/2016/6/15/1064。

* 本文译者:Michael23,文章参考来源:Blogspot,转载请注明来自FreeBuf黑客与极客(FreeBuf.COM)

原文发布于微信公众号 - FreeBuf(freebuf)

原文发表时间:2016-06-30

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏云计算教程系列

如何在Ubuntu 14.04上设置R.

R是一种流行的开源编程语言,专门用于统计计算和图形。它被统计学家广泛用于开发统计软件和执行数据分析。R的优势之一是允许用户创作和提交自己的包,因此它具有高度且易...

882
来自专栏一个爱瞎折腾的程序猿

express使用记录

2071
来自专栏大魏分享(微信公众号:david-share)

几种微服务部署方式对比与总结

在项目迭代的过程中,不可避免需要”上线“。上线对应着部署,或者重新部署;部署对应着修改;修改则意味着风险。 目前有很多用于部署的技术,有的简单,有的复杂;有的得...

3816

Kafka体系结构:日志压缩

这篇文章是从我们介绍Kafka 体系结构的一系列文章中获得的启发,包括Kafka topic架构,Kafka生产者架构,Kafka消费者架构和Kafka生态系统...

2893
来自专栏C/C++基础

Linux命令(28)——tee命令

tee命令从标准输入读取数据后,将数据重定向到给定的文件和标准输出。给定的文件可以有多个。

1211
来自专栏zhangdd.com

linux服务器性能检测工具nmon使用

今天介绍一款linux系统服务器性能检测的工具-nmon及nmon_analyser (生成性能报告的免费工具),亲测可用。 一.介绍 nmon 工具可以帮...

2923
来自专栏智能计算时代

Envoy架构概览(8):统计,运行时配置,追踪和TCP代理

统计 特使的主要目标之一是使网络可以理解。特使根据配置如何发出大量的统计数据。一般来说,统计分为两类: 下游:下游统计涉及传入的连接/请求。它们由侦听器,HTT...

4315
来自专栏苦逼的码农

TCP流量控制机制

上篇文章讲了TCP拥塞控制机制的原理,没看过的不妨看下:5分钟读懂拥塞控制,这篇文章讲讲TCP流量控制机制。

1872
来自专栏耕耘实录

再提一下Linux系统中的MD5校验

版权声明:本文为耕耘实录原创文章,各大自媒体平台同步更新。欢迎转载,转载请注明出处,谢谢。

2105
来自专栏杨建荣的学习笔记

浅谈Orabbix监控指标(r6笔记第27天)

对于Orabbix监控Oracle来说,它是提供了一个相对轻量级的客户端来综合监控多个数据库实例。从这一点来看,它的角色有点类似于工作中使用的SQLDevelo...

4439

扫码关注云+社区

领取腾讯云代金券