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 以原始服务的端口名重新分配该槽位。
这种策略的限制在于,我们需要 `com.apple.security.application-groups` 权限才能在 launchd 中注册服务。还有其他方法可以在 launchd 中存放 Mach 端口,但使用应用程序组无疑是最简单的,并且足以用于此概念验证。
### 冒充被释放的服务
一旦我们生成了崩溃者(crasher)App Extension 并释放了 launchd 中的 Mach 发送权,我们需要重新分配该 Mach 端口名,使其对应我们持有接收权的发送权。这样,launchd 发送到该端口名的任何消息都将由我们接收,并且每当 launchd 将该端口名共享给客户端时,客户端将获得指向我们端口的发送权。特别是,如果我们能够释放 launchd 对某个 Mach 服务的发送权,那么任何向 launchd 请求该服务的进程都将获得指向我们自己的端口的发送权,而不是真正的服务端口。这使我们能够冒充该服务或执行中间人攻击,检查客户端发送给该服务的所有消息。
让已释放的端口名被重用并指向我们拥有的端口也相当简单,考虑到我们已经决定使用 application-groups 权限:只需在 launchd 中注册虚拟 Mach 服务,直到其中一个重用原始端口名。我们需要分批进行,一次注册大量虚拟服务,检查是否有任何一个成功重用了已释放的端口名,然后注销它们。原因是我们需要确保我们的注册能够沿 IPC 端口空闲列表一直回溯,以找回我们想要的那个被埋藏的端口名。
我们可以通过使用 `bootstrap_look_up` 查找原始服务来检查是否成功重用了已释放的端口名:如果它返回的是我们注册的服务端口之一,就说明成功了。
一旦我们成功注册了一个获得与原始服务相同端口名的新服务,任何在 launchd 中查找原始服务的客户端都将被授予指向我们端口的发送权,而不是真正的服务端口。因此,我们实际上正在向系统其余部分冒充原始服务(或者至少是向在我们攻击后查找该服务的那些进程)。
阶段 1:获取 host-priv 端口
---------------------------------------------------------------------------------------------------
一旦我们能够冒充任意系统服务,下一步就是获取 host-priv 端口。这一步很简单,并且不受 iOS 11.3 更改的影响。此攻击的总体思路是冒充 SafetyNet,使 ReportCrash 崩溃,然后从异常消息中随即将消亡的 ReportCrash 任务端口获取 host-priv 端口。
### 关于 ReportCrash 和 SafetyNet
ReportCrash 负责在 iOS 上生成崩溃报告。这一个二进制文件实际上提供 4 种不同的服务(每个服务位于不同的进程中,尽管并非所有服务在任何给定时间都在运行):
1. `com.apple.ReportCrash` 负责为崩溃的进程生成崩溃报告。它是 `EXC_CRASH`、`EXC_GUARD` 和 `EXC_RESOURCE` 异常的主机级异常处理程序。
2. `com.apple.ReportCrash.Jetsam` 处理 Jetsam 报告。
3. `com.apple.ReportCrash.SimulateCrash` 为模拟崩溃创建报告。
4. `com.apple.ReportCrash.SafetyNet` 是 `com.apple.ReportCrash` 服务所注册的异常处理程序。
我们感兴趣的是 `com.apple.ReportCrash` 和 `com.apple.ReportCrash.SafetyNet`,下文简称为 ReportCrash 和 SafetyNet。两者都是基于 MIG 的服务,并且运行着基本相同代码。
当 ReportCrash 启动时,它会在 launchd 中查找 SafetyNet 服务,并将返回的端口设置为任务级异常处理程序。其意图似乎是,如果 ReportCrash 本身崩溃,将由另一个独立进程为它生成崩溃报告。然而,这条代码路径看起来已经失效:ReportCrash 为 `mach_exception_raise` 消息注册了 SafetyNet,尽管 ReportCrash 和 SafetyNet 都只处理 `mach_exception_raise_state_identity` 消息。尽管如此,这两种服务仍然存在,并且可以从 iOS 容器沙盒内访问。
### ReportCrash 操控原语
为了执行以下攻击,我们需要能够操控 ReportCrash(或 SafetyNet)使其按我们想要的方式运行。具体来说,我们需要以下能力:按需启动 ReportCrash、强制 ReportCrash 退出、使 ReportCrash 崩溃,以及确保在我们使用 ReportCrash 时它不会退出。下面我将描述我们如何实现每个目标。
要启动 ReportCrash,我们只需向它发送一条 Mach 消息:launchd 会按需启动它。然而,由于其特殊的设计,除 `mach_exception_raise_state_identity` 之外的任何消息类型都会导致 ReportCrash 停止响应新消息并最终退出。因此,如果我们希望它在之后保持存活,就需要发送 `mach_exception_raise_state_identity` 消息。
要使 ReportCrash 退出,我们只需向它发送任何其他类型的 Mach 消息即可。
有很多方法可以让 ReportCrash 崩溃。最简单的方法可能是发送一条线程端口设置为 `MACH_PORT_NULL` 的 `mach_exception_raise_state_identity` 消息。
最后,我们需要确保在我们使用 ReportCrash 期间它不会退出。它每处理一条 `mach_exception_raise_state_identity` 消息,就会派生另一个线程来监听下一条消息,同时原始线程生成崩溃报告。一旦所有正在生成崩溃报告的未完成线程完成,ReportCrash 就会退出。因此,如果我们能让其中一个正在生成崩溃报告的线程停滞,就可以让它永不退出。
我找到的最简单方法是发送一条在 task 和 thread 字段中包含自定义端口的 `mach_exception_raise_state_identity` 消息。一旦 ReportCrash 尝试生成崩溃报告,它将对“task”端口调用 `task_policy_get`,这会使其向我们发送的端口发送一条 Mach 消息并等待回复。但由于“task”端口只是一个普通的 Mach 端口,我们可以直接不回复该 Mach 消息,ReportCrash 将无限期地等待 `task_policy_get` 返回。
### 从 ReportCrash 提取 host-priv
对于漏洞利用的第一阶段,攻击计划相对直接:
1. 启动 SafetyNet 服务,并强制它在我们攻击期间保持存活。
2. 使用 launchd 服务冒充原语来冒充 SafetyNet。这为我们提供了一个新的端口,我们可以在此端口上接收原本发给真实 SafetyNet 服务的消息。
3. 让任何现有的 ReportCrash 实例退出。这样,我们可以确保 ReportCrash 在下一步中查找我们的 SafetyNet 端口。
4. 启动 ReportCrash。ReportCrash 会在 launchd 中查找 SafetyNet,并将结果端口——也就是我们拥有接收权的伪造 SafetyNet 端口——设置为 `EXC_CRASH` 消息的目的地。
5. 触发 ReportCrash 崩溃。在发现原始异常类型没有已注册的处理程序后,ReportCrash 将进入进程死亡阶段。此时,XNU 会发现 ReportCrash 注册了伪造的 SafetyNet 端口来接收 `EXC_CRASH` 异常,因此它将生成一条异常消息并发送到该端口。
6. 然后我们在伪造的 SafetyNet 端口上监听 `EXC_CRASH` 消息。该消息的类型将是 `mach_exception_raise`,这意味着它将包含 ReportCrash 的任务端口。
7. 最后,我们对 ReportCrash 任务端口使用 `task_get_special_port` 来获取 ReportCrash 的主机端口。由于 ReportCrash 不受沙盒限制并且以 root 身份运行,这就是 host-priv 端口。
在沙盒逃逸的这一阶段结束时,我们会得到一个可用的 host-priv 端口。仅这一点就表明这是一个严重的安全问题。
阶段 2:逃逸沙盒
---------------------------------------------------------------------------------------------------
尽管我们拥有了 host-priv 端口,但我们的目标是完全逃逸沙盒,并以具有 `task_for_pid-allow` 权限的 root 身份运行代码。实现这一目标的第一步就是简单地逃逸沙盒。
严格来说,我们没有必要在逃逸沙盒之前获取 host-priv 端口:这两个步骤相互独立,可以以任意顺序进行。然而,如果此阶段或后续阶段失败,系统会处于不稳定状态,因此最好将其放在后面。
总体攻击思路是再次使用相同的 launchd 漏洞来冒充系统服务。不过,这次我们的目标是冒充一个客户端会在 Mach 消息中发送其任务端口的服务。在 iOS 11.2.6 上通过实验很容易发现,如果我们冒充由 backboardd 托管的 `com.apple.CARenderServer`(以下简称 CARenderServer),然后与 `com.apple.DragUI.druid.source` 通信,不受沙盒限制的 druid 守护进程会将其任务端口通过 Mach 消息发送到伪造的服务端口。
漏洞利用的这一步在 iOS 11.3 上失效了,因为 druid 不再在发给 CARenderServer 的 Mach 消息中发送其任务端口。尽管如此,我仍然相信该漏洞仍然可以用来逃逸沙盒。一种方法是寻找信任其他服务输入的不受沙盒限制的服务。如果没有替换系统服务的能力,这些类型的“漏洞”就永远无法被利用,这意味着它们无论对 Apple 内部还是外部而言,都可能是一个低优先级的攻击面。
### 使 druid 崩溃
就像 ReportCrash 一样,我们需要能够在 druid 已经在运行时强制其重启,以便它在 launchd 中查找我们伪造的 CARenderServer 端口。我决定使用 libxpc 中的一个已经被计划修复的 bug 来实现这一目的。
在查看 libxpc 时,我发现了一个越界读取(out-of-bounds read),可以用来强制任何 XPC 服务崩溃:```C
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 服务将会崩溃——无论是当 strlen 解引用越界内存时,还是当 _xpc_serializer_advance 试图将序列化器推进到所提供数据的末尾之外时。
当我发现这个漏洞时,它已经在 iOS 11.3 Beta 中被修复,因此我没有向 Apple 报告。该漏洞利用代码作为一个独立项目存放在我的 xpc-crash 仓库中。
为了利用这个漏洞使 druid 崩溃,我们只需向 druid 服务发送一条畸形的 XPC 消息,使得字典的键未终止并延伸到消息的最后一个字节。
在 iOS 11.2.6 上,利用我们的服务冒充原语来获取 druid 的任务端口很简单:
一旦我们获得了 druid 的任务端口,我们仍然需要弄清楚如何在 druid 进程内执行代码。
问题在于,XNU 保护平台二进制的任务端口不被非平台二进制修改。该防御机制在函数 task_conversion_eval 中实现,该函数由 convert_port_to_locked_task 和 convert_port_to_task_with_exec_token 调用:```C
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;
}
依赖这些函数的 MIG 转换例程,包括 `convert_port_to_task` 和
`convert_port_to_map`,在我们对 druid 的任务调用它们时会因此失败。例如,
`mach_vm_write` 不会允许我们操纵 druid 的内存。
然而,在查看 XNU 中的 MIG 文件 `osfmk/mach/task.defs` 时,我注意到了一些
有趣的事情:```C
/*
* 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_threads 虽然用于枚举任务中的线程,但实际上接收的是 task_inspect_t 而不是 task_t,这意味着 MIG 会使用 convert_port_to_task_inspect 而非 convert_port_to_task 来转换它。快速查看 convert_port_to_task_inspect 会发现该函数没有执行 task_conversion_eval 检查,也就是说我们可以在平台二进制文件上成功调用它。这之所以有趣,是因为返回的线程并不是 thread_inspect_t 权限,而是完整的 thread_act_t 权限。换句话说,task_threads 将一个不可修改的任务权限提升为可修改的线程权限。而且由于不存在与之对应的 thread_conversion_eval,这意味着即使某个任务是平台二进制文件,我们也可以使用 Mach 线程 API 修改其中的线程。
为了利用这一点,我编写了一个名为 threadexec 的库,它在 Mach 线程 API 之上构建了功能完备的函数调用能力。threadexec 项目本身是一项相当大的工程,但由于它与本 exploit 仅间接相关,这里就不详细解释其内部工作原理了。
一旦我们在 druid 中获得了 host-priv 端口和不受沙盒限制的代码执行能力,完整沙盒逃逸的下一阶段就是安装一个新的主机级异常处理器。鉴于我们目前的能力,这一过程非常直接:
host_get_exception_ports 获取 EXC_BAD_ACCESS 当前的主机级异常处理器。EXC_BAD_ACCESS 的新主机级异常处理器。host_set_exception_ports,将我们的 Mach 端口注册为 EXC_BAD_ACCESS 的主机级异常处理器。此阶段之后,每当进程访问无效内存地址(且没有注册异常处理器)时,一条 EXC_BAD_ACCESS 异常消息就会被发送到我们新的异常处理器端口。这将让我们获得任何崩溃进程的任务端口;又因为 EXC_BAD_ACCESS 是一种可恢复异常,这次我们就能用该任务端口来执行代码。
下一阶段是在 ReportCrash 中触发一个 EXC_BAD_ACCESS 异常,使其任务端口通过异常消息发送到我们新的异常处理器端口:
EXC_BAD_ACCESS 异常。由于 ReportCrash 没有为 EXC_BAD_ACCESS 注册异常处理器(请记住 SafetyNet 注册的是 EXC_CRASH),该异常将被传递到主机级异常处理器。KERN_SUCCESS,向内核表明该异常已处理,ReportCrash 可以恢复运行。到这一步,我们已经在不受沙盒限制、以 root 权限运行且带有 task_for_pid-allow 的进程中获得了代码执行能力。
接下来的两个阶段并非严格必要,但还是应该执行。
一旦我们在 ReportCrash 中获得了代码执行能力,就应该使用 druid 重置 EXC_BAD_ACCESS 的主机级异常处理器:
host_set_exception_ports,重新注册旧的 EXC_BAD_ACCESS 主机级异常处理器。这将使我们的异常处理器端口不再接收其他崩溃进程的异常消息。
最后一步是修复我们为了冒充 launchd 的 IPC 命名空间中的服务端口并释放它们时,对 launchd 造成的破坏:
task_for_pid 获取 launchd 的任务端口。mach_port_insert_right,将真实服务端口以原始名称推入 launchd 的 IPC 空间。完成此步骤后,系统应再次完全正常。成功利用后,应该无需强制重置设备,因为该 exploit 会自行修复所有破坏。
Blanket 还打包了一个后渗透 payload,它绕过 amfid 并生成一个绑定 shell。本节将介绍其实现方式。
即使在 ReportCrash 中获得了代码执行能力,使用这种能力也并不容易:我们只能在进程内进行单独的函数调用,这使得执行复杂任务非常痛苦。理想情况下,我们希望有一种方法能够以 ReportCrash 的权限原生运行代码,要么将代码注入 ReportCrash,要么以相同(或更高)权限生成一个新进程。
Blanket 选择了进程生成这条路。我们利用 task_for_pid 以及我们在 ReportCrash 中的平台二进制文件身份,获取 launchd 的任务端口,并在 launchd 内部创建一个我们可以控制的线程。然后使用该线程调用 posix_spawn 来启动我们的 payload 二进制文件。该 payload 二进制文件可以使用受限的 entitlements 进行签名,包括 task_for_pid-allow,以授予额外的能力。
为了让 iOS 接受我们新生成的二进制文件,我们需要绕过代码签名。多年来人们讨论过各种策略,但目前最常见的策略是为 amfid 注册一个异常处理器,然后进行数据补丁,使 amfid 在尝试调用 MISValidateSignatureAndCopyInfo 时崩溃。这使我们能够伪造该函数的实现,假装代码签名是有效的。
然而,我认为还有一种更健壮、更灵活的方法:我们完全可以不修补 amfid,只需在内核中注册一个新的 amfid 端口。
内核通过一个名为 HOST_AMFID_PORT 的主机特殊端口来跟踪应将消息发送到哪个 amfid 端口。如果我们拥有不受沙盒限制的 root 代码执行能力,就可以将该端口设置为新值。Apple 通过检查验证请求的回复是否真的来自 amfid 来防御这种攻击:发送方的 cdhash 会与 amfid 的 cdhash 进行比较。然而,这实际上并不能阻止消息被发送到 amfid 以外的进程;它只能阻止回复来自非 amfid 进程。如果我们建立一个三角关系:内核将消息发送给我们,我们生成回复并把它传给 amfid,然后 amfid 将回复发送给内核,那么我们就能够绕过发送方检查。
这种方法有许多优势,其中最大的优势大概是可以访问 verify_code_directory 服务例程中的额外标志。尽管 amfid 并未全部使用它们,但 amfid 还可以设置许多其他输出标志来控制代码签名的行为。下面是 verify_code_directory 的部分原型:```C
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);
对于越狱开发者来说,特别值得关注的是 `is_apple` 参数。这个参数似乎并不会被 amfid 使用,但一旦设置,它将导致内核设置 `CS_PLATFORM_BINARY` 代码签名标志,从而授予应用程序平台二进制特权。特别地,这意味着应用程序现在可以使用任务端口(task ports)直接修改平台二进制文件。
本次攻击利用的漏洞
---------------------------------------------------------------------------------------------------
这次攻击利用了若干本身并非安全漏洞、但会削弱各种漏洞缓解措施有效性的漏洞。并非所有这些漏洞都需要一并修补,因为其中一些是部分冗余的,但无论如何都值得全部列出。
在内核中:
1. `task_threads` 可以将只读的 `task_inspect_t` 提升为具备修改能力的 `thread_act_t`。
2. 没有 `thread_conversion_eval` 来为线程执行 `task_conversion_eval` 的角色。
3. 非平台二进制文件可以使用平台二进制的 `task_inspect_t` 权限。
4. 未沙盒化进程的异常消息可能会被传递到沙盒化进程,即使这提供了一种逃逸沙盒的途径。目前尚不清楚是否有干净的修复方案来解决这个漏洞。
5. 未沙盒化的代码执行、host-priv 端口以及使 `task_for_pid-allow` 进程崩溃的能力可以组合起来构建一个绕过 `task_for_pid` 的方案。(该方案是:调用 `host_set_exception_ports` 设置一个新的主机级异常处理程序,然后使 `task_for_pid-allow` 进程崩溃,以接收其任务端口并利用该授权执行代码。)
在应用扩展中:
1. 共享应用组(application group)的应用扩展之间可以通过 Mach 消息进行通信,尽管文档暗示主机应用与应用扩展之间不应存在通信的可能。
建议的修复和缓解措施
---------------------------------------------------------------------------------------------------
我建议以下修复,大致按重要性排序:
1. 仅在返回 `KERN_SUCCESS` 时,在 launchd 服务例程中释放 Mach 端口。这将修复 Mach 端口替换漏洞。
2. 堵住 `task_threads` 这个漏洞,防止非平台二进制文件利用平台二进制的任务端口实现代码执行。
3. 修复 ReportCrash 中的崩溃问题。
4. 应最小化容器沙盒内可访问的 Mach 服务集合。在我看来,大多数 iOS 应用没有正当理由与 ReportCrash 或 SafetyNet 通信。
5. 应尽可能多地让进程运行在沙盒中。我不确定 druid 是否必须取消沙盒才能正常运行,但如果不是,则应将其放入合适的沙盒中。
6. 应消除死代码。SafetyNet 似乎并未执行其预期的功能。如果它不再需要,或许应该将其移除。
7. 关闭基于 `host_set_exception_ports` 的 `task_for_pid` 绕过方案。例如,考虑是否值得将 `host_set_exception_ports` 限制为仅限 root 使用,或是在某些配置下限制 host-priv 端口的可用性。这违反了 Mach 优雅的基于能力的设计,但 `host_set_exception_ports` 可能是一个很有吸引力的滥用目标。
8. 考虑是否值得为 `task_inspect_t` 添加 `task_conversion_eval`。
运行 blanket
---------------------------------------------------------------------------------------------------
Blanket 应适用于任何运行 iOS 11.2.6 的设备。
1. 下载项目: ```
git clone https://github.com/bazad/blanket
cd blanket
headers/config.h,将 APP_GROUP 改为你之前指定的应用程序组标识符。之后,你应该就能在设备上构建并运行该项目了。
如果 blanket 运行成功,它会执行 payload 二进制文件(源码位于
blanket_payload/blanket_payload.c),默认情况下该文件会在 4242 端口启动一个绑定 shell(bind shell)。你可以使用 netcat 连接到该端口并运行任意 shell 命令。
非常感谢 Ian Beer 和 Jonathan Levin 在 iOS 安全与内部机制研究方面所做的出色工作。
我于 2018 年 1 月发现了此漏洞,并于 2 月下旬开始开发该漏洞利用程序。我在 4 月 13 日将此问题报告给了 Apple。Apple 将 launchd 中 Mach 端口替换漏洞编号为 CVE-2018-4280,并于 7 月 9 日在 iOS 11.4.1 和 macOS 10.13.6 中对其进行了修补。
Blanket 以 MIT 许可证发布。
Brandon Azad