任意只读文件漏洞分析
简介
漏洞形成原因:
使用 splice(2)
^5系统调用从一个只读文件向一个管道^6中传输数据时,会使管道用于保存数据的缓冲区共享文件的 page cache。由于 PIPE_BUF_FLAG_CAN_MERGE
标志位的存在,调用 splice(2)
之后再向管道中写入数据时,写入的数据会直接写到文件的 page cache 中。
漏洞危害:低权限用户可利用此漏洞向本没有写权限的文件中写入数据,进而实现提权。
修复方法:根据使用的发行版,关注官方的漏洞通告并升级内核到已修复的版本。
漏洞分析过程
漏洞的利用过程与 Linux 管道和 splice(2)
系统调用的实现机制有关,因此当了解了二者的实现机制后,就很容易理解漏洞的形成原因。
因此漏洞分析过程分两部分:第一部分结合内核源码介绍管道和 splice(2)
的实现原理,第二部分通过运行 PoC 并动态调试内核,来实际体验并验证漏洞的触发过程。
先导知识
pipe 实现机制
首先给出一张描述 pipe 相关内核数据结构之间关系的图:
【一>所有资源获取<一】1、网络安全学习路线 2、电子书籍(白帽子)3、安全大厂内部视频 4、100 份 src 文档 5、常见安全面试题 6、ctf 大赛经典题目解析 7、全套工具包 8、应急响应笔记
创建 pipe
创建 pipe 的系统调用有两个:pipe(2)
和 pipe2(2)
,原型为:
系统调用的定义在 /fs/pipe.c
文件中:
两个系统调用的入口都是 do_pipe2()
函数。这个函数的功能是:
调用
__do_pipe_flags()
函数创建两个struct file
结构体实例和两个对应的文件描述符。调用
copy_to_user()
函数将两个文件描述符拷贝给系统调用参数pipefd
。调用
fd_install()
函数将文件描述符和struct file
结构体实例关联起来。
__do_pipe_flags()
再看 __do_pipe_flags()
函数。函数原型为:
第一个参数 fd
用于保存创建的两个文件描述符,第二个参数用于保存创建的两个 struct file
结构体实例,第三个参数是系统调用参数 flags
的值。
__do_pipe_flags()
函数的工作为:
检查非法的标志位组合。
调用
create_pipe_files()
函数创建两个struct file
结构体实例。调用两次
get_unused_fd_flags()
函数创建两个文件描述符。调用
audit_fd_pair()
函数处理审计相关的工作。
create_pipe_files()
再看 create_pipe_files()
函数函数的用途是根据传入的标志位创建两个 struct file
结构体实例。流程为:
调用
get_pipe_inode()
函数创建一个 inode 实例。如果标志位设置了
O_NOTIFICATION_PIPE
位,则初始化一个 watch 队列。调用
alloc_file_pseudo()
函数创建一个strcut file
实例,并将private_data
字段的值设置为inode->i_pipe
的值。调用
alloc_file_clone()
函数拷贝一个struct file
实例,同样将其private_data
字段的值设置为inode->i_pipe
的值。调用
stream_open()
函数打开两个文件。
get_pipe_inode()
接下来看看 get_pipe_inode()
函数是如何创建 inode 实例的。
调用
new_inode_pseudo()
函数创建一个 inode 实例。调用
alloc_pipe_info()
函数创建一个pipe_inode_info
实例。设置 inode 实例的以下字段:
inode->i_pipe
设置为pipe
实例指针。inode->i_fop
设置为pipefifo_fops
变量的指针。inode->i_state
设置为I_DIRTY
。inode->i_mode
设置为S_IFIFO | S_IRUSR | S_IWUSR
。inode->i_uid
设置为fsuid
,inode->i_gid
设置为fsgid
。inode->i_atime
、inode->i_mtime
、inode->i_ctime
均设置为当前时间。
关键的内核数据结构
这里涉及到第一个关键的结构体 struct pipe_inode_info
内核使用这个结构体来描述一个 pipe:
pipe 中的数据保存在结构体 pipe_buffer
page
字段:
顺便看看 alloc_pipe_info()
函数是怎样初始化 pipe_inode_info
结构体的。
使用
kzalloc
函数创建一个pipe_inode_info
实例。kzalloc
函数与kmalloc
类似,只不过会初始化分配的内存。根据用户是否有
CAP_SYS_RESOURCE
权限决定 pipe 缓冲区的大小,并保存在pipe_bufs
变量里。缓冲区的大小以页为单位。非 root 用户可以将缓冲区大小扩展为最大1048576
个字节,保存在pipe_max_size
变量中。可以通过/proc/sys/fs/pipe-max-size
调整这个值。默认大小为PIPE_DEF_BUFFERS
(16)个内存页。检查当前用户是否创建了过多的 pipe。
调用
kcalloc
函数为pipe_inode_info
结构体的bufs
字段分配内存。kcalloc
与kzalloc
类似,只不过是分配连续若干个指定大小的内存块。初始化
pipe_buffer
中的其它成员:初始化读写队列。
将读者和写者的数量初始化为 1。
pipe 的最大可使用量、缓冲区大小、记账个数都初始化为
pipe_bufs
变量的值。设置用户为当前用户。
初始化互斥锁。
读写 pipe
上文中提到的 pipefifo_fops
是一个 struct file_operations
类型的常量,表示 pipe 文件支持的文件操作有哪些,以及保存了对应操作的函数指针:
在上面 create_pipe_files()
函数中,会将 file
结构体实例的 f_op
字段设置成 pipefifo_fops
结构体的指针。用户态执行上面支持的系统调用时,VFS 会调用结构体中相应的函数。
以 write(2)
系统调用为例,进入系统调用入口之后,实际会调用 vfs_write()
函数。而 pipe 支持 write_iter
而不是 write
,因此会接着执行 new_sync_write()
:
call_write_iter()
是一个内联函数:
其它系统调用类似,不再赘述。总之,从 pipe 中读取数据时,最终调用的是 pipe_read()
函数;向 pipe 中写入数据时,最终调用的是 pipe_write()
函数。
pipe_write()
先来看 pipe_write()
函数的主要流程:
如果 pipe 读者的数量为 0,则向进程发送
SIGPIPE
信号,并返回EPIPE
错误。计算要写入的数据总大小是否是页帧大小的倍数,并将余数保存在
chars
变量中。如果
chars
不为零,而且 pipe 不为空,则:获取 pipe 头部的缓冲区。
如果缓冲区设置了标志位
PIPE_BUF_FLAG_CAN_MERGE
,且缓冲区中已有的数据长度与chars
的和不超过一个页帧的大小,则将chars
长度的数据写入到当前的缓冲区中。如果剩余要写入的数据大小为零,则直接返回。
在 for 循环中:
判断 pipe 的读者数量是否为零。
如果 pipe 没有被填满:
获取 pipe 头部的缓冲区。
如果还没有为缓冲区分配页帧,则调用
alloc_page()
函数分配一个。使用自旋锁锁住 pipe 的读者等待队列。再次检测 pipe 是否被填满,是则终止当前循环,执行下一次循环。
将
struct pipe_inode_info
实例的head
字段值增加 1。并释放自旋锁。设置当前缓冲区的字段。
如果创建 pipe 时指定了
O_DIRECT
选项,则将缓冲区的flags
字段设置为PIPE_BUF_FLAG_PACKET
,否则设置为PIPE_BUF_FLAG_CAN_MERGE
。将要写入的数据拷贝到当前的缓冲区中,并设置相应的偏移量字段。
splice 系统调用
splice()
系统调用避免在内核地址空间与用户地址空间的拷贝,从而快速地在两个文件描述符之间传递数据。函数原型为:
此次漏洞使用的情况是从文件向管道传递数据,因此 fd_in
指代一个普通文件,off_in
表示从指定的文件偏移处开始读取,fd_out
指代一个 pipe,len
表示要传输的数据长度,flags
表示标志位。详细情况可以参考手册。
看看 splice()
系统调用的主要流程。系统调用的定义在 fs/splice.c
文件中,主要工作由 __do_splice()
函数完成。
__do_splice()
在做完简单的参数检查之后,又调用 do_splice()
函数实现主要工作。
do_splice()
中,会根据两个文件描述符的类型进入不同的分支。当前情况下,fd_out
指代一个 pipe,因此会进入 if (opipe)
这个分支。主要工作通过 do_splice_to()
函数完成。
do_splice_to()
在 do_splice_to()
中,主要功能是通过输入文件的 splice_read()
方法实现的。这里以 ext4
文件系统为例,在 fs/ext4/file.c
文件中查看 ext4_file_operations
变量可知,ext4
文件系统中,splice_read
使用的是定义在 fs/splice.c
中的 generic_file_splice_read()
方法。接着通过调试可知接下来的函数调用链:
call_read_iter()
是一个定义在 include/linux/fs.h
中的内联函数,实际调用的是输入文件的 read_iter()
方法。而 ext4
文件系统的 read_iter()
方法是 ext4_file_read_iter()
。在当前情况下,会调用 generic_file_rad_iter()
,其接着调用 generic_file_buffered_read()
。
copy_page_to_iter_pipe()
generic_file_buffered_read()
是通用的文件读取例程,将文件读取到 page cache 后会通过 copy_page_to_iter()
函数将文件对应的 page cache 与 pipe 的缓冲区关联起来。实际的关联操作通过定义在 /lib/iov_iter.c
中的 copy_page_to_iter_pipe()
实现:
漏洞复现
分析
如果了解了向 pipe 写入数据的过程,以及 splice()
系统调用从文件向 pipe 传输数据的过程,就不难理解漏洞的形成原因了。对照漏洞发现者提供的 PoC 来解释漏洞形成原因:
首先创建一个 pipe。接着每次向 pipe 中写入一个页帧大小的数据。从
pipe_write()
可知,每次写入都不会进入if (chars && !was_empty)
这个分支,因为写入数据的大小为页帧大小的整数倍时,chars
的值总为零。创建 pipe 的时候没有指定O_DIRECT
标志,因此在 for 循环中会将每个pipe_buffer
的标志位设置为PIPE_BUF_FLAG_CAN_MERGE
。接下来打开要覆写的文件,并通过
splice()
系统调用向 pipe 中写入一个字节。根据splice()
的实现,将文件从硬盘读取到 page cache 后,会把文件对应的 page 与pipe_buffer
的page
字段关联起来,并且不会重置pipe_buffer
的flags
字段。也就是说,此时flags
字段的值仍为PIPE_BUF_FLAG_CAN_MERGE
。最后向 pipe 中写入小于一个页帧大小的数据。进入
pipe_write()
之后,会进入if (chars && !was_empty)
分支。由于在copy_page_to_iter_pipe()
中,将文件的 page 与pipe_buffer
的page
字段关联之后,将pipe_inode_info
实例的head
值增加了 1,因此为了将小于一个页帧的数据写入到前一个pipe_buffer
中, if 分支里获取pipe_buffer
的时候将head
值减 1,从而此时pipe_buffer
的 page 指向的是文件的 page。
调试验证
首先创建一个要覆写的文件并用随机字符串填充:
然后在 GDB 中分别在 pipe_write
和 copy_page_to_iter_pipe
两个函数设置断点:
然后在 GDB 中使用 continue
命令让虚拟机继续运行,并执行 PoC 程序。然后会在 pipe_write
处停止。使用下面的 GDB 脚本可以看到,pipe 的所有 pipe_buffer
中的标志位都为零:
然后接着执行 15 次 continue
命令,在第 16 次向 pipe 中写入数据之前停止。再次查看所有 pipe_buffer
的标志位,发现都被置为了 PIPE_BUF_FLAG_CAN_MERGE
:
当最后一次 pipe_write
执行完后,pipe->head
的值为 16。
接着执行 continue
命令,会在 copy_page_to_iter_pipe
处停下来。单步进入几步之后,先把 pipe
变量和文件对应的 page 实例的地址保存到变量中。
因为当前 pipe->head
的值是 16,而 pipe->ring_size
的值时默认的 16,因此第 395 行代码中取到的是第一个 pipe_buffer
。
接下来将文件的 page 与 pipe_buffer
的 page
字段关联起来,并将 pipe 的 head
字段加一,即此时为 17。
接着 continue,会停在 pipe_write
处。接着单步执行,会进入触发漏洞的 if 分支。然后查看 buf->page
的值,和之前保存的文件的 page 的地址相同。继续之后,文件覆写成功:
低权限用户篡改没有写权限文件的验证
在上面的验证过程中,由于使用的是最简单的内核以及 busybox,因此使用 root 用户。为了验证低权限用户可以成功篡改没有写权限的文件,在此使用 ArchLinux
发行版,以 5.10.69-1-lts
内核版本作验证:
结论
经复现过程可知,漏洞利用方式相对简单,建议受影响的机器立即升级到官方最新版本。
评论