Linux Dirty Cred 是一种基于 Dirty Pipe 漏洞所创新出来的新型漏洞利用方式。通过 Dirty Cred 的这种利用流程,其他位于 Linux 内核中的一些内存漏洞,在对其进行漏洞利用的过程里,可以转换为逻辑漏洞,来绕过当前所有的内核缓解机制(包括 CFI 控制流完整性保护)。
Dirty Cred 的核心利用思路是使用高权限 credential 对象来交换低权限 credential 对象,从而达到提权的目的。该论文目前已中 CCS 2022 & Black Hat USA 2022,属实是一个比较有趣的思路。
在讲述 Dirty Cred 前,需要做一些背景介绍来帮助理解。
Linux Dirty Pipe CVE-2022-0847 是今年早些时候爆发出来的一个 Linux 内核提权漏洞。我曾在上半年写过一篇分析它的文章 - Linux Dirty Pipe CVE-2022-0847 漏洞分析 - Kipre's Blog,因此就不在这里赘述了。
简单概括一下成因:
Pipe 结构是由一个环形队列组成,其中队列元素分别为实际存放数据的物理页的引用。对于某次 pipe 的写入操作,如果 pipe 队列头所在元素上的标志位为 PIPE_BUF_FLAG_CAN_MERGE,那就说明这次写入的数据可以直接合并至队列头的物理页里,无需重新创建新队列元素,减少内存占用。
Linux 中存在一个称为 splice 的系统调用,它可以直接将文件中的数据追加进某个 pipe 中。其本质原理是将该文件的页面缓存引用直接添加进 pipe 的队列头部。由于文件页面缓存可能用在多个地方,因此这些页面缓存在 pipe 队列中元素上的标志位就不能标注 PIPE_BUF_FLAG_CAN_MERGE,以便于防止在向 pipe 写入新数据时,错误地把新数据与页面缓存上的数据合并,对页面缓存进行误修改。
由于 Dirty Pipe 漏洞的根源是 pipe 队列元素上标志位的未初始化漏洞,恶意黑客可以先往 pipe 内使用 write 函数灌注大量数据,使得 pipe 队列上的每个元素标志位都标有 PIPE_BUF_FLAG_CAN_MERGE,再紧接着 read 出这些数据,将 pipe 清空,并之后使用 splice 系统调用将任意可读文件(例如 /etc/passwd
)的页面缓存加载进 pipe 中。但 pipe 队列元素上的标志位并没有被重置,因此对于加载进 pipe 中的页面缓存元素,每个队列元素上的标志位都将残留先前所设置的 PIPE_BUF_FLAG_CAN_MERGE,这样一来后续的 write 便可直接污染本不该被修改的文件页面缓存,使得特权文件(例如 /etc/passwd
)在内存中的数据被篡改,造成提权。
有意思的是,整个漏洞利用流程完全不涉及各类缓解机制。Dirty Pipe 是一个彻头彻尾的逻辑漏洞,这类逻辑漏洞可以完全绕过缓解机制,从而进行提权等操作。但 Dirty Pipe 又高度依赖 pipe 本身的能力(那种可以通过 pipe 将数据注入进任意文件的能力),换句话说即逻辑漏洞因为是逻辑错乱导致的问题,自然漏洞利用就必须与这个功能部件相关的逻辑高度关联。由于逻辑漏洞在相关逻辑的关联性较强,因此漏洞可以被非常容易地防护,影响范围并不会特别广。
Linux 的 Credentials,通常将其认为是内核中用于存放特权信息的内核属性。我们所熟知的 Credentials 有两种(总数不止两种):
struct cred
:其中存放了一个 task 的权限信息,例如 GID、UID 等等。如果能任意修改一个低权限进程的 cred 结构体,那么我们就可以将该进程提权至高权限(例如 root)。
// includelinuxcred.h
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
...
}
struct file
: 存放一个文件的部分权限信息,例如 read & write 权限等。如果一个低权限用户可以任意修改高权限文件(例如 /etc/passwd),那么同样也能造成提权的目的。
// includelinuxfs.h
struct file {
...
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op; /*
* Protects f_ep_links, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode; // !!: O_RDWR
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred; // !!: cred
struct file_ra_state f_ra;
...
}
需要注意的是,struct file 只保存已被打开文件的信息。如果某个文件连打开的权限都没有,那自然就不可能会有对应的 struct file 结构体。
至于文件的属主等其他特权信息,则存放在 struct inode
中,这里不再赘述。
众所周知,Linux 内核主要使用 slab 分配器来进行内存分配。slab 分配器中主要维护了两种内存缓存(即可以理解成两套作用不同的内存分配方式):
这类 cred 和 file 结构体等 credential 对象都是在 dedicated cache 中分配,而大多数内存漏洞发生的地方都是在 generic cache 中。
可以在终端中键入 sudo cat /proc/slabinfo
来查看 slab 分配器的具体信息。其中这些名字互不相同的内存块即 dedicated cache:
后面那些名称中带有 kmalloc 的即 generic cache:
攻击者层面
不考虑硬件对漏洞利用所带来的帮助。
被攻击平台层面
先简单介绍一下 CVE-2021-4154, 来说明 Dirty Cred 是如何利用的,先上一张图:
其实看图也能大致看出来是什么样的过程。太长不看版本就是,写入一个文件需要顺序执行:
如果在这两个步骤之中进行竞争,在成功检查文件权限后(/tmp/x 可写),触发漏洞恶意将原先的 credential 结构体(这里是 file 结构体)释放,并创建 高权限的 credential 结构体(例如/etc/passwd
的 file 结构体)来占据这个内存空洞,那么待写入的数据就会被写入进 /etc/passwd 中,造成本地提权。
那么 Dirty Cred 所面对的挑战其实也可以看得出来:
内存破坏漏洞常见的种类有:
接下来将分别说明如何利用这几种内存漏洞,来达到使用 privileged credential 置换 unprivileged credential 的目的。
太长不看,直接看图:
还是常规的 OOB write 的利用操作:尝试越界写入下一个结构体的字段,将该结构体原先指向低权限 credential 结构体指针被修改为指向高权限 credential 结构体指针。这种修改指向的方法是通过往指针低两个字节写入0(即 0x0000)来进行的,之所以是写两个字节的 0 而不是其他的,是因为攻击者希望把原指针修改为当前页所在首部的 privileged credentials。攻击者可以通过频繁创建 privileged credentials 对象来占据新页面的首部位置,为后续修改指针做准备。 由于页面以 0x1000 字节对齐,而写入两个字节的 0 要求 privilege credential 所在的地址以 0x10000 字节对齐,因此可能需要以 1/16 的概率进行爆破才能利用成功。
UAF 和先前介绍的 CVE-2021-4154 漏洞利用流程差不多。
Double Free 的利用略显复杂,先上图:
利用流程大致是:
在 vulnerable object 所在的 cache 中,大量分配对象,使得
这么做的目的只有一个:使某个内存页面的被回收时机可控。因为如果这个页面上的所有对象全部释放,那么该空闲页面自然就会被回收。
尝试触发两次 double free 漏洞,使得最终某个被释放内存块上有两个悬垂指针。
释放该 vulnerable object 所在页面上的所有对象,使得该页面被回收进分配器中,并被用于 credential 的内存分配(即成为 dedicated cache)
在这块已经成为 credential dedicate cache 的内存页面上大量分配 credential 结构体,占据该页面的内存空间(即 **Figure 3(f)**)。
注意到两个悬垂指针可能不会与 credential object 对齐,因此需要用掉一个悬垂指针来释放出一块 credential object 的内存空洞出来。
分配新 credential object,占据这个内存空洞。这样就可以达到两个指针共同指向一个 credential object 的效果,后续的利用就可参照 UAF 的方式来进行,这里就不再赘述了。
这里有个有趣的问题:一个原先指向 generic cache 的指针,如果这个指针所指向内存变更为 dedicated cache,那么后续对这个以为是 generic pointer 实则是 dedicated pointer 进行 free 操作时,这个 free 的大小是如何界定的?为什么 free 的大小是 credential object 的大小呢?
通过查阅 slab 分配器的 kfree 逻辑,发现它的释放逻辑与被释放地址高度相关。首先会尝试根据被释放地址获取其对应的 slab_cache 结构,然后再根据结构中所存放的信息来释放对应的 object size。换句话说,如果 kfree 释放的地址在 generic cache中,那就会走 generic cache 的释放逻辑;如果是在 dedicated cache 中,那就会走 dedicated cache 的释放逻辑。这么做或许是为了提高可用性,使得释放两个不同 cache 的内存块可以使用同一个 kfree 接口。
Dirty Cred 需要在检查文件写权限 - 实际写入数据 这两步之中,成功将低权限 credential 替换为高权限 credential。由于 credential 的替换需要一些时间,因此如果能延长这个竞争窗口,那就能非常成功的进行漏洞利用。
这里需要先介绍两个有趣的机制,分别是 Userfaultfd 和 FUSE,这两种机制都允许用户无限延长竞争窗口。
在多线程程序中,userfaultfd 允许一个线程管理其他线程所产生的 Page Fault 事件。当某个线程触发了 Page Fault,该线程将立即陷入 sleep,而其他线程则可以通过 userfaultfd 来读取出这个 Page Fault 事件,并进行处理。
Userfaultfd 常用于条件竞争漏洞利用中。但悲伤的是,为了防止 userfaultfd 在内核漏洞利用中的滥用,在内核 5.11 版本开始,非特权的 userfaultfd 默认是禁用的(LWN: Blocking userfaultfd() kernel-fault handling)。
参考:Linux Manual Page(
man userfaultfd
)。
FUSE 是一个用户层文件系统框架,允许用户实现自己的文件系统。用户可以在该框架中注册 handler,来指定应对文件操作请求。这样一来便可以在实际操作文件之前,执行 handler 暂停内核执行,尽可能地延长窗口。
在 Linux 4.13 之前,系统调用 writev 的实现大致如下:
攻击者可以在权限检查执行完成后,在调用 import_iovec
时触发缺页错误,从而利用 userfaultfd 机制来暂停内核的执行。
但在 linux 4.13 版本之后,该函数的实现变成了如下,即将 import_iovec 函数的调用提前了:
这就使得刚刚所说的利用方法不再有效,需要换一种方式。
由于 Linux 中文件系统是以多层形式实现,即高层接口调用底层函数来实现操作,因此在写入文件数据时,最终都会调用到一个称为 generic_perform_write
的函数,该函数中会主动触发一次 Page Fault,同样可以利用 userfaultfd 来实现利用:
以 ext4 文件系统的数据写入为例,可以看到在执行 generic_perform_write
函数进行实际的数据写入之前,都需要对 inode 进行一次上锁(即 inode_lock(inode)
调用):
如果有一个进程率先对某个文件进行超大量数据写入,那么另一个进程在对相同文件执行写入操作时,将会一直等待 inode 锁的释放。通过测试可知,4GB 数据的写入可以使得后一个进程等待数十秒(取决于硬盘性能),因此这个 inode 锁同样可以延长竞争窗口。
由于 Dirty Cred 十分需要控制 privilege credential 对象的分配时机,控制该对象的分配成为了一个关键点。
在用户层中,有两种方法可以分配 privilege credential:
/etc/passwd
等特权文件。在内核层中,当内核创建新的 kernel thread 时,当前 kernel thread 将会被复制,于此同时其 privileged cred 结构体也会被拷贝一份。因此只要能找到稳定创建 kernel thread 的方式,Dirty Cred 就能稳定地创建 privileged cred 结构体。有两种方法可以做到这点:
往 kernel workqueue 中填充大量任务,动态创建新的 kernel thread 来执行任务。
调用 usermode helper (一种允许内核创建用户模式进程的机制),一种最常见的应用场所是加载内核模块至内核空间中。
// kernelkmod.c
static int call_modprobe(char *module_name, int wait)
{
struct subprocess_info *info;
static char *envp[] = {
"HOME=/",
"TERM=linux",
"PATH=/sbin:/usr/sbin:/bin:/usr/bin",
NULL
}; char **argv = kmalloc(sizeof(char *[5]), GFP_KERNEL);
if (!argv)
goto out;
module_name = kstrdup(module_name, GFP_KERNEL);
if (!module_name)
goto free_argv;
argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name; /* check free_modprobe_argv() */
argv[4] = NULL;
// 调用 usermode helper
info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
if (!info)
goto free_module_name;
return call_usermodehelper_exec(info, wait | UMH_KILLABLE);
free_module_name:
kfree(module_name);
free_argv:
kfree(argv);
out:
return -ENOMEM;
}
内核在加载内核模块时,需要在内核层执行 modprobe 程序,来在标准安装驱动路径下搜索目标驱动。
Linux 5.16.15
对象中包含 credential 对象且可控制该对象在内核堆上的分配时机。
从上图中可以看到,
几乎每个 generic cache 都至少有两个可利用对象
credential 在可利用对象中的偏移量有较大差别,而这可以提高 Dirty Cred 的利用成功率
尤其是 OOB 漏洞可覆写的偏移量可能偏差较大。
有五个可利用对象所包含的 credential 的相对偏移量为 0,提高了 Dirty Cred 在内存破坏范围较小情况下的利用成功率。
要求:
从上图中可得知,在所有缓解机制全部启动的情况下,Dirty Cred 的利用成功率为:16/24。其中:
Dirty Cred 之所以能成功,最核心的是:内核的内存隔离是基于类型而不是基于权限来做的。
防护方法其实很简单:将 privileged credentials 与其他 unprivileged credentials 隔离开。
如何做:使用 vzalloc/kvfree
函数来在 virtual memory 中创建与释放 privileged credentials 内存。这样就能使得 privileged 和 unprivileged 对象所在的 memory cache 是隔离开的。
之所以使用 virtual memory 来存放 privileged credentials,是因为
这里顺带提一句 kmalloc 和 vmalloc 所分配内存的性质:
都是分配的内核内存 kmalloc 保证分配的内存在物理地址空间上连续;vmalloc 保证虚拟地址空间上连续(需要配置页表) kmalloc 能分配的大小有限,vmalloc 能分配的大小相对较大 vmalloc 因为要设置页表,自然会慢一点
要被隔离的 credential 结构体为:
之所以要把这两个隔离,个人猜测是这两种类型的结构(GLOBAL_ROOT_UID or writable file)创建的次数相对其他结构(非特权级 UID 或者 只读文件结构)较少。
由于这种隔离是在 credential 创建时所确定的,那如果某个非特权 cred 结构体被原地提权(例如通过 setuid/cap_setuid
),那就会造成这种内存隔离形同虚设。鉴于此,可以尝试在 alter_cred_subscribers
函数被执行时,在虚拟内存区域新创建一个特权 cred, 而非在原先 cred 上进行修改。但这种防护方法很依赖 Linux 未来的开发发展,倘若以后 Linux 新开发了一种原地修改 cred 的方式,那么这种防护就无效了,因此这个防护被留待 Future work。
Dirty Cred 防护的性能评估:
从中可得知绝大部分的性能开销都非常的小(< 3%),不会影响系统的正常使用。但其中 10k File Create 的性能开销达到了 7%,这是因为 vmalloc 的执行速度会比 kmalloc 低很多,因为需要重新进行内存映射等等;而 10k File Delete 的性能开销相对较小一点, 因为 Linux 内核使用 RCU 机制来异步进行文件删除,以提高内核执行速度。
RCU (Read-copy update) 是 Linux内核中的一种数据同步机制。
上图评估结果中还出现了“轻微的性能改善”,这个纯粹是实验所产生的噪声,不是真的改善(虽然这个实验重复了多次基准测试)。
- END -