专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
网信内蒙古  ·  【每日一学】涉密人员出境保密管理 ·  2 小时前  
小鹿学Java  ·  微信怎么更换实名认证?详细步骤与注意事项 ·  5 小时前  
小鹿学Java  ·  微信怎么更换实名认证?详细步骤与注意事项 ·  5 小时前  
计算机与网络安全  ·  网络安全资料库开通了 ·  昨天  
北京普法  ·  2025年度防范非法金融活动宣传月正式启动 ·  2 天前  
北京普法  ·  2025年度防范非法金融活动宣传月正式启动 ·  2 天前  
财联社AI daily  ·  “本源悟空”刷新纪录! ·  2 天前  
财联社AI daily  ·  “本源悟空”刷新纪录! ·  2 天前  
51好读  ›  专栏  ›  看雪学苑

劫持SUID程序提权彻底理解Dirty_Pipe:从源码解析到内核调试

看雪学苑  · 公众号  · 互联网安全  · 2025-05-19 17:59

正文

请到「今天看啥」查看全文


pipe_buffer {
struct page *page; // 直接指向物理内存页(漏洞利用目标
unsigned int offset, len; //页内偏移,有效数据长度
const struct pipe_buf_operations *ops; // 操作函数表
unsigned int flags; // 状态标志
unsigned long private ; // 私有数据
};


管道数据存储在离散的物理内存页中,通过 struct pipe_buffer 数组( bufs )管理:


bufs 数组 :数组中的每个元素对应一个内存页(struct page),通过page字段直接指向物理页帧。这样,内核可以直接定位到存储管道数据的物理内存位置。


非连续内存管理 :页之间无需连续,内核通过数组索引实现逻辑上的环形链表。这种非连续内存管理方式,充分利用了内存空间,避免了因连续内存分配困难而导致的资源浪费。在进行数据读写时,内核根据head和tail指针在bufs数组中的索引,找到对应的缓冲区进行操作,同时通过环形链表的逻辑,实现数据的循环读写。例如,当head指针到达数组末尾时,下一次写入会回到数组开头,继续填充缓冲区。


管道本质是一个由内核维护的环形缓冲区,通过 head tail 指针实现高效的数据读写:


可以看一个Pipe缓冲区的实际示意图:



这张图片展示了一个 pipe 的基本数据结构,具体是如何通过循环缓冲区(circular buffer)来管理数据传输。


或者参考一下这个结构图:



pipe-> bufs[0] ** 到 pipe->** bufs[15] :这是管道的 16 个缓冲区,每个缓冲区对应一个 pipe_buffer 结构体。


pipe->tail 和 pipe->head pipe->tail 指向当前读取位置, pipe->head 指向当前写入位置。缓冲区中的黄色区域表示当前正在被使用的缓冲区( inuse ),即当前正在读取或写入的部分。


页面管理 :每个 pipe_buffer 结构体对应一个 4KB 的页面,图中显示了这些页面的分布情况,并标记了哪些部分是正在被使用的。


讲解Linux管道Pipe如何进行数据写入和读取

当我们使用read和write向pipe进行数据写入和读取的时候,read和write会寻找到pipe_write和pipe_read进行数据写入和读取!


根据前面的管道结构体的讲解可知,pipe_write和pipe_read进行数据操作的时候实际都是对pipe->buf的内容进行写入和读取!


pipe_write写入流程

数据写入管道的操作由内核中的pipe_write函数负责。在数据写入过程中,pipe_write会调用copy_page_from_iter函数来完成从用户空间到内核管道缓冲区的实际数据复制。下面对pipe_write函数的执行流程进行详细拆解:


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;  // 获取管道信息
...
    head = pipe->head;                 // 获取当前头指针
...
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&  // 检查缓冲区是否可合并
...
            ret = copy_page_from_iter(buf->page, offset, chars, from);  // 复制数据到缓冲区
...
struct pipe_buffer *buf = &pipe->bufs[head & mask];  // 获取当前缓冲区
...
            pipe->head = head + 1;  // 移动头指针
            ...
            buf = &pipe->bufs[head & mask];  // 获取新缓冲区
            buf->page = page;    // 设置缓冲区页
            buf->ops = &anon_pipe_buf_ops;  // 设置缓冲区操作
            buf->offset = 0;     // 设置偏移量
            buf->len = 0;        // 初始长度为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);  // 复制数据到页
...
return ret;               // 返回实际写入的字节数
}


写入流程 :数据按页写入 bufs[head] ,更新 head 指针;若缓冲区满,写进程进入睡眠。


pipe_write 函数写入数据过程中,获取管道的写指针head,通过head & mask的运算,在pipe->bufs数组中定位当前用于写入的缓冲区buf。这里的mask是根据管道缓冲区总数计算得出的掩码,用于实现环形缓冲区的循环访问。最后调用copy_page_from_iter函数,将用户空间的数据从from迭代器中复制到内核分配的页面中,完成数据写入操作。


写入标记


if (is_packetized(filp))  // 如果是数据包模式
                buf->flags = PIPE_BUF_FLAG_PACKET;  // 设置数据包标志
else
                buf->flags = PIPE_BUF_FLAG_CAN_MERGE;  // 设置可合并标志


可以发现这里当第一次向管道写入数据的时候会将 pipe->bufs[i]->flags 字段赋值为PIPE_BUF_FLAG_CAN_MERGE,如果是网络数据通过pipe传输的话就会赋值PIPE_BUF_FLAG_PACKET;


if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&  // 检查缓冲区是否可合并
...
            ret = copy_page_from_iter(buf->page, offset, chars, from);  // 复制数据到缓冲区


如果想继续在管道写入数据会首先检查buf->flags字段和buf->page是否有剩余空间,再次调用pipe_write可以继续向这个buf->page写入数据!


pipe_read输出流程

数据从管道中读取的操作由内核中的pipe_read函数负责。在读取过程中,pipe_read会调用copy_page_to_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;  // 获取管道信息
...
        unsigned int head = pipe->head;  // 获取管道头指针
        unsigned int tail = pipe->tail;  // 获取管道尾指针
        unsigned int mask = pipe->ring_size - 1 ;  // 环形缓冲区掩码

if (!pipe_empty(head, tail)) {  // 如果管道不为空
struct pipe_buffer *buf = &pipe->bufs[tail & mask];  // 获取当前缓冲区
...

            written = copy_page_to_iter(buf->page, buf->offset, chars, to);  // 复制数据到用户空间
...
            ret += chars;            // 更新已读取字节数
            buf->offset += chars;    // 更新缓冲区偏移
            buf->len -= chars;       // 减少缓冲区剩余数据
...
                tail++;             // 移动尾指针
                pipe->tail = tail;  // 更新管道尾指针
...
return ret;                    // 返回实际读取的字节数
}


读取流程 :从 bufs[tail] 读取数据,更新 tail 指针;若缓冲区空,读进程阻塞。


获取管道的读指针tail,通过tail & mask的运算,在pipe->bufs数组中定位当前用于读取的缓冲区buf。再调用copy_page_to_iter函数,将缓冲区buf中的数据从指定偏移量buf->offset开始,复制chars字节到用户空间的目标迭代器to中。最后将缓冲区的偏移量buf->offset向后移动已读取的字节数,减少缓冲区中剩余的有效数据长度buf->len。将读指针tail向后移动一位,并更新管道的读指针pipe->tail。


读取操作的通俗作用 :可以将管道的内容读取出来,并且每次读取都可以算作清理管道数据!


Page cahce : Linux内核page cache机制

Linux内核的 Page Cache机制 是操作系统中用于提升磁盘I/O性能的核心组件,它通过将磁盘数据缓存在内存中,减少对慢速磁盘的直接访问。以下是对其工作原理和关键特性的详细解释:


什么是Page Cache?


定义 :Page Cache是内核管理的一块内存区域,用于缓存磁盘上的文件数据块(以内存页为单位,通常4KB)。

目标 :通过内存缓存加速对磁盘数据的读写操作,利用内存的高速特性弥补磁盘的延迟缺陷。

缓存内容 :普通文件、目录、块设备文件等。



Page Cache的工作原理

读操作


1. 缓存命中 :  当应用程序读取文件时,内核首先检查数据是否在Page Cache中。若存在(缓存命中),直接返回内存中的数据, 无需访问磁盘


2. 缓存未命中 :  若数据不在缓存中,内核从磁盘读取数据,存入Page Cache,再拷贝到用户空间。后续访问同一数据时可直接使用缓存。


写操作


1. 缓冲写入(Writeback)
当一个文件已经被打开过,那么应用程序的写操作默认修改的是Page Cache中的缓存页,而非直接写入磁盘。


只在特定情况下,内核通过**延迟写入(Deferred Write)策略,将脏页(被修改的页)异步刷回磁盘(由 pdflush flusher 线程触发)。


优点 :合并多次小写入,减少磁盘I/O次数。


风险 :系统崩溃可能导致数据丢失(需通过 fsync() sync() 强制刷盘)。


2. 直写(Writethrough) :  某些场景(如要求强一致性)会同步写入磁盘,但性能较低(较少使用)。


相关资料:


◆Linux内核Page Cache和Buffer Cache关系及演化历史 - CharyGao - 博客园

◆深入理解Linux 的Page Cache-page cache

◆一文看懂 | 什么是页缓存(Page Cache)_pagecache-CSDN博客


syscall splice : Linux中的零拷贝机制源码讲解

1. 零拷贝机制概述

传统的文件拷贝过程(open()→read()→write())需要在用户态和内核态之间多次切换,并进行 CPU 和 DMA 之间的数据拷贝,开销较大。而利用 splice 系统调用可以实现内核态内的“零拷贝”,只进行少量的上下文切换,从而极大提高数据传输效率。


传统拷贝: 4次上下文切换、2次 CPU 拷贝、2次 DMA 拷贝


最简单的,就是open()两个文件,然后申请一个buffer,然后使用read()/write()来进行拷贝。但这样效率太低,原因是一对read()和write()涉及到4次上下文切换,2次CPU拷贝,2次DMA拷贝。



splice 零拷贝: 只需2次上下文切换


再dirty_pipe使用splice进行0拷贝的话就可以实现极高的效率,只需要两次上下文切换即可完成拷贝!



2. splice 系统调用实现流程

为了理解 splice 零拷贝的内部实现,我们可以通过动态调试定位到关键函数 copy_page_to_iter_pipe 。在该函数设置断点,并使用 gdb 查看调用栈,可以看到整个 splice 的调用链条。调用栈大致分为以下几个层次:




可以很快发现整个splice的调用链!


--文件系统层
#0  copy_page_to_iter_pipe
#1  copy_page_to_iter
#2  generic_file_buffered_read
--核心功能层
#3  call_read_iter
#4  generic_file_splice_read
#5  do_splice
--系统调用入口层
#6  __do_sys_splice 实际系统调用实现
#7  __se_sys_splice 处理系统调用参数的安全包装
#8  __x64_sys_splice 这是x86_64架构特定的系统调用入口
#9  do_syscall_64
#10 entry_SYSCALL_64


SYSCALL_DEFINE6(splice, ...) 中,主要完成文件描述符转换、参数合法性检查,并调用 do_splice 进行实际的数据处理。


SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in,
int, fd_out, loff_t __user *, off_out,
size_t, len, unsignedint, flags)
{
struct fd in, out;
...
if (in.file) {
...
if (out.file) {
            error = do_splice(in.file, off_in, out.file, off_out,
                      len, flags);
...
}


2.1 do_splice 函数

/*
 * Determine where to splice to/from.
 */
longdo_splice(struct file *in, loff_t __user *off_in,
struct file *out, loff_t __user *off_out,
size_t len, unsignedint flags) {
struct pipe_inode_info *ipipe;
struct pipe_inode_info *opipe;
...

    ipipe = get_pipe_info(in, true);  //用来判断和获取目标是否为管道
    opipe = get_pipe_info(out, true);

if (ipipe && opipe) {  //in和out都是管道
...
    }

if (ipipe) {//in是管道
....
    }

if (opipe) {//out是管道
....
        ret = wait_for_space(opipe, flags);// 等待管道有可用空间(如果是阻塞模式可能休眠)
if (!ret) {// 等待成功(有空间可用)
...
            ret = do_splice_to(in, &offset, opipe, len, flags);
        }
...
else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
            ret = -EFAULT;
...
}


根据输入和输出的文件是否与 pipe 相关,选择不同的处理分支:


pipe → pipe: 直接调用 splice_pipe_to_pipe 进行管道间数据传递。

pipe → 文件: do_splice_from ,处理从 pipe 写入文件的情况。

文件 → pipe: do_splice_to ,处理从文件读取数据填充到 pipe。


在 dirty_pipe 漏洞中,重点就在文件 → pipe 的场景,因为利用了 splice 复制过程中对管道内部管理机制的不足,才使得漏洞得以被利用。

2.2 do_splice_to 函数

该函数验证读取权限,检查长度,之后调用文件操作中实现的 splice_read 。如果文件操作没有自定义该接口,则使用 default_file_splice_read


/*
 * Attempt to initiate a splice from a file to a pipe.尝试从文件向管道发起 splice 操作
 */
staticlongdo_splice_to(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsignedint flags){
int ret;

if (unlikely(!(in->f_mode & FMODE_READ))) // 如果输入文件不可读,则返回错误
return -EBADF;

    ret = rw_verify_area(READ, in, ppos, len); // 验证读取权限和边界
if (unlikely(ret 0)) // 如果验证失败,则返回错误码
return ret;

if (unlikely(len > MAX_RW_COUNT)) // 限制读取长度,防止超过最大允许值
        len = MAX_RW_COUNT;

if (in->f_op->splice_read) // 如果文件支持 splice_read 操作,则调用
return in->f_op->splice_read(in, ppos, pipe, len, flags);
return default_file_splice_read(in, ppos, pipe, len, flags); // 否则使用默认的 splice 读取实现
}


这里的关键是 in->f_op->splice_read ,此处调用的 generic_file_splice_read 来从文件中读取页面,并填充到管道中。


也可以通过动态调试来定位 in->f_op->splice_read 调用的是什么函数:


p *((struct file *) in->f_op->splice_read)


如何通过动态调试定位源码:


pwndbg> p in->f_op->splice_read
$1 = (ssize_t (*)(struct file *, loff_t *, struct pipe_inode_info *, size_t,
unsignedint)) 0xffffffff8120fd20 
pwndbg> p in->f_op
$2 = (conststruct file_operations *) 0xffffffff82027600 


2.3 generic_file_splice_read 函数

/**
 * generic_file_splice_read - 从文件向管道拼接数据
 * @in: 源文件
 * @ppos: 文件中的位置指针
 * @pipe: 目标管道
 * @len: 要拼接的字节数
 * @flags: 拼接标志位
 *
 * 描述:
 *    从给定文件读取页面并填充到管道。只要文件有基本可用的->read_iter()方法即可使用。
 */
ssize_tgeneric_file_splice_read(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsignedint flags){
struct iov_iter to; // 管道迭代器
struct kiocb kiocb; // I/O控制块
unsignedint i_head; // 保存管道起始头位置
...
iov_iter_pipe(&to, READ, pipe, len); // 初始化管道迭代器(读方向)
    i_head = to.head; // 记录当前管道头位置
...
    ret = call_read_iter(in, &kiocb, &to); // 调用文件系统的read_iter方法
if (ret > 0) { // 成功读取数据
        *ppos = kiocb.ki_pos; // 更新文件位置
file_accessed(in); // 标记文件被访问
...

return ret; // 返回实际传输字节数或错误码
}
EXPORT_SYMBOL(generic_file_splice_read); // 导出符号供模块使用


该函数内部构造了一个 pipe 的迭代器 iov_iter ,然后通过调用 call_read_iter 实际执行数据读取操作。读取成功后会更新文件位置并调用 file_accessed 更新访问时间。


staticinline ssize_tcall_read_iter(struct file *file, struct kiocb *kio,
struct iov_iter *iter){
return file->f_op->read_iter(kio, iter);
}


可以发现调用了call_read_iter函数最后也可以通过动态调试定位到函数generic_file_read_iter.

2.4 generic_file_read_iter

/**
 * generic_file_read_iter - 通用文件系统读取例程
 * @iocb: 内核I/O控制块
 * @iter: 数据读取的目标迭代器
 *
 * 这是所有能直接使用页缓存的文件系统的"read_iter()"例程
 *
 * iocb->ki_flags中的IOCB_NOWAIT标志表示当无法立即读取数据时应返回-EAGAIN
 * 但它不会阻止预读操作
 *
 * iocb->ki_flags中的IOCB_NOIO标志表示不应为读取或预读发起新I/O请求
 * 当没有数据可读时返回-EAGAIN。当会触发预读时,返回可能为空的部分读取结果
 *
 * 返回值:
 * * 复制的字节数(即使是部分读取)
 * * 如果没有读取任何数据则返回负错误码(如果设置了IOCB_NOIO则可能返回0)
*/
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
    size_t count = iov_iter_count(iter); /* 获取要读取的总字节数 */
    ssize_t retval = 0; /* 初始化返回值 */

...
    retval = generic_file_buffered_read(iocb, iter, retval); /* 执行缓冲读取 */
...
}
EXPORT_SYMBOL(generic_file_read_iter); /* 导出符号供内核模块使用 */


generic_file_read_iter 是所有能够直接利用页缓存的文件系统的通用读取例程。该函数处理直接 I/O 与缓冲读取的场景,确保在非阻塞或阻塞模式下都能正确返回数据或错误码。

2.5 generic_file_buffered_read

/**
 * generic_file_buffered_read - generic file read routine
 * @iocb: the iocb to read  // 要读取的I/O控制块
 * @iter: data destination  // 数据目的地
 * @written: already copied  // 已经拷贝的字节数
 * 使用mapping->a_ops->readpage()函数进行实际底层操作这看起来有点丑,但goto语句实际上有助于理清错误处理等逻辑
 * 返回值:
 * * 拷贝的总字节数,包括已经@written的部分
 * * 如果没有拷贝任何数据则返回负的错误码






请到「今天看啥」查看全文


推荐文章
网信内蒙古  ·  【每日一学】涉密人员出境保密管理
2 小时前
计算机与网络安全  ·  网络安全资料库开通了
昨天
财联社AI daily  ·  “本源悟空”刷新纪录!
2 天前
财联社AI daily  ·  “本源悟空”刷新纪录!
2 天前
真叫卢俊的地产观  ·  售楼处里,没有属于“空巢青年”的爱情
8 年前
Kris在路上  ·  来不及了,快上车
7 年前