利用abort提权


#1

0x00.前言

在玄武实验室的日推中发现了这个漏洞,发现又是个没见过的bypass姿势,于是就来研究一下吧,这个是利用了launchd的一个漏洞,通过向他发送恶意消息可以将对应的进程dealloc掉,然后伪造这个进程,相当于做port间的中间人的攻击,就可以拿到其他进程的send right,在这一点的基础上进行沙盒逃逸,提权和绕过签名。

通过Brandon的写的文章我们来对整个的利用过程进行一个剖析,其实也可以视为是对他的文章的翻译,稍微修改了下,因为原文已经说的算是比较清楚了。

0x01.漏洞产生处

Brandon在进行iOS上的crash报告研究的时候,发现了这个漏洞,可以一种特殊的crash方式,可以让内核向launchd发送一个Mach message,从而使launchd将这个进程的send right在他的ipc_spaceover-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会销毁掉消息中的threadtask 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_portsend right,而不是task本身的,所以同理,通过thread_set_special_port这个API就能达到我们的目的了。

总的来说,我们分为下面几步:

  1. 通过thread_set_exception_ports来将launchd作为异常处理者
  2. 通过bootstrap_look_up来找到我们想要伪装的服务
  3. 通过 task_set_special_port/thread_set_special_port设置将要替代的服务,用于替代异常消息中的send right
  4. 调用abort,内核就会生成EXC_CRASH类型的异常消息发送给launchd
  5. launchd解析异常消息释放掉目标服务

在crash之后继续运行

因为调用abort之后我们的进程就会被杀掉了,我们想要继续运行接下来的代码就需要新的方法

如果是其他的异常类型进程是可以恢复的,只需要将其thread exception handler设置为launchd,而task级别的设置为他自己。那么在launchd无法处理这个异常的时候,就会交给它自身了,从而线程状态并告知内核异常消息已经被处理。但是一个进程不能捕捉到它自身的EXC_CRASH消息,所以我们需要两个进程。

一个策略就是首先在另一个进程中触发漏洞,强制设置kernel portcrash掉,然而,用App extension是一个更好的方式。

App extension在iOS 8中引入,它提供了将应用的一些功能打包,运行在应用之外的能力,它的代码运行在一个隔离的沙盒进程中,本来是和App extension通信的API,但是Ian McDowell写了一个文章描述如何通过私有APINSExtension去启动应用扩展并和它通信,我们也就是通过向launchd注册应用扩展服务的那个端口和应用扩展进程之间通信。

避免launchd中的端口复用

这里就是说了一个老生常谈的技巧,为了防止端口被其他的服务给抢占了,我们可以注册大量的服务,持有这些端口的recv right,那么等我们abort的时候,这些端口也被释放掉了,构造出一长串的freelist,而且我们最先释放的就是我们的目标服务,所以之后注册的服务就不大可能会复用到它头上来了。

这个方法的局限性就在于我们需要com.apple.security.application-groupsentitlement去向launchd注册服务,虽然还有其他方式,但这种毫无疑问是最简单的了。

伪装成被释放的服务

在我们的应用扩展释放了launchd中的目标服务的send right,我们需要占有那个port name,从而可以做port之间的中间人攻击,截获所有客户端和service通信的消息。

这里因为已经使用了应用组的entitlement,所以我们就注册大量的服务直到他们其中的一个重用到了之前的那个port name,那么其他的客户端寻找目标服务的时候launchd就会将客户端的send right返回给我们的端口,而不是原先的服务。

0x02.攻击步骤

源代码都在sandbox_escape.c中,感兴趣的可以去参考链接中下载继续分析一下。

步骤1.获取host-priv端口

我们的目标就是伪造SafetyNet,然后使ReportCrash崩溃掉,然后从异常消息中取回ReportCrashtask port,然后通过task_get_special_port拿到host-priv port,这就是我们整个流程的思路。

ReportCrash和SafetyNet

ReportCrash是在iOS系统上生成崩溃报告的,它事实上有4个服务,每一个都在不同的进程中:

  1. com.apple.ReportCrash,它是EXC_CRASHEXC_GUARDEXC_RESOURCEhost level的处理者
  2. com.apple.ReportCrash.Jetsam 处理Jetsam的报告
  3. com.apple.ReportCrash.SimulateCrash 创建模拟器的崩溃报告
  4. com.apple.ReportCrash.SafetyNetcom.apple.ReportCrash 的异常处理服务

ReportCrash启动的时候,它会在launchd中去寻找SafetyNet服务,并将返回的端口作为task level的异常处理,也就是说,当ReportCrash崩溃的时候,由SafetyNet去处理它的消息,不仅如此,这两个服务在沙盒中都是可以访问到的。

操作ReportCrash的前提

要想引出接下来的攻击,我们必须要达成接下来的步骤:后台ReportCrash,然后强迫它退出,奔溃掉,并保证我们使用它的时候它是一直运行的,至于为什么这样做,怎么做到接下来就是解释部分了:

启动部分很简单,只需要通过一条Mach messagelaunchd收到请求就会在启动他了,然而由于它奇怪的设定,除了mach_exception_raise_state_identity之外的任何消息都是使它停止接收新消息并退出,如果我们之后要让它一直存活就要注意这一点。

退出很简单就不说了,崩溃有很多方式,最简单的就是发送一个thread port设置为MACH_PORT_NULLmach_exception_raise_state_identity消息即可。

要保持让它一直运行,而且我们只能发送mach_exception_raise_state_identity消息,所以我们只能从这个消息上去想办法,ReportCrash只有当所有生成崩溃报告的线程完成之后才会退出,所以我们只要想办法阻塞其中一个线程即可

从函数的调用可以发现当ReportCrash想要创建一个崩溃报告的时候,会通过task_policy_get方法从异常消息中获取task port,这会向那个端口发送一个消息并等待回复,而我们的这个task port可以自己设置,从而让它一直等待回复,而ReportCrash则一直等待task_policy_get这个函数去返回。

下面解释为什么要这么做:

  1. 我们要伪造的服务是SafetyNet,通过漏洞将它释放掉然后我们自己占有原来的那个port name
  2. 让所有的ReportCrash实例退出掉,来确保接下来的ReportCrash会去查找我们伪造的服务,并将其作为EXC_CRASH的接收目标
  3. 崩溃ReportCrash,我们伪造的服务将接收到崩溃消息
  4. 从消息中可以提取到ReportCrashtask port
  5. 通过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消息就可以让druidcrash了。

获取druid的task port

  1. 通过launchd的漏洞伪造CARenderServer
  2. 通过Mach message启动druid
  3. 如果没有收到task port就用libxpc的bug杀掉再重启
  4. 拿到druidtask port

绕过platform binarytask port的限制

这里的platform binary是指拥有苹果签名的二进制

虽然我们拿到了druidtask 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_tMach 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;
}

这就意味着哪怕我们拿到了druidtask 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

  1. 通过mach_port_allocate创建端口,本地持有recieve right
  2. 利用thread_set_special_port(THREAD_KERNEL_PORT)send right送给远端的task,那么远端线程通过mach_thread_self()就可以拿到了
  3. 而对于在远端创建的话我们不能使用上面的方法,因为mach_port_allocate()创建返回的port name是在远端内存中,我们是没有读权限的,至少目前没有,所以我们用的是mach_reply_port(),然后用mach_port_insert_right创建send right
  4. 远端的线程用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分配内存,然后通过地址读写将本地的对象拷贝过去,唯一的一个问题就是本地taskMach memory entry send right在远端并不适用,port name是对于本地task而言的,这里还是利用thread_set_special_port,然后再覆盖0x18偏移的地址即可,这样再通过xpc_shmem_remote()映射就完成了,自此,我们达成了从一个线程到拥有一个task的权限转换。

步骤3.创建一个新的host层级的异常处理

  1. 通过host_get_exception_ports拿到host level对于EXC_BAD_ACCESS的异常处理端口
  2. 分配一个端口作为新的异常处理
  3. host-priv portsend right给我们刚创建的端口
  4. 利用我们在druid中的上下文调用host_set_exception_ports设置我们新的异常处理端口

完成之后,任意访问非法内存并且没有注册的异常处理的进程,我们就可以通过EXC_BAD_ACCESS异常消息拿到那些进程的task port,由于这个异常是可恢复的,那么就意味着可以通过task port去执行代码了。

步骤4.拿到ReportCrash的task port

我们之所以要再次获取这个,是因为之前的ReportCrash进程已经crash

  1. 让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,所以异常消息会发送到我们分配的那个端口上去

  2. 接收到异常消息之后,将task portthread port保存下来,并让进程恢复

  3. 利用端口在进程内做代码执行,就像druid中一样

因为ReportCrashtask_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

  1. 通过task_for_pid拿到launchdtask port

对于我们伪造的每个服务,都进行以下操作:

  1. 拿到fake service的port name
  2. 将fake port和服务都销毁掉
  3. 调用mach_port_insert_right把真实的服务再塞回去

0x03.提权过程

已经拿到了launchdtask port了,然后在launchd中起一个线程,通过posix_spawn把我们的payload再给开起来,这里是让launchd作为其父进程,不然会被杀掉。

接下来的过程就是bypassamfid,干掉签名,常规的方式就是注册异常处理,伪造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完成的。

0x04.参考链接

bazed‘s poc


#2

太强了。。


#3

那么问题来了,我什么时候才能看懂这篇文章?


#4

emmmmm 我连前言都看不懂。。。。。太强了!!!


#5

看不懂只能膜系列