7.1.5 CVE–2018-1000001 glibc realpath 缓冲区下溢漏洞

下载文件

漏洞描述

该漏洞涉及到 Linux 内核的 getcwd 系统调用和 glibc 的 realpath() 函数,可以实现本地提权。漏洞产生的原因是 getcwd 系统调用在 Linux-2.6.36 版本发生的一些变化,我们知道 getcwd 用于返回当前工作目录的绝对路径,但如果当前目录不属于当前进程的根目录,即从当前根目录不能访问到该目录,如该进程使用 chroot() 设置了一个新的文件系统根目录,但没有将当前目录的根目录替换成新目录的时候,getcwd 会在返回的路径前加上 (unreachable)。通过改变当前目录到另一个挂载的用户空间,普通用户也可以完成这样的操作。然后返回的这个非绝对地址的字符串会在 realpath() 函数中发生缓冲区下溢,从而导致任意代码执行,再利用 SUID 程序即可获得目标系统上的 root 权限。

漏洞复现

推荐使用的环境
备注

操作系统

Ubuntu 16.04

体系结构:64 位

调试器

gdb-peda

版本号:7.11.1

漏洞软件

glibc

版本号:2.23-0ubuntu9

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

void main() {
    //char *path;

    struct {
        char canary[16];
        char buffer[80];
    } buf;
    memset(buf.canary, 47, 1);  // put a '/' before the buffer
    memset(buf.buffer, 48, sizeof(buf.buffer));

    //path = getcwd(NULL, 0);
    //puts(path);

    chroot("/tmp");
    //path = getcwd(NULL, 0);
    //puts(path);

    realpath("../../../../BBBB", buf.buffer);
    if (!strcmp(buf.canary, "/BBBB")) {
        puts("Vulnerable");
    } else {
        puts("Not vulnerable");
    }
}

执行 realpath() 前:

执行 realpath() 后:

正常情况下,字符串 \BBBB 应该只能在 buffer 范围内进程操作,而这里它被复制到了 canary 里,也就是发生了下溢出。

漏洞分析

getcwd() 的原型如下:

它用于得到一个以 null 结尾的字符串,内容是当前进程的当前工作目录的绝对路径。并以保存到参数 buf 中的形式返回。

首先从 Linux 内核方面来看,在 2.6.36 版本的 vfs: show unreachable paths in getcwd and proc 这次提交,使得当目录不可到达时,会在返回的目录字符串前面加上 (unreachable)

可以看到在引进了 unreachable 这种情况后,仅仅判断返回值大于零是不够的,它并不能很好地区分开究竟是绝对路径还是不可到达路径。然而很可惜的是,glibc 就是这样做的,它默认了返回的 buf 就是绝对地址。当然也是由于历史原因,在修订 getcwd 系统调用之前,glibc 中的 getcwd() 库函数就已经写好了,于是遗留下了这个不匹配的问题。

从 glibc 方面来看,由于它仍然假设 getcwd 将返回绝对地址,所以在函数 realpath() 中,仅仅依靠 name[0] != '/' 就断定参数是一个相对路径,而忽略了以 ( 开头的不可到达路径。

__realpath() 用于将 path 所指向的相对路径转换成绝对路径,其间会将所有的符号链接展开并解析 /.//../ 和多余的 /。然后存放到 resolved_path 指向的地址中,具体实现如下:

当传入的 name 不是一个绝对路径,比如 ../../xrealpath() 将会使用当前工作目录来进行解析,而且默认了它以 / 开头。解析过程是从后先前进行的,当遇到 ../ 的时候,就会跳到前一个 /,但这里存在一个问题,没有对缓冲区边界进行检查,如果缓冲区不是以 / 开头,则函数会越过缓冲区,发生溢出。所以当 getcwd 返回的是一个不可到达路径 (unreachable)/ 时,../../x 的第二个 ../ 就已经越过了缓冲区,然后 x 会被复制到这个越界的地址处。

补丁

漏洞发现者也给出了它自己的补丁,在发生溢出的地方加了一个判断,当 dest == rpath 的时候,如果 *dest != '/',则说明该路径不是以 / 开头,便触发报错。

但这种方案似乎并没有被合并。

最终采用的方案是直接从源头来解决,对 getcwd() 返回的路径 path 进行检查,如果确定 path[0] == '/',说明是绝对路径,返回。否则转到 generic_getcwd()(内部函数,源码里看不到)进行处理:

Exploit

umount 包含在 util-linux 中,为方便调试,我们重新编译安装一下:

exp 主要分成两个部分:

  • prepareNamespacedProcess():准备一个运行在自己 mount namespace 的进程,并设置好适当的挂载结构。该进程允许程序在结束时可以清除它,从而删除 namespace。

  • attemptEscalation():调用 umount 来获得 root 权限。

简单地说一下 mount namespace,它用于隔离文件系统的挂载点,使得不同的 mount namespace 拥有自己独立的不会互相影响的挂载点信息,当前进程所在的 mount namespace 里的所有挂载信息在 /proc/[pid]/mounts/proc/[pid]/mountinfo/proc/[pid]/mountstats 里面。每个 mount namespace 都拥有一份自己的挂载点列表,当用 clone 或者 unshare 函数创建了新的 mount namespace 时,新创建的 namespace 会复制走一份原来 namespace 里的挂载点列表,但从这之后,两个 namespace 就没有关系了。

首先为了提权,我们需要一个 SUID 程序,mount 和 umount 是比较好的选择,因为它们都依赖于 realpath() 来解析路径,而且能被所有用户使用。其中 umount 又最理想,因为它一次运行可以操作多个挂载点,从而可以多次触发到漏洞代码。

由于 umount 的 realpath() 的操作发生在堆上,第一步就得考虑怎样去创造一个可重现的堆布局。通过移除可能造成干扰的环境变量,仅保留 locale 即可做到这一点。locale 在 glibc 或者其它需要本地化的程序和库中被用来解析文本(如时间、日期等),它会在 umount 参数解析之前进行初始化,所以会影响到堆的结构和位于 realpath() 函数缓冲区前面的那些低地址的内容。漏洞的利用依赖于单个 locale 的可用性,在标准系统中,libc 提供了一个 /usr/lib/locale/C.UTF-8,它通过环境变量 LC_ALL=C.UTF-8 进行加载。

在 locale 被设置后,缓冲区下溢将覆盖 locale 中用于加载 national language support(NLS) 的字符串中的一个 /,进而将其更改为相对路径。然后,用户控制的 umount 错误信息的翻译将被加载,使用 fprintf() 函数的 %n 格式化字符串,即可对一些内存地址进行写操作。由于 fprintf() 所使用的堆栈布局是固定的,所以可以忽略 ASLR 的影响。于是我们就可以利用该特性覆盖掉 libmnt_context 结构体中的 restricted 字段:

在安装文件系统时,挂载点目录的原始内容会被隐藏起来并且不可用,直到被卸载。但是,挂载点目录的所有者和权限没有被隐藏,其中 restricted 标志用于限制堆挂载文件系统的访问。如果我们将该值覆盖,umount 会误以为挂载是从 root 开始的。于是可以通过卸载 root 文件系统做到一个简单的 DoS(如参考文章中所示,可以在Debian下尝试)。

当然我们使用的 Ubuntu16.04 也是在漏洞利用支持范围内的:

prepareNamespacedProcess() 函数如下所示:

所创建的各种类型文件如下:

然后在父进程里可以对子进程进行设置,通过设置 setgroups 为 deny,可以限制在新 namespace 里面调用 setgroups() 函数来设置 groups;通过设置 uid_mapgid_map,可以让子进程自己设置好挂载点。结果如下:

这样准备工作就做好了。进入第二部分 attemptEscalation() 函数:

通过栈喷射在内存中放置大量的 "AANGUAGE=X.X" 环境变量,这些变量位于栈的上部,包含了大量的指针。当运行 umount 时,很可能会调用到 realpath() 并造成下溢。umount 调用 setlocale 设置 locale,接着调用 realpath() 检查路径的过程如下:

因为所布置的环境变量是错误的(正确的应为 "LANGUAGE=X.X"),程序会打印出错误信息,此时第一阶段的 message catalogue 文件被加载,里面的格式化字符串将内存 dump 到 stderr,然后正如上面所讲的设置 restricted 字段,并将一个 L 写到喷射栈中,将其中一个环境变量修改为正确的 "LANGUAGE=X.X"。

由于语言发生了改变,umount 将尝试加载另一种语言的 catalogue。此时 umount 会有一个阻塞时间用于创建一个新的 message catalogue,漏洞利用得以同步进行,然后 umount 继续执行。

更新后的格式化字符串现在包含了当前程序的所有偏移。但是堆栈中却没有合适的指针用于写入,同时因为 fprintf 必须调用相同的格式化字符串,且每次调用需要覆盖不同的内存地址,这里采用一种简化的虚拟机的做法,将每次 fprintf 的调用作为时钟,路径名的长度作为指令指针。格式化字符串重复处理的过程将返回地址从主函数转移到了 getdate()execl() 两个函数中,然后利用这两个函数做 ROP。

被调用的程序文件中包含一个 shebang(即"#!"),使系统调用了漏洞利用程序作为它的解释器。然后该漏洞利用程序修改了它的所有者和权限,使其变成一个 SUID 程序。当 umount 最初的调用者发现文件的权限发生了变化,它会做一定的清理并调用 SUID 二进制文件的辅助功能,即一个 SUID shell,完成提权。

Bingo!!!(需要注意的是其所支持的系统被硬编码进了利用代码中,可看情况进行修改。exp

参考资料

Last updated

Was this helpful?