Blanket 是一个针对 iOS 11.2.6 的沙盒逃逸工具,尽管其主漏洞直到 iOS 11.4.1 才被修补。它利用 launchd 中的一个 Mach 端口替换漏洞(CVE-2018-4280),以及其他服务中的几个较小漏洞,在 ReportCrash 进程内执行代码。该进程不受沙盒限制,以 root 身份运行,并拥有 task_for_pid-allow 权限。这便授予了对手机上每个进程的全面控制权,包括像 amfid 这样的安全关键进程。
该漏洞利用包含多个阶段。本 README 将逐步解释主要漏洞以及沙盒逃逸的各阶段。
在研究 iOS 的崩溃报告时,我发现了 launchd 中的一个 Mach 端口替换漏洞。通过以特定方式崩溃,进程可以让内核向 launchd 发送一条 Mach 消息,导致 launchd 过度释放其 IPC 命名空间中某个 Mach 端口的发送权。这使得攻击者能够向系统其余部分冒充其可以查找到的任何 launchd 服务,从而开辟出许多权限提升的途径。
该漏洞在 macOS 上同样存在,但在 iOS 上触发该漏洞要困难得多,因为 launchd 中有检查机制,用于确保 Mach 异常消息来自内核。
launchd 在其主端口上多路复用多个不同的 Mach 消息处理器,其中包括一个用于异常消息的 MIG 处理器。如果某个进程向自己的 bootstrap 端口发送 mach_exception_raise 或 mach_exception_raise_state_identity 消息,launchd 将把该消息作为主机级异常来接收并处理。
遗憾的是,launchd 对这些消息的处理存在缺陷。如果异常类型为 EXC_CRASH,launchd 将释放消息中发送的线程和任务端口,然后从服务例程返回 KERN_FAILURE,导致 MIG 系统再次释放这些线程和任务端口。(其假设是:如果服务例程返回成功,则表示它已取得 Mach 消息中所有资源的所有权;而如果服务例程返回错误,则表示它未取得任何资源的所有权。)
以下是 launchd 针对 mach_exception_raise 消息的服务例程代码,使用 IDA/Hex-Rays 反编译并稍作编辑以提高可读性:```C
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) If the message was sent by
{ // a process with a nonzero PID
result = KERN_FAILURE; // (any non-kernel process),
} // the message is rejected.
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) The "thread" port sent in
if ( kr ) // the message is deallocated.
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
kr = deallocate_port(task); // (d) The "task" port sent in the
if ( kr ) // message is deallocated.
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( exception == EXC_CRASH ) // (e) If the exception type is
result = KERN_FAILURE; // EXC_CRASH, then KERN_FAILURE
else // is returned. MIG will
result = 0; // deallocate the ports again.
}
*__stack_chk_guard_ptr;
return result;
}
这就是代码所做的事情:
1. 此函数是 `mach_exception_raise` 异常消息的 Mach 服务例程:当 launchd 处理 `mach_exception_raise` Mach 异常消息时,由 Mach 系统直接调用。服务例程的参数从 Mach 消息中解析,因此由消息的发送方控制。
2. 在 (b) 处,launchd 检查该 Mach 异常消息是否由内核发送。发送方的审计令牌在第 5 个字段中包含发送进程的 PID,只有内核的该字段才为零。如果消息不是由内核发送,则会被拒绝。
3. 消息中的线程端口和任务端口在 (c) 和 (d) 处被显式释放。
4. 在 (e) 处,launchd 检查异常类型是否为 `EXC_CRASH`,如果是则返回 `KERN_FAILURE`。其目的是确保不处理 `EXC_CRASH` 消息,大概是为了让 ReportCrash 作为 corpse 处理程序被调用。然而,此时返回 `KERN_FAILURE` 将导致稍后清理异常消息时再次释放任务端口和线程端口。这意味着这两个端口将被过度释放。
为了让这个漏洞变得有用,我们需要释放 launchd 对其所提供(vend)的某个 Mach 服务的发送权,这样我们就能向系统其余部分冒充该服务。这意味着我们需要异常消息中的任务端口和线程端口真正成为指向我们想在 launchd 中释放的 Mach 服务端口的发送权。然后,一旦我们向 launchd 发送了恶意异常消息并释放了服务端口,我们会尝试让同一个端口名被重用,但这一次是用在我们持有接收权的 Mach 端口上。这样,当客户端请求 launchd 为其提供该服务 Mach 端口的发送权时,launchd 将转而提供指向我们端口的发送权,从而让我们向该客户端冒充该服务。之后,有许多不同的途径可以获取系统权限。
### 触发漏洞
为了真正触发该漏洞,我们需要绕过“消息由内核发送”的检查。这是因为如果我们直接将异常消息发送给 launchd,它只会被丢弃。我们需要设法让内核发送一条“恶意”异常消息,其中包含指向系统服务的 Mach 发送权,而不是真实的线程端口和任务端口。
事实证明,有一个 Mach 陷阱(trap)`task_set_special_port`,可用于设置自定义发送权,在某些情况下代替真正的任务端口。其中一种情况是内核代表任务生成异常消息时:内核不会在异常消息中放置真正的任务发送权,而是使用由 `task_set_special_port` 提供的发送权。更具体地说,如果任务调用 `task_set_special_port` 为其 `TASK_KERNEL_PORT` 特殊端口设置自定义值,然后该任务崩溃,内核生成的异常消息的“task”字段将包含指向自定义端口的发送权,而不是真正的任务端口。等效的 API `thread_set_special_port` 可用于在生成的异常消息的“thread”字段中设置自定义端口。
由于这种行为,让内核生成一条“恶意”异常消息(其中包含 Mach 服务端口而非任务端口和线程端口)实际上一点也不难。但是,我们仍然需要确保生成的异常消息能够传递给 launchd。
同样,只要你知道正确的 API,确保内核将“恶意”异常消息传递给 launchd 并不困难。函数 `thread_set_exception_ports` 可以将任意 Mach 发送权设置为该线程异常消息的投递目标端口。因此,我们只需以 bootstrap 端口调用 `thread_set_exception_ports`,之后我们生成的任何异常都会使内核向 launchd 发送异常消息。
拼图的最后一块是获得正确的异常类型。该漏洞只会在 `EXC_CRASH` 异常时被触发。稍加尝试和错误排查即可发现,我们可以通过调用标准 `abort` 函数轻松生成 `EXC_CRASH` 异常。
因此,总而言之,我们可以使用现成的、有详细文档的 API,让内核代表我们生成恶意的 `EXC_CRASH` 异常消息并将其传递给 launchd,从而触发漏洞并释放 Mach 服务端口:
1. 使用 `thread_set_exception_ports` 将 launchd 设置为该线程的异常处理程序。
2. 调用 `bootstrap_look_up` 从 launchd 获取我们要冒充的服务的服务端口。
3. 调用 `task_set_special_port`/`thread_set_special_port`,在异常消息中使用该服务端口代替真正的任务端口和线程端口。
4. 调用 `abort`。内核将向 launchd 发送 `EXC_CRASH` 异常消息,但消息中的任务端口和线程端口将是目标服务端口。
5. launchd 将处理该异常消息并释放服务端口。
### 崩溃后运行代码
上述策略有一个问题:调用 `abort` 会杀死我们的进程。如果我们希望触发漏洞后还能运行任何代码,就需要一种在另一个进程中执行崩溃的方法。
(对于其他异常类型,进程实际上可以从异常中恢复。恢复的方式是将该进程的线程异常处理程序设置为 launchd,并将其任务异常处理程序设置为自身。在 launchd 处理异常但未能成功处理之后,内核会将异常发送给任务处理程序,任务处理程序会重置线程状态并告知内核异常已被处理。然而,进程无法捕获自身的 `EXC_CRASH` 异常,因此我们确实需要两个进程。)
一种策略是先利用 iOS 上另一个进程的漏洞,强制该进程设置其内核端口并崩溃。然而,对于概念验证(proof-of-concept)来说,创建一个 App Extension 更容易。
App Extension 于 iOS 8 中引入,提供了一种将应用的某些功能打包的方式,使其在应用之外也可使用。App Extension 的代码运行在独立的沙盒进程中。这使得启动一个进程变得非常简单:该进程设置其特殊端口,将 launchd 注册为其 `EXC_CRASH` 异常处理程序,然后调用 `abort`。
应用没有受支持的方式可以编程方式启动自己的 App Extension 并与之通信。不过,Ian McDowell 撰写了一篇 [很棒的文章][Multi-Process iOS App Using NSExtension],描述了如何使用私有 `NSExtension` API 启动 App Extension 进程并与之通信。我在这里使用了几乎相同的策略。唯一的区别是,我们需要向 App Extension 进程传递一个 Mach 端口,这涉及在 launchd 中注册一个 App Extension 会连接的虚拟服务(dummy service)。
[Multi-Process iOS App Using NSExtension]: https://ianmcdowell.net/blog/nsextension/
### 防止 launchd 中的端口重用
如果你按上述方式运行该漏洞利用,你会注意到一个挑战:有时你无法重新获得已释放的端口。原因在于内核在空闲列表(freelist)中跟踪进程的空闲 IPC 条目,因此当一个新端口在 IPC 表中分配时,刚释放的端口名会被重用(带有不同的 generation number)。因此,只有当 launchd 没有先将该 IPC 条目槽位用于其他端口时,我们才能重新分配我们想要的端口名。
解决这个问题的方法是将空闲 IPC 条目槽位埋入空闲列表的下方,这样如果 launchd 分配新端口,会优先使用其他槽位。我们该怎么做?我们可以在 launchd 中注册一批虚拟 Mach 服务,这些服务使用我们持有接收权的端口。当我们调用 `abort` 时,异常处理程序会首先触发,然后进程状态(包括 Mach 端口)将被清理。当 launchd 收到 `EXC_CRASH` 异常时,它会无意中释放目标服务端口,并将与该端口名对应的 IPC 条目槽位置于空闲列表的头部。然后,当我们 App Extension 的其余 Mach 端口被销毁时,launchd 会收到通知并释放虚拟服务端口,从而将目标 IPC 条目槽位埋到刚释放端口槽位的后面。因此,只要 launchd 分配的端口数少于我们注册的虚拟服务数,目标槽位就仍会在空闲列表中,也就是说我们仍然可以让 launchd 以原始服务的端口名重新分配该槽位。