CVE-2022-0847浅析
背景
页与页缓存
页是操作系统管理内存的基本单位,也是CPU中MMU(内存管理单元)内存映射的最小单位,与Linux内存管理机制息息相关。与CPU处理速度相比,磁盘读写速度过慢,因此Linux内核引入了页缓存机制。当进程尝试读取文件时,内核会将文件内容加载到内存的页缓存里。后续再次读取该文件时,内核会直接从页缓存中获取数据,而无需访问磁盘。另一方面,程序通过正常途径修改页缓存中的数据后,内核会将这些修改标记为「脏页」,并会适时将它们写回磁盘。
管道
管道是一种重定向形式,能够将一个程序的输出发送给另一个程序作为输入,从而实现进程间通信。它以一对文件描述符的形式提供给进程,其中一个用于读取,另一个用于写入。向管道的一端写入数据,即可从另一端读取该数据。在内核实现中,这套文件描述符接口并不与真实的磁盘设备相关联,而是连接到了内核空间中专门维护的内存缓冲区。
对于Linux 5.8版本而言,管道是由内存页组成的环形缓冲区,内核主要通过pipe_inode_info结构体来管理管道。其使用循环数组的方式,定义了指向缓冲数组的指针bufs,并通过head和tail索引。管道的环形缓冲区大小有限(PIPE_DEF_BUFFERS定义了缓冲区大小),进程从管道读取数据后,消耗掉的槽位会被回收,供后续写入复用。
循环数组中的每个槽位为pipe_buffer结构体,存储相关元数据,包括标志位flags,以及直接指向物理内存页的指针page:
1 | /* SPDX-License-Identifier: GPL-2.0 */ |
PIPE_BUF_FLAG_CAN_MERGE亦为标志位之一。当进程调用write()向管道的某一全新槽位写入数据时,内核会分配一个匿名页(即不与磁盘文件关联的纯内存页),并为该槽位打上PIPE_BUF_FLAG_CAN_MERGE标志。随后继续执行写操作时,内核如果发现当前槽位带有此标志,且页面尚未写满,便不会再申请新页面,而是直接将新数据追加到当前页中。这样能够优化管道写入的过程,减少内存分配和管理的开销。
当往管道写入普通匿名页时,buf->ops会被赋值为指向anon_pipe_buf_ops;而当通过splice()传入页缓存时,buf->ops会被赋值为指向page_cache_pipe_buf_ops。这也是判断能否追加写入的依据之一。在5.8之前,内核即采用该依据(即buf->ops == &anon_pipe_buf_ops),而非读取标志位。在commit f6dd975583bd后,判断逻辑改为现状。
零拷贝与splice()
一些进程间数据传输的方法(如从文件读出再写入管道)需要在用户空间与内核空间之间多次拷贝数据,开销较大。为了追求极致性能,Linux提供了splice()系统调用以实现零拷贝。其可以直接在两个文件描述符之间移动数据,唯需其中某个传输端为管道。
当使用splice()将文件页缓存推入管道时,内核底层会分配一个pipe_buffer槽位,并将其page指针指向文件的页缓存。copy_page_to_iter_pipe函数相关代码片段如下:
1 | // SPDX-License-Identifier: GPL-2.0-only |
攻击流程
攻击者首先填满并清空管道,使得管道环形缓冲区的每个槽位(pipe_buffer)都残留PIPE_BUF_FLAG_CAN_MERGE标志位。随后,攻击者调用splice()将目标可读文件的一段内容以零拷贝的方式切入管道。
此时漏洞被触发:内核在处理splice()时,相关函数并未清空新分配槽位的flags标志位,导致原先残留的PIPE_BUF_FLAG_CAN_MERGE被错误保留。受此标志位误导,当攻击者继续向管道写入数据时,内核不会分配新的匿名页,而是直接将数据追加到先前引用的文件页缓存中,从而篡改缓存内容。尽管磁盘上的文件内容往往不会改变(参见set_page_dirty()),但内核会读取被篡改的页缓存内容,从而受到欺骗。
另一方面,该漏洞不仅能在攻击者无写入权限的情况下生效,甚至还可以篡改不可变文件、只读的Btrfs快照以及只读挂载点(如 CD-ROM)中的文件内容。这是因为页缓存始终是可写的,而在写入管道后,内核不会校验后端存储的文件系统权限。
不过,该漏洞也有一定局限性。首先,攻击者无法从页边界写入数据:若splice()中长度设为0以试图从偏移量0处开始追加,则该操作将被内核短路返回,导致映射失败;其次,攻击者无法跨越页边界连续写入:单个管道槽位的映射容量被严格限制在单一物理页之内,溢出的数据会被写入新申请的匿名页中;最后,攻击者无法突破文件大小的约束:内核并不会更新该文件Inode中的元数据,后续数据虽在页缓存里,但由于超出文件大小,读取时会被内核忽略。
复现
该网站提供了漏洞的PoC。其流程与上方描述一致,此处不再赘述。
为了避免污染宿主机,我使用Vagrant搭建虚拟机环境,并采用Libvirt作为后端。虚拟机中使用Metarget构建环境。对于该漏洞,使用Ubuntu 20.04可以搭建相关环境。部署完环境后,验证内核版本确实在受影响范围内:
1 | uname -r |

为了直观地演示越权覆盖,我创建了由root拥有,且普通用户仅有读取权限、无写入权限的测试文件:
1 | sudo sh -c 'echo "This file belongs to root, you cannot modify it." > /tmp/target.txt' |
此时,若尝试以普通用户(vagrant)的身份修改它,会被操作系统拒绝:
1 | echo "test" > /tmp/target.txt |

考虑到C标准库版本可能不一致的问题,我在靶机内部编译了PoC代码。该PoC的限制之一是不能从页边界写入,因此我设置偏移量为5(即「This 」之后),写入的Payload为tomorin_was_here: MyGO!!!!!:
1 | ./shared_code/CVE-2022-0847/poc/exploit /tmp/target.txt 5 'tomorin_was_here: MyGO!!!!!' |
再次查看该文件的内容,会发现内容已经被篡改,原本受保护的内容被强行注入了「MyGO!!!!!」的印记:
1 | cat /tmp/target.txt |

据此思路,只需将目标文件替换为系统的关键文件(如/etc/passwd),攻击者即可实现真正的提权攻击。
修复与后续
漏洞发现者Max Kellermann于该patch中修复了该漏洞,即在处理splice()的函数中,显式清空新分配槽位的flags标志位。
漏洞发现者的博文也值得一读,他从发现服务器上的文件意外损坏开始,总结时间规律,编写测试程序锁定内核问题,最后阅读代码定位漏洞。这种排查思路对我颇具启发。
尽管排查过程如此硬核,但这个漏洞的根因极其低级:在复用pipe_buffer结构体时,开发者采用逐字段赋值的方式,却忘记重置flags字段,导致旧状态残留。因此我认为,在该情况下,采用结构体整体赋值(如*buf = (struct pipe_buffer){ ... }以触发隐式初始化,参见《ISO/IEC 9899:2011 (C11)》 § 6.7.9 Initialization, ¶ 21),抑或是在语言层面引入更严格的状态流转机制,即可避免此种问题发生。