System Internals

XNU Under the Hood

The signed kernelcache from the last post is now running. This is XNU taken apart from an attacker's view: a Mach and BSD hybrid, the port-as-capability model the whole iOS security stack rests on, the ipc_space table that holds a task's authority, and what tfp0 (a handle to the kernel task) actually is and why every kernel exploit climbs toward it.

The previous post left an iPhone in a very specific state. The boot chain has verified and launched a kernelcache, and XNU is now running as the most privileged code on the application processor. That kernelcache is XNU and every kext the device needs, prelinked into one image and loaded by iBoot. This post takes that kernel apart.

The organizing question is simple. Everything a process does to the kernel, and everything an exploit does to escalate, converges on one capability: a handle, held in userland, that reads and writes kernel memory. In iOS folklore that handle is called tfp0. This article is the map of the concepts you cross to understand what tfp0 actually is, and why it is the objective every kernel exploit climbs toward.

To get there we need three things: what kind of kernel XNU is, how it represents authority, and how that representation produces (or denies) kernel read/write.

Everything here is public: Apple’s open-source XNU, the Apple Platform Security documentation, and published research from Project Zero and others. It contains no exploit, private detail, or 0day.

Not a microkernel, not a monolith

XNU is a hybrid, and the word is doing real work. Its core is Mach, which owns inter-process communication, virtual memory, and scheduling. Bolted onto that is a BSD personality, which owns the POSIX surface: processes, the syscall table, the filesystem layer, sockets, and credentials. IOKit, the C++ driver runtime covered in a later post, and libkern round it out. All of it is linked into a single image, the kernelcache, running in one privileged address space at EL1, the kernel’s privilege level.

The hybrid choice is a performance decision. BSD and IOKit could have been separate Mach servers reached by message passing, the way a true microkernel would arrange them. Instead they are co-located in kernel space and call each other by direct function call.

That decision has a consequence an attacker cares about. There is no internal privilege boundary between these subsystems below the guarded-monitor line that Apple added later: the Page Protection Layer (PPL), then the Secure Page Table Monitor (SPTM), both discussed near the end. A memory-safety bug in a niche IOKit driver or a BSD socket option corrupts the same heap that holds ipc_port objects and page tables, so a BSD bug regularly ends up as a Mach primitive.

The cleanest way to hold the hybrid in your head is to notice that many concepts have a Mach half and a BSD half of the same underlying thing:

Concept Mach side BSD side
The process task (address space, ports, threads) proc (pid, credentials, file descriptors)
The thread of execution thread (the schedulable entity) uthread (syscall state)
The syscall table mach_trap_table (negative numbers) sysent (positive numbers)
The handle to a kernel object Mach port: mach_port_name_t into ipc_space file descriptor: int into p_fd
IPC Mach message (mach_msg) sockets, pipes, signals
Virtual memory vm_map, vm_object, pmap (Mach owns it) mmap, mprotect (BSD uses it)
The security model capabilities (you can do what you hold) identity (kauth_cred_t: uid, MACF label)

A process is both a task and a proc, linked by a back-pointer, and a syscall from EL0 lands in one of two dispatch tables depending on the sign of the syscall number. Two rows are worth reading carefully, because they answer a question people new to Mach always ask.

A Mach port is the exact analogue of a file descriptor. Both are a small integer, valid only inside one process, that indexes a per-process table in the kernel and names a kernel object you never touch directly. You read and write a file through an int; you talk to a service, a task, or a driver through a mach_port_name_t. And like a file descriptor, a port is transferable: you hand a port to another process inside a Mach message, the same way you hand a file descriptor to another process with SCM_RIGHTS over a Unix socket.

A Mach message is the analogue of writing to a socket or pipe, with one difference. A Mach message can carry port rights and out-of-line memory, not just bytes. Sending a message can transfer a capability. That is why the rest of iOS security is built on Mach IPC and not on the BSD socket layer.

The Mach half

Tasks and threads

A task is a container, not a schedulable thing. It owns an address space (vm_map), a port namespace (ipc_space), a set of threads, and a handful of special ports it starts life with. A thread is the schedulable entity inside a task: it carries register state and its own control port. Both live in dedicated kernel zones and, on modern hardware, are increasingly sequestered and pointer-signed.

For an attacker the task structure matters because it is the thing you eventually want to forge. A fake task you control, referenced by a port the kernel believes is a task control port, is a fake kernel task port. Hold that thought; it is where this section is going.

Ports: the unit of authority

This is the center of the whole article, so it is worth going slowly.

What a port is. A Mach port is an object inside the kernel, a struct ipc_port. At heart it is a message queue with an ownership and rights model attached. Userland never sees the object or its address. It sees only a name, a mach_port_name_t, which is a small integer valid inside the naming task. The port is the Mach analogue of the file descriptor from the table above, and everything a task is allowed to do it does by holding the right kind of port.

How rights work. The authority is not the port, it is the right you hold to it. There are five kinds:

The whole capability model is one sentence: the right you hold decides what you are allowed to do. Holding a send right to a service’s port means you can talk to that service, nothing more. Holding the receive right means you are that service.

How a name becomes an object. When you make a Mach call with a port name, the kernel translates that name to an ipc_port through your task’s ipc_space, described in the next subsection. The important part here is that the translation is checked: the right type has to match, and a generation number has to match, so a stale name fails safely rather than pointing at whatever object reused the slot.

Kobject ports: the bridge from IPC to the rest of the kernel. Most ports front a userland message queue. But some front a kernel object. When they do, the port’s io_bits field carries a kobject type, one of a fixed set such as IKOT_TASK_CONTROL, IKOT_THREAD_CONTROL, IKOT_HOST_PRIV, or IKOT_IOKIT_CONNECT, and the ip_kobject field points at the real kernel object: a task, a thread, an IOKit user client. Helper functions like convert_port_to_task(port) check the kobject type in io_bits, then dereference ip_kobject and hand back the task. This is what turns “I hold a port” into “I act on a kernel object.”

What ports are for, and the punchline. A task exercises all of its authority through ports. Its bootstrap port reaches launchd and, through it, other services. Its task self port lets it call the mach_vm_* family on its own address space. Its exception ports receive a thread’s state when it faults. Its host port answers unprivileged queries, while the separate host-priv port gates privileged host RPCs.

And then the one that matters most. A send right to an IKOT_TASK_CONTROL port whose ip_kobject is the kernel’s own task lets you call mach_vm_read and mach_vm_write against kernel memory. That single capability is kernel read/write from userland. It is tfp0, and the entire back half of this post is about it. So it comes down to one move: obtain that port, or forge something the kernel reads as that port.

Forging it is exactly the offensive angle. If you can make a ipc_port you control read back with io_bits set to IKOT_TASK_CONTROL and ip_kobject pointing at a task you also control, you have a fake kernel task port even when the real one is out of reach. On arm64e devices (A12 and later) ip_kobject and the entry pointers are PAC-signed (pointer authentication) with per-field discriminators, so you cannot simply copy a pointer in from elsewhere. That is a large part of why forging a port is hard today, and it is the subject of the later post on pointer authentication.

ipc_space: a task’s table of capabilities

Ports are the objects. ipc_space is where a task keeps its names for them, and it is as important as the ports themselves.

What it is. An ipc_space is a task’s port-name namespace: the per-task table that maps the port names userland sees to the real ipc_port objects in the kernel. It hangs off the task as itk_space. It is, precisely, the Mach counterpart of the file-descriptor table p_fd. Your authority as a task is the set of entries in your ipc_space. What you cannot name, you cannot touch.

How it works. The space holds is_table, an array of struct ipc_entry. A 32-bit port name splits into two parts:

Each ipc_entry carries ie_object, a pointer to the ipc_port (PAC-signed on arm64e), and ie_bits, which packs the right type in its low bits and the generation in its high bits. Resolving a name is therefore: take the index, look up is_table[index], check that the generation in the name matches the generation in ie_bits, check the right type, then dereference ie_object. Every Mach call that takes a port name walks this path.

What it implies. Two things, and both are load-bearing for exploitation.

The generation counter is the anti-stale-name mechanism. When a slot is freed and later reused for a different port, its generation is bumped, so an old name that carried the old generation no longer validates. It resolves to a dead name instead of silently aliasing the new port. Defeating that, by getting a name reused before the generation rolls over or by abusing a table-reallocation bug, is the classic stale port name primitive: an old name now resolves to a different port than the one it named, which is capability-level type confusion.

And is_table is an ordinary kernel-heap object whose size and placement in the heap an attacker can influence by allocating ports. Corrupting an ie_object pointer means a name now resolves to an ipc_port you control, and from there you are back to forging a kobject port, a fake task port, and tfp0.

Aside: walking a live ipc_space in lldb

If you have a kernel debugger on a jailbroken device or in Corellium, you can watch the whole chain with your own eyes, which is the fastest way to make it concrete. The walk is task, then space, then table, then entries, then the port, then its kobject type:

(lldb) p (task_t)current_task()
(task_t) $0 = 0xffffffe0...

(lldb) p $0->itk_space
(ipc_space_t) $1 = 0xffffffe0...

# the table and its current size
(lldb) p $1->is_table
(lldb) p $1->is_table_size

# entry N: its ie_bits (right type + generation) and the port it names
(lldb) p $1->is_table[N].ie_bits
(lldb) p (ipc_port_t)$1->is_table[N].ie_object

# follow the port to its kobject type in io_bits
(lldb) p ((ipc_port_t)$1->is_table[N].ie_object)->ip_object.io_bits

Read the kobject-type bits of io_bits and you can tell, entry by entry, which name is your task self port, which is the host port, and so on. Do the same walk against a port you should not have, and a receive-versus-send right or a kobject type that does not belong is exactly the kind of thing that tells you a bug worked.

The rest of the Mach machinery

The remaining Mach pieces matter but do not need the same depth here, because later posts own them.

Virtual memory underlies every memory bug. Per task there is a vm_map, a sorted set of vm_map_entry ranges, each backed by a vm_object that owns physical pages, with pmap holding the architecture page tables. Two details recur in exploitation: vm_map_copy, the transient object created to carry out-of-line message data, is a favorite heap-spray and disclosure primitive; and pmap is the object the page-table monitors (PPL, then SPTM) exist to protect, which is why a VM bug that reaches pmap is worth more than one that does not.

Mach traps are the raw entry points. mach_trap_table is indexed by negated syscall numbers from EL0. The entries you drive constantly in an exploit are the _kernelrpc_* primitives (mach_port_allocate, mach_port_insert_right, mach_port_mod_refs, mach_vm_allocate, and friends) and mach_msg2_trap, the modern consolidated message path. These are reachable from almost any sandbox, which is what makes them the core surface of nearly every escalation.

Zones are the allocator. zalloc slices pages into fixed-size elements of one kind, with dedicated zones for hot types such as ipc.ports. This is where an exploit lines up its objects in memory, and the later post on building a read/write primitive lives here.

Messages are what flows through ports. mach_msg2 copies a user message into an ipc_kmsg and processes its typed descriptors, including port-right transfers and out-of-line memory. The message path is one of the densest bug surfaces in the kernel, but it is the subject of the dedicated IPC post, so here it is enough to know that a port without messages does nothing, and that the descriptor-handling code is where the interesting lifetime bugs live.

The BSD half

The BSD side is where identity lives, and identity is what you eventually rewrite.

Syscalls dispatch through sysent, indexed by the positive syscall numbers, the mirror image of the Mach trap table.

struct proc is the process from BSD’s point of view: p_pid, the file-descriptor table p_fd, a back-pointer to the task, and, most importantly, p_ucred, the pointer to its credentials.

Credentials are a kauth_cred_t, holding the familiar cr_uid and group set, and a field that matters more on iOS than the uid does: cr_label, the slot for the Mandatory Access Control Framework (MACF), the kernel hook layer that AMFI (Apple Mobile File Integrity) and the sandbox plug into as policy modules to hang their per-process policy, covered in the next two posts. Credentials are reference-counted and copy-on-write. The offensive beat here is direct: once you have kernel read/write, patching your process’s p_ucred to point at the kernel process’s credentials makes you root, and editing the MACF label sheds the sandbox and grants entitlements. This, not a shellcode payload, is the canonical thing an iOS kernel exploit does with its read/write.

VFS, sockets, and kauth round out the surface. They are lower-glamour but reach the same heap. The archetype is SockPuppet (Ned Williamson, CVE-2019-8605): a use-after-free in a BSD socket option, whose exploitation is pure Mach-port heap craft. A BSD bug, turned into a Mach primitive, on the shared heap.

tfp0: the objective

Now the payoff.

tfp0 is read “task-for-pid-zero.” task_for_pid(pid) is a Mach trap that returns a send right to that process’s task control port. Pid 0 is kernel_task, the kernel’s own task. So task_for_pid(0) returns a send right to the kernel task port, and holding that right lets you call mach_vm_read and mach_vm_write on the kernel task, whose address space is kernel memory. That is arbitrary kernel read/write from userland, through ordinary, documented Mach APIs.

tfp0 is a capability, not a technique. It is not a bug and not an exploitation method. It is the prize an exploit produces: the stable kernel read/write primitive, expressed as a Mach port. The bug and the work to exploit it are the technique; tfp0 is what you build with them. People call it a “primitive,” but in the sense of “the kernel read/write primitive you are aiming for,” not a step in the attack.

The name is now generic. On modern iOS you almost never get the kernel task by literally calling task_for_pid(0); Apple restricted that path and it no longer hands the kernel task port to userland. Instead an exploit forges a fake kernel task port, a port the kernel reads as IKOT_TASK_CONTROL over a task the attacker controls, as described in the ports section. The capability is the same; only the way you obtain it changed. So “tfp0” has drifted into a generic term meaning “obtain the kernel task,” whatever the actual mechanism. When someone says a chain “gets tfp0,” they mean it reaches userland kernel read/write, not that it called a particular trap.

The APIs are not exotic. mach_vm_read(task, addr, size, ...) reads bytes from a task’s address space, and mach_vm_write(task, addr, data, ...) writes them. These are the same calls a debugger uses: lldb attaches to a process by taking its task port and then reads and writes the debuggee’s memory with exactly these functions. tfp0 is that mechanism pointed at kernel_task. All of the power comes from which task the port names, a task you were never supposed to be able to name, and none of it from anything unusual in the API.

Read, write, and why you need both. Not all primitives are equal, and it is worth being precise about the ladder, because a “read capability” and full read/write are very different things:

Primitive What you can do What it gives you
Read only (an info leak) read kernel memory knowledge: defeat KASLR (kernel address randomization), locate p_ucred. You change nothing, so this is reconnaissance, not control.
Write only write kernel memory the ability to change things, but writing blind is useless without a read to tell you where.
Read and write (tfp0) read and write anywhere action: read to locate p_ucred, write to overwrite it. This is game over.

A simple read capability tells you where things are. Only a write lets you act. tfp0 is the objective precisely because it packages both, arbitrarily and stably through mach_vm_*, rather than as a fragile one-shot you have to keep re-triggering. Turning a limited primitive into full read/write, upgrading a relative read or a single write-what-where into clean arbitrary access, is a craft of its own, and it is the subject of a later post; here the point is only to understand what the finished capability is.

The shape of a kernel exploit

With the vocabulary in place, almost every iOS kernel exploit reads as the same arc:

  1. a memory-safety bug gives limited control (an out-of-bounds write, a use-after-free, a refcount error);
  2. controlled reallocation reclaims the freed or adjacent memory with attacker bytes;
  3. that memory is type-confused into a kobject port or a disclosable vm_map_copy;
  4. which yields arbitrary read/write, a userland kernel task port, tfp0;
  5. which patches p_ucred and the MACF label, and the process is now root and out of its sandbox.

Two historical bugs show the pattern cleanly. Brandon Azad’s voucher_swap (CVE-2019-6225) freed an ipc_voucher through a reference-counting error in MIG (the Mach Interface Generator, which auto-writes the code that unpacks Mach messages for kernel services), then reallocated it as a port array and rode the dangling port to a fake task port: the archetype of “refcount bug to kernel read/write.” Ian Beer’s “task_t considered harmful” was not memory corruption at all but a design-level capability confusion, where passing task ports across a privilege boundary let a caller confuse which task a kobject port referred to. Both are capability confusions, not memory corruption, which is why this post spent its length there.

What changed by 2026

The abstractions above are stable; the exploitation economics inverted. The details belong to later posts, but the headline changes are worth naming so the model above is not read as current-year-easy.

Control ports such as the task self port are now immovable and pinned, so the old tricks that swapped or freed a task’s own control port fault instead. Reference counts moved to the hardened os_refcnt framework, which saturates and panics rather than wrapping, closing the overflow-to-use-after-free class. mach_port_guard lets a holder bind a context to a port so unexpected operations fault.

Bigger still, and covered in the mitigations posts: kalloc_type (iOS 15) segregates the heap by type signature, so a freed object can only be reclaimed by types that share its signature, which breaks the generic “reallocate the freed slot as an ipc_port” move that step 2 above relied on. On A15 and later, SPTM and TXM (the Trusted Execution Monitor) took page tables and code-signing out of XNU entirely, so even a full kernel read/write can no longer rewrite page tables or forge a code-signing verdict. On A19, Memory Integrity Enforcement makes many linear overflows and use-after-frees synchronously fatal. tfp0 is now the start of the hard part, not the end of it.

Hands-on: reading the structures out of the kernelcache

The previous post pulled a kernelcache apart the long way, through the Image4 container. To just get one open in a disassembler, ipsw downloads and decompresses it in a single command, without fetching the whole IPSW:

ipsw download ipsw --device iPhone10,3 --version 15.0 --kernel
# writes a decompressed arm64 Mach-O under a build-named folder, e.g.
# 19A346__iPhone10,3/kernelcache.release.iPhone10,3_6

file 19A346__iPhone10,3/kernelcache.release.iPhone10,3_6
# Mach-O 64-bit executable arm64

iPhone10,3 is an A11, so this image is plain arm64, not arm64e, and the pointer fields you will see are not PAC-signed. That is useful for a first read: you get the raw layout without the signing, and the pointer authentication that wraps these same fields on A12 and later is what the later PAC post adds on top.

Open the file in a disassembler with arm64 support. On macOS that means Hopper, Ghidra, or IDA Pro; note that the IDA Home tier is x86-only and will not load it. Point it at the whole kernelcache (kernel plus all kexts) and let the analysis finish.

A release kernelcache carries almost no symbol names, so recover them before anything else. ipsw kernel sym, paired with blacktop’s symbolicator signatures, rebuilds most of them and writes a JSON you apply with the matching script for Ghidra, IDA Pro, Binary Ninja, or radare2; IDA users also have the older ida_kernelcache. With names back, the functions and tables below are one jump away.

Work through four things:

1. The syscall gates. Two dispatch tables sit at the front of the kernel: mach_trap_table (Mach traps, reached from EL0 as negative syscall numbers) and sysent (the BSD syscalls, positive numbers). ipsw dumps the BSD table in full, each entry with its number, handler, argument count, and C prototype:

ipsw kernel syscall kernelcache.release.iPhone10,3_6

The BSD syscall table (sysent) dumped by ipsw: each entry's number, handler address, argument count, and prototype

mach_trap_table is the Mach-side parallel. In the disassembler it sits at its symbol as a raw array of { argument count, handler pointer } entries, entry zero being the reserved kern_invalid trap.

2. Port to kernel object. Decompile any member of the convert_port_to_* family; convert_port_to_map_with_flavor is a clean one. You see the shape the kobject bridge always takes: it reads the port, checks a type byte, dereferences the kernel object behind it (the ip_kobject), and acts on it. This one even panics with a userspace has access to a kernel map ... through task warning when that object turns out to be the kernel’s own map, which is a fair reminder of what these ports gate.

convert_port_to_map_with_flavor decompiled in Ghidra: it reads the port, checks a type byte, dereferences the kernel object, and warns when that object is the kernel map

3. The name lookup. Decompile ipc_right_lookup_read, the routine that resolves a port name for a read. It walks the exact path from the ipc_space section: the name is shifted right by 8 for the table index (param_2 >> 8), that index scaled by the ipc_entry size (* 0x18) picks the slot in is_table, and each entry holds ie_object at offset 0 and ie_bits (the right type and generation) at + 8.

ipc_right_lookup_read decompiled in Ghidra: the port name shifted right by 8 for the table index, scaled by the ipc_entry size, resolving to the entry's ie_object and ie_bits

4. Where identity lives. The last piece is the write target. struct proc holds p_ucred, a kauth_cred_t carrying cr_uid and the cr_label that AMFI and the sandbox hang their per-process policy on. That credential is what a kernel read/write overwrites at the end of a chain: patch p_ucred to a privileged cred and the process is root and out of its sandbox, the concrete form of the BSD-half point from earlier. The kauth_cred_* accessors that symbolication turns up read the very fields you would change.

Where this leaves us

The kernel is now a concrete object rather than a name. It is a Mach and BSD hybrid in one address space; authority is held as ports and named through ipc_space; and the objective every escalation shares is a single capability, tfp0, a userland handle to kernel read/write that you obtain today by forging the kernel task port rather than by asking for it.

That capability is only interesting because of what it is allowed to overwrite, and the next posts are about the machinery that decides. The MACF label we just met inside p_ucred is where the following article starts: the framework that decides, at every exec, what is even allowed to run, and how AMFI, code signing, and trust caches hang off it.

Notes and sources

Everything here is drawn from open source, vendor documentation, and published research.

Work like this is what we do under engagement.

contact@sigreturn.com Vulnerability Research