在本文中,我想为您提供之前发布的CVE-2021-20226(ZDI-2021-001)的技术描述。我发现这脆弱性并通过零日计划向供应商报告给供应商。本文并非旨在通知您漏洞的危险,而是从技术角度分享提示。
可以在下面的链接找到漏洞和攻击方法的概述。此博客将更详细地解释。
如果您有任何疑问或发现任何错误,如果您可以单独与我联系,我会感激。而且,本文中的代码基本上是指Linux内核5.6.19的Linux内核源代码。
IO_URICE是2021年的主动更新的功能之一,并且信息随着版本的变化而变化(自发现时间以来已经进行了许多变化)。因此,请注意,即使在撰写博客时,信息也不是最新的。
我将解释我写的Poc的轮廓,但我不会发布实际代码。
请参阅Internet上发布的一些博客/幻灯片,以获取用户的角度的规格和详细说明。从这里,我将继续解释你理解它的假设的IO_uring的轮廓。
在IO_URIP中,文件描述符首先由专用的系统调用(IO_URE_SETUP)生成,并且通过发出MMAP()系统调用,提交队列(SQ)和完成队列(CQ)在用户空间内存中映射/共享。这用作两侧的环形缓冲器(内核/用户空间)。通过将SQE(提交队列条目)写入共享内存,注册了每个系统呼叫的条目,例如读/写/发送/ reCV。然后通过调用io_ougn_enter()启动执行。
顺便说一下,这次重要的部分是实现异步执行的实现,所以我将专注于此。首先解释它,IO_URE并不总是异步地执行,但它根据需要异步执行。请先参考以下代码。(在此之后,内核v5.8将用于解释行为。行为可能与您的环境略有不同。)
#define _gnu_source #include< sched.h> #include< stdio.h> #include< string.h> #include< stdlib.h> #include< signal.h> #include< sys / syscall.h> #include< sys / fcntl.h> #include< err.h> #include< unistd.h> #include< sys / mman.h> #include< linux / io_uring.h> #define syschk(x)({\ typeof(x)__res =(x); \ if(__res ==(typeof(x)) - 1)\ err(1," syschk("# X")"); \ __res; \})静态intring_fd; struct iovec * io; #define size 32 char _buf [size]; int main(void){//初始化uring struct io_uping_params params = {}; uring_fd = syschk(syscall(__ nr_io_uring_setup,/ *条目= * / 10,& params)); unsigned char * sq_ring = syschk(null,0x1000,prot_read | prot_write,map_shared,uring_fd,iing_off_sq_ring)); unsigned char * cq_ring = syschk(null,0x1000,prot_read | prot_write,map_shared,uring_fd,iing_off_cq_ring)); struct io_uring_sqe * sqes = syschk(mmap(null,0x1000,prot_read | prot_write,map_shared,uring_fd,iings_off_sqes)); io = malloc(sizeof(struct iovec)* 1); io [0] .iov_base = _buf; IO [0] .iov_len = size; struct timespec ts = {.tv_sec = 1}; SQEES [0] =(struct io_uring_sqe){.opcode = iOrience_op_timeout,//.flags = iosqe_io_hardlink,.len = 1,.addr =(unsigned long)& ts}; SQEES [1] =(struct io_uring_sqe){.opcode = iOrience_op_readv,.addr = Io,.flags = 0,.len = 1,.off = 0,.fd = syschk(打开(" / etc / passwd& #34;,o_rdonly))}; ((int *)(sq_ring + params.sq_off.array))[0] = 0; ((int *)(sq_ring + params.sq_off.array)[1] = 1; (*(int *)(sq_ring + params.sq_off.tail))+ = 2; int提交= syschk(syscall(__ nr_io_uping_enter,uring_fd,/ * to_submit = * / 2,/ * min_complete = * / 0,/ * flags = * / 0,/ * sig = * / null,/ * sigsz = * / 0 ));虽然(1){ULEEP(100000); if(* _ buf){puts(" readv执行。");休息;放("等待。"); }}
在此代码中,在执行ITORION_OP_TIMEOUT和IORING_OP_READV执行必要的设置之后,它开始执行,然后每0.1秒检查一次,以查看READV()是否完成。似乎readv()将在1秒后完成,考虑到它按环缓冲区的顺序执行。但是,当我实际运行时,结果如下。
也就是说,立即完成READV()的执行。这是因为,正如我之前所说的那样,它根据需要异步地执行,但在这种情况下,可以立即完成READV()的执行(因为已知其执行不会停止)。所以后续操作首先互动(ITORING_OP_TIMEOUT忽略)。作为测试,使用以下SystemTap [¹]脚本检查readv()是否同步执行(=在系统调用的处理程序中)。
[¹]:允许您灵活地执行脚本的工具,例如跟踪内核(但不仅)函数并在跟踪点输出变量。我喜欢这个工具,因为内核调试是一个麻烦。
↓当在执行上述SystemTap脚本时执行先前的程序(文件名为样本的名称)时,这是输出。如果是异步,很容易想象执行任务在某些工作人员中注册,但由于它在此处执行,因此打印名为系统调用的可执行文件的名称。
那么我在哪里呢?答案是“传递给内核线程,因为确定需要异步执行”。有几个标准,如果它们相遇,它们将被排队进入异步执行的队列中。这里有些例子。
}否则如果(req->标志& req_f_force_async){...... / * *切勿尝试内联提交IOSQE_ASYNC设置,直接*到异步执行。 * / req-> work.flags | = io_wq_work_concurrent; io_queue_async_work(req);
2.由为每次操作准备的逻辑决定。 (例如,在调用READV()时添加IOCB_NOWAIT标志,如果预计执行停止,则返回EAGAIN)
static int io_read(struct io_kiocb * req,struct io_kiocb ** nxt,bool force_nonblock){...... ret = rw_verify_area(阅读,req->文件,& kiocb-> ki_pos,iov_count); if(!RET){SSIZE_T RET2; if(req-> file-> f_op-> read_iter)ret2 = call_read_iter(req->文件,kiocb,& erer); else ret2 = loop_rw_iter(读取,req->文件,kiocb,& erer); / *捕获强制非阻塞提交的返回* / if(!force_nonblock || ret2!= -iagain){kiocb_done(kiocb,ret2,nxt,req-> in_async); } else {copy_iov:ret = io_setup_async_rw(req,io_size,Iovec,Inline_vecs,& erer);如果(RET)转到OUT_FREE;返回-Again; }} ......}
返回EAGAIN时,它会导出进入异步执行的队列(如果它是一种使用文件描述符的操作,它会在此处获取对文件结构的引用)。
静态void __io_queue_sqe(struct io_kiocb * req,const struct io_uring_sqe * sqe){...... ret = io_issue_sqe(req,sqe,& nxt,true); / * *如果文件没有标记为Nowait,或者如果文件*' t支持非阻塞读/写尝试* / if(RET == -AGAIN& !(req-> flags& req_f_nowait)||(req->标志& req_f_must_punt))){punt:if(io_op_defs [req->操作码] .file_table){ret = io_grab_files(req);如果(RET)GOTO ERR; } / * *排队到异步执行,Worker将在实际提交IOCB时发布*提交参考。 * / io_queue_async_work(req); goto done_req; } ......}
static int io_issue_sqe(struct io_kiocb * req,const struct io_uring_sqe * sqe,struct io_kiocb ** nxt,bool force_nonblock){struct io_ring_ctx * ctx = req-> ctx; int ret;切换(req->操作码){case iing_op_nop:ret = io_nop(req);休息;案例IORING_OP_READV:CASE IORICE_OP_READ_FIXED:case iousing_op_read:if(sqe){ret = io_read_prep(req,sqe,force_nonblock); if(et< 0)破裂; } ret = io_read(req,nxt,force_nonblock);休息;
3.使用IOSQE_IO_LINK | IOSQE_IO_HARDLINK标志(指定执行顺序)和更早地执行其执行顺序的操作以确定需要异步执行。
(如下面的代码中所述连接为链接,按顺序执行,如果在中间满足条件2,则将在异步执行队列中排出整个链接)
静态bool io_submit_sqe(struct io_kiocb * req,const struct io_uring_sqe * sqe,struct io_submit_state * struct,struct io_kiocb **链接){...... / * *如果我们已经有一个头请求,则为异步队列这一个头部完成后提交。如果我们没有' t有一个头,但是* iosqe_io_link在sqe中设置,开始一个新的头。一旦链条完成,这将是*提交同步。如果没有那些*条件是真的(正常请求),那么只需队列。 * / if(*链接){...... list_add_tail(& req-> link_list,& head-> link_list); / *链接的最后一个请求,inqueue链接* / if(!(sqe_flags&(iosqe_io_link | iosqe_io_hardlink))){io_queue_link_head(head); * link = null; }}否则{......如果(sqe_flags&(iosqe_io_link | iosqe_io_hardlink)){req-> flags | = req_f_link; init_list_head(& req-> link_list); if(io_alloc_async_ctx(req)){ret = -eagain; goto err_req; } ret = io_req_defer_prep(req,sqe); if(ret)req-> flags | = req_f_fail_link; *链接= req; } else {io_queue_sqe(req,sqe); }}返回true; }
严格来说,IORING_OP_TIMEOUT是一个小小的特殊性,并且不会返回EAGAIN,如2.但(我认为)很容易理解,所以我用它作为一个样本。如下所示,通过将需要异步执行(IORING_OP_TIMEOUT)与另一个操作的操作链接,您可以看到先前的IORING_OP_READV在等待1秒后肯定执行。
将IOSQE_IO_HARDLINK标志添加到上面的示例代码中的IORING_OP_TIMEOUT操作,以阐明它链接到后续操作。
此时,如果以与以前相同的方式执行执行IO_READ()的进程的名称,则会获得以下输出。
正如您通过查看进程列表所看到的,这是一个内核线程。
$ PS AUX | Grep -a 2-u型1样品Garyo 131388 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0. S 19:03 0:00 [IO_WQ_MANAGER]根131390 0.0 0.0 0 0? S 19:03 0:00 [IO_WQE_WORKER-0]
此后,此内核线程将被称为“工人”。此工作人员由以下代码生成,然后,Dequeue和执行队列的异步执行任务。
静态bool create_io_worker(struct io_wq * wq,struct io_wqe * wqe,int index){...... worker-> task = kthread_create_on_node(io_wqe_wayer,worker,wqe->节点," io_wqe_worker-%d /%d",index,wqe->节点); ......}
旁边:如前所述,IORING_OP_TIMEOUT从下图略有不同,但它被描述为简单起见。严格来说,当调用io_timeout()时,它会在处理程序中设置IO_TIMEOUT_FN()并启动计时器。在通过定时器的时间设置后,调用IO_TimeOut_fn()以将连接到异步执行队列中的链路的操作加载。换句话说,IORING_OP_TIMEOUT本身不会在异步执行队列中排队。超时用于解释中,因此很容易想象执行将停止。
发现异步处理由运行作为内核线程的工作者执行。但是,这里有预防措施。由于Worker runninng作为内核线程,因此执行上下文与调用IO_uping相关系统调用的线程不同。这里,“执行上下文”是指与处理相关联的任务组结构和与之相关联的各种信息。例如,mm(管理进程的虚拟内存空间),cred(保存UID / gid / capability),files_struct(保存文件描述符的表。文件_struct结构中的文件结构数组,文件描述符是其索引) 等等。
当然,如果它没有引用调用系统调用的线程中的这些结构,它可以指的是错误的虚拟内存或文件描述符表,或使用内核线程权限(≒root)发出I / O操作[² ]。
[²]:顺便说一下,这是一种实际漏洞,而且当时它忘了切换信用,并且可以用root权限执行操作。虽然当时未实现相当于打开的Open()的操作,但是可以在SendMsg的SCM_Credentials选项中通知通知发件人的权限的权限。这是D-Bus周围的问题,因为权威是由它确认的。 https://www.exploit-db.com/exploits/47779.
因此,在IO_URICE中,这些引用将传递给工人,以便工作者通过在执行之前通过切换自己的上下文来共享执行上下文。例如,您可以看到,然后将引用MM和CRECT传递给REQ->在以下代码中工作。
静态内联void io_req_ward_grab_env(struct io_kiocb * req,const struct io_op_def * def){if(!req-> work.mm& def-> caven-> caven-> comper_mm){mmgrab(current-> mm); Req-> Work.mm =电流 - > mm; }如果(!req-> work.creds)req-> work.creds = get_current_cred(); if(!req-> work.fs&& def-> capid_fs){spin_lock(& current-> fs->锁定); if(!current-> fs-> in_exec){req-> work.fs = current-> fs; req-> work.fs->用户++; } else {req-> work.flags | = io_wq_wark_cancel; } Spin_unlock(& current-> fs->锁定); }如果(!req-> work.task_pid)req-> work.task_pid = task_pid_vnr(当前); }
您可以看到对files_struct的引用传递给req->在以下代码中工作。
static int io_grab_files(struct io_kiocb * req){...... if(ctx-> ring_fd)== ctx-> ring_file){list_add(& req-> indight_entry,& ctx-&gt ;填充_List); Req->标志| = req_f_inflight; req-> work.files = current->文件; RET = 0; } ......}
然后,在执行之前,将它们替换为工作人员的当前内容(获取当前正在运行的Task_struct的宏)。
静态void io_wayer_handle_work(struct io_wayer * worker)__leleases(wqe-> lock){struct io_wq_ware * work,* old_work = null,* put_work = null; struct io_wqe * wqe = worker-> wqe; struct io_wq * wq = wqe-> wq; do {......如果(工作 - >文件& current->文件!= work->文件){task_lock(current); current-> files = work->文件; Task_unlock(当前); }如果(工作 - > fs&& current-> fs!=工作 - > fs)current-> fs =工作 - 和gt; fs; if(工作 - > mm!= worker-> mm)io_wq_switch_mm(worker,work); if(worker-> cur_creds!= work-> creds)io_wq_switch_creds(工人,工作); ......工作 - > func(&工作); ......}虽然(1); }
现在,让我们继续解释漏洞。在下面的代码中(我之前发布),您可以看到工作人员正在向执行系统调用的线程的文件传递给工作者将在不递增参考计数器的情况下引用的结构。
static int io_grab_files(struct io_kiocb * req){...... if(ctx-> ring_fd)== ctx-> ring_file){list_add(& req-> indight_entry,& ctx-&gt ;填充_List); Req->标志| = req_f_inflight; req-> work.files = current->文件; RET = 0; } ......}
顺便说一下,如简要介绍的说明,当在队列中延长任务时,从指定的文件描述符(传递给IO_kiocb结构)首先保留对队列中的队列中的参考。
static int io_req_set_file(struct io_submit_state * state,struct io_kiocb * req,const struct io_uring_sqe * sqe){struct io_ring_ctx * ctx = req-> ctx;无符号旗帜; int fd;标志= READ_ONCE(SQE->标志); fd = read_once(sqe-> fd); if(!io_req_needs_file(req,fd))返回0; if(标志& iosqe_fixed_file){if(不可能(!ctx-> file_data ||(未签名)fd> = ctx-> nr_user_files)return -ebadf; fd = array_index_nospec(fd,ctx-> nr_user_files); req-> file = io_file_from_index(ctx,fd); if(!req->文件)返回-ebadf; req->标志| = req_f_fixed_file; percpu_ref_get(& ctx-> file_data-> refs); }否则{if(req-> caless_fixed_file)return -ebadf; trace_io_uping_file_get(CTX,FD); req-> file = io_file_get(州,FD); if(不太可能(!req->文件))返回-ebadf;返回0; }
因此,工作人员不必再次从文件描述符中检索它,并且不需要引用files_struct结构。如果是这样,似乎没有问题是files_struct结构的参考计数器未递增(因为未使用它)。但是在Linux内核5.5及更高版本中,此假设不存在。这是因为现在可以通过IO_UPURS获得影响文件描述符表的系统调用,例如打开/关闭/接受。显然,这些系统调用会影响文件描述符表,因此它看起来可以用于利用的东西,
即使您只需调用Open / Close / Incept等,如果Files_struct结构可用,则不会发生任何内容。 - 当然,当通过多个线程处理相同的文件时,系统调用具有对策,因此无法在调用线程和工人之间引起竞争条件。
通过将Files_struct设置为0,新进程可以将其作为该进程的文件重用。重用时,工作人员将参考新进程的Files_struct。 - 但是,文件结构已经从文件描述符获得的,所以它̶c̶a̶n̶n̶o̶t̶得到一个参考文件结构的新的进程(这是一个谎言,我将描述在“搁置”的一部分。) - 这是可能的插入文件通过打开文件,结构进入新进程的文件描述符表。但它不会被引用。 (因为人们在编程时不使用固定文件描述符编号。)
在这里,我将在通过多个线程处理相同文件时,在对策中解释文件结构的参考计数器的机制。是的,这是一个扰流板。结论将是它实际上可以滥用。
要了解文件结构中的参考计数器如何工作,我们首先需要了解实际打开/关闭的内容。当然,行为根据要打开的实际文件而变化,但以下内容可以常见。
静态结构文件* __ alloc_file(int标志,const struct cred * cred){struct文件* f; int错误; f = kmem_cache_zalloc(filp_cachep,gfp_kernel); ...... atomic_long_set(& f-> f_count,1); ......返回f; }
静态long do_sys_openat2(int dfd,const char __user * filename,struct open_how * how){...... fd = get_unused_fd_flags(how-> flags); if(fd> = 0){struct文件* f = do_filp_open(dfd,tmp,& op); if(is_err(f)){put_unused_fd(fd); fd = ptr_err(f); } else {fsnotify_open(f); fd_install(fd,f); }} putname(tmp);返回FD; }
int __close_fd(struct files_struct * files,unsigned fd){struct文件*文件;结构fdtable * fdt; spin_lock(& files-> file_lock); fdt = files_fdtable(文件); if(fd> = fdt-> max_fds)goto out_unlock; file = fdt-> fd [fd];如果(!文件)转到out_unlock; rcu_assign_pointer(fdt-> fd [fd],null); __put_unused_fd(文件,fd); spin_unlock(& files-> file_lock);返回filp_close(文件,文件); Out_unlock:Spin_unlock(& files-> file_lock);返回-ebadf; }
这里重要的是fget()/ fput()functio
......