【CVE.0x08】CVE-2022-0995 漏洞复现及简要分析
本文最后更新于:2023年8月13日 晚上
我在看着你👁_👁
0x00.一切开始之前
CVE-2022-0995 是近日爆出来的一个存在于 _观察队列事件通知子系统_(watch_queue event notification subsystem)中的一个堆溢出漏洞,该漏洞自内核版本 5.8
中伴随着 watch queue subsystem 引入,在 5.17-rc7
版本中被修复
不过虽然获得了 7.1
的 CVSS 评分,但这个漏洞似乎并没有什么热度,不过在笔者看来这仍然是一个品相不错的漏洞
在开始之前我们先来补充一些基础知识
General notification mechanism
通用通知机制 是建立在标准管道驱动之上的,其可以有效地将来自内核的通知消息拼接到用户打开的管道中,我们可以通过 CONFIG_WATCH_QUEUE
编译选项启用(默认开启)
该机制通过一个以特殊模式打开的管道实现,内核生成的消息被保存到管道内部的循环环形缓冲区中(pipe_buffer
队列),通过 read()
进行读取,由于在某些情况下我们可能想要将添加的内容还原到环上,因此在此类管道上禁用了 splice 以及类似功能(因为这可能导致其与通知消息交织在一起)
管道的所有者应当告诉内核哪些资源其想要通过该管道进行观察,只有连接到该管道上的资源才会往里边插入消息,需要注意的是一个资源可能会与多个管道绑定并同时将消息插入所有管道
若环中没有可用的插槽或可用的预分配的 message buffer(一个管道默认只有 16 个 pipe_buffer
——对应 16 张内存页),则消息将会被丢弃,在这两种情况下,read()
将在读取当前缓冲区的最后一条消息后将 WATCH_META_LOSS_NOTIFICATION
插入输出缓冲区
Watch Queue(Notification Output)API
一个 观测队列 (watch queue)是由一个应用分配的用以记录通知的缓冲区,其工作原理完全隐藏在管道设备驱动中,但有必要获得一个对其的引用以设置一个观测,可以通过以下 API 进行管理:
struct watch_queue *get_watch_queue(int fd);
由于观测队列在内核中通过实现缓冲区的管道的文件描述符表示,用户空间必须通过系统调用传递该文件描述符,这可以用于从系统调用中查找指向观测队列的不透明指针
void put_watch_queue(struct watch_queue *wqueue);
该函数用以丢弃从
get_watch_queue()
获得的引用
Event Filter
当一个观测队列被创建后,我们可以应用一组 过滤器 (filters)以限制接收的事件:
1 |
|
其中 filter 应为一个 struct watch_notification_filter
类型变量,其中 nr_filters
表示 filters[]
数组中过滤器的数量,而 __reserved
应为 0:
1 |
|
filters[]
为一个 watch_notification_type_filter
类型的结构体数组,该结构体定义如下:
1 |
|
type
为要过滤的事件类型,应当为类似WATCH_TYPE_KEY_NOTIFY
的值info_filter
与info_mask
充当通知记录的信息字段的过滤器,仅在以下情况才将通知写入缓冲区:1
(watch.info & info_mask) == info_filter
例如,这可以用于忽略不在一个挂载树上的观测点的事件
subtype_filter
为一个指示我们感兴趣的子类型的 bitmask,subtype_filter[0]
的 0 位对应子类型 0,1 位对应子类型 1,以此类推
若 ioctl() 的参数为 NULL,则过滤器将被移除,我们将接收到所有来自观测源的事件
内核中 watch queue subsystem 中 Event Filter 的实现
前面我们抄了一大段的 kernel document,现在我们来深入源码看一下 watch queue subsystem 的实现机制
当我们调用 ioctl(fd, IOC_WATCH_QUEUE_SET_FILTER, &filter)
时,会调用 do_vfs_ioctl()
判断 cmd 进行处理,而我们的 IOC_WATCH_QUEUE_SET_FILTER
不在其列表中,所以最后会走到 vfs_ioctl()
1 |
|
在 vfs_ioctl()
中会调用 file 结构体自身的函数表中的 unlocked_ioctl
指针
1 |
|
那么这里我们需要将目光放回管道的创建流程中分配文件描述符的部分,存在如下调用链:
1 |
|
alloc_file()
分配一个 file 结构体并将其函数表设为上层调用传入的函数表,而在 create_pipe_files()
中传入的函数表为 pipefifo_fops
:
1 |
|
因此最终调用到的是 pipe_ioctl()
,对于 cmd IOC_WATCH_QUEUE_SET_FILTER
而言,最终会调用 watch_queue_set_filter()
函数
1 |
|
0x01.漏洞分析
漏洞便发生在 watch_queue_set_filter()
中将 filter 数组从用户空间拷贝到内核空间的过程当中,现在让我们仔细审视这个函数的执行流程,在一开始时首先会将用户空间的 watch_notification_filter
结构拷贝到内核空间:
1 |
|
之后 memdup_user()
分配一块临时空间,将用户空间的 filter 数组拷贝至该临时空间
1 |
|
接下来会遍历每一个 watch_notification_type_filter
结构,记录 type 在指定范围的 filter 的数量到变量 nr_filter
中,这里其判断一个 type 是否合法的范围是 sizeof(wfilter->type_filter) * 8
1 |
|
接下来会分配真正储存 filter 的的空间,这里用了一个 struct_size()
导出的大小为 sizeof(wfilter) + sizeof(filters) * nr_filter
(感兴趣的同学可以自行阅读源码),注意到这里计算大小用的是我们前面遍历计算得到的 nr_filter
:
1 |
|
之后是将 filter 数组拷贝到分配的空间上,我们的第一个漏洞便出现在这里,其判断 type 是否合法使用的是 sizeof(wfilter->type_filter) * BITS_PER_LONG)
,与前面 nr_filter 的计算存在不一致性:
1 |
|
而 BITS_PER_LONG
定义于 /include/asm-generic/bitsperlong.h
中,在 32 位下为 32,64 位下为64:
1 |
|
那么前后对 type 范围的计算便存在不一致,我们不难想到的是我们可以指定几个 filter 的 type 为(计算 nr_filter 时的合法 type 上限值,拷贝 filter 时的合法 type 上限值)这个范围内的特定值,这样就能越界拷贝一定数量的 filter,从而完成堆上的越界写
那么这里我们容易计算得出触发第一个漏洞的 type 的范围应为 [0x80, 0x400)
而第二个漏洞则存在于上面这段代码中对 __set_bit()
的调用,该函数定义如下:
1 |
|
其作用便是将 addr 偏移 BIT_WORD(nr) 处的 BIT_MASK(mask) 位进行置 1 操作,这里的 BIT_WORD()
宏主要是除以 long 类型所占位数(64),而 BIT_MASK()
宏则是对 long 类型所占位数求模后结果作为 unsigned long 值 1 左移的位数导出结果数值:
1 |
|
而传入的第一个参数刚好为 type,由于我们的 type 可以在 [0x80, 0x400)
范围内取,而分配的 filter 空间却未必有那么大,因此这里存在一个越界置 1 位的漏洞,我们可以通过设置一个较大的 type 完成堆上越界置 1 位的操作
例如对于 kmalloc-96
而言,我们的对象可以覆盖到下图所示范围(本图来自于 breezeO_o师傅的博客):
0x02.漏洞利用
在目前公开的 exp 中对该漏洞的利用其实是基于 __set_bit()
进行利用的,因为相较于不好控制的 filter 溢出,越界写 1 位则更方便我们控制一些指针,例如 msg_msg->m_list
双向链表
在这份公开的 exp 中使用的其实是与 CVE-2021-22555 相同的利用技巧,只不过篡改 msg_msg
头部的方式不是邻接溢出写 0,而是越界写 1;接下来笔者将大幅拷贝使用与 CVE-2021-22555 相同的利用技巧完成对该漏洞的利用
提权
Step.I 堆喷 msg_msg
,建立主从消息队列,构造重叠辅助消息
现在我们有了一个堆上越界写 1 位,我们该怎么利用呢?比较朴素的一种思想便是覆写一个结构体中的指针,利用 partial overwrite 使得两个这样的结构体的头部指针指向同一个结构体,从而实现 object overlapping
那么选用什么样的结构体作为 victim 呢?这里我们选择使用 msg_msg
这一结构体,其长度可控,且开头正好是内核双向链表结构体,我们所能覆写的为其 next 指针:
1 |
|
当我们在一个消息队列上发送多个消息时,会形成如下结构:
我们不难想到的是,我们可以在一开始时先创建多个消息队列,并分别在每一个消息队列上发送两条消息,形成如下内存布局,这里为了便利后续利用,第一条消息(主消息)的大小为 96,第二条消息(辅助消息)的大小为 0x400:
之后我们读出其中几个消息队列的主消息以产生空洞,再利用 ioctl(fd, IOC_WATCH_QUEUE_SET_FILTER, &filter)
获取到我们刚释放的 msg_msg
结构体的空间
这里需要注意的是我们至少要释放两个主消息,因为在分配到 watch_filter 之前 memdup_user() 还需要获取一个对象
1 |
|
对于 __set_bit()
而言其可以置 1 的范围如下图所示,刚好可以覆盖到下一相邻 object 的前 16 字节
利用越界置 1 位我们可以覆写到其相邻的主消息的 next 指针,若该位刚好被由 0 变为 1,则我们很容易构造出在两个消息队列上存在两个主消息指向同一个辅助消息的这样的局面
我们可以通过在主从消息中放置对应的值来标识喷射的不同的消息队列,遍历读取所有队列来感知指向了同一辅助消息的两个队列
利用
MSG_COPY
标志位可以读取消息队列上的消息而不释放,参见这里
Step.II 释放辅助消息,构造 UAF
此时我们将辅助消息释放掉,便能成功完成 UAF 的构建,此时我们仍能通过其中一个消息队列访问到该辅助消息对应 object,但实际上这个 object 已经在 freelist 上了
Step.III 堆喷 sk_buff
伪造辅助消息,泄露 UAF obj 地址
接下来我们考虑如何利用这个 UAF,因为其仍位于消息队列上所以我们考虑伪造 msg_msg
结构体进行后续的利用,这里我们选用另外一个常用来进行堆喷的结构体——sk_buff
,类似于 msg_msg
,其同样可以提供近乎任意大小对象的分配写入与释放,但不同的是 msg_msg
由一个 header 加上用户数据组成,而 sk_buff
本身不包含任何用户数据,用户数据单独存放在一个 object 当中,而 sk_buff 中存放指向用户数据的指针
至于这个结构体的分配与释放也是十分简单,sk_buff 在内核网络协议栈中代表一个「包」,我们不难想到的是我们只需要创建一对 socket,在上面发送与接收数据包就能完成 sk_buff 的分配与释放,最简单的办法便是用 socketpair 系统调用创建一对 socket,之后对其 read & write 便能完成收发包的工作
接下来我们考虑如何通过伪造 msg_msg
结构体完成信息泄露,我们不难想到的是可以伪造一个 msg_msg
结构体,将其 m_ts
域设为一个较大值,从而越界读取到相邻辅助消息的 header,泄露出堆上地址
我们泄露出来的是哪个地址?让我们重新将目光放回到消息队列的结构上:
我们不难知道的是,该辅助消息的 prev 指针指向其主消息,而该辅助消息的 next 指针指向该消息队列的 msg_queue
结构,这是目前我们已知的两个“堆上地址”
接下来我们伪造 msg_msg->next
,将其指向我们的 UAF object 相邻的辅助消息对应的主消息头部往前,从而读出该主消息的头部,泄露出对应的辅助消息的地址,有了这个辅助消息的地址,再减去 0x400 便是我们的 UAF 对象的地址
通过伪造 msg_msg->next 可以完成任意地址读,参见这里
Step.IV 堆喷 pipe_buffer
,泄露内核基址
现在我们已知了可控区域的地址,接下来让我们来考虑泄露内核 .text 段的基址,以及如何劫持 RIP 完成提权
之前我们为什么将辅助消息的大小设为 0x400?除了方便对齐以外,还有一层考虑就是这个大小刚好有一个十分实用的结构体 pipe_buffer
数组,既能帮我们泄露内核代码段基址,也能帮我们劫持 RIP
当我们创建一个管道时,在内核中会生成数个连续的 pipe_buffer
结构体,申请的内存总大小刚好会让内核从 kmalloc-1k 中取出一个 object
1 |
|
在 pipe_buffer
中存在一个函数表成员 pipe_buf_operations
,其指向内核中的函数表 anon_pipe_buf_ops
,若我们能够将其读出,便能泄露出内核基址,操作如下:
- 利用
sk_buff
修复辅助消息,之后从消息队列中接收该辅助消息,此时该 object 重回 slub 中,但sk_buff
仍指向该 object - 喷射
pipe_buffer
,之后再接收sk_buff
数据包,我们便能读出 pipe_buffer 上数据,泄露内核基址
Step.V 伪造 pipe_buffer,构造 ROP,劫持 RIP,完成提权
当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buffer_operations->release
这一指针,而 UAF object 的地址对我们而言是已知的,因此我们可以直接利用 sk_buff 在 UAF object 上伪造函数表与构造 ROP chain,再选一条足够合适的 gadget 完成栈迁移便能劫持 RIP 完成提权
Final EXPLOIT
最终的 exp 如下(基本上就是把 CVE-2021-22555 的 exp 里 trigger oob 的函数改一下就能打通了):
1 |
|
运行即可完成提权
0x03.漏洞修复
该漏洞在内核主线的 这个 commit 中被修复,这个 commit 增加的修改比较多,我们主要关注对于该漏洞其改变的部分:
1 |
|
- 修复了前后判定不一致的问题
- 将 type 的范围限定为
WATCH_TYPE__NR
(值为 2)
笔者个人认为这个修复还是比较成功的