本文是Exploiting Recursion in the Linux Kernel的翻译文章。
在Linux上,用户级进程通常具有大约8 MB长的堆栈。如果一个程序溢出堆栈(例如使用无限递归)通常会被堆栈下方的防护页捕获。
linux內核堆栈是非常不同的(例如在处理系统调用时)。它们相对较短:在32位x86上为4096 8192个字节,在x86-64上为16384个字节。 (内核堆栈大小由THREAD_SIZE_ORDER和THREAD_SIZE指定。)它们使用内核的buddy分配器进行分配,这是内核对页面大小分配的正常分配器(以及两个幂的页数),并且不会创建防护页。这意味着如果内核堆栈溢出,它们会和正常的数据重叠。所以内核代码必须非常小心(在通常情况下),不要在堆栈上进行大的分配,并且必须防止过多的递归。
Linux上的大多数文件系统都不使用底层设备(伪文件系统,如sysfs,procfs,tmpfs等),或使用块设备(通常是硬盘上的分区)作为后备设备。但是ecryptfs和overlayfs这两个文件系统例外:他们将堆叠文件系统作为后备设备,这些文件系统使用另一个文件系统中的文件夹(或者,在overlayfs中,多个其他文件夹,通常来自不同的文件系统)。(作为后备设备的文件系统称为下层文件系统,下层文件系统中的文件称为下层文件。)
堆叠文件系统的想法是它或多或少地转发对较低文件系统的访问,但对传递的数据进行一些修改。 overlayfs将多个文件系统合并成一个通用视图,ecryptfs执行透明加密。
堆叠文件系统的潜在危险是因为它们的虚拟文件系统处理程序经常调用底层文件系统的处理程序,与直接访问底层文件系统相比,堆叠文件系统增加了堆栈使用率。如果可以使用堆栈文件系统作为另一个堆栈文件系统等的支持设备,则在某些时候,内核堆栈会溢出,因为每一层文件系统堆栈都会增加内核堆栈深度。然而,通过在文件系统嵌套层的数量上设置限制(FILESYSTEM_MAX_STACK_DEPTH)就可以避免这种情况,即最多只能将两层嵌套文件系统放置在非堆栈文件系统之上。
procfs伪文件系统包含系统上每个正在运行的进程的一个目录,每个目录包含描述进程的各种文件。 我们感兴趣的是每个进程的mem
,environ
和cmdline
文件,因为从它们读取会导致同步访问目标进程的虚拟内存。 这些文件公开不同的虚拟地址范围:
mem
公开整个虚拟地址范围(并且需要PTRACE_MODE_ATTACH访问)environ
将内存范围从mm-> env_start暴露给mm-> env_end(并且需要PTRACE_MODE_READ访问)cmdline
将内存范围从mm-> arg_start暴露给mm-> arg_end,当mm-> arg_end之前的最后一个字节是空字节时,否则,它会变得更复杂。如果有可能去mmap()“mem”文件(这没有多大意义,不要太想它),你可以设置像这样的映射:
然后,假设/proc/$pid/mem
映射必须出错,进程C中映射的读取页面错误会导致进程B中出现页面错误,这会导致进程B中出现另一个页面错误, 这反过来又会导致页面从进程A的内存出现错误——也就是递归页面错误。
然而,这并不会起作用。mem,environ和cmdline文件只有正常读取和写入的VFS处理程序,而不是mmap:
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处理程序只要较低文件具有读取处理程序并包含有效的加密数据就可以工作)。
在这一点上,我们可以拼凑出攻击步骤。
我们首先创建一个PID $A的进程A。然后,使用/proc/$A
创建一个ecryptfs mount /tmp/$A
作为较低的文件系统。(ecryptfs只能与一个密钥一起使用,以禁用文件名加密。)现在,如果/proc/$A/
中的相应文件在文件开头处包含有效的ecryptfs标头,则可以映射/tmp/$A/mem
,/ tmp/$A/environ
和/tmp/$A/cmdline
。
除非我们已经拥有root权限,否则我们不能将任何东西映射到进程A中的地址0x0,这对应于/proc/$A/mem
中的偏移量0,
所以从/proc/$A/mem
开始读取将始终返回 -EIO和/proc/$A/mem
永远不会包含有效的ecryptfs标头。因此,在潜在的攻击中,只有environ和cmdline文件很有趣。
在使用1CONFIG_CHECKPOINT_RESTORE编译的内核上(至少在Ubuntu的发行版内核中是这种情况),使用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
。(在没有检查点恢复支持的内核上,攻击的执行起来稍微麻烦一些,但仍然可以通过重新执行期望的参数区域和环境区域长度,然后替换部分堆栈内存映射。)
如果将有效的加密ecryptfs文件加载到进程A的内存中,并将其环境区域配置为指向该区域,则可以在/tmp/$A/environ
处访问环境区域中的数据的解密视图。这个文件然后可以映射到另一个进程的内存中,进程B.为了能够重复这个进程,一些数据必须用ecryptfs重复加密,创建一个ecryptfs 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会安装它),可以使用/sbin/mount.ecryptfs_private setuid helper来完成。 (它验证用户拥有源目录,但这不是问题,因为用户拥有他自己的进程的procfs目录。)
下面的解释有时是特定在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不正确,那么内核会递归地选择然后发生混乱(固定为29d6455178a0 中的直接恐慌)。
由于很难再thread_info结构中找到任何值得定位的东西(并且因为它能很好地显示移出thread_info的东西不是一个足够的缓冲)我选择了一种不同的策略:将堆栈溢出到堆栈前的分配中,然后利用堆栈和其他分配重叠。这种方法的问题是,canary和thread_info结构的某些组件不能被覆盖。布局看起来是这样(绿色可以破坏,红色不可以被破坏,如果根据值贬值,黄色可能会不愉快):
幸运的是,有堆栈框架包含洞 - 如果递归的底部使用cmdline而不是environ,那么在递归过程中会有一个5-QWORD的洞没有被触及,这足以涵盖从STACK_END_MAGIC到flags的所有内容。在使用安全递归级别和调试帮助程序内核模块时,可以看到这一点,该模块使用标记喷射堆栈以使堆栈中的洞(绿色)可见:
下一个问题是,这个洞只出现在特定的堆栈深度,而为了成功的利用,它需要精确地处于正确的位置。 但是,有几个技巧可以一起用来对齐堆栈:
在测试了各种组合之后,我结束了一系列的environ文件和cmdline文件,将write() 系统调用和uid_map的VFS写入处理程序。
在这一点上,我们可以在不触及任何危险区域的情况下缓存到以前的分配中。现在,当堆栈指针指向前面的分配时,内核线程的执行必须暂停,堆栈指针指向的分配应该被新堆栈覆盖,然后应该继续执行内核代码。
要在递归内部暂停内核线程,在设置映射链之后,可以用链接结尾处的匿名映射替换为FUSE映射(userfaultfd在这里不起作用;它不捕获远程内存访问)。
对于前面的分配,我的漏洞利用管道。 当数据写入新分配的空管道时,将使用buddy分配器为该数据分配页面。 我的漏洞利用管道页面简单地分配内存,同时创建使用clone()触发页面错误的进程。使用clone()而不是fork()的优点是,使用适当的flags,更少的信息会被复制,因此存储器分配噪声更少。
在clone()过程中,当在FUSE中暂停时,所有管道页面都被填充到(但不包括)递归过程的预期RSP后面的第一个保存的RIP。写入较少会导致第二个管道写入在实现RIP控制之前使用的clobber堆栈数据,这可能会导致内核崩溃。然后,当递归进程在FUSE中暂停时,将对所有管道执行第二次写入操作,以使用完全由攻击者控制的新堆栈覆盖保存的RIP及其后面的数据。
此时,最后一道防线应该是KASLR。 正如其安全功能页面中所述,Ubuntu支持x86和amd64上的KASLR - 但您必须手动启用它,因为这样会打破休眠。这个bug最近已经被修复了,所以现在发行版应该可以默认开启KASLR - 即使安全性好处并不是很大,开启这个功能也许是有意义的,因为它并没有真正花费任何东西。由于大多数机器可能未配置为在内核命令行上传递特殊参数,因此我假设KASLR在编译到内核中时处于非活动状态,因此攻击者知道内核文本和静态数据的地址。
现在用ROP在内核中执行任意操作是微不足道的,有两种方法可以继续使用该漏洞。 你可以使用ROP执行像commit_creds(prepare_kernel_cred(NULL))这样的等效操作。我选择了另一种方式。 请注意,在堆栈溢出期间,保留了addr_limit的原始KERNEL_DS值。通过所有保存的堆栈图返回将最终将addr_limit重置为USER_DS——但如果我们直接返回到用户空间,addr_limit将保留KERNEL_DS。 因此,我的漏洞利用下面的新堆栈,它或多或少是堆栈顶部数据的副本:
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方法继续执行。 该方法然后可以使用管道写入任意内核地址,因为copy_to_user()中的地址检查被禁用:
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以root权限执行coredump处理程序:
char *core_handler = "|/tmp/crash_to_root";
kernel_write(0xffffffff81e87a60, core_handler, strlen(core_handler)+1);
该错误使用两个单独的修补程序修复:2f36db710093不允许在没有mmap处理程序的情况下通过ecryptfs打开文件,只是可以确定的是,e54ad7f1ee26不允许在procfs之上堆叠任何东西,因为procfs中存在很多magic值,并且没有任何理由在堆栈上堆叠任何东西。
然而,我为这个并不完全广泛使用的bug编写了完整的root exploit的原因是,我想要证明Linux堆栈溢出可能以非常明显的方式发生,即使现有的缓解措施打开,它们仍然可以被利用。
在我的错误报告中,我要求内核安全列表向内核堆栈添加防护页,并从堆栈底部删除thread_info结构,以便更可靠地减轻此类错误,类似于其他操作系统和grsecurity已经在做的事情。 Andy Lutomirski实际上已经开始着手这方面的工作,现在他已经发布了可以添加警戒页面的补丁