CVE-2022-0847内核提权漏洞分析复现
CVE-2022-0847内核提权漏洞分析复现
翻译翻译:
A flaw was found in the way the “flags” member of the new pipe buffer structure was lacking proper initialization in copy_page_to_iter_pipe and push_pipe functions in the Linux kernel and could thus contain stale values. An unprivileged local user could use this flaw to write to pages in the page cache backed by read only files and as such escalate their privileges on the system.
就是有一个pipe结构中,有一个flag成员,他初始化的时候调用了copy_page_to_iter_pipe、push_pipe两个函数,这两个函数有可能不能正确的初始化,最终导致的就是只读文件可写,然后就可以导致一个本地的权限提升。
0x01 背景知识
首先需要解释上面漏洞概述中的一些noun
一、文件读写背景知识
1、传统IO模式
磁盘的IO读写速度是很慢的,所以一般当我们访问一个磁盘文件的时候,首先会将其内容装载到物理内存中,后续的访问都是直接取内存中的副本来读取数据。因为一个文件的内存副本,后续可能会被很多进程打开使用,为了保证大家都能快速的访问,Linux设计了这样一个Page Cache机制管理起物理内存中映射的页框。
如果用户进程使用read/write读写文件,那么内核会先将载入数据的物理内存映射到内核虚拟内存buffer。然后再将内核的buffer数据拷贝到用户态。
这里简单的做一个理解(,这里磁盘和物理内存都是硬件,然后物理内存的内容通过页表映射,相当于对于内存做了一个索引,但是用户要读数据,不能直接和硬件交互,也不能直接读内核,用户只能和操作系统提供的接口交互,也就是上图左边写的拷贝两个字,意思是从内核空间中把buffer再复制到用户空间,这样在用户空间中,其他进程需要buffer时,再与进程1进行通信。
为了加深理解,我们再举个例子:
现在我要将硬盘中的一个文件上传到网络上,电脑需要做些什么呢?首先,通过read()
把数据从硬盘读取到内核缓冲区,然后,再复制到用户缓冲区;之后,再通过write()
写入到socket缓冲区
,最后写入网卡设备。
整个过程发生了4次用户态和内核态的上下文切换和4次拷贝,具体流程如下:
- 用户进程通过
read()
方法向操作系统发起调用,此时上下文从用户态转向内核态 - DMA控制器把数据从硬盘中拷贝到读缓冲区
- CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,
read()
返回 - 用户进程通过
write()
方法发起调用,上下文从用户态转为内核态 - CPU将应用缓冲区中数据拷贝到socket缓冲区
- DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,
write()
返回
这里存在一个问题,那就是太慢,这样交互的过程始终存在一个经过内核复制到用户,再经过用户复制到内核的过程,如果能直接和内核,让内核直接处理这些事情,那就就更快了!因此,我们就引入了零拷贝的概念。
2、新的IO 零拷贝
首先,零拷贝并不是不拷贝!上述的图中就可以看出,最少的拷贝次数是两次(从硬盘到内核,然后直接到网卡)。因此,零拷贝的作用就是减少了拷贝文件的次数。
mmap:
这里有针对性地,就以一个mmap为例,讲一讲具体如何实现零拷贝:
依然是上面的例子:
mmap+write简单来说就是使用mmap
替换了read+write中的read操作,减少了一次CPU的拷贝。
mmap
主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。
整个过程发生了4次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
- 用户进程通过
mmap()
方法向操作系统发起调用,上下文从用户态转向内核态 - DMA控制器把数据从硬盘中拷贝到读缓冲区
- 上下文从内核态转为用户态,mmap调用返回
- 用户进程通过
write()
方法发起调用,上下文从用户态转为内核态 - CPU将读缓冲区中数据拷贝到socket缓冲区
- DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,
write()
返回
总结一下:mmap
的方式节省了一次CPU拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输。
下面这张图也是同样的意思,不发生系统调用,跨越用户和内核的边界做上下文切换。用户进程可以使用mmap直接将用户态的buffer 映射到物理内存,不需要进行系统调用,直接访问自己的mmap区域即可访问到那段物理内存内容。
sendfile:
相比mmap
来说,sendfile
同样减少了一次CPU拷贝,而且还减少了2次上下文切换!
可以看到,整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
- 用户进程通过
sendfile()
方法向操作系统发起调用,上下文从用户态转向内核态 - DMA控制器把数据从硬盘中拷贝到读缓冲区
- CPU将读缓冲区中数据拷贝到socket缓冲区
- DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,
sendfile
调用返回
但是,sendfile
方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
当然,我们可能还有很多效率更高,copy次数更少的零拷贝方式,但是我们只需要了解mmap和senfile这两种,方便我们理解下面的内容即可。
二、pipe相关的结构与函数
文件通过管道传输流程:
in端 == write == pipe == splice == out端
out端通过splice与内核缓冲区进行共享,然后in端调用write将内容拷贝到内核缓冲区进而写入到out端。
1、pipe_write
pipe
是内核提供的一个通信管道,通过pipe/pipe2
函数创建,返回两个文件描述符,一个用于发送数据,另一个用于接受数据,类似管道的两段。
在内核中的实现,通常pipe 缓存空间总长度65536 字节用页的形式进行管理,总共16页(一页4096字节),页面之间并不连续,而是通过数组进行管理,形成一个环形链表。维护两个链表指针,一个用来写(pipe->head
),一个用来读(pipe->tail
),两个指针中间的就是正在使用的东西。
看源码:
1 | static ssize_t |
过程如下:
- 如果当前管道(pipe)中不为空(head==tail判定为空管道),则说明现在管道中有未被读取的数据,则获取head 指针,也就是指向最新的用来写的页,查看该页的len、offset(为了找到数据结尾)。接下来尝试在当前页面续写
- 判断 当前页面是否带有 PIPE_BUF_FLAG_CAN_MERGE flag标记,如果不存在则不允许在当前页面续写。或当前写入的数据拼接在之前的数据后面长度超过一页(即写入操作跨页),如果跨页,则无法续写。
- 如果无法在上一页续写,则另起一页
- alloc_page 申请一个新的页
- 将新的页放在数组最前面(可能会替换掉原有页面),初始化值。
- buf->flag 默认初始化为PIPE_BUF_FLAG_CAN_MERGE ,因为默认状态是允许页可以续写的.
- 拷贝写入的数据,没拷贝完重复上述操作。
上述这个过程讲的就是如何从内核读取一个数据到用户态,也就是所谓的in到out;
这里写一下我的理解:pip-buf是指针,对于相应的page地址做写操作,但是写之前需要判断一个flag,来判断是否能写,这里的page相当于上面提到的一个虚拟内存空间,不直接指向物理内存。
2、零拷贝——splice()
上文中提到,为了免去数据由内核复制到用户态的额外消耗,操作系统额外提供零拷贝的方法,让用户直接操作内核中的数据。
在pipe机制中,直接让用户能够通过buf直接指向内核态中的内容,这个方法也就是splice。(这里解释一下,所谓零拷贝只是相对于传统拷贝的一种思想,上文中提到的mmap和将要提到的splice都是对于零拷贝这种思想的不同的实现方法。)
这里由于我们pipe里面的地址直接指向了物理地址,也就是page cache(page cache是文件在内存中的映像,长期储存在内存中,可以反复被调用,下一部分会详细讲到),此时,当我们再使用pip_write函数时,就是直接更改物理内存中的文件映像了。
3、page cache
我们知道文件一般存放在硬盘中,CPU 并不能直接访问硬盘中的数据,而是需要先将硬盘中的数据读入到内存中,然后才能被 CPU 访问。由于读写硬盘的速度比读写内存要慢很多(DDR4 内存读写速度是机械硬盘500倍,是固态硬盘的200倍),所以为了避免每次读写文件时,都需要对硬盘进行读写操作,Linux 内核使用 页缓存(Page Cache) 机制来对文件中的数据进行缓存。
为了提升对文件的读写效率,Linux 内核会以页大小(4KB)为单位,将文件划分为多数据块。当用户对文件中的某个数据块进行读写操作时,内核首先会申请一个内存页(称为 页缓存
)与文件中的数据块进行绑定。如下图所示:
如上图所示,当用户对文件进行读写时,实际上是对文件的 页缓存
进行读写。所以对文件进行读写操作时,会分以下两种情况进行处理:
- 当从文件中读取数据时,如果要读取的数据所在的页缓存已经存在,那么就直接把页缓存的数据拷贝给用户即可。否则,内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把页缓存的数据拷贝给用户。
- 当向文件中写入数据时,如果要写入的数据所在的页缓存已经存在,那么直接把新数据写入到页缓存即可。否则,内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把新数据写入到页缓存中。对于被修改的页缓存,内核会定时把这些页缓存刷新到文件中。
0x02 漏洞原理
一、原理分析
splice()系统调用将包含文件的页面缓存(page cache), 链接到pipe的环形缓冲区(pipe_buffer)时, 在copy_page_to_iter_pipe 和 push_pipe函数中未能正确清除页面的”PIPE_BUF_FLAG_CAN_MERGE”属性, 导致后续进行pipe_write()操作时错误的判定”write操作可合并(merge)”, 从而将非法数据写入文件页面缓存, 导致任意文件覆盖漏洞。
在了解了上述内容之后,这个原理就非常奇葩了。在调用零拷贝的核心函数splice函数时,存在这样的调用栈:
SYSCALL_DEFINE6(splice,…) -> __do_sys_splice -> __do_splice-> do_splice
splice_file_to_pipe -> do_splice_to
generic_file_splice_read(in->f_op->splice_read 默认为 generic_file_splice_read)
call_read_iter -> filemap_read
- copy_page_to_iter -> copy_page_to_iter_pipe
漏洞所在的copy_page_to_iter_pipe 函数主要做的工作就是将pipe 缓存页结构指向要传输的文件的文件缓存页,其代码如下:
1 | static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, |
可以看到中间各种的赋值,就是在初始化Pipe_buf中的相应的内容,让其指向page cache,但是这里竟然没有初始化进行权限校验的flag。。。
这个flag很明显就是为了针对用户不具有读写权限的文件,不能乱写,结果这里没初始化,那这个flag还有什么意义呢?一位前辈也这么说:
PIPE_BUF_FLAG_CAN_MERGE 这个flag 总共就出现了5次,一次#define 声明,两次在pipe_write 里。剩下两次都在splice 之中:
而且根据这个变量参与的代码可知,这个变量的意义就是是否允许在当前最新pipe 缓存页中续写;一般pipe 自己申请的页,就是个普通页,续写就续写很正常。什么情况不能续写,那就是这个页不是你pipe 自己申请的页,你不可以随便改。所以由目前的状况来看,几乎也就splice 中涉及到了非pipe 自己申请的页。换言之,PIPE_BUF_FLAG_CAN_MERGE 这个flag 就是为splice 设计的。然后你告诉我你不初始化的吗?
在继续对这个洞深入了解之后,我发现是历史原因。。。
二、漏洞的迭代过程
参看: https://www.anquanke.com/post/id/270067#h2-7 最后一部分
总结一下:
Linux 2.6, 引入了splice()
系统调用;
Linux 4.9, 添加了iov_iter(就是一个迭代器模型,不重要)对Pipe的支持, 其中copy_page_to_iter_pipe()
与push_pipe()
函数实现中当时就缺少对pipe buffer中flag
的初始化操作!
但在当时并无大碍, 因为当时使用判断是否的属性:can_merge
属性。
Linux 5.1, 由于在众多类型的pipe_buffer中, 只有anon_pipe_buf_ops
这一种情况的can_merge
属性是为1的(can_merge
字段在结构体中占一个int大小的空间), 所以, 将pipe_buf_operations
结构体中的can_merge
属性删除, 并且把merge操作时的判断改为指针判断, 合情合理。正是如此, copy_page_to_iter_pipe()
中对buf->ops
的初始化操作已经不包含can_merge
属性初始化的功能了, 只是push_write()
中merge操作的判断依然正常, 所以依然不会触发漏洞。
简单的说,由于can_merge
属性占空间,所有又被删掉了,添加了直接判断类型的代码。
Linux 5.8中, 把各种类型的pipe_buf_operations
结构体进行合并, 正式把can_merge
标记改为PIPE_BUF_FLAG_CAN_MERGE
合并进入flag属性中, 知道此时, 4.9补丁中没有flag字段初始化
的隐患才真正生效。
简单的说,前面没有用到flag,所以没有初始化;后面用到了flag的时候,以为前面已经初始化了,就没有检查,总之还是愚蠢至极。
0x03 poc构造
一、利用步骤
- 创建一个管道
- 将管道填充满(通过pipe_write),这样所有的buf(pipe 缓存页)都初始化过了,flag 默认初始化为PIPE_BUF_FLAG_CAN_MERGE
- 将管道清空(通过pipe_read),这样通过splice 系统调用传送文件的时候就会使用原有的初始化过的buf结构。
- 调用splice 函数将想要篡改的文件传送入
- 继续向pipe写入内容(pipe_write),这时就会覆盖到文件缓存页了,完成暂时文件篡改。
二、文件选择
能越权读写之后,我们需要选择一个文件对其进行修改,达成提权的效果,那么选哪个呢?
在原poc中,他选择的文件是**/etc/passwd**:
我们先看看源文件里面有些什么:
每个用户的形式以这样的结构表示
用户名:密码:UID(用户ID):GID(组ID):描述性信息:主目录:默认Shell
这里有两个关键字段:
密码
“x” 表示此用户设有密码,但不是真正的密码(也不能是,谁都能读还得了嘛),真正的密码保存在 /etc/shadow 文件中,但是这里也是可以存真实的密码加盐后的值的,因此我们可以利用。
UID
UID,也就是用户 ID。每个用户都有唯一的一个 UID,UID 是一个 0~65535 之间的数,不同范围的数字表示不同的用户身份,具体如表所示:
UID 范围 | 用户身份 |
---|---|
0 | 超级用户。UID 为 0 就代表这个账号是管理员账号。在 Linux 中,如何把普通用户升级成管理员呢?只需把其他用户的 UID 修改为 0 就可以了,这一点和 Windows 是不同的。不过不建议建立多个管理员账号。 |
1~499 | 系统用户(伪用户)。也就是说,此范围的 UID 保留给系统使用。其中,1 |
500~65535 | 普通用户。通常这些 UID 已经足够用户使用了。但不够用也没关系,2.6.x 内核之后的 Linux 系统已经可以支持 232 个 UID 了。 |
利用方式:
首先,使用perl语言生成带有盐值的密码:
命令: perl -le ‘print crypt(“test”,“addedsalt”)’
输出:adMpHktIn0tR2
然后将下面的内容,将test用户的信息加入到/etc/passwd 文件末尾;
test:adMpHktIn0tR2:0:0:User_like_root:/root:/bin/bash
三、poc
官方poc:
1 |
|
利用思路和上述的思路是一样的。