前言
其实并不是很想复现这个洞,但是在前些天fmyy告诉了我一个利用方式fuse
,虽然他也给我推荐了对应的CVE,不过我更加愿意看墨晚鸢佬的博客。这个CVE复现结束之后应该会有很长一段时间不会继续复现CVE了,后续的打算是更多的学习kernel fuzz
。
https://www.willsroot.io/2022/01/cve-2022-0185.html 这里是这个CVE发现者的文章,里面提到了其是被syzkaller
给fuzz出来的。
Filesystem mount API 分析
在Linux下的文件系统的挂载,mount
系统调用被用以将文件系统挂载到以 /
为根节点的文件树上,例如我们可以用如下命令挂载硬盘 /dev/sdb1
到 /mnt/temp
目录下,之后就能在该目录下进行文件访问:
1 | sudo mount /dev/sdb1 /mnt/temp |
然而新的mount API
将上面的一个简单的mount
系统调用的功能拆分成了多个新的系统调用,多个系统调用分别对应了不同文件系统挂载阶段。
fsopen
在Linux中一直秉持着一切皆文件的思想,在新的mount API
中也有对应的映照,首先则是fsopen
就类似于open
系统调用,其用于打开一个文件系统,并返回一个文件系统描述符(称为文件系统上下文)。
由于标准库中还未添加其相关代码,因此需要手写raw syscall
来进行相关的系统调用,例如我们可以使用如下代码打开一个空白的 ext4
文件系统上下文(需要 CAP_SYS_ADMIN
权限,或是开启了 unprivileged namespace
的情况下使用 unshare()
系统调用创建带有该权限的 namespace
):
这里创建的是一个空白的文件系统上下文,并没有与任何的实际设备进行关联。
在内核中调用fsopen
的会进入到如上函数,最终会在fscontext_create_fd
函数创建一个file
结构体,并且返回文件描述符。
fscontext_alloc_log
通过名字可以看出来这里分配的是用于log
的内存。
fs_context_for_mount
这个函数的返回值的类型为fs_context
,其作用也就是创建一个文件系统上下文结构体。
strndup_user
函数则是获取用户态传入的文件系统名,get_fs_type
这里是获取其type
。
上面是fs_context
结构体的定义,前面提到其是通过fs_context_for_mount
函数申请的,这个函数内部是直接调用了alloc_fs_context
函数
首先这里通过kzalloc
函数分配一个堆块给到了fs_context
结构体,后续设置其对应的属性,接着设置其命名空间,最后则是进行初始化。
在完成了前面的操作之后,最终进行具体文件系统对应初始化工作的其实是调用 file_system_type
中的 init_fs_context
函数指针对应的函数完成的,这里我们可以看到对于未设置 init_fs_context
的文件系统类型而言其最终会调用 legacy_init_fs_context()
进行初始化
1 | static int legacy_init_fs_context(struct fs_context *fc) |
这里的主要操作是给fs_context->fs_private
分配legacy_fs_context
结构体,并赋值其ops为legacy_fs_context_ops
。
1 | struct legacy_fs_context { |
结构体定义如上,标识了一块指定长度与类型的缓冲区。
fsconfig
在完成了空白的文件系统上下文的创建之后,我们还需要对其进行相应的配置,以便于后续的挂载操作,这个配置的功能对应到的就是 fsconfig()
系统调用
fsconfig()
系统调用根据不同的 cmd 进行不同的操作,对于挂载文件系统而言其核心操作主要就是两个 cmd:
FSCONFIG_SET_STRING
:设置不同的键值对参数FSCONFIG_CMD_CREATE
:获得一个 superblock 并创建一个 root entry
示例用法如上所示,这里创建了一个键值对 "source"=/dev/sdb1
表示文件系统源所在的设备名
在内核中也是fsconfig
的实现也是比较长,主要根据不同的cmd进入到不同的swith分支
在前面主要操作是对参数进行各种检测,紧接着获取到文件描述符,接着获取fs_config
,随后拷贝key字段到内核中,最终根据不同的cmd进入switch
1 | case FSCONFIG_SET_STRING: |
这里主要关注这一个分支,在分支中设置完param
之后进入后续流程,最终进入到vfs_fsconfig_locked
函数进行处理。
fsmount
完成了文件系统上下文的创建与配置,接下来终于来到文件系统的挂载操作了,fsmount()
系统调用用以获取一个可以被用以进行挂载的挂载实例,并返回一个文件描述符用以下一步的挂载
move_mount
最后使用move_mount
系统调用将挂载实例在挂载点之间移动,对于尚未进行挂载的挂载实例而言,进行挂载的操作便是从空挂载点 ""
移动到对应的挂载点(例如 "/mnt/temp"
),此时我们并不需要给出目的挂载点的 fd,而可以使用 AT_FDCWD
,引入了 move_mount()
之后,我们最终的一个用以将 "/dev/sdb1"
以 "ext4"
文件系统挂载到 "/mnt/temp"
的完整示例程序如下:
这里介绍几乎就是照抄a3和知乎的文章
漏洞分析
前面提到在fsconfig
函数中,最终会调用vfs_fsconfig_locked
函数
可以看到上述函数中依旧是根据cmd进入不同的swith分支
1 | enum fsconfig_command { |
根据定义,最终会进入到default
分支中,最终会调用vfs_parse_fs_param
函数
而在此函数中会调用到fs_context->ops->parse_param
,接着根据前面在legacy_init_fs_context
函数中会对fs_context->ops
赋值为legacy_fs_context_ops
1 | const struct fs_context_operations legacy_fs_context_ops = { |
根据前面所述,最终会调用到legacy_parse_param
函数中。
首先在ctx->data_size
中取出已拷贝的大小,随后根据param->type
计算出len,若是不存在ctx->legacy_data
则会申请一张页面大小,后续则是从param
中取出数据写到ctx-legacy_data
中去。
可以看到在计算出len之后其实是经过了一次判断的,len > PAGE_SIZE - 2 - size
这里就是其表达式,不过存在问题的是在函数开头定义size
使用的是unsigned int
,所以这个判断就成了无符号类型的判断了,一旦size + 2
大于PAGE_SIZE
那么这个判断是会一直成立的,从而达到了溢出的效果。
不过这里需要注意的是在前面的fsconfig
系统调用实现的函数中在对param
进行初始化时使用的是这样一条语句
1 | param.string = strndup_user(_value, 256); |
这里也就限制了我们单次写入的大小只能是0x100
个字节,不过可以看到的是在legacy_parse_param
函数末尾是又对ctx->data_size
进行了赋值并且值的大小为len + size
和size += param->size;
,并且后面在拷贝的时候使用的是ctx->legacy_data + size
。所以我们想要达到溢出的效果需要将size
构造为4095。
前面提到了size
最终的值是那两个的和,但其实还存在两个操作会对其做增加操作,也就是在每一条前面都会加上一个","
而在key后面都会加上一个"="
所以其实写入的最终效果如下
1 | ,key=val |
所以每一次拷贝的长度其实是strlen(key) + strlen(val) + 2
漏洞利用
可以预见的是,当我们控制size = 4095
时,他会在下一个相邻object
写入=
以及末尾的一个\x00
,所以这里采取的办法是不直接覆盖相邻object
的内容,而是直接覆盖掉后一个object
的内容。
FUSE
在以往的文章中提到了userfaultfd
系统调用,可惜的是在Linux 5.11
起就不再能用普通用户进行调用了,然而其实FUSE
也是可以达到重样的效果的。
首先简单介绍一下FUSE
,即用户空间文件系统,该功能允许非特权用户在用户空间实现一个用户态文件系统,开发者只需要实现对应的文件操作接口就可以在用户空间实现一个文件系统,而不需要重新编译内核,这给开发者提供了相当的便利。
FUSE
自 Linux 2.6.14
版本引入,主要由两部分组成:
- FUSE 内核模块,负责与 kernel 的 VFS 进行交互,并向用户空间实现的文件系统进程暴露
/dev/fuse
块设备接口 - 用户空间的 libfuse 库 负责向用户程序提供封装好的接口,开发者基于该库进行用户空间文件系统的开发:由一个
FUSE daemon
守护进程负责与内核模块进行交互并进行文件系统的具体操作
FUSE 的基本运行原理如下:
FUSE daemon
守护进程通过 libfuse 库的fuse_main()
注册文件系统与对应的处理函数,并挂载到对应的目录下(例如/mnt/fuse
)- 用户进程访问挂载点下的文件(例如
/mnt/fuse/file
),来到内核中的 VFS 对应 inode 的inode_operations
中的处理函数,交由 FUSE 内核模块进行处理 - FUSE 内核模块将请求转换为与用户态 daemon 进程间约定的格式,交由用户态对应的
FUSE daemon
守护进程进行处理 - 在
FUSE daemon
调用文件系统创建时注册的对应的处理函数,这一步可能会需要访问实际的文件系统 FUSE daemon
完成处理,返回结果至 FUSE 内核模块,再经由 VFS 返回给用户进程
这里不过多介绍了,后面就说说基本用法就行了,也和userfaultfd
类似,是一个模板。
在使用时需要先实现上面函数表中的函数接口,我们自定义的用户态文件系统的操作其实都是通过对该函数表中定义的函数回调实现的。
不难想到,注册一个用户空间文件系统,为读写等接口注册回调函数,使用 mmap 将该文件系统中的一个文件映射到内存中,当进程在内核中读写这块 mmap 内存时,便会触发缺页异常,此时控制权便会转交到我们注册的回调函数当中,然而在回调函数中的操作是我们可以控制的,所以效果就很类似于userfaultfd
了。
不过常规的 libfuse 库并不支持静态编译,这使得我们无法像以往一样先静态编译一个 exp 再传到远程,不过在此CVE的github仓库中存在其静态编译的操作。https://github.com/Crusaders-of-Rust/CVE-2022-0185
pipe_buffer
这里有两种利用方式,首先就是我们在 向pipe_buffer说yes! 文章中提到的使用pipe_buffer
构造出页级的UAF,最终实现任意地址读写。这里简单介绍一下在这个漏洞中如何使用就行,不过多停留了 绝对不是因为我是懒狗不想写exp ,这篇文章重点还是看FUSE
的用法,所以具体还是在另一种利用手法。
首先,在开始size = 4095
时即便是传入的key
为\x00
时也会在下一个object
中写入一个"="
,所以不幸的是不能直接修改下一个紧邻的pipe_buffer->page
。前面也提到了这里选择的方式修改下一个object
紧邻的下一个object
,不过我们如果单纯使用pipe_buffer
进行堆喷时会出现一个问题,在后续寻找被覆盖page
指针的pipe_buffer
的idx
时会出现一个大问题,那就是因为前面修改导致读取pipe
时导致kernel panic
。所以a3选择的办法是使用msg_msg
进行大量堆喷,通过修改m_ts
来判断哪个msg_msg
是被覆盖掉了,之后这个msg_msg
就不再使用防止出现kernel panic
,那此时也就成功将漏洞转化成了off by null
了,后续的使用其实就和上面的文章中一致了,不熟悉的朋友可以去看看。
当然这里还需要考虑的就是order
了,此处申请的object
对应的order
为3。当然,各位知道知道的pipe
提供fcntl(F_SETPIPE_SZ)
调用可以去修改pipe_buffer
的数量,所以可以达到对应的order
当然msg_msg
同理。
内部隔离分析
在看完第一种利用方式的朋友们可能会有点疑惑,”为什么可以使用msg_msg
?”。在ctf-wiki中写了”在linux kernel 5.9之前和linux kernel 5.11之后都是存在堆块隔离的”。
在kernel 5.14之后存在如上的cache type
,其中常被认为隔离的是KMALLOC_CGROUPT
其对应的是flag为GFP_KERNEL_ACCOUNT
的申请,可以在slabinfo
文件中看到其cache的名字为kmalloc-cg-*
。而GFP_KERNEL
则对应的就是KMALLOC_NORMAL
类型,在slabinfo
中就是普通的kmalloc-*
。
下面简单介绍一下内存隔离的原理:
在内核kmalloc
的实现里面可以看到的是,会给kmem_cache_alloc_trace
传入一个cache,另外kmalloc_caches
是一个二重数组,首先是根据对应的type
然后根据size
确定不同的index
取出最终的cache
。
这里重点看一下kmalloc_type
函数
这里主要看一下KMALLOC_NOT_NORMAL_BITS
的定义,因为kernel默认存在CONFIG_MEMCG_KMEM
选项所以添加了__GFP_ACCOUNT
标识为,以至于flag为GFP_KERNEL_ACCOUNT
时不会直接返回KMALLOC_NORMAL
了,这也就导致了堆块隔离。
根据上面的分析会发现在linux kernel 5.9
之前确实没有KMALLOC_CGROUP
这样一个新建的kmem_cache
,不过其实在此之前依旧是存在隔离的,这里分析一下在linux kernel 5.9
之前的隔离实现原理。
在之前的kmem_cache
结构体的定义如上,可以看到的是内部会根据是否开启了MEMCG
这个选项来添加struct memcg_cache_params memcg_params;
这样一个额外的结构体。
可以看到的是首先会存放一个根slab的指针,在memcg_caches
这里存放若干个子memcg slab
管理结构。
1 | struct memcg_cache_array { |
也就是说其可以通过根slab和子slab互相寻找。在上面这里结构体的定义中entries
就是用于存放memcg slab
的数组。所以可以理解为每一个根slab管理结构(根slab管理结构根据大小分类)都有一个对应的子memcg slab列表。
上面多为理论中的内容,下面讨论一下在实际面对时所遇到的问题:
在这个CVE中,所使用的所有分配对象的函数都为kmalloc
那么这里先从这里看起
可以看到上面在分为了两条分支,根据的是size是否为定量,那么根据这个cve正好会分别进入上面的两条分支中。在分配msg_msg
时会进入到下面的__kmalloc
函数中,在分配ctx->legacy_data
时则会进入到上面的分支中。
这里主要关注下面的__kmalloc
函数,可以看到其中会先进入到kmalloc_slab
获取对应的slab
,其实根据动态调试的结果看到的是这里的slab与分配ctx->legacy_data
时进入kmem_cache_alloc_trace
函数的第一个参数是一致的所以我当时就很迷惑,随即请教了a3又看了一下linux kernel 5.9
的commit才知道会在slab_alloc
函数中出现问题。
这个函数其实就是对slab_alloc_node
函数的套娃操作,然而slab_alloc_node
函数内部首先会调用slab_pre_alloc_hook
函数,起先并未注意到其返回值也是s所以并未当回事,那么现在详细分析一下
可以看到最后会判断是否开启了memcg
选项,并检测调用时的flags,所以也就是在这个位置导致slab改变了。
进入函数内部查看会发现其就是对额外的结构体做的一系列操作
msg_msg
至此可以开始认真分析关于此利用方法了,首先考虑的是如何实现泄漏内核地址。我们知道msg_msg
结构体如下:
1 | /* one msg_msg structure for each message */ |
其中next
指针指向的是msg_msgseg
结构体,而这个结构体在前面的文章中提到过,当我们发送的消息大小大于0xfd0
时将超出范围的内容补充到msg_msgseg
结构体,总体结构就是一个单向链表的结构。这里选择的办法肯定不能是内存搜索,这样存在的问题太多了,很容易造成kernel panic
。
关注msg_msgseg
结构体的分配过程可以知道的是在Linux kernel 5.4版本依旧是通过普通的slab申请的,所以这里的选择是尽可能小的生成msg_msgseg
结构体,随后使用seq_operations
结构体来泄漏出内核基地址。
1 | struct seq_operations { |
在以往的文章中介绍过这个结构体,这里简单提一下,这个结构体是内部全为函数指针的结构体,所以可以很轻松的泄漏。按照a3的做法,这里泄漏的办法是在每生成一个msg_msgseg
时就分配一个seq_operations
结构体,在最后完成msg_msg
结构体的堆喷之后又大量堆喷seq_operations
结构体,这样可以大大提高成功率使二者挨在一起再通过修改m_ts
成员即可实现泄漏。
那么接下来需要考虑的是任意地址写的问题了
这里可以注意到的是在对msg_msg
写完之后会进入下面的for循环,其会根据next
指针然后再进行写,后面的写就是写入到msg_msgseg
结构体中了。如果我们能够在第一次写的时候修改掉msg_msg->next
指针即可实现任意地址写了。
面对上面的思路,使用userfaultfd
是很明显可以实现的,不过既然这篇文章提到了FUSE
那么这里肯定就使用FUSE
了,不过思路都是一样的。所以这里的整体思路就是通过mmap
创建两块连续的内存区域,让后一块内存区域和FUSE
挂载点下的文件做映射,那么在读取下一块内存时就会进入到我们预先写到的read
函数中去了,在这个处理函数中使用fsconfig
中的漏洞去修改掉msg_msg->next
指针,在结束处理函数之后就会继续往已经被我们修改的指针地址写入内容了,完成了任意地址写。这里因为只泄漏了内核基地址所以这里写的地方也选择的是modprobe_path
进行提权。
综上可得exp
踩坑记
首先在虚拟机中跑FUSE
时踩了一个大坑,在一篇文章中( 这里提到的文章就不放出来了,可能是师傅们不小心写错了 )指出FUSE
无法在ctf环境中运行是因为bzImage
的问题,经过询问发现其问题主要是文件系统过于残缺导致的。随后听取a3佬的意见更多的学习了fuse原理之后成功解决了问题,我这里使用的是syzkaller
中的工具使用debootstrap
搭建的一个文件系统
因为我也稍做了点修改,怕以后忘记了这里贴出来记录一下。
第二个坑就是关于上面提到的内部隔离问题,同样也是在某位师傅的博客文章中提到了在linux kernel 5.14
以前不存在内部隔离问题,随即居然以下犯上去说a3师傅写的ctfwiki错了,在经过几天挣扎之后终于注意到了在linux kernel 5.9
以前的内部隔离实现。
再记录一下编译选项
参考链接:
https://arttnba3.cn/2023/01/11/CVE-0X09-CVE-2022-0185/
https://zhuanlan.zhihu.com/p/93592262
https://www.willsroot.io/2022/01/cve-2022-0185.html
v1.5.2