【CVE.0x09】CVE-2022-0185 漏洞复现及简要分析
本文最后更新于:2023年8月13日 晚上
还是 mount 好
0x00.一切开始之前
CVE-2022-0185 是 2022 年初爆出来的一个位于 filesystem context 系统中的 fsconfig
系统调用中的一个堆溢出漏洞,对于有着 CAP_SYS_ADMIN
权限(或是开启了 unprivileged namespace)的攻击者而言其可以利用该漏洞完成本地提权,该漏洞获得了高达 8.4
的 CVSS 评分
发现漏洞的安全研究员的挖掘与利用过程参见这里,本文编写时也有一定参考
本文选择内核版本 5.4
进行分析,在开始分析之前,我们先来补充一些基础知识
Filesystem mount API 初探
相信大家对于 Linux 下的文件系统的挂载都是非常熟悉—— mount
系统调用被用以将文件系统挂载到以 /
为根节点的文件树上,例如我们可以用如下命令挂载硬盘 /dev/sdb1
到 /mnt/temp
目录下,之后就能在该目录下进行文件访问:
1 |
|
或是通过编写程序的方式使用裸的 mount
系统调用进行挂载:
1 |
|
但是总有些人想搞个大新闻,以 AL Viro 为首的开发者认为旧的 mount
系统调用存在诸多漏洞与设计缺陷,于是决定重写一套新的 mount API,并成功被合并到内核主线,称之为 Filesystem Mount API
新的 mount API 将过去的一个简单的 mount
系统调用的功能拆分成了数个新的系统调用,对应不同的文件系统挂载阶段,于是乎现在 Linux 上有着两套并行的 mount API
👴的评价是闲着没事干可以去把村口大粪挑一下
Step.I - fsopen: 获取一个 filesystem context
还记得笔者以前说过的 Linux 中一切皆文件 的哲学吗,在新的 mount API 中也遵循了这样的哲学——如果说 open()
系统调用用以打开一个文件并提供一个文件描述符,那么 fsopen()
系统调用便用于打开一个文件系统,并提供一个”文件系统描述符“——称之为 **文件系统上下文
**(filesystem context)
由于标准库中还未添加 new mount API 相关的代码,因此我们需要写 raw syscall 来进行相关的系统调用,例如我们可以使用如下代码打开一个空白的 ext4
文件系统上下文(需要 CAP_SYS_ADMIN
权限,或是开启了 unprivileged namespace 的情况下使用 unshare()
系统调用创建带有该权限的 namespace):
1 |
|
需要注意的是这里创建的是一个空白的文件系统上下文,并没有与任何实际设备或文件进行关联——这是我们需要在接下来的步骤中完成的配置
✳ fsopen() in kernel
superblock、dentry 这类的 VFS 基础知识不在此处科普,请自行了解:)
在内核当中,fsopen()
系统调用的行为实际上对应创建的是一个 fs_context
结构体作为 filesystem context,创建一个对应的 file 结构体并分配一个文件描述符:
1 |
|
其中 fs_context
的具体定义如下:
1 |
|
fs_context
的初始化在 alloc_fs_context()
中完成,在 fsopen()
中对应的是 FS_CONTEXT_FOR_MOUNT
:
1 |
|
在完成了通用的初始化工作后,最终进行具体文件系统对应初始化工作的其实是调用 file_system_type
中的 init_fs_context
函数指针对应的函数完成的,这里我们可以看到对于未设置 init_fs_context
的文件系统类型而言其最终会调用 legacy_init_fs_context()
进行初始化,主要就是为 fs_context->fs_private
分配一个 legacy_fs_context
结构体,并将 fs_context
的函数表设置为 legacy_fs_context_ops
:
1 |
|
legacy_fs_context
结构体的定义如下,标识了一块指定长度与类型的缓冲区:
1 |
|
Step.II - fsconfig: 设置 filesystem context 的相关参数与操作
在完成了空白的文件系统上下文的创建之后,我们还需要对其进行相应的配置,以便于后续的挂载操作,这个配置的功能对应到的就是 fsconfig()
系统调用
fsconfig()
系统调用根据不同的 cmd 进行不同的操作,对于挂载文件系统而言其核心操作主要就是两个 cmd:
FSCONFIG_SET_STRING
:设置不同的键值对参数FSCONFIG_CMD_CREATE
:获得一个 superblock 并创建一个 root entry
示例用法如下所示,这里创建了一个键值对 "source"=/dev/sdb1
表示文件系统源所在的设备名:
1 |
|
✳ fsconfig() in kernel
内核空间中的 fsconfig()
实现比较长,但主要就是根据 cmd 进行各种 switch,这里就不贴完整的源码了:
1 |
|
而 fsconfig()
的核心作用主要还是根据 cmd 进行参数的封装,最后进入到 VFS 中的操作则通过 vfs_fsconfig_locked()
完成
Step.III - fsmount: 获取一个挂载实例
完成了文件系统上下文的创建与配置,接下来终于来到文件系统的挂载操作了,fsmount()
系统调用用以获取一个可以被用以进行挂载的挂载实例,并返回一个文件描述符用以下一步的挂载
示例用法如下:
1 |
|
Step.IV - move_mount: 将挂载实例在挂载点间移动
最后来到一个不统一以 fs 开头进行命名的 move_mount()
系统调用,其用以将挂载实例在挂载点间移动:
- 对于尚未进行挂载的挂载实例而言,进行挂载的操作便是从空挂载点
""
移动到对应的挂载点(例如"/mnt/temp"
),此时我们并不需要给出目的挂载点的 fd,而可以使用AT_FDCWD
引入了 move_mount()
之后,我们最终的一个用以将 "/dev/sdb1"
以 "ext4"
文件系统挂载到 "/mnt/temp"
的完整示例程序如下:
1 |
|
这一套流程下来便是 new Filesystem mount API 的基本用法
0x01.漏洞分析
legacy_parse_param() - 整型溢出导致的越界拷贝
前面我们提到该漏洞发生于 fsconfig()
系统调用中,若我们给的 cmd
为 FSCONFIG_SET_STRING
,则在内核中存在如下调用链:
1 |
|
在 vfs_parse_fs_param()
中会调用 fs_context->ops->parse_param
函数指针:
1 |
|
前面我们讲到对于未设置 init_fs_context
的文件系统类型而言其最终会调用 legacy_init_fs_context()
进行初始化,其中 fs_context
的函数表会被设置为 legacy_fs_context_ops
,其 parse_param
指针对应为 legacy_parse_param()
函数:
1 |
|
漏洞便发生在该函数中,在计算 len > PAGE_SIZE - 2 - size
时,由于 size 为 unsigned int
,若 size + 2 > PAGE_SIZE
,则 PAGE_SIZE - 2 - size
的结果会下溢为一个较大的无符号值,从而绕过 len 的检查,这里的 size 来源为 ctx->data_size
,即已拷贝的总的数据长度,
1 |
|
在后面的流程中会从用户控件将数据拷贝到 ctx->legacy_data
上,而 ctx->legacy_data
仅分配了一张页面大小,但后续流程中的拷贝是从 ctx->legacy_data[size]
开始的,由于 size 可以大于一张页大小,因此此处可以发生数据数据写入,由于 ctx->legacy_data
在分配时使用的是通用的分配 flag GFP_KERNEL
,因此可以溢出到绝大多数的常用结构体中
1 |
|
这里需要注意的是,由于 fsconfig 的限制,我们单次写入的最大长度为 256 字节,因此我们需要多次调用 fsconfig 以让其逐渐逼近 PAGE_SIZE
,而 len > PAGE_SIZE - 2 - size
的检查并非完全无效,由于 size 为已拷贝数据长度而 len 为待拷贝数据长度,因此只有当 size 累加到 4095 时才会发生整型溢出,这里我们在进行溢出前需要卡好已拷贝数据长度刚好为 4095
由于 legacy_parse_param()
中拷贝的结果形式为 ",key=val"
,故我们有如下计算公式:
单次拷贝数据长度 = len(key) + len(val) + 2
下面笔者给出一个笔者自己计算的 4095:
1 |
|
Proof of Concept
由于大部分的文件系统类型都未设置 init_fs_context
,因此最后都可以走到 legacy_parse_param()
的流程当中,例如 ext4
文件系统的 file_system_type
定义如下:
1 |
|
这里我们将通过 ext4 文件系统进行漏洞复现,我们只需要越界写足够长的一块内存,通常都能写到一些内核结构体从而导致 kernel panic
需要注意的是 filesystem mount API 需要命名空间具有 CAP_SYS_ADMIN
权限,但由于其仅检查命名空间权限,故对于没有该权限的用户则可以通过 unshare(CLONE_NEWNS|CLONE_NEWUSER)
创建新的命名空间,以在新的命名空间内获取对应权限
1 |
|
运行,成功通过堆溢出造成 kernel panic:
0x02.漏洞利用
下面笔者给出两种利用方法,其中第二重尚未经过验证(不过理论上可行)
方法一、覆写 pipe_buffer
构造页级 UAF
虽然这篇博客写于 1 月,但是这个通用利用方法笔者最初公开于 4 月份的 D^3CTF2023-d3kcache 中,后面想了想用在这个漏洞上也是挺好的,所以现在给出一份使用该方法进行利用的 exp :)
Step.I - 利用 msg_msg
定位溢出位置
由于下次写入必定会向下一个对象内写入一个 '='
和一个 '\0'
,而这个 '='
就很不可爱,因此我们选择不直接利用与其相邻的第一个 4k 对象,而是覆写与其相邻的第二个 4k 对象,这样我们便能只向第二个 4k 对象内写入一个可爱的 \x00
:)
这里笔者选择首先堆喷 msg_msg
,利用漏洞将 m_ts
改大,通过 MSG_COPY
读取检查被覆写的 msg_msg
并释放除了该 msg_msg 以外的其他 msg_msg
Step.II - fcntl(F_SETPIPE_SZ) 更改 pipe_buffer 所在 slub 大小,构造页级 UAF
接下来我们考虑溢出的目标对象,现在我们仅想要使用一个 \x00
字节完成利用,毫无疑问的是我们需要寻找一些在结构体头部便有指向其他内核对象的指针的内核对象,我们不难想到的是 pipe_buffer
是一个非常好的的利用对象,其开头有着指向 page
结构体的指针,而 page
的大小仅为 0x40
,可以被 0x100 整除,若我们能够通过 partial overwrite 使得两个管道指向同一张页面,并释放掉其中一个,我们便构造出了页级的 UAF:
同时管道的特性还能让我们在 UAF 页面上任意读写,这真是再美妙不过了:)
但是有一个小问题,pipe_buffer
来自于 kmalloc-cg-1k
,其会请求 order-2 的页面,而漏洞对象大小为 4k,其会请求 order-3 的页面,如果我们直接进行不同 order 间的堆风水的话,则利用成功率会大打折扣 :(
但 pipe 可以被挖掘的潜力远比我们想象中大得多:)现在让我们重新审视 pipe_buffer
的分配过程,其实际上是单次分配 pipe_bufs
个 pipe_buffer
结构体:
1 |
|
这里注意到 pipe_buffer
不是一个常量而是一个变量,那么我们能否有方法修改 pipe_buffer 的数量?答案是肯定的,pipe 系统调用非常贴心地为我们提供了 F_SETPIPE_SZ
让我们可以重新分配 pipe_buffer 并指定其数量:
1 |
|
那么我们不难想到的是我们可以通过 fcntl() 重新分配单个 pipe 的 pipe_buffer 数量,:
- 对于每个 pipe 我们指定分配 64 个 pipe_buffer,从而使其向 kmalloc-cg-2k 请求对象,而这将最终向 buddy system 请求 order-3 的页面
由此,我们便成功使得 pipe_buffer
与题目模块的对象处在同一 order 的内存页上,从而提高 cross-cache overflow 的成功率
不过需要注意的是,由于 page 结构体的大小为 0x40,其可以被 0x100 整除,因此若我们所溢出的目标 page 的地址最后一个字节刚好为 \x00
, 那就等效于没有溢出 ,因此实际上利用成功率仅为 75%
(悲)
Step.III - 构造二级自写管道,实现任意内存读写
有了 page-level UAF,我们接下来考虑向这张页面分配什么结构体作为下一阶段的 victim object
由于管道本身便提供给我们读写的功能,而我们又能够调整 pipe_buffer
的大小并重新分配结构体,那么再次选择 pipe_buffer
作为 victim object 便是再自然不过的事情:)
接下来我们可以通过 UAF 管道读取 pipe_buffer 内容,从而泄露出 page、pipe_buf_operations 等有用的数据(可以在重分配前预先向管道中写入一定长度的内容,从而实现数据读取),由于我们可以通过 UAF 管道直接改写 pipe_buffer
,因此将漏洞转化为 dirty pipe 或许会是一个不错的办法(这也是本次比赛中 NU1L 战队的解法)
但是 pipe 的强大之处远不止这些,由于我们可以对 UAF 页面上的 pipe_buffer
进行读写,我们可以继续构造出第二级的 page-level UAF:
为什么要这么做呢?在第一次 UAF 时我们获取到了 page 结构体的地址,而 page 结构体的大小固定为 0x40,且与物理内存页一一对应,试想若是我们可以不断地修改一个 pipe 的 page 指针,则我们便能完成对整个内存空间的任意读写,因此接下来我们要完成这样的一个利用系统的构造
再次重新分配 pipe_buffer
结构体到第二级 page-level UAF 页面上,由于这张物理页面对应的 page 结构体的地址对我们而言是已知的,我们可以直接让这张页面上的 pipe_buffer 的 page 指针指向自身,从而直接完成对自身的修改:
这里我们可以篡改 pipe_buffer.offset
与 pipe_buffer.len
来移动 pipe 的读写起始位置,从而实现无限循环的读写,但是这两个变量会在完成读写操作后重新被赋值,因此这里我们使用三个管道:
- 第一个管道用以进行内存空间中的任意读写,我们通过修改其 page 指针完成 :)
- 第二个管道用以修改第三个管道,使其写入的起始位置指向第一个管道
- 第三个管道用以修改第一个与第二个管道,使得第一个管道的 pipe 指针指向指定位置、第二个管道的写入起始位置指向第三个管道
通过这三个管道之间互相循环修改,我们便实现了一个可以在内存空间中进行近乎无限制的任意读写系统 :)
Step.IV - 提权
有了内存空间中的任意读写,提权便是非常简便的一件事情了,这里笔者选择通过修改当前进程的 task_struct 的 cred 为 init_cred 的方式来完成提权
init_cred
为有着 root 权限的 cred,我们可以直接将当前进程的 cred 修改为该 cred 以完成提权,这里iwom可以通过 prctl(PR_SET_NAME, "");
修改 task_struct.comm
,从而方便搜索当前进程的 task_struct
在内存空间中的位置:)
不过 init_cred
的符号有的时候是不在 /proc/kallsyms
中导出的,我们在调试时未必能够获得其地址,因此这里笔者选择通过解析 task_struct
的方式向上一直找到 init
进程(所有进程的父进程)的 task_struct
,从而获得 init_cred
的地址
FINAL EXPLOIT
exp 如下:
1 |
|
运行即可完成提权
方法二、结合 FUSE + msg_msg
进行任意地址写
现在我们有了任意长度的堆溢出,而可溢出对象用的分配 flag 为 GFP_KERNEL
、大小为 4k(一张内存页大小),那么我们不难想到可以基于我们的老朋友 System V 消息队列结构体来完成利用
Step.I - 堆喷 msg_msg,覆写 m_ts 字段进行越界读取
我们先来复习一下消息队列中一条消息的基本结构,当我们调用 msgsnd 系统调用在指定消息队列上发送一条指定大小的 message 时,在内核空间中会创建这样一个结构体作为信息的 header:
1 |
|
当我们在单个消息队列上发送一条消息时,若大小不大于【一个页面大小 - header size】,则仅使用一个 msg_msg
结构体进行存储,而当我们单次发送大于【一个页面大小 - header size】大小的消息时,内核会额外补充添加 msg_msgseg
结构体,其与 msg_msg
之间形成如下单向链表结构,而单个 msg_msgseg
的大小最大为一个页面大小,超出这个范围的消息内核会额外补充上更多的 msg_msgseg
结构体,链表最后以 NULL 结尾:
由于我们有越界写,那么我们不难想到的是我们可以将 msg_msg
与 ctx->legacy_data
堆喷到一起,之后越界写入相邻 msg_msg
的 header 将 m_ts
改大,之后我们再使用 msgrcv()
读取消息,便能读取出超出该消息范围的内容,从而完成越界读取;由于我们的越界写入会破坏 msg_msg
头部的双向链表,因此在读取时我们应当使用 MSG_COPY
以保持消息在队列上不会被 unlink
由于 ctx->legacy_data
的大小已经是 4k 了,故我们考虑在 msg_msgseg
上完成越界读取,由于 msgrcv()
拷贝消息时以单链表结尾 NULL 作为终止,故我们最多可以在 msg_msgseg
上读取将近一张内存页大小的数据,因此我们考虑让 msg_msgseg
的消息尽量小,从而让我们能够越界读取到更多的 object
接下来考虑如何使用越界读取进行数据泄露,这里我们考虑堆喷其他的可以泄露数据的小结构体与我们的 msg_msgseg
混在一起,从而使得我们越界读取时可以直接读到我们堆喷的这些小结构体,从而泄露出内核代码段加载基地址,那么这里笔者考虑堆喷 seq_operations 来完成数据的泄露
为了提高越界写入 msg_msg
的成功率,笔者选择先堆喷一部分 msg_msg
,之后分配 ctx->legacy_data
, 接下来再堆喷另一部分 msg_msg
为了提高数据泄露的成功概率,笔者选择在每次在消息队列上发送消息时都喷一个 seq_operations
,在完成消息队列的发送之后再喷射大量的 seq_operations
不过需要注意的是我们的越界写并不一定能写到相邻的 msg_msg
,也可能写到其他结构体或是 free object,若 free object 的 next 指针刚好位于开头被我们 overwrite 了,则会在后面的分配中导致 kernel panic
Step.II - 堆喷 msg_msg,利用 FUSE 在消息拷贝时覆写 next 字段进行任意地址写
接下来我们该考虑如何进行提权的工作了,通过覆写 msg_msg
的方式我们同样可以进行任意地址写的操作,由于消息发送时在 do_msgsnd()
当中是先分配对应的 msg_msg
与 msg_msgseg
链表作为消息的存储空间再进行拷贝,那么我们不难想到的是我们可以先发送一个大于一张内存页大小的消息,这样会分配一个 4k 的 msg_msg
与一个 msg_msgseg
,在 do_msgsnd()
中完成空间分配后在 msg_msg
上进行数据拷贝的时候,我们在另一个线程当中使用越界写更改 msg_msg
的 header,使其 next 指针更改到我们想要写入数据的地方,当 do_msgsnd()
开始将数据拷贝到 msg_msgseg
上时,由于 msg_msg
的 next 指针已经被我们所更改,故其会将数据写入到我们指定的地址上,从而完成任意地址写
不过 do_msgsnd()
的所有操作在一个系统调用中完成,因此这需要我们进行条件竞争,而常规的条件竞争通常很难成功,那么我们不难想到的是我们可以利用 userfaultfd 让 do_msgsnd()
在拷贝数据到 msg_msg
时触发用户空间的缺页异常,陷入到我们的 page fault handler 中,我们在 handler 线程中再进行越界写,之后恢复到原线程,这样利用的成功率便大大提高了
但是自 kernel 版本 5.11 起非特权用户无法使用 userfaultfd,而该漏洞影响的内核版本包括 5.11以上的版本,因此我们需要使用更为通用的办法——用户空间文件系统(filesystem in userspace,FUSE)可以被用作 userfaultfd 的替代品,帮助我们完成条件竞争的利用
不过需要注意的是,由于 slub allocator 的随机性,我们并不能保证一定能够溢出到陷入 FUSE 中的 msg_msg ,因此需要多次分配并进行检查以确保我们完成了任意地址写
有了任意地址写,现在该考虑写哪里、写什么了,我们可以通过覆写一些全局函数表来劫持内核执行流,或是覆写一些其他的东西完成提权,这里笔者选择覆写 modprobe_path
完成提权,当我们执行一个格式非法的程序时,内核会以 root 权限执行 modprobe_path
所指的应用,我们只需要将其改为我们的恶意脚本的路径即可
FINAL EXPLOIT
最后由笔者编写的 exp 如下,因为一些原因暂时无法进行验证,但是思路应该是对的,在理论上应当可行:
内核地址泄露的部分经过验证了,但是对于 FUSE 进行利用的部分,由于笔者在复现漏洞时使用的是 CTF 中 kernel pwn 的简易环境,故没法使用 FUSE:(
1 |
|
0x03.漏洞修复
该漏洞在内核主线的 这个 commit 当中被修复,主要就是将减法换成了加法,避免了无符号整型下溢的问题,笔者认为这个修复还是比较成功的:
1 |
|