0x00.前言
在玄武实验室的日推中发现了这个漏洞,发现又是个没见过的bypass
姿势,于是就来研究一下吧,这个是利用了launchd
的一个漏洞,通过向他发送恶意消息可以将对应的进程dealloc
掉,然后伪造这个进程,相当于做port
间的中间人的攻击,就可以拿到其他进程的send right
,在这一点的基础上进行沙盒逃逸,提权和绕过签名。
通过Brandon的写的文章我们来对整个的利用过程进行一个剖析,其实也可以视为是对他的文章的翻译,稍微修改了下,因为原文已经说的算是比较清楚了。
0x01.漏洞产生处
Brandon在进行iOS上的crash报告研究的时候,发现了这个漏洞,可以一种特殊的crash
方式,可以让内核向launchd
发送一个Mach message
,从而使launchd
将这个进程的send right
在他的ipc_space
中over-dealloced
掉(double free)但是我们关注的重点并不在这个漏洞本身。那么我们就可以冒充这个进程。
这个漏洞在macOS
上也出现了,只不过在iOS上触发条件更为严格,因为在iOS上要求这个Mach message
从内核发送。
launchd在处理EXC_CRASH异常消息时的over-deallocation
当一个进程发送mach_exception_raise
或者 mach_exception_raise_state_identity
消息给他的bootstap port
的时候,launchd
将会把这个异常消息作为一个host level
的异常去接收。
不幸的是,launchd
去处理这些代码的方式是有问题的,当异常的类型是EXC_CRASH
的时候,launchd
会销毁掉消息中的thread
和task port
并返回KERN_FAILURE
,接下来MIG系统会把这些再次销毁(这样的原因是因为如果返回的是KERN_SUCCESS
,就意味着launchd持有着这个消息中的资源,如果是KERN_FAILURE
,就意味着它并没有这些资源的所有权)
下面就是处理部分的代码:
kern_return_t __fastcall
catch_mach_exception_raise( // (a) The service routine is
mach_port_t exception_port, // called with values directly
mach_port_t thread, // from the Mach message
mach_port_t task, // sent by the client. The
exception_type_t exception, // thread and task ports could
mach_exception_data_t code, // be arbitrary send rights.
mach_msg_type_number_t codeCnt)
{
__int64 __stack_guard; // ST28_8@1
kern_return_t kr; // w0@1 MAPDST
kern_return_t result; // w0@4
__int64 codes_left; // x25@6
mach_exception_data_type_t code_value; // t1@7
int pid; // [xsp+34h] [xbp-44Ch]@1
char codes_str[1024]; // [xsp+38h] [xbp-448h]@7
__stack_guard = *__stack_chk_guard_ptr;
pid = -1;
kr = pid_for_task(task, &pid);
if ( kr )
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( current_audit_token.val[5] ) // (b) 如果发送这个消息的进程pid不是0
{ // (不是内核进程)
result = KERN_FAILURE; // 那么就会被拒绝
}
else
{
if ( codeCnt )
{
codes_left = codeCnt;
do
{
code_value = *code;
++code;
__snprintf_chk(codes_str, 0x400uLL, 0, 0x400uLL, "0x%llx", code_value);
--codes_left;
}
while ( codes_left );
}
launchd_log_2(
0LL,
3LL,
"Host-level exception raised: pid = %d, thread = 0x%x, "
"exception type = 0x%x, codes = { %s }",
pid,
thread,
exception,
codes_str);
kr = deallocate_port(thread); // (c) 消息中的"thread" port
if ( kr ) // 被deallocate掉了
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
kr = deallocate_port(task); // (d) 消息中的"task" port
if ( kr ) // 被deallocat掉了
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( exception == EXC_CRASH ) // (e) 如果异常的类型是
result = KERN_FAILURE; // EXC_CRASH, 就会返回
else // KERN_FAILURE,MIG
result = 0; // 就会再次deallocate这些port
}
*__stack_chk_guard_ptr;
return result;
}
要想真正利用这个漏洞,就要能控制我们想要释放的服务,然后伪装成这个服务,那么我们就有很多的机会去提权呢,那么如何做到精准的释放呢?
触发漏洞
我们之所以能够触发漏洞来精准的释放我们想要释放的服务来源于task_set_special_port
,在内核生成一个task
的异常消息的时候,内核会使用task_set_special_port
的send right
,而不是task
本身的,所以同理,通过thread_set_special_port
这个API就能达到我们的目的了。
总的来说,我们分为下面几步:
- 通过
thread_set_exception_ports
来将launchd
作为异常处理者 - 通过
bootstrap_look_up
来找到我们想要伪装的服务 - 通过
task_set_special_port
/thread_set_special_port
设置将要替代的服务,用于替代异常消息中的send right
- 调用
abort
,内核就会生成EXC_CRASH
类型的异常消息发送给launchd
-
launchd
解析异常消息释放掉目标服务
在crash之后继续运行
因为调用abort
之后我们的进程就会被杀掉了,我们想要继续运行接下来的代码就需要新的方法
如果是其他的异常类型进程是可以恢复的,只需要将其thread exception handler
设置为launchd
,而task
级别的设置为他自己。那么在launchd
无法处理这个异常的时候,就会交给它自身了,从而线程状态并告知内核异常消息已经被处理。但是一个进程不能捕捉到它自身的EXC_CRASH
消息,所以我们需要两个进程。
一个策略就是首先在另一个进程中触发漏洞,强制设置kernel port
并crash
掉,然而,用App extension
是一个更好的方式。
App extension
在iOS 8中引入,它提供了将应用的一些功能打包,运行在应用之外的能力,它的代码运行在一个隔离的沙盒进程中,本来是和App extension
通信的API,但是Ian McDowell写了一个文章描述如何通过私有APINSExtension
去启动应用扩展并和它通信,我们也就是通过向launchd
注册应用扩展服务的那个端口和应用扩展进程之间通信。
避免launchd中的端口复用
这里就是说了一个老生常谈的技巧,为了防止端口被其他的服务给抢占了,我们可以注册大量的服务,持有这些端口的recv right
,那么等我们abort
的时候,这些端口也被释放掉了,构造出一长串的freelist
,而且我们最先释放的就是我们的目标服务,所以之后注册的服务就不大可能会复用到它头上来了。
这个方法的局限性就在于我们需要com.apple.security.application-groups
的entitlement
去向launchd
注册服务,虽然还有其他方式,但这种毫无疑问是最简单的了。
伪装成被释放的服务
在我们的应用扩展释放了launchd
中的目标服务的send right
,我们需要占有那个port name
,从而可以做port
之间的中间人攻击,截获所有客户端和service
通信的消息。
这里因为已经使用了应用组的entitlement
,所以我们就注册大量的服务直到他们其中的一个重用到了之前的那个port name
,那么其他的客户端寻找目标服务的时候launchd
就会将客户端的send right
返回给我们的端口,而不是原先的服务。
0x02.攻击步骤
源代码都在sandbox_escape.c
中,感兴趣的可以去参考链接中下载继续分析一下。
步骤1.获取host-priv
端口
我们的目标就是伪造SafetyNet
,然后使ReportCrash
崩溃掉,然后从异常消息中取回ReportCrash
的task port
,然后通过task_get_special_port
拿到host-priv port
,这就是我们整个流程的思路。
ReportCrash和SafetyNet
ReportCrash
是在iOS系统上生成崩溃报告的,它事实上有4个服务,每一个都在不同的进程中:
-
com.apple.ReportCrash
,它是EXC_CRASH
、EXC_GUARD
和EXC_RESOURCE
在host level
的处理者 -
com.apple.ReportCrash.Jetsam
处理Jetsam
的报告 -
com.apple.ReportCrash.SimulateCrash
创建模拟器的崩溃报告 -
com.apple.ReportCrash.SafetyNet
是com.apple.ReportCrash
的异常处理服务
当ReportCrash
启动的时候,它会在launchd
中去寻找SafetyNet
服务,并将返回的端口作为task level
的异常处理,也就是说,当ReportCrash
崩溃的时候,由SafetyNet
去处理它的消息,不仅如此,这两个服务在沙盒中都是可以访问到的。
操作ReportCrash的前提
要想引出接下来的攻击,我们必须要达成接下来的步骤:后台ReportCrash
,然后强迫它退出,奔溃掉,并保证我们使用它的时候它是一直运行的,至于为什么这样做,怎么做到接下来就是解释部分了:
启动部分很简单,只需要通过一条Mach message
,launchd
收到请求就会在启动他了,然而由于它奇怪的设定,除了mach_exception_raise_state_identity
之外的任何消息都是使它停止接收新消息并退出,如果我们之后要让它一直存活就要注意这一点。
退出很简单就不说了,崩溃有很多方式,最简单的就是发送一个thread port
设置为MACH_PORT_NULL
的mach_exception_raise_state_identity
消息即可。
要保持让它一直运行,而且我们只能发送mach_exception_raise_state_identity
消息,所以我们只能从这个消息上去想办法,ReportCrash
只有当所有生成崩溃报告的线程完成之后才会退出,所以我们只要想办法阻塞其中一个线程即可
从函数的调用可以发现当ReportCrash
想要创建一个崩溃报告的时候,会通过task_policy_get
方法从异常消息中获取task port
,这会向那个端口发送一个消息并等待回复,而我们的这个task port
可以自己设置,从而让它一直等待回复,而ReportCrash
则一直等待task_policy_get
这个函数去返回。
下面解释为什么要这么做:
- 我们要伪造的服务是
SafetyNet
,通过漏洞将它释放掉然后我们自己占有原来的那个port name
- 让所有的
ReportCrash
实例退出掉,来确保接下来的ReportCrash
会去查找我们伪造的服务,并将其作为EXC_CRASH
的接收目标 - 崩溃
ReportCrash
,我们伪造的服务将接收到崩溃消息 - 从消息中可以提取到
ReportCrash
的task port
- 通过
task_get_special_port
拿到host port
,因为这个是以root
身份运行的,所以就是一个host priv
端口
步骤2.沙盒逃逸
虽然拿到了host priv
端口,但是我们还没有在沙盒之中,所以我们还需要进行沙盒逃逸,严格的来说这两步并不存在先后顺序,只是沙盒逃逸会让系统变得不稳定,所以我们就先拿到host priv
端口再说。
这一步中我们还是利用launchd
的漏洞去拿到task port
,伪造的服务是CARenderServer
,然后和com.apple.DragUI.druid.source
通信,druid
是一个无沙盒的守护进程,会将它的task port
通过Mach message
传给我们伪造的服务。
但是这个方式在iOS11.3
之后就不能用了,但是可以去寻找其他符合的服务,但前提是我们能够伪造成系统的服务,不然就是一切就休,不用谈下一步了
崩溃druid
就像之前对ReportCrash
所做的事情一样,这里用到了一个libxpc
的bug去达成,作者发现了一个可以让任何XPC
服务崩溃掉的越界读:
void _xpc_dictionary_apply_wire_f
(
OS_xpc_dictionary *xdict,
OS_xpc_serializer *xserializer,
const void *context,
bool (*applier_fn)(const char *, OS_xpc_serializer *, const void *)
)
{
...
uint64_t count = (unsigned int)*serialized_dict_count;
if ( count )
{
uint64_t depth = xserializer->depth;
uint64_t index = 0;
do
{
const char *key = _xpc_serializer_read(xserializer, 0, 0, 0);
size_t keylen = strlen(key);
_xpc_serializer_advance(xserializer, keylen + 1);
if ( !applier_fn(key, xserializer, context) )
break;
xserializer->depth = depth;
++index;
}
while ( index < count );
}
...
}
很显然的看出来上面的strlen
函数没有对用户的数据做检查,所以在反序列化的时候访问越界内存或者_xpc_serializer_advance
尝试找到data的末尾都会导致crash。
所以我们只需要构造一个键值没有闭合的字典作为XPC消息就可以让druid
crash了。
获取druid的task port
- 通过
launchd
的漏洞伪造CARenderServer
- 通过
Mach message
启动druid
- 如果没有收到
task port
就用libxpc
的bug杀掉再重启 - 拿到
druid
的task port
绕过platform binary
的task port
的限制
这里的platform binary
是指拥有苹果签名的二进制
虽然我们拿到了druid
的task port
,但是并不能做到在这个进程内的代码执行,原因就是因为task_conversion_eval
,在源码中可以看到调用关系:
task_t
convert_port_to_task(
ipc_port_t port)
{
return convert_port_to_task_with_exec_token(port, NULL);
}
task_t
convert_port_to_task_with_exec_token(
ipc_port_t port,
uint32_t *exec_token)
{
task_t task = TASK_NULL;
if (IP_VALID(port)) {
ip_lock(port);
if ( ip_active(port) && ip_kotype(port) == IKOT_TASK ) {
task_t ct = current_task();
task = (task_t)port->ip_kobject;
assert(task != TASK_NULL);
if (task_conversion_eval(ct, task)) {
ip_unlock(port);
return TASK_NULL;
}
...
return (task);
}
上面的调用关系只是一个示例,事实上,内核在处理含有task_t
, ipc_space_t
, vm_map_t
或者 vm_task_entry_t
的Mach message
的时候,最终调用都会到task_conversion_eval
来检查是否合法
其中task_conversion_eval
就进行了校验,每个task
只能使用他们自己的task ports
,只有kernel task
才有所有的权限:
kern_return_t
task_conversion_eval(task_t caller, task_t victim)
{
/*
* Tasks are allowed to resolve their own task ports, and the kernel is
* allowed to resolve anyone's task port.
*/
if (caller == kernel_task) {
return KERN_SUCCESS;
}
if (caller == victim) {
return KERN_SUCCESS;
}
/*
* Only the kernel can can resolve the kernel's task port. We've established
* by this point that the caller is not kernel_task.
*/
if (victim == kernel_task) {
return KERN_INVALID_SECURITY;
}
#if CONFIG_EMBEDDED
/*
* On embedded platforms, only a platform binary can resolve the task port
* of another platform binary.
*/
if ((victim->t_flags & TF_PLATFORM) && !(caller->t_flags & TF_PLATFORM)) {
#if SECURE_KERNEL
return KERN_INVALID_SECURITY;
#else
if (cs_relax_platform_task_ports) {
return KERN_SUCCESS;
} else {
return KERN_INVALID_SECURITY;
}
#endif /* SECURE_KERNEL */
}
#endif /* CONFIG_EMBEDDED */
return KERN_SUCCESS;
}
这就意味着哪怕我们拿到了druid
的task port
,也没有办法通过mach_vm_*
去修改它的任何东西
/*
* Returns the set of threads belonging to the target task.
*/
routine task_threads(
target_task : task_inspect_t;
out act_list : thread_act_array_t);
除了上面那些类型之外,还有其他的类型是没有进行检查的,如task_name_t
, task_inspect_t
, and ipc_space_inspect_t
等。Bradon
在浏览MIG方法的时候发现有一个函数task_threads
,枚举task
内的线程,重点是这里的参数是task_inspect_t
而非task_t
,这就意味着MIG转换的时候用的并不是convert_port_to_task
而是convert_port_to_task_inspect
,从这个函数的逆向代码中可以看到其中并没有进行task_conversion_eval
,这意味着函数可以执行成功,更有意思的一点是返回的并不是thread_inspect_t rights
,而是thread_act_t
。也就是说,通过task_threads
这个函数,我们将不可修改的task right
替换成了可以修改的thread right
,在线程层次上也不存在说类似task
层面上的校验,也就是说我们可以通过Mach thread API
去绕过task_conversion_eval
。
Brandon还在已有的Mach thread API
上封装了一个能力更强的库threadexec,在Poc中用的就是这个库。
threadexec
Brandon在他的新博客中对于这个库的操作做了一些补充,这个库做的事情其实是有限的,我们可以暂停,开启线程,拥有寄存器的读写权限,但是我们并不能直接去访问内存和创建新线程,因为thread_create_running
的参数中存在task_t
。
由于ARM64架构的函数调用的原因,只要我们参数不超过八个,都是由寄存器提供,并且返回值也在寄存器中,也就是说我们可以控制线程去调用我们指定的函数(因为可以修改pc)。
接下来是为了和远程的线程进行双向通信,需要创建两个Mach recieve right
,一个在本地task
,一个在远端的task
,本地的端口也就是指是本地task
持有recieve right
,让远端的task
持有send right
:
- 通过
mach_port_allocate
创建端口,本地持有recieve right
- 利用
thread_set_special_port(THREAD_KERNEL_PORT)
将send right
送给远端的task
,那么远端线程通过mach_thread_self()
就可以拿到了 - 而对于在远端创建的话我们不能使用上面的方法,因为
mach_port_allocate()
创建返回的port name
是在远端内存中,我们是没有读权限的,至少目前没有,所以我们用的是mach_reply_port()
,然后用mach_port_insert_right
创建send right
- 远端的线程用
thread_set_special_port
存储send right
,本地通过thread_get_special_port
拿到,自此完成双端通信
这里线程虽然没办法直接去读写内存,但是因为pc
指针可控,所以还是可以创造基本的读写权限的:
uint64_t read_func(uint64_t *address){
return *address;
}
_read_func:
ldr x0, [x0]
ret
uint64_t write_func(uint64_t *address, uint64_t value){
*address = value;
}
_write_func:
str x1, [x0]
ret
可以通过寻找哪些库函数具有上面这种类似的结构就可以拿来复用,Brandon找到的是property_getName
,这个可以直接拿来用,用于实现地址写的是_xpc_int64_set_value
,这个的汇编代码是:
__xpc_int64_set_value:
str x1, [x0, 0x18]
ret
所以我们调用的时候把地址减一个0x18
即可,得到读写权限就可以着手准备共享内存了。
为了更方便的传输数据,共享内存还是很有必要的,有了之后我们的任意地址读写就可以通过在共享内存区域进行memcpy
来完成,并且函数的调用参数也不限于8个,因为可以在栈上去布置数据。
直接利用XPC来创建是比较方便的做法,在本地来说,通过mach_vm_allocate
,然后利用xpc_shmem_create
来为区域创建一个OS_xpc_sheme
对象,并且这个方法为我们创建了更底层的Mach memory entry
,并将其send right
存储到了OS_xpc_sheme
对象偏移0x18
的位置。
下面就是在远端创建同样的对象,然后映射内存,这就是之前为什么要获得读写权限,我们首先通过远程调用为OS_xpc_csheme
分配内存,然后通过地址读写将本地的对象拷贝过去,唯一的一个问题就是本地task
的Mach memory entry send right
在远端并不适用,port name
是对于本地task
而言的,这里还是利用thread_set_special_port
,然后再覆盖0x18
偏移的地址即可,这样再通过xpc_shmem_remote()
映射就完成了,自此,我们达成了从一个线程到拥有一个task
的权限转换。
步骤3.创建一个新的host层级的异常处理
- 通过
host_get_exception_ports
拿到host level
对于EXC_BAD_ACCESS
的异常处理端口 - 分配一个端口作为新的异常处理
- 将
host-priv port
和send right
给我们刚创建的端口 - 利用我们在
druid
中的上下文调用host_set_exception_ports
设置我们新的异常处理端口
完成之后,任意访问非法内存并且没有注册的异常处理的进程,我们就可以通过EXC_BAD_ACCESS
异常消息拿到那些进程的task port
,由于这个异常是可恢复的,那么就意味着可以通过task port
去执行代码了。
步骤4.拿到ReportCrash的task port
我们之所以要再次获取这个,是因为之前的ReportCrash
进程已经crash
了
-
让ReportCrash触发
EXC_BAD_ACCESS
mach_port_t reportcrash = context->reportcrash_service; reportcrash_keepalive_assertion_t reportcrash_assertion = reportcrash_keepalive(reportcrash); if (reportcrash_assertion == 0) { ERROR("Could not generate keepalive assertion for %s", REPORTCRASH_NAME); return false; } ... // 触发EXC_BAD_ACCESS的异常 reportcrash_keepalive_assertion_release(reportcrash_assertion);
因为
ReportCrash
并没有这种消息的处理者,EXC_CRASH
消息的处理者是SafetyNet
,所以异常消息会发送到我们分配的那个端口上去 -
接收到异常消息之后,将
task port
和thread port
保存下来,并让进程恢复 -
利用端口在进程内做代码执行,就像
druid
中一样
因为ReportCrash
是task_for_pid-allow
的进程,需要用到它的entitlement
。
步骤5.恢复原来的host-level异常处理
接下来的两步并不是一定要做但是最好还是做一下,这样exploit执行完成之后我们并不需要去重启设备或者做别的操作,和之前的系统基本一致。
当我们拿到ReportCrash
内的代码执行之后,我们应该将原来的host level exception handler
恢复回去,通过druid
调用host_set_exception_ports
去重置异常处理端口:
bool ok = threadexec_host_set_exception_ports(
context->druid_tx,
context->host_priv,
EXC_MASK_BAD_ACCESS,
context->host_exception_handler,
context->host_exception_behavior,
context->host_exception_flavor);
步骤6.修复launchd
- 通过
task_for_pid
拿到launchd
的task port
对于我们伪造的每个服务,都进行以下操作:
- 拿到fake service的
port name
- 将fake port和服务都销毁掉
- 调用
mach_port_insert_right
把真实的服务再塞回去
0x03.提权过程
已经拿到了launchd
的task port
了,然后在launchd
中起一个线程,通过posix_spawn
把我们的payload
再给开起来,这里是让launchd
作为其父进程,不然会被杀掉。
接下来的过程就是bypass
amfid,干掉签名,常规的方式就是注册异常处理,伪造MISValidateSignatureAndCopyInfo
函数实现,让它签名一直判断过。
但是Brandon使用了另一种方式,他没有选择去patchamfid
,而是在内核中注册了一个新的amfid port
内核通过一个叫做HOST_AMFID_PORT
的端口去检测给amfid
发送消息的人,因为我们已经有了沙盒外root权限的代码执行,所以这个可以干掉,苹果还有一个检测就是会将发送者的cdhash和amfid的进行比较,但是我们只要只要将amfid作为中间人,内核发送消息给我们,我们发给amfid,amfid再发给内盒,形成一个环,就可以绕过了。
再下面就是用到了这个函数:
kern_return_t
verify_code_directory(
mach_port_t amfid_port,
amfid_path_t path,
uint64_t file_offset,
int32_t a4,
int32_t a5,
int32_t a6,
int32_t * entitlements_valid,
int32_t * signature_valid,
int32_t * unrestrict,
int32_t * signer_type,
int32_t * is_apple,
int32_t * is_developer_code,
amfid_a13_t a13,
amfid_cdhash_t cdhash,
audit_token_t audit);
其中有一个参数是isApple
,如果这个参数被设置了,AFMI会修改标识位为CS_PLATFORM_BINARY
,当前进程就成为了platform binary
,可以直接操控task port
了,这些操作当然也是通过threadexec
完成的。