浅析 Linux Dirty Cred 新型漏洞利用方式

一、简介

Linux Dirty Cred 是一种基于 Dirty Pipe 漏洞所创新出来的新型漏洞利用方式。通过 Dirty Cred 的这种利用流程,其他位于 Linux 内核中的一些内存漏洞,在对其进行漏洞利用的过程里,可以转换为逻辑漏洞,来绕过当前所有的内核缓解机制(包括 CFI 控制流完整性保护)。

Dirty Cred 的核心利用思路是使用高权限 credential 对象来交换低权限 credential 对象,从而达到提权的目的。该论文目前已中 CCS 2022 & Black Hat USA 2022,属实是一个比较有趣的思路。

二、背景介绍

在讲述 Dirty Cred 前,需要做一些背景介绍来帮助理解。

1. Dirty Pipe

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 将数据注入进任意文件的能力),换句话说即逻辑漏洞因为是逻辑错乱导致的问题,自然漏洞利用就必须与这个功能部件相关的逻辑高度关联。由于逻辑漏洞在相关逻辑的关联性较强,因此漏洞可以被非常容易地防护,影响范围并不会特别广。

2. Credentials

Linux 的 Credentials,通常将其认为是内核中用于存放特权信息的内核属性。我们所熟知的 Credentials 有两种(总数不止两种):

  1. struct cred:其中存放了一个 task 的权限信息,例如 GID、UID 等等。如果能任意修改一个低权限进程的 cred 结构体,那么我们就可以将该进程提权至高权限(例如 root)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // include\linux\cred.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 */
    ...
    }
  2. struct file: 存放一个文件的部分权限信息,例如 read & write 权限等。如果一个低权限用户可以任意修改高权限文件(例如 /etc/passwd),那么同样也能造成提权的目的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // include\linux\fs.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 中,这里不再赘述。

3. Allocator

众所周知,Linux 内核主要使用 slab 分配器来进行内存分配。slab 分配器中主要维护了两种内存缓存(即可以理解成两套作用不同的内存分配方式):

  1. dedicated cache: 这里的内存是用于分配给内核中的常用对象。在该缓存中被分配的结构体将始终保持初始化状态,以便于提高分配速度。
  2. generic cache: 通用缓存。大多数情况下其内存块的大小与 2 的幂次方对齐。

这类 cred 和 file 结构体等 credential 对象都是在 dedicated cache 中分配,而大多数内存漏洞发生的地方都是在 generic cache 中。

可以在终端中键入 sudo cat /proc/slabinfo 来查看 slab 分配器的具体信息。其中这些名字互不相同的内存块即 dedicated cache:

image-20221006192115747

后面那些名称中带有 kmalloc 的即 generic cache:

image-20221006192309397

三、威胁模型

  • 攻击者层面

    • 低权限用户可以接触访问目标 Linux 系统
    • 已经存在一个堆破坏的内存漏洞
    • 打算使用该漏洞进行本地提权

    不考虑硬件对漏洞利用所带来的帮助。

  • 被攻击平台层面

    • 启用所有缓解机制(例如 KASLR, SMAP, SMEP, CFI, KPTI)

四、面对的挑战

先简单介绍一下 CVE-2021-4154, 来说明 Dirty Cred 是如何利用的,先上一张图:

image-20221006195145289

其实看图也能大致看出来是什么样的过程。太长不看版本就是,写入一个文件需要顺序执行:

  1. 文件权限检查(是否可写)
  2. 开始实际写入数据至文件

如果在这两个步骤之中进行竞争,在成功检查文件权限后(/tmp/x 可写),触发漏洞恶意将原先的 credential 结构体(这里是 file 结构体)释放,并创建 高权限的 credential 结构体(例如/etc/passwd 的 file 结构体)来占据这个内存空洞,那么待写入的数据就会被写入进 /etc/passwd 中,造成本地提权。

那么 Dirty Cred 所面对的挑战其实也可以看得出来:

  1. 如何将内存破坏漏洞,转换为能够置换 credential object 的原语。
  2. 如何延长文件的权限检查- 数据写入的竞争窗口。
  3. 如何创建高权限的 credential object,来占据先前被释放的低权限 credential object 内存空洞。

五、 置换 credential object

内存破坏漏洞常见的种类有:

  1. Invalid-Write: Out Of Bound Write (Read 肯定没法利用了,只能泄露数据)、以及 Use after Free
  2. Invalid-Free: Double Free

接下来将分别说明如何利用这几种内存漏洞,来达到使用 privileged credential 置换 unprivileged credential 的目的。

1. Out Of Bound Write

太长不看,直接看图:

image-20221006203908551

还是常规的 OOB write 的利用操作:尝试越界写入下一个结构体的字段,将该结构体原先指向低权限 credential 结构体指针被修改为指向高权限 credential 结构体指针。这种修改指向的方法是通过往指针低两个字节写入0(即 0x0000)来进行的,之所以是写两个字节的 0 而不是其他的,是因为攻击者希望把原指针修改为当前页所在首部的 privileged credentials。攻击者可以通过频繁创建 privileged credentials 对象来占据新页面的首部位置,为后续修改指针做准备。
由于页面以 0x1000 字节对齐,而写入两个字节的 0 要求 privilege credential 所在的地址以 0x10000 字节对齐,因此可能需要以 1/16 的概率进行爆破才能利用成功。

2. Use After Free

UAF 和先前介绍的 CVE-2021-4154 漏洞利用流程差不多。

  1. 如果 UAF 的地方在 credential dedicated cache上,那只需释放掉原先的 unprivileged credential,使用新创建的 privileged credential 对象来占据这个内存空洞,即可完成置换。
  2. 如果 UAF 的地方在 generic cache 上(大多数情况),那就要求这个 UAF 漏洞拥有 invalid-write 的能力。即先释放出一个内存空洞,使用一个带有 credential pointer 的可利用对象来占据这个内存空洞,然后利用 UAF 悬垂指针来改这个 credential pointer 即可。

3. Double Free

Double Free 的利用略显复杂,先上图:

image-20221006220306654

利用流程大致是:

  1. vulnerable object 所在的 cache 中,大量分配对象,使得

    1. 这些所分配的对象,其释放时机可控
    2. “大量分配对象” 的这个大量,是要分配至少一个页面的内存空间。

    这么做的目的只有一个:使某个内存页面的被回收时机可控。因为如果这个页面上的所有对象全部释放,那么该空闲页面自然就会被回收。

  2. 尝试触发两次 double free 漏洞,使得最终某个被释放内存块上有两个悬垂指针

  3. 释放该 vulnerable object 所在页面上的所有对象,使得该页面被回收进分配器中,并被用于 credential 的内存分配(即成为 dedicated cache)

  4. 在这块已经成为 credential dedicate cache 的内存页面上大量分配 credential 结构体,占据该页面的内存空间(即 Figure 3(f))。

  5. 注意到两个悬垂指针可能不会与 credential object 对齐,因此需要用掉一个悬垂指针来释放出一块 credential object 的内存空洞出来。

  6. 分配新 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 的替换需要一些时间,因此如果能延长这个竞争窗口,那就能非常成功的进行漏洞利用。

1. 有趣的机制

这里需要先介绍两个有趣的机制,分别是 UserfaultfdFUSE,这两种机制都允许用户无限延长竞争窗口

a. Userfaultfd

在多线程程序中,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)。

b. FUSE

FUSE 是一个用户层文件系统框架,允许用户实现自己的文件系统。用户可以在该框架中注册 handler,来指定应对文件操作请求。这样一来便可以在实际操作文件之前,执行 handler 暂停内核执行,尽可能地延长窗口。

2. Userfaultfd 利用方式

在 Linux 4.13 之前,系统调用 writev 的实现大致如下:

image-20221006234319329

攻击者可以在权限检查执行完成后,在调用 import_iovec 时触发缺页错误,从而利用 userfaultfd 机制来暂停内核的执行。

但在 linux 4.13 版本之后,该函数的实现变成了如下,即将 import_iovec 函数的调用提前了:

image-20221006234539132

这就使得刚刚所说的利用方法不再有效,需要换一种方式。

由于 Linux 中文件系统是以多层形式实现,即高层接口调用底层函数来实现操作,因此在写入文件数据时,最终都会调用到一个称为 generic_perform_write 的函数,该函数中会主动触发一次 Page Fault,同样可以利用 userfaultfd 来实现利用:

image-20221006235624520

3. 文件系统 lock 的利用方式

以 ext4 文件系统的数据写入为例,可以看到在执行 generic_perform_write 函数进行实际的数据写入之前,都需要对 inode 进行一次上锁(即 inode_lock(inode) 调用):

image-20221007000247163

如果有一个进程率先对某个文件进行超大量数据写入,那么另一个进程在对相同文件执行写入操作时,将会一直等待 inode 锁的释放。通过测试可知,4GB 数据的写入可以使得后一个进程等待数十秒(取决于硬盘性能),因此这个 inode 锁同样可以延长竞争窗口。

七、分配特权对象

由于 Dirty Cred 十分需要控制 privilege credential 对象的分配时机,控制该对象的分配成为了一个关键点。

用户层中,有两种方法可以分配 privilege credential:

  1. 大量执行 Set-UID 程序(例如 sudo),或者频繁创建特权级守护进程(例如 sshd),从而创建 privilege cred 结构体。
  2. 使用 ReadOnly 方式来打开诸如 /etc/passwd 等特权文件。

内核层中,当内核创建新的 kernel thread 时,当前 kernel thread 将会被复制,于此同时其 privileged cred 结构体也会被拷贝一份。因此只要能找到稳定创建 kernel thread 的方式,Dirty Cred 就能稳定地创建 privileged cred 结构体。有两种方法可以做到这点:

  1. 往 kernel workqueue 中填充大量任务,动态创建新的 kernel thread 来执行任务。

  2. 调用 usermode helper (一种允许内核创建用户模式进程的机制),一种最常见的应用场所是加载内核模块至内核空间中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // kernel\kmod.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 程序,来在标准安装驱动路径下搜索目标驱动

八、评估

1. 评估环境

Linux 5.16.15

2. 可利用的内核对象

对象中包含 credential 对象可控制该对象在内核堆上的分配时机

image-20221007101939000

从上图中可以看到,

  1. 几乎每个 generic cache 都至少有两个可利用对象

  2. credential 在可利用对象中的偏移量有较大差别,而这可以提高 Dirty Cred 的利用成功率

    尤其是 OOB 漏洞可覆写的偏移量可能偏差较大。

  3. 有五个可利用对象所包含的 credential 的相对偏移量为 0,提高了 Dirty Cred 在内存破坏范围较小情况下的利用成功率。

3. 满足评估条件的 CVE 漏洞

要求:

  • 在 2019 年及以后报告的 Linux 内核漏洞
  • 能够在 Linux 堆上进行堆破坏
  • 触发无需特定硬件条件支持
  • 可复现相应内核 panic

image-20221007101924928

从上图中可得知,在所有缓解机制全部启动的情况下,Dirty Cred 的利用成功率为:16/24。其中:

  1. Double Free 的漏洞能全部完成利用
  2. OOB 中存在一些不能完成利用的 case,有些是因为 OOB write 所在的地方是 virtual memory 而不是 kmalloc‘ed 内存,暂无可利用对象。
  3. UAF 中一些不能完成利用的 case 是:有些只能 UAF read,不能进行 invalid-write;还有些是能 invalid-write 但是写入的位置不在可利用对象的 credential 字段上。

九、Dirty Cred 防护

Dirty Cred 之所以能成功,最核心的是:内核的内存隔离是基于类型而不是基于权限来做的。

防护方法其实很简单:将 privileged credentials 与其他 unprivileged credentials 隔离开。

如何做:使用 vzalloc/kvfree 函数来在 virtual memory 中创建与释放 privileged credentials 内存。这样就能使得 privileged 和 unprivileged 对象所在的 memory cache 是隔离开的。

之所以使用 virtual memory 来存放 privileged credentials,是因为

  1. 如果是使用两个不同的 kmalloc’ed memory cache,那有可能通过 Linux 内核重用机制来把 privileged credentials 所在内存页与 unprivileged 所在页合并,造成隔离失效。
  2. 虚拟内存区域是内核动态分配虚拟连续的内存,驻留在 VMALLOC_START 至 VMALLOC_END 中的内存区域。这就使得虚拟内存区域中的内存永远不会与直接映射的内存区域重叠。

这里顺带提一句 kmalloc 和 vmalloc 所分配内存的性质:

  1. 都是分配的内核内存
  2. kmalloc 保证分配的内存在物理地址空间上连续;vmalloc 保证虚拟地址空间上连续(需要配置页表)
  3. kmalloc 能分配的大小有限,vmalloc 能分配的大小相对较大
  4. vmalloc 因为要设置页表,自然会慢一点

要被隔离的 credential 结构体为:

  1. UID 为 GLOBAL_ROOT_UID 的 struct cred(privileged credentials)
  2. 打开方式中带有可写的 struct file(unprivileged credentials)

之所以要把这两个隔离,个人猜测是这两种类型的结构(GLOBAL_ROOT_UID or writable file)创建的次数相对其他结构(非特权级 UID 或者 只读文件结构)较少。

由于这种隔离是在 credential 创建时所确定的,那如果某个非特权 cred 结构体被原地提权(例如通过 setuid/cap_setuid),那就会造成这种内存隔离形同虚设。鉴于此,可以尝试在 alter_cred_subscribers 函数被执行时,在虚拟内存区域新创建一个特权 cred, 而非在原先 cred 上进行修改。但这种防护方法很依赖 Linux 未来的开发发展,倘若以后 Linux 新开发了一种原地修改 cred 的方式,那么这种防护就无效了,因此这个防护被留待 Future work。

Dirty Cred 防护的性能评估:

image-20221007111610551

从中可得知绝大部分的性能开销都非常的小(< 3%),不会影响系统的正常使用。但其中 10k File Create 的性能开销达到了 7%,这是因为 vmalloc 的执行速度会比 kmalloc 低很多,因为需要重新进行内存映射等等;而 10k File Delete 的性能开销相对较小一点, 因为 Linux 内核使用 RCU 机制来异步进行文件删除,以提高内核执行速度。

RCU (Read-copy update) 是 Linux内核中的一种数据同步机制

上图评估结果中还出现了“轻微的性能改善”,这个纯粹是实验所产生的噪声,不是真的改善(虽然这个实验重复了多次基准测试)。

十、参考链接

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2024 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~