【CVE.0x09】CVE-2022-0185 漏洞复现及简要分析
本文最后更新于:2023年1月11日 下午
还是 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.漏洞利用
现在我们有了任意长度的堆溢出,而可溢出对象用的分配 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 |
|