【摘要】 本文详细介绍了CVE-2022-0847漏洞形成根因,相应补丁修复方法,通过本文让读者对CVE-2022-0847漏洞有更清晰的了解。
CVE-2022-0847不需要调用特权syscall就能完成对任意只读文件的修改(有点类似之前的脏牛,但底层原理其实不一样),且由于利用过程中不涉及内存损坏,因此不需要ROP等利用方法,也自然不需要知道内核基址等信息,故不需要对内核版本进行适配(因此可以被广泛利用,危害巨大)。
本质上,这个漏洞是由内存未初始化造成的,且从2016年就存在了,但在当时并不能发生有趣的利用,直到2020年由于对pipe内部实现进行了一些修改,才让这个“BUG”变成了能够利用的“漏洞”。
这个漏洞主要涉及到两个syscall:
pipe:https://man7.org/linux/man-pages/man2/pipe.2.html
splice:https://man7.org/linux/man-pages/man2/splice.2.html
pipe,我想使用linux的都不陌生它的作用,因此直接从底层实现开始说。
pipe在内核中使用struct pipe_inode_info进行管理,注释中为比较重要的几个字段。
/**
* struct pipe_inode_info - a linux kernel pipe
* @head: The point of buffer production
* @tail: The point of buffer consumption
* @max_usage: The maximum number of slots that may be used in the ring
* @ring_size: total number of buffers (should be a power of 2)
* @tmp_page: cached released page
* @bufs: the circular array of pipe buffers
**/structpipe_inode_info{...unsignedinthead;unsignedinttail;unsignedintmax_usage;unsignedintring_size;...structpage*tmp_page;...structpipe_buffer*bufs;...};
pipe在内核中使用了环状buffer(bufs字段),而默认的数量为16个(PIPE_DEF_BUFFERS),每一个struct pipe_buffer管理一个buffer,而一个buffer为一页的大小(默认0x1000)。pipe为FIFO的结构体,这可以从head和tail两个字段体现出来,head指向最新生产的buffer,而tail指向开始消费的buffer。
pipe_buffer为如下的结构体,其中这里的page并不直接指向目标页,而是一个物理页的页框(实际使用过程中通过kmap_atomic()获取对应的虚拟地址)。毕竟pipe需要考虑到跨进程,这里在结构体中使用物理页是明知智选。
// >>> include/linux/pipe_fs_i.h:17/**
* struct pipe_buffer - a linux kernel pipe buffer
* @page: the page containing the data for the pipe buffer
* @offset: offset of data inside the @page
* @len: length of data inside the @page
* @ops: operations associated with this buffer. See @pipe_buf_operations.
* @flags: pipe buffer flags. See above.
* @private: private data owned by the ops.
**/structpipe_buffer{structpage*page;unsignedintoffset,len;conststructpipe_buf_operations*ops;unsignedintflags;unsignedlongprivate;};
接着我们分析下pipe的使用。假设用户向分配的pipe中写入数据,在内核层就会进入函数pipe_write:
// >>> fs/pipe.c:415/* 415 */staticssize_t/* 416 */pipe_write(structkiocb*iocb,structiov_iter*from)/* 417 */{/* 418 */structfile*filp=iocb->ki_filp;// 拿到pipe结构体/* 419 */structpipe_inode_info*pipe=filp->private_data;/* 420 */unsignedinthead;/* 421 */ssize_tret=0;// total_len为此次写入的长度/* 422 */size_ttotal_len=iov_iter_count(from);/* 423 */ssize_tchars;/* 424 */bool was_empty=false;/* 425 */bool wake_next_writer=false;------/* 457 */head=pipe->head;/* 458 */was_empty=true;// 考虑使用merge/* 459 */chars=total_len&(PAGE_SIZE-1);// 如果len&0xFFF !=0 且当前使用的页/* 460 */if(chars&&!pipe_empty(head,pipe->tail)){/* 461 */unsignedintmask=pipe->ring_size-1;/* 462 */structpipe_buffer*buf=&pipe->bufs[(head-1)&mask];/* 463 */intoffset=buf->offset+buf->len;/* 464 *//* 465 */if((buf->flags&PIPE_BUF_FLAG_CAN_MERGE)&&// 可以merge/* 466 */offset+chars<=PAGE_SIZE){// 小于一页/* 467 */ret=pipe_buf_confirm(pipe,buf);------// 拷贝内容/* 471 */ret=copy_page_from_iter(buf->page,offset,chars,from);------/* 480 */}/* 481 */}/* 482 */// merge失败,或者merge不完全,接着处理剩下的内容/* 483 */for(;;){------/* 491 */head=pipe->head;// 如果pipe没满/* 492 */if(!pipe_full(head,pipe->tail,pipe->max_usage)){/* 493 */unsignedintmask=pipe->ring_size-1;// 取当前的pipe buffer/* 494 */structpipe_buffer*buf=&pipe->bufs[head&mask];/* 495 */structpage*page=pipe->tmp_page;/* 496 */intcopied;/* 497 */// 如果当前page是空的,就创建新的page/* 498 */if(!page){/* 499 */page=alloc_page(GFP_HIGHUSER|__GFP_ACCOUNT);------/* 504 */pipe->tmp_page=page;/* 505 */}------/* 519 */// head++/* 520 */pipe->head=head+1;/* 521 */spin_unlock_irq(&pipe->rd_wait.lock);/* 522 *//* 523 */// 开始初始化 pipe buffer 的各个字段/* 524 */buf=&pipe->bufs[head&mask];/* 525 */buf->page=page;/* 526 */buf->ops=&anon_pipe_buf_ops;/* 527 */buf->offset=0;/* 528 */buf->len=0;/* 529 */if(is_packetized(filp))// 一般不走/* 530 */buf->flags=PIPE_BUF_FLAG_PACKET;/* 531 */else// 设置flag PIPE_BUF_FLAG_CAN_MERGE/* 532 */buf->flags=PIPE_BUF_FLAG_CAN_MERGE;/* 533 */pipe->tmp_page=NULL;/* 534 */// 复制内容/* 535 */copied=copy_page_from_iter(page,0,PAGE_SIZE,from);------/* 541 */ret+=copied;/* 542 */buf->offset=0;/* 543 */buf->len=copied;
可以看到,在pipe_write中使用了merge的思想,如果我们分16次向pipe中写入1字节,这16字节不会并不会分别占用16个pipe_buffer,而是连续占用第一个pipe_buffer。这很好理解,不然pipe就堵死了,那利用率就太低了。而负责管理merge的是struct pipe_buffer中的flags字段PIPE_BUF_FLAG_CAN_MERGE。
相对应的,pipe_read也是通过pipe_inode_info拿到pipe_buffer进行读取,这里就不在分析。需要注意的是,pipe_buffer在read过程中只会被修改其offset和len字段,并不会被释放或是修改其flags字段,也就是说PIPE_BUF_FLAG_CAN_MERGE一但设置,则在read/write的过程中就不会再被清除掉。
接着来分析一下splice这个syscall。
splice是在Linux 2.6.16中被引入的(5274f052e7b3dbd81935772eb551dfd0325dfa9d),本质上是为了解决文件对拷的效率问题,它实现了“零拷贝”。
这里稍微展开说说零拷贝。可以思考下在Linux上你会如何实现文件对拷?
最简单的,就是open()两个文件,然后申请一个buffer,然后使用read()/write()来进行拷贝。但这样效率太低,原因是一对read()和write()涉及到4次上下文切换,2次CPU拷贝,2次DMA拷贝。
因此稍微聪明点的人,会使用mmap()+write()的组合,这样涉及4次上下⽂切换,1次 CPU 拷⻉,2次DMA 拷⻉。
更近一步的,会使用sendfile(),调用sendfile()只需提供两个互拷的fd,以及拷贝的长度即可。与 mmap 内存映射⽅式不同的是, sendfile 调⽤中 I/O 数据对⽤户空间是完全不可⻅的。因此它只涉及2次上下⽂切换,2次DMA 拷⻉。
splice()类似,不过使用了pipe机制,从而不需要硬件的支持就能实现两个fd间的零拷贝。它也只涉及2 次上下⽂切换,2次DMA 拷⻉。
一般我们用下面的模式使用splice实现文件对拷:
intin_fd=open(file_to_read);intout_fd=open(file_to_write);intanon_pipes[2];pipe(anon_pipes);whilehas_content_to_copy:splice(in_fd,&in_off,anon_pipes[1],NULL,size);splice(anon_pipes[0],NULL,out_fd,&out_off,size);close(in_fd);close(out_fd);
可以看到,splice底层用到了pipe。splice支持对接多种设备,例如普通文件,socket等。下面我们啃一下splice的源码,以上面的splice(in_fd,&in_off,anon_pipes[1],NULL,size);为例:
// >>> fs/splice.c:1325/* 1325 */SYSCALL_DEFINE6(splice,int,fd_in,loff_t__user*,off_in,/* 1326 */int,fd_out,loff_t__user*,off_out,/* 1327 */size_t,len,unsignedint,flags)/* 1328 */{------// splice是对__do_splice的简单包装/* 1343 */error=__do_splice(in.file,off_in,out.file,off_out,/* 1344 */len,flags);------/* 1350 */}// __do_splice 是对 do_splice 的简单包装// >>> fs/splice.c:1008/* 1008 */longdo_splice(structfile*in,loff_t*off_in,structfile*out,/* 1009 */loff_t*off_out,size_tlen,unsignedintflags)/* 1010 */{------/* 1011 */structpipe_inode_info*ipipe;/* 1012 */structpipe_inode_info*opipe;------// 从 in/out 中尝试取得 pipe_inode_info/* 1020 */ipipe=get_pipe_info(in,true);/* 1021 */opipe=get_pipe_info(out,true);------// 上面例子中in是普通文件,out是pipe,因此不进这里/* 1037 */if(ipipe){------/* 1068 */}------// 进这里/* 1070 */if(opipe){------// 调用 do_splice_to/* 1093 */ret=do_splice_to(in,&offset,opipe,len,flags);------/* 1104 */}------/* 1107 */}// >>> fs/splice.c:770/* 770 */staticlongdo_splice_to(structfile*in,loff_t*ppos,/* 771 */structpipe_inode_info*pipe,size_tlen,/* 772 */unsignedintflags)/* 773 */{------// 这里根据in的f_op->splice_read选择对应的函数// 由于是普通文件,所以://// >>> fs/read_write.c:28// /* 28 */ const struct file_operations generic_ro_fops = {// ------// /* 32 */ .splice_read = generic_file_splice_read,// /* 33 */ };/* 788 */returnin->f_op->splice_read(in,ppos,pipe,len,flags);/* 789 */}// >>> fs/splice.c:298/* 298 */ssize_tgeneric_file_splice_read(structfile*in,loff_t*ppos,/* 299 */structpipe_inode_info*pipe,size_tlen,/* 300 */unsignedintflags)/* 301 */{/* 302 */structiov_iterto;/* 303 */structkiocbkiocb;/* 304 */unsignedinti_head;/* 305 */intret;/* 306 */// 从pipe中取数据,得到 to/* 307 */iov_iter_pipe(&to,READ,pipe,len);/* 308 */i_head=to.head;/* 309 */init_sync_kiocb(&kiocb,in);/* 310 */kiocb.ki_pos=*ppos;// 进入这里,其实是调用in->f_op->read_iter(&kiocb,&to);// 即 generic_file_read_iter()/* 311 */ret=call_read_iter(in,&kiocb,&to);------/* 328 */}// 之后: // generic_file_read_iter()// -> generic_file_buffered_read()// -> copy_page_to_iter()// >>> lib/iov_iter.c:916/* 916 */size_tcopy_page_to_iter(structpage*page,size_toffset,size_tbytes,/* 917 */structiov_iter*i)/* 918 */{------/* 921 */if(i->type&(ITER_BVEC|ITER_KVEC)){------/* 926 */}elseif(unlikely(iov_iter_is_discard(i))){------/* 931 */}elseif(likely(!iov_iter_is_pipe(i)))/* 932 */returncopy_page_to_iter_iovec(page,offset,bytes,i);/* 933 */else// 这里的i其实就是前面generic_file_splice_read中的to,因此是pipe/* 934 */returncopy_page_to_iter_pipe(page,offset,bytes,i);/* 935 */}// 终于来到了我们今天的主角:copy_page_to_iter_pipe// >>> lib/iov_iter.c:375/* 375 */staticsize_tcopy_page_to_iter_pipe(structpage*page,size_toffset,size_tbytes,/* 376 */structiov_iter*i)/* 377 */{------/* 378 */structpipe_inode_info*pipe=i->pipe;------/* 379 */structpipe_buffer*buf;------/* 394 */off=i->iov_offset;------/* 395 */buf=&pipe->bufs[i_head&p_mask];/* 396 */if(off){------/* 405 */}/* 406 */if(pipe_full(i_head,p_tail,pipe->max_usage))/* 407 */return0;/* 408 */// 划重点!!! 没有设置buf->flags/* 409 */buf->ops=&page_cache_pipe_buf_ops;/* 410 */// page ref_count ++/* 411 */get_page(page);// 直接把普通文件的pipe拿来放到pipe中/* 412 */buf->page=page;/* 413 */buf->offset=offset;/* 414 */buf->len=bytes;/* 415 *//* 416 */pipe->head=i_head+1;/* 417 */i->iov_offset=offset+bytes;/* 418 */i->head=i_head;/* 419 */out:/* 420 */i->count-=bytes;/* 421 */returnbytes;/* 422 */}
可以看到,最主要的逻辑就在copy_page_to_iter_pipe中,之所以splice实现了CPU的零拷贝是因为他直接对目标页的ref count进行了递增,然后把目标页的物理页页框复制到pipe buffer的page处,但这里却忘记设置pipe buffer的flags字段。
OK,现在梳理完了这两个syscall的逻辑,也发现在splice中存在对pipe buffer的flags字段为初始化漏洞,那一种可行的利用思路就出来了。
使用pipe read/write,我们可以让目标pipe的每个pipe buffer都带上PIPE_BUF_FLAG_CAN_MERGEflag。之后打开目标文件,并使用splice 写到之前处理过的pipe中,splice底层会帮助我们把目标文件的page cache 设置到pipe buffer的page字段,但却没有修改flags字段。之后我们再调用pipe write时由于存在PIPE_BUF_FLAG_CAN_MERGEflag字段,内容会接着上次被写入同一个page中,但page其实已经变成了目标文件的page cache,导致直接修改了目标文件page cache。如果之后有其他文件尝试读取这个文件,kernel会优先返回cache中的内容,也就是被我们修改后的page cache。但由于这个修改并不会触发page的dirty属性,因此若由于内存紧张后或系统重启等原因,就会导致这个cache内kernel丢弃,再次读取文件内核就会重新从磁盘中取出未被我们修改的内容(这就是和脏牛的不同点)。
这个bug其实在2016年就产生了,但为什么在2020年才能被利用呢?这就涉及到linux代码的历史了。
最早的时候,是否能够merge并不是通过struct pipe_buffer中的flags字段来管理,而是通过struct pipe_buf_operations中的can_merge字段来判断。因此在splice被加入linux时,splice提供了一个新的pipe_buf_operations叫page_cache_pipe_buf_ops,如下:
staticstructpipe_buf_operationspage_cache_pipe_buf_ops={.can_merge=0,.map=page_cache_pipe_buf_map,.unmap=page_cache_pipe_buf_unmap,.release=page_cache_pipe_buf_release,};
其中can_merge字段默认就是0,这就解释了为什么在copy_page_to_iter_pipe中不存在对flags的设置逻辑,因为只需要修改fops到page_cache_pipe_buf_ops就可以了。
之后在2016年的一个commit中 commit 241699cd72a8 “new iov_iter flavour: pipe-backed” (Linux 4.9, 2016),添加了两个函数,其中一个就是copy_page_to_iter_pipe,里面对pipe_buffer的flags没有进行初始化,但现在还没出什么大问题,因为此时can_merge参数还在fops中,且flags中也没有什么有趣的选项。
时间来到2019年,Commit 01e7187b4119 “pipe: stop using ->can_merge” (Linux 5.0, 2019)中开始对can_merge字段下手了,但这个时候操刀还比较暴力,除了把所有使用所有fops中的can_merge字段删除外,还增加了一个函数叫pipe_buf_can_merge,可能是发现除了匿名管道外,所有的管道都不支持merge,所以只要判断一下fops是不是anon_pipe_buf_ops就行了。到目前为止,merge操作和16年的未初始化bug还没挂钩。
staticboolpipe_buf_can_merge(structpipe_buffer*buf){returnbuf->ops==&anon_pipe_buf_ops;}
终于,在2020年,可能还是感觉这种判断太过于暴力,于是把merge操作的判断塞进了pipe_buffer的flags中:Commit f6dd975583bd “pipe: merge anon_pipe_buf*_ops” (Linux 5.8, 2020) 。16年埋下的bug终于在4年后变成了漏洞。
内核的修复方法很简单,把两处pipe buffer的flags未初始化补上即可。
diff--git a/lib/iov_iter.c b/lib/iov_iter.cindex b0e0acdf96c15..6dd5330f7a995100644---a/lib/iov_iter.c+++b/lib/iov_iter.c@@-414,6+414,7@@staticsize_tcopy_page_to_iter_pipe(struct page*page,size_t offset,size_t byreturn0;buf->ops=&page_cache_pipe_buf_ops;+buf->flags=0;get_page(page);buf->page=page;buf->offset=offset;@@-577,6+578,7@@staticsize_tpush_pipe(struct iov_iter*i,size_t size,break;buf->ops=&default_pipe_buf_ops;+buf->flags=0;buf->page=page;buf->offset=0;buf->len=min_t(ssize_t,left,PAGE_SIZE);