/voucher_swap

Obtains the kernel task port and establishes a kernel function calling primitive on the iPhone XS, iPhone XR, and iPhone 8 running iOS 12.1.2

Primary LanguageC

voucher_swap - Exploit for P0 issue 1731 on iOS 12.1.2 Brandon Azad

---- Issue 1731: CVE-2019-6225 --------------------------------------------------------------------

iOS/macOS: task_swap_mach_voucher() does not respect MIG semantics leading to use-after-free

Consider the MIG routine task_swap_mach_voucher():

routine task_swap_mach_voucher(
		task		: task_t;
		new_voucher	: ipc_voucher_t;
	inout	old_voucher	: ipc_voucher_t);

Here's the (placeholder) implementation:

kern_return_t
task_swap_mach_voucher(
	task_t			task,
	ipc_voucher_t		new_voucher,
	ipc_voucher_t		*in_out_old_voucher)
{
	if (TASK_NULL == task)
		return KERN_INVALID_TASK;

	*in_out_old_voucher = new_voucher;
	return KERN_SUCCESS;
}

The correctness of this implementation depends on exactly how MIG ownership semantics are defined for each of these parameters.

When dealing with Mach ports and out-of-line memory, ownership follows the traditional rules (the ones violated by the bugs above):

  1. All Mach ports (except the first) passed as input parameters are owned by the service routine if and only if the service routine returns success. If the service routine returns failure then MIG will deallocate the ports.

  2. All out-of-line memory regions passed as input parameters are owned by the service routine if and only if the service routine returns success. If the service routine returns failure then MIG will deallocate all out-of-line memory.

But this is only part of the picture. There are more rules for other types of objects:

  1. All objects with defined MIG translations that are passed as input-only parameters are borrowed by the service routine. For reference-counted objects, this means that the service routine is not given a reference, and hence a reference must be added if the service routine intends to keep the object around.

  2. All objects with defined MIG translations that are returned in output parameters must be owned by the output parameter. For reference-counted objects, this means that output parameters consume a reference on the object.

And most unintuitive of all:

  1. All objects with defined MIG translations that are passed as input in input-output parameters are owned (not borrowed!) by the service routine. This means that the service routine must consume the input object's reference.

Having defined MIG translations means that there is an automatic conversion defined between the object type and its Mach port representation. A task port is one example of such a type: you can convert a task port to the underlying task object using convert_port_to_task(), and you can convert a task to its corresponding port using convert_task_to_port().

Getting back to Mach vouchers, this is the MIG definition of ipc_voucher_t:

type ipc_voucher_t = mach_port_t
		intran: ipc_voucher_t convert_port_to_voucher(mach_port_t)
		outtran: mach_port_t convert_voucher_to_port(ipc_voucher_t)
		destructor: ipc_voucher_release(ipc_voucher_t)
	        ;

This definition means that MIG will automatically convert the voucher port input parameters to ipc_voucher_t objects using convert_port_to_voucher(), convert the ipc_voucher_t output parameters into ports using convert_voucher_to_port(), and discard any extra references using ipc_voucher_release(). Note that convert_port_to_voucher() produces a voucher reference without consuming a port reference, while convert_voucher_to_port() consumes a voucher reference and produces a port reference.

To confirm our understanding of the MIG semantics outlined above, we can look at the function _Xtask_swap_mach_voucher(), which is generated by MIG during the build process:

mig_internal novalue _Xtask_swap_mach_voucher
	(mach_msg_header_t *InHeadP, mach_msg_header_t *OutHeadP)
{
...
	kern_return_t RetCode;
	task_t task;
	ipc_voucher_t new_voucher;
	ipc_voucher_t old_voucher;
...
	task = convert_port_to_task(In0P->Head.msgh_request_port);

	new_voucher = convert_port_to_voucher(In0P->new_voucher.name);

	old_voucher = convert_port_to_voucher(In0P->old_voucher.name);

	RetCode = task_swap_mach_voucher(task, new_voucher, &old_voucher);

	ipc_voucher_release(new_voucher);

	task_deallocate(task);

	if (RetCode != KERN_SUCCESS) {
		MIG_RETURN_ERROR(OutP, RetCode);
	}
...
	if (IP_VALID((ipc_port_t)In0P->old_voucher.name))
		ipc_port_release_send((ipc_port_t)In0P->old_voucher.name);

	if (IP_VALID((ipc_port_t)In0P->new_voucher.name))
		ipc_port_release_send((ipc_port_t)In0P->new_voucher.name);
...
	OutP->old_voucher.name = (mach_port_t)convert_voucher_to_port(old_voucher);

	OutP->Head.msgh_bits |= MACH_MSGH_BITS_COMPLEX;
	OutP->Head.msgh_size = (mach_msg_size_t)(sizeof(Reply));
	OutP->msgh_body.msgh_descriptor_count = 1;
}

Tracing where each of the references are going, we can deduce that:

  1. The new_voucher parameter is deallocated with ipc_voucher_release() after invoking the service routine, so it is not owned by task_swap_mach_voucher(). In other words, task_swap_mach_voucher() is not given a reference on new_voucher.

  2. The old_voucher parameter has a reference on it before it gets overwritten by task_swap_mach_voucher(), which means task_swap_mach_voucher() is being given a reference on the input value of old_voucher.

  3. The value returned by task_swap_mach_voucher() in old_voucher is passed to convert_voucher_to_port(), which consumes a reference on the voucher. Thus, task_swap_mach_voucher() is giving _Xtask_swap_mach_voucher() a reference on the output value of old_voucher.

Finally, looking back at the implementation of task_swap_mach_voucher(), we can see that none of these rules are being followed:

kern_return_t
task_swap_mach_voucher(
	task_t			task,
	ipc_voucher_t		new_voucher,
	ipc_voucher_t		*in_out_old_voucher)
{
	if (TASK_NULL == task)
		return KERN_INVALID_TASK;

	*in_out_old_voucher = new_voucher;
	return KERN_SUCCESS;
}

This results in two separate reference counting issues:

  1. By overwriting the value of in_out_old_voucher without first releasing the reference, we are leaking a reference on the input value of old_voucher.

  2. By assigning the value of new_voucher to in_out_old_voucher without adding a reference, we are consuming a reference we don't own, leading to an over-release of new_voucher.

---- Exploit flow ---------------------------------------------------------------------------------

First we allocate a bunch of pipes so that we can spray pipe buffers later.

Then we spray enough Mach ports to fill the ipc.ports zone and cause it to grow and allocate fresh pages from the zone map; 8000 ports is usually sufficient. That way, when we allocate our pipe buffers, there's a high chance the pipe buffers lie directly after the ports in kernel memory. The last port that we allocate is the base port.

Next we write a 16383-byte pattern to our pipe buffers, causing them to allocate from kalloc.16384. XNU limits the global amount of pipe buffer memory to 16 MB, but this is more than sufficient to fill kalloc.16384 and get some pipe buffers allocated after our base port in kernel memory.

We fill the pipes with fake Mach ports. For each pipe buffer we fill, we set the fake ports' ip_kotype bits to specify which pair of pipe file descriptors corresponds to this pipe buffer.

Now that we've allocated some pipe buffers directly after the base port, we set up state for triggering the vulnerability. We spray several pages of Mach vouchers, and choose one near the end to be the target for use-after-free. We want the target voucher to lie on a page containing only sprayed vouchers, so that later we can free all the vouchers on that page and make the page available for zone garbage collection.

Then we spray 15% of physical memory size with allocations from kalloc.1024. We'll free this memory later to ensure that there are lots of free pages to encourage zone garbage collection.

Next we stash a pointer to the target voucher in our thread's ith_voucher field using thread_set_mach_voucher(), and then remove the added voucher reference using the task_swap_mach_voucher() vulnerability. This means that even though ith_voucher still points to the target voucher, there's only one reference on it, so just like the rest of the vouchers it'll be freed once we destroy all the voucher ports in userspace.

At this point we free the kalloc.1024 allocations, destroy the voucher ports to free all the vouchers, and start slowly filling kernel memory with out-of-line ports allocations to try and trigger a zone gc and get the page containing our freed target voucher (which ith_voucher still points to) reallocated with out-of-line ports. In my experiments, spraying 17% of physical memory size is sufficient.

We'll try and reallocate the page containing the freed voucher with a pattern of out-of-line Mach ports that overwrites certain fields of the voucher. Specifically, we overwrite the voucher's iv_port field, which specifies the Mach port that exposes this voucher to userspace, with NULL and overwrite the iv_refs field, which is the voucher's reference count, with the lower 32 bits of a pointer to the base port.

Overwriting iv_refs with the lower 32 bits of a pointer to the base port will ensure that the reference count is valid so long as the base port's address is small enough. This is necessary for us to call thread_get_mach_voucher() later without triggering a panic. Additionally, the pointer to the base port plays double-duty since we'll later use the task_swap_mach_voucher() vulnerability again to increment iv_refs and change what was a pointer to the base port so that it points into our pipe buffers instead.

Once we've reallocated the voucher with our out-of-line ports spray, we call thread_get_mach_voucher(). This interprets ith_voucher, which points into the middle of our out-of-line ports spray, as a Mach voucher, and since iv_port is NULL, a new Mach voucher port is allocated to represent the freed voucher. Then thread_get_mach_voucher() returns the voucher port back to us in userspace, allowing us to continue manipulating the freed voucher while it still overlaps the out-of-line ports array.

Next we increment the voucher's iv_refs field using task_swap_mach_voucher(), which modifies the out-of-line pointer to the base port overlapping iv_refs so that it now points into the pipe buffers.

And since we guaranteed that every possible fake port inside the pipe buffers looks valid, we can now safely receive the messages containing the out-of-line ports spray to recover a send right to a fake ipc_port overlapping our pipe buffers.

Our next step is to determine which pair of pipe file descriptors corresponds to the pipe buffer. Since we set each possible fake port's ip_kotype bits earlier while spraying pipe buffers, we can use mach_port_kobject() to retrieve the fake port's ip_kotype and determine the overlapping pipe. And at this point, we can now inspect and modify our fake port by reading and writing the pipe's contents.

We can now discard all the filler ports and pipes we allocated earlier, since they're no longer needed.

Our next step is to build a kernel memory read primitive. Although we have a send right to an ipc_port overlapping our pipe buffer, we don't actually know the address of our pipe buffer in kernel memory. And if we want to use the pid_for_task() trick to read memory, we'll need to build a fake task struct at a known address so that we can make our fake port's ip_kobject field point to it. So our next goal should be to find the address of our pipe buffer.

Unfortunately, unlike prior exploits that have produced a dangling port, we only have a send right to our fake port, not a receive right. This means we have few options for modifying the port's state in such a way that it stores a pointer inside the ipc_port struct that allows us to determine its address.

One thing we can do is call mach_port_request_notification() to generate a request that a dead name notification for the fake port be delivered to the base port. This will cause the kernel to allocate an array in the fake port's ip_requests field and store a pointer to the base port inside that array. Thus, we only need a single 8-byte read to get the address of the base port, and since the base port is at a fixed offset from the fake port (determined by how many times we incremented the freed voucher's iv_refs field), we can use the address of the base port to calculate the address of our pipe buffer.

Of course, that means that in order to build our arbitrary read primitive, we need ... another arbitrary read primitive. So why is this helpful? Because our first read primitive will leak memory every time we use it while the second one will not.

The problem we need to resolve in order to use pid_for_task() to read kernel memory is that we need to get a fake task struct whose bsd_info field points to the address we want to read at a known address in kernel memory. One way to do that is to simply send a Mach message containing our fake task struct to the fake port, and then read out the port's ip_messages.imq_messages field via the pipe to get the address of the ipc_kmsg struct containing the message. Then we can compute the address of the fake task inside the ipc_kmsg and rewrite the fake port to be a task port pointing to the fake task, allowing us to call pid_for_task() to read 4 bytes of kernel memory.

Using this technique, we can read the value of the base port pointer in the ip_requests array and then compute the address of the fake port and the containing pipe buffer. And once we know the address of the pipe buffer, we can create the fake task by writing to our pipe to avoid leaking memory on each read.

Now that we have a stable kernel read primitive, we can find the address of the host port and read out the host port's ip_receiver field to get the address of the kernel's ipc_space.

I then borrow Ian's technique of iterating through all the ipc_port elements in the host port's zalloc block looking for the kernel task port. Once we find the kernel task port, we can read the ip_kobject field to get the kernel task, and reading the task's map field gives us the kernel's vm_map.

At this point we have everything we need to build a fake kernel task inside our pipe buffer, giving us the ability to read and write kernel memory using mach_vm_read() and mach_vm_write().

The next step is to build a permanent fake kernel task port. We allocate some kernel memory with mach_vm_allocate() and then write a new fake kernel task into that allocation. We then modify the fake port's ipc_entry in our task so that it points to the new fake kernel task, which allows us to clean up the remaining resources safely.

We remove the extra reference on the base port, destroy the voucher port allocated by the call to thread_get_mach_voucher() on the freed voucher, deallocate the ip_requests array, and destroy the leaked ipc_kmsg structs used during our first kernel read primitive.

This leaves us with a stable system and a fake kernel task port with which we can read and write kernel memory.

---- Kernel function calling / PAC bypass ---------------------------------------------------------

In order to call kernel functions I use the iokit_user_client_trap() technique. This works without modification on non-PAC devices, but on PAC-enabled devices like the iPhone XS we need to do a little extra work.

First we get a handle to an IOAudio2DeviceUserClient. Since the container sandbox usually prevents us from accessing this class, we briefly replace our proc's credentials with the kernel proc's credentials to bypass the sandbox check.

Once we have an IOAudio2DeviceUserClient, we read the value of the user client's trap field, which points to a heap-allocated IOExternalTrap object. Then, to call an arbitrary kernel function, we simply overwrite the trap to point to the target function and then call IOConnectTrap6() from userspace.

This technique has several limitations at this stage: we only control the values of registers X1 - X6, the return value gets truncated to 32 bits, and the function pointer that we call must already have a valid PACIZA signature (that is, a PAC signature using the A-instruction key with context 0). Thus, we'll need to find a way to generate PACIZA signatures on arbitrary functions.

As it turns out, one way to do this is to call the module destructor for the com.apple.nke.lttp kext. There is already a PACIZA'd pointer to the function l2tp_domain_module_stop() in kernel memory, so we already have the ability to call it. And as the final step in tearing down the module, l2tp_domain_module_stop() calls sysctl_unregister_oid() on the sysctl__net_ppp_l2tp global sysctl_oid struct, which resides in writable memory. And on PAC-enabled systems, sysctl_unregister_oid() executes the following instruction sequence on the sysctl_oid struct:

LDR             X10, [X9,#0x30]!                ;; X10 = old_oidp->oid_handler
CBNZ            X19, loc_FFFFFFF007EBD330
CBZ             X10, loc_FFFFFFF007EBD330
MOV             X19, #0
MOV             X11, X9                         ;; X11 = &oid_handler
MOVK            X11, #0x14EF,LSL#48             ;; X11 = 14EF`&oid_handler
AUTIA           X10, X11                        ;; X10 = AUTIA(oid_handler, 14EF`&handler)
PACIZA          X10                             ;; X10 = PACIZA(X10)
STR             X10, [X9]                       ;; old_oidp->oid_handler = X10

That means that the field sysctl__net_ppp_l2tp->oid_handler will be replaced with the value PACIZA(AUTIA(sysctl__net_ppp_l2tp->oid_handler, )).

Clearly we can't forge PACIA signatures at this point, so AUTIA will fail and produce an invalid pointer value. This isn't NULL or some constant sentinel, but rather is the XPAC'd value with two of the pointer extension bits replaced with an error code to make the resulting pointer invalid. And this is interesting because when PACIZA is used to sign a pointer with invalid extension bits, what actually happens is that first the corrected pointer is signed and then one bit of the PAC signature is flipped, rendering it invalid.

What this means for us is that even though sysctl__net_ppp_l2tp->oid_handler was not originally signed, this gadget overwrites the field with a value that is only one bit different from a valid PACIZA signature, allowing us to compute the true PACIZA signature. And if we use this gadget to sign a pointer to a JOP gadget like "mov x0, x4 ; br x5", then we can execute any kernel function we want with up to 4 arguments.

We then use the signed "mov x0, x4 ; br x5" gadget to build a PACIA-signing primitive. There are a small number of possible PACIA gadgets, of which we use one that starts:

PACIA           X9, X10
STR             X9, [X2,#0xF0]

In order to use this gadget, we execute the following JOP program:

X1 = &"MOV X10, X3 ; BR X6"
X2 = KERNEL_BUFFER
X3 = CONTEXT
X4 = POINTER
X5 = &"MOV X9, X0 ; BR X1"
X6 = &"PACIA X9, X10 ; STR X9, [X2,#0xF0]"
PC = PACIA("MOV X0, X4 ; BR X5")

MOV             X0, X4
BR              X5

MOV             X9, X0
BR              X1

MOV             X10, X3
BR              X6

PACIA           X9, X10
STR             X9, [X2,#0xF0]

This leaves us with the PACIA'd pointer in kernel memory, which we can read back using our read primitive. Thus, we can now perform arbitrary PACIA forgeries. And using a similar technique with a PACDA gadget, we can produce PACDA forgeries.

All that's left is to get control over X0 when doing a function call. We read in the IOAudio2DeviceUserClient's vtable and use our forgery gadgets to replace IOAudio2DeviceUserClient::getTargetAndTrapForIndex() with IOUserClient::getTargetAndTrapForIndex() and replace IOUserClient::getExternalTrapForIndex() with IORegistryEntry::getRegistryEntryID(). Then we overwrite the user client's registry entry ID field with a pointer to the IOExternalTrap. Finally we write the patched vtable into allocated kernel memory and replace the user client's vtable pointer with a forged pointer to our fake vtable.

And at this point we now have the ability to call arbitrary kernel functions with up to 7 arguments using the iokit_user_client_trap() technique, just like on non-PAC devices.

---- Running the exploit --------------------------------------------------------------------------

For best results, reboot the device and wait a few seconds before running the exploit. I've seen reliability above 99.5% on my devices after a fresh boot (the completed exploit has never failed for me).

Running the exploit twice without rebooting will almost certainly panic, since it will mess up the heap groom and possibly result in base port having a too-large address.

After getting kernel read/write and setting up kernel function calling, the exploit will trigger a panic by calling an invalid address with special values in registers X0 - X6 to demonstrate that function calling is successful.

---- Platforms ------------------------------------------------------------------------------------

I've tested on an iPhone 8, iPhone XR, and iPhone XS running iOS 12.1.2. You can add support for other devices in the files voucher_swap/parameters.c and voucher_swap/kernel_call/kc_parameters.c. The exploit currently assumes a 16K kernel page size, although it should be possible to remove this requirement. The PAC bypass also relies on certain gadgets which may be different on other versions or devices.

This vulnerability was fixed in iOS 12.1.3, released January 22, 2019: https://support.apple.com/en-us/HT209443

---- Other exploits -------------------------------------------------------------------------------

This bug was independently discovered and exploited by Qixun Zhao (@S0rryMybad) as part of a remote jailbreak. He developed a clever exploit strategy that reallocates the voucher with OSStrings; you can read about it here: http://blogs.360.cn/post/IPC%20Voucher%20UaF%20Remote%20Jailbreak%20Stage%202%20(EN).html