hacktricks/macos-hardening/macos-security-and-privileg.../macos-proces-abuse/macos-ipc-inter-process-com.../macos-thread-injection-via-...

12 KiB
Raw Permalink Blame History

macOS Thread Injection via Task port

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥

This post was copied from https://bazad.github.io/2018/10/bypassing-platform-binary-task-threads/ (which contains more information)

Code

1. Thread Hijacking

The first thing we do is call task_threads() on the task port to get a list of threads in the remote task and then choose one of them to hijack. Unlike traditional code injection frameworks, we cant create a new remote thread because thread_create_running() will be blocked by the new mitigation.

Then, we can call thread_suspend() to stop the thread from running.

At this point, the only useful control we have over the remote thread is stopping it, starting it, getting its register values, and setting its register values. Thus, we can initiate a remote function call by setting registers x0 through x7 in the remote thread to the arguments, setting pc to the function we want to execute, and starting the thread. At this point, we need to detect the return and make sure that the thread doesnt crash.

There are a few ways to go about this. One way would be to register an exception handler for the remote thread using thread_set_exception_ports() and to set the return address register, lr, to an invalid address before calling the function; that way, after the function runs an exception would be generated and a message would be sent to our exception port, at which point we can inspect the threads state to retrieve the return value. However, for simplicity I copied the strategy used in Ian Beers triple_fetch exploit, which was to set lr to the address of an instruction that would infinite loop and then poll the threads registers repeatedly until pc pointed to that instruction.

2. Mach ports for communication

The next step is to create Mach ports over which we can communicate with the remote thread. These Mach ports will be useful later in helping transfer arbitrary send and receive rights between the tasks.

In order to establish bidirectional communication, we will need to create two Mach receive rights: one in the local task and one in the remote task. Then, we will need to transfer a send right to each port to the other task. This will give each task a way to send a message that can be received by the other.

Lets first focus on setting up the local port, that is, the port to which the local task holds the receive right. We can create the Mach port just like any other, by calling mach_port_allocate(). The trick is to get a send right to that port into the remote task.

A convenient trick we can use to copy a send right from the current task into a remote task using only a basic execute primitive is to stash a **send right to our local port in the remote thread**s THREAD_KERNEL_PORT special port using thread_set_special_port(); then, we can make the remote thread call mach_thread_self() to retrieve the send right.

Next we will set up the remote port, which is pretty much the inverse of what we just did. We can make the remote thread allocate a Mach port by calling mach_reply_port(); we cant use mach_port_allocate() because the latter returns the allocated port name in memory and we dont yet have a read primitive. Once we have a port, we can create a send right by calling mach_port_insert_right() in the remote thread. Then, we can stash the port in the kernel by calling thread_set_special_port(). Finally, back in the local task, we can retrieve the port by calling thread_get_special_port() on the remote thread, giving us a send right to the Mach port just allocated in the remote task.

At this point, we have created the Mach ports we will use for bidirectional communication.

3. Basic memory read/write

Now we will use the execute primitive to create basic memory read and write primitives. These primives wont be used for much (we will soon upgrade to much more powerful primitives), but they are a key step in helping us to expand our control of the remote process.

In order to read and write memory using our execute primitive, we will be looking for functions like these:

uint64_t read_func(uint64_t *address) {
    return *address;
}
void write_func(uint64_t *address, uint64_t value) {
    *address = value;
}

They might correspond to the following assembly:

_read_func:
    ldr     x0, [x0]
    ret
_write_func:
    str     x1, [x0]
    ret

A quick scan of some common libraries revealed some good candidates. To read memory, we can use the property_getName() function from the Objective-C runtime library:

const char *property_getName(objc_property_t prop)
{
    return prop->name;
}

As it turns out, prop is the first field of objc_property_t, so this corresponds directly to the hypothetical read_func above. We just need to perform a remote function call with the first argument being the address we want to read, and the return value will be the data at that address.

Finding a pre-made function to write memory is slightly harder, but there are still great options without undesired side effects. In libxpc, the _xpc_int64_set_value() function has the following disassembly:

__xpc_int64_set_value:
    str     x1, [x0, #0x18]
    ret

Thus, to perform a 64-bit write at address address, we can perform the remote call:

_xpc_int64_set_value(address - 0x18, value)

With these primitives in hand, we are ready to create shared memory.

4. Shared memory

Our next step is to create shared memory between the remote and local task. This will allow us to more easily transfer data between the processes: with a shared memory region, arbitrary memory read and write is as simple as a remote call to memcpy(). Additionally, having a shared memory region will allow us to easily set up a stack so that we can call functions with more than 8 arguments.

To make things easier, we can reuse the shared memory features of libxpc. Libxpc provides an XPC object type, OS_xpc_shmem, which allows establishing shared memory regions over XPC. By reversing libxpc, we determine that OS_xpc_shmem is based on Mach memory entries, which are Mach ports that represent a region of virtual memory. And since we already have shown how to send Mach ports to the remote task, we can use this to easily set up our own shared memory.

First things first, we need to allocate the memory we will share using mach_vm_allocate(). We need to use mach_vm_allocate() so that we can use xpc_shmem_create() to create an OS_xpc_shmem object for the region. xpc_shmem_create() will take care of creating the Mach memory entry for us and will store the Mach send right to the memory entry in the opaque OS_xpc_shmem object at offset 0x18.

Once we have the memory entry port, we will create an OS_xpc_shmem object in the remote process representing the same memory region, allowing us to call xpc_shmem_map() to establish the shared memory mapping. First, we perform a remote call to malloc() to allocate memory for the OS_xpc_shmem and use our basic write primitive to copy in the contents of the local OS_xpc_shmem object. Unfortunately, the resulting object isnt quite correct: its Mach memory entry field at offset 0x18 contains the local tasks name for the memory entry, not the remote tasks name. To fix this, we use the thread_set_special_port() trick to insert a send right to the Mach memory entry into the remote task and then overwrite field 0x18 with the remote memory entrys name. At this point, the remote OS_xpc_shmem object is valid and the memory mapping can be established with a remote call to xpc_shmem_remote().

5. Full control

With shared memory at a known address and an arbitrary execution primitive, we are basically done. Arbitrary memory reads and writes are implemented by calling memcpy() to and from the shared region, respectively. Function calls with more than 8 arguments are performed by laying out additional arguments beyond the first 8 on the stack according to the calling convention. Transferring arbitrary Mach ports between the tasks can be done by sending Mach messages over the ports established earlier. We can even transfer file descriptors between the processes by using fileports (special thanks to Ian Beer for demonstrating this technique in triple_fetch!).

In short, we now have full and easy control over the victim process. You can see the full implementation and the exposed API in the threadexec library.

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥