The previous post ended on the sandbox profile of a confined process: the exact set of Mach services, files, and IOKit user clients that process is allowed to reach. This post takes one of those user clients and follows it down. When a sandboxed app is allowed to open an IOKit user client, it can call, from userland, straight into kernel driver code. That path is where most iOS local privilege escalation (LPE) begins: going from an app’s own privileges up to the kernel’s.
IOKit is XNU’s driver framework. (XNU, “X is Not Unix”, is the kernel iOS and macOS share.) Hundreds of drivers live inside it, for the GPU, the display, the camera, the neural engine, the codecs, storage, USB, HID, and each one can publish an endpoint that userland is allowed to open and call. Most of that code is C++ written years ago by different teams, all reached through the same handful of calls. It is the single widest kernel attack surface an unprivileged process can reach, and it is why an iOS LPE almost always runs through a driver.
There are too many drivers to go through one by one. They all work the same way though: the same few calls to open one and call it, and the same kind of table inside that decides which function your call reaches. Learn that once and you can read any of them. This post stops at the bug and how you reach it. Turning a bug into a stable kernel read/write comes later in the series.
Everything here is public: Apple’s open-source XNU, the IOKit headers, the Apple Platform Security documentation, and published research from Brandon Azad, Ian Beer, Saar Amar, Karol Mazurek and others. The one bug walked in detail, CVE-2022-32832, is fixed and has a public writeup and proof-of-concept. There is no exploit, private detail, or 0day here.
What IOKit is
If you come from Linux, the shape is ioctl. A user client is a device node you open, and IOConnectCallMethod is the call you make on it afterwards. Most of the vocabulary maps over:
| iOS / IOKit | Linux |
|---|---|
IOServiceOpen() → io_connect_t |
open("/dev/foo") → fd |
IOConnectCallMethod(conn, selector, …) |
ioctl(fd, cmd, arg) |
| the selector | the ioctl command number |
IOExternalMethodDispatch[] |
the driver’s switch (cmd) |
the declared checkStructureInputSize |
the size encoded in _IOW(type, nr, struct) |
| the IORegistry | sysfs and the device tree |
retain() / release() on OSObject |
kref_get() / kref_put() on a kobject |
The row worth stopping on is the argument contract. Linux encodes a size in the command number by convention and leaves each driver to honour it. IOKit writes the expected counts in a table, and the framework checks them before the handler runs. That table is what you read when you audit an IOKit driver.
IOKit is written in a stripped-down C++ that Apple calls libkern: no exceptions, no runtime type information (RTTI), no multiple inheritance, because this runs in the kernel. Without RTTI, a driver cannot recover an object’s type from the language at runtime, so IOKit tracks it separately: a runtime cast is written OSDynamicCast(SomeClass, obj) and returns NULL on a mismatch. Skip that cast, or trust a type without it, and you have a type confusion.
Almost every IOKit object derives from one base class, OSObject, and two facts about it carry through the whole post. Its first field is a pointer to the object’s vtable (the table of function pointers behind every virtual C++ call), so controlling an object’s contents puts you one indirection from controlling a call target, which is why Apple signs that pointer with PAC (Pointer Authentication Codes) on modern hardware. And it is reference counted: free an object while something still points at it and you have a use-after-free.
The drivers sit in a tree, the IORegistry, that you can browse on any Mac or device. A driver matches to a piece of hardware, goes live, and can then vend a user client: the object userland actually opens and calls. That user client is what we attack.
The userland-to-kernel bridge
Opening a user client takes one call:
io_connect_t conn;
IOServiceOpen(service, mach_task_self(), type, &conn);
IOServiceOpen asks a driver to create a user client for your task. The sandbox and entitlement checks happen here, in the framework rather than in the driver: a MACF hook, mac_iokit_check_open_service, is where Sandbox.kext enforces your profile’s iokit-open rules, and the framework itself tests any entitlement the driver named in kIOUserClientEntitlementsKey. If they pass you get back an io_connect_t. That handle is a Mach port, a send right in the sense from the XNU post: an unforgeable reference to a kernel object, this one being your user client. Everything you do to the driver now goes through that port.
You call a method on it with one of the IOConnectCall* functions:
IOConnectCallMethod(conn, selector,
scalarInput, scalarInputCnt, // uint64_t array
structInput, structInputSize, // opaque bytes
scalarOutput, &scalarOutputCnt,
structOutput, &structOutputSize);
You choose the selector (which method to call) and two payloads: an array of 64-bit scalars and an opaque struct blob. You also supply the count or size of each. Hold on to that: the input, and the numbers describing how big it is, both come from userland. On the way in, the kernel packs them into an IOExternalMethodArguments structure and calls the driver’s externalMethod. Whether the driver checks those numbers before trusting them is the difference between a working call and a bug.
(The synchronous IOConnectCall* functions cross into the kernel as one MIG routine, io_connect_method; the IOConnectCallAsync* variants use a separate one, io_connect_async_method. MIG, the Mach Interface Generator, is the kernel’s RPC-stub compiler, and its generated stubs are a bug surface of their own.)
Where the call lands: the dispatch table
Inside externalMethod, the driver turns your selector into an actual function. Almost always it does this with a table, one entry per selector, and the entry says two things: which function to call, and what the arguments are allowed to be.
The classic form is an array of IOExternalMethodDispatch, one 0x18-byte entry per selector:
struct IOExternalMethodDispatch {
IOExternalMethodAction function; // the handler
uint32_t checkScalarInputCount; // required scalar count
uint32_t checkStructureInputSize; // required struct size
uint32_t checkScalarOutputCount;
uint32_t checkStructureOutputSize;
};
The four check* fields are the bounds. If your call’s scalar count or struct size does not match what the entry declares, the call is refused before the handler runs. The comparison itself has always lived in IOUserClient::externalMethod. What was left to each driver was declaring the right numbers, bounds-checking the selector against the array before indexing it, and routing the call through the base implementation at all. Getting any of those wrong is the oldest IOKit bug there is: the handler indexes or copies using a count the caller controls, and writes out of bounds.
kIOUCVariableStructureSize, 0xFFFFFFFF, in any of the four check fields means “variable, do not check”: the caller may send any count or size, and the handler has to validate it itself. Those are the selectors to read first, because the check moved out of the framework and into hand-written code, which is where the mistakes are.
IOUserClient2022 arrived in iOS 16 and by now carries most of the clients worth attacking. Its entries are 0x28 bytes: the same five fields plus a flag for whether the method may be called asynchronously and an optional entitlement string the caller must hold. The framework now also bounds-checks the selector against the array, gates async calls behind that flag, can require a per-selector entitlement, and can single-thread externalMethod for you. That removed most of the “driver forgot to bounds-check” bugs. What is left is the 0xFFFFFFFF handlers that still check their own sizes, and logic bugs the count checks were never going to catch.
The surface, and how to see it
Hundreds of drivers, each vending one or more user clients, each user client exposing tens or hundreds of selectors: that is the surface. What you can reach is a subset your sandbox decides. Its profile lists the user-client classes the process may open, the iokit-open rules from the sandbox post, and that list is your target set. Start with what is live.
Count them. One iOS 15 kernelcache for the iPhone X carries 240 prelinked kexts, the kernel extensions that hold the drivers:
$ ipsw kernel kexts kernelcache.release.iPhone10,3
• Kexts count=240
...
0xfffffff008304bd0: com.apple.driver.AppleMobileFileIntegrity (1.0.5)
0xfffffff0085f8d98: com.apple.iokit.IOSurface (302.9)
0xfffffff008d86d30: com.apple.filesystems.apfs (1933.12.1)
0xfffffff008f16720: com.apple.driver.AppleAVE2 (500.92.7)
0xfffffff009015130: com.apple.security.sandbox (300.0)
0xfffffff00946c5b0: com.apple.iokit.IOGPUFamily (35.8)
0xfffffff0094872a8: com.apple.AGXG10P (187.33)
Not every kext vends a user client, and not every user client is reachable from a sandbox, but that is the pool you are drawing from. com.apple.filesystems.apfs, at 0xfffffff008d86d30, is the one we come back to.
See which are live. ioclasscount prints how many instances of each class exist right now. On any Mac:
ioclasscount | grep -i userclient | sort -t= -k2 -rn | head -12
IOHIDEventServiceUserClient = 187
RootDomainUserClient = 164
AppleKeyStoreUserClient = 102
IOSurfaceRootUserClient = 88
AGXDeviceUserClient = 81
IOUserClient = 40
IOHIDResourceDeviceUserClient = 34
IOUserUserClient = 15
IOUserClient2022 = 15
IOReportUserClient = 9
IOMobileFramebufferUserClient = 9
AppleCredentialManagerUserClient = 8
ioclasscount reports an instance count bumped by the number of direct subclasses that have any instances, so a concrete class like IOSurfaceRootUserClient at 88 really is 88 live connections, while an abstract base reports how many of its direct subclasses are in use. IOSurfaceRootUserClient is the object a later post uses to build kernel read/write. IOMobileFramebufferUserClient is the family behind several bugs used in the wild. And IOUserClient2022 is abstract, so its 15 is not 15 connections: it is 15 driver user-client classes that have adopted the 2022 dispatcher and have live instances right now. ioreg -l gives you the same tree with every property, if you want to see what one driver exposes.
Open one and call it. The bridge from earlier, as a real program: open a service, call one method, print what came back.
#include <IOKit/IOKitLib.h>
#include <mach/mach.h>
#include <stdio.h>
static void try_open(const char *name) {
io_service_t svc = IOServiceGetMatchingService(
kIOMainPortDefault, IOServiceMatching(name));
if (!svc) { printf("%-26s no such service\n", name); return; }
io_connect_t conn = 0;
kern_return_t kr = IOServiceOpen(svc, mach_task_self(), 0, &conn);
printf("%-26s IOServiceOpen -> 0x%08x\n", name, kr);
if (kr == KERN_SUCCESS) {
uint64_t in[1] = {0};
kr = IOConnectCallScalarMethod(conn, 0, in, 1, NULL, NULL);
printf("%-26s selector 0 -> 0x%08x\n", name, kr);
IOServiceClose(conn);
}
IOObjectRelease(svc);
}
int main(void) {
try_open("IOSurfaceRoot");
try_open("AppleAPFSContainer");
try_open("AGXAccelerator");
return 0;
}
$ clang iokit.c -framework IOKit -framework CoreFoundation -o iokit
$ ./iokit
IOSurfaceRoot IOServiceOpen -> 0x00000000
IOSurfaceRoot selector 0 -> 0xe00002c2
AppleAPFSContainer IOServiceOpen -> 0x00000000
AppleAPFSContainer selector 0 -> 0xe00002c2
AGXAccelerator IOServiceOpen -> 0xe00002c7
IOSurfaceRoot and AppleAPFSContainer both opened, 0x00000000 being kIOReturnSuccess, so an ordinary unprivileged process is now holding a live connection to a kernel driver. Calling selector 0 on either returns 0xe00002c2, kIOReturnBadArgument: the driver compared the single scalar we sent against what that selector declares and refused before the handler ran. That is the dispatch table’s check* fields rejecting the call, seen from userland. AGXAccelerator never opened at all: 0xe00002c7 is kIOReturnUnsupported, so the GPU service is in the registry but will not vend us this kind of client.
Return codes alone tell you which drivers you can reach and which selectors refuse which shapes of argument. The full list is in <IOKit/IOReturn.h>.
Watch the call leave the process. The kernel end of this is is_io_connect_method (the is_ prefix marks the MIG server routine), and tracing it needs a DTrace fbt probe, which System Integrity Protection blocks on a stock Mac. The userland end needs nothing but lldb on your own binary:
$ lldb iokit
(lldb) b IOConnectCallScalarMethod
Breakpoint 1: where = IOKit`IOConnectCallScalarMethod, address = 0x00000001848516a0
(lldb) run # the address moves: lldb resolves it before launch, then the shared cache slides
IOSurfaceRoot IOServiceOpen -> 0x00000000
Process 78068 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x000000018f47d6a0 IOKit`IOConnectCallScalarMethod
IOKit`IOConnectCallScalarMethod:
-> 0x18f47d6a0 <+0>: pacibsp
0x18f47d6a4 <+4>: sub sp, sp, #0x50
0x18f47d6a8 <+8>: stp x29, x30, [sp, #0x40]
0x18f47d6ac <+12>: add x29, sp, #0x40
(lldb) bt
* frame #0: 0x000000018f47d6a0 IOKit`IOConnectCallScalarMethod
frame #1: 0x0000000100000654 iokit`try_open + 224
frame #2: 0x000000010000054c iokit`main + 36
frame #3: 0x000000018ad5fda4 dyld`start + 6992
(lldb) register read x0 x1 x2 x3
x0 = 0x0000000000001b0f
x1 = 0x0000000000000000
x2 = 0x000000016fdfeb50
x3 = 0x0000000000000001
x0 is 0x1b0f, the io_connect_t, which is a Mach port name in our task. x1 is the selector, 0. x2 points at our scalar array on the stack and x3 is its count, 1. Those last two are the pair the driver has to check, and kIOReturnBadArgument above is what it looks like when it does.
pacibsp signs the return address before the function does anything else, so PAC is already in force on the userland side of this call.
The bug classes
Four classes cover most of them.
Unbounded counts (out-of-bounds read/write). The oldest one. A handler trusts a caller-supplied count or size, or a length embedded in the struct input, and reads or writes past its buffer. This is the class the dispatch-table check* fields exist to kill.
Type confusion. A driver treats an object as a type it is not, usually by skipping an OSDynamicCast or trusting a type tag in a property dictionary parsed from your input. IOKit passes structured data around as OSDictionary, OSArray, OSData, and the code that unpacks those is a recurring source of confusions.
Use-after-free and lifecycle. A user client, or an object it returned, is freed while a call is still using it. It usually happens during teardown: closing the connection, the provider terminating underneath you, an async call finishing late. Getting the reference count slightly wrong causes the same thing.
Races. A handler takes no lock, so two threads calling the same connection at once corrupt its state, most often by freeing the same object twice. Nothing about the counts is wrong; the bug is that the driver assumed one call at a time. Not every client is racy: a driver that funnels externalMethod through an IOCommandGate is serialised by its workloop, and an IOUserClient2022 client that asks for it is serialised by the framework. Check which before you spend a night racing threads. This is the class our case study lands in.
One bug, start to finish: CVE-2022-32832
Take iOS 15.0 for the iPhone X, load it in Ghidra, and follow the chain: the string AppleAPFSUserClient leads to the OSMetaClass constructor that registers the class, the metaclass leads through getMetaClass to the class vtable, and the vtable ends exactly where the dispatch table begins.

Parsed as an array of IOExternalMethodDispatch, the table runs 61 entries, selectors 0 to 60, and each one states its calling contract. Selector 0 takes a 504-byte structure and returns 4 bytes; selector 2 takes 16 bytes and returns 16. Many entries are NULL: selectors 11 to 28 in one unbroken run, and there are more further down. The index space is wider than the surface you can actually call.
Not one entry in the table uses the 0xFFFFFFFF wildcard. Every populated selector declares a fixed size, so the framework really does check each call before the handler sees it. Selector 49 stands out for what it declares: zero scalars in, zero structure in, zero of either out. There is not one byte to validate, so every check* field passes trivially and the call goes straight through to this:

The driver keeps one in-progress operation per connection, held as a single pointer inside the user client. Finalising it means tearing that operation down, then clearing the pointer. Tidied into C, with the names from the public write-up:
IOReturn methodDeltaCreateFinalize(AppleAPFSUserClient *this)
{
if (this->deltaCreateCtx == NULL) // at this + 0xf0
return kIOReturnNotReady;
deltaCreateTeardown(this->deltaCreateCtx);
this->deltaCreateCtx = NULL; // cleared after the teardown, not before
return 0;
}
Two threads on one connection both pass the test holding the same value:
thread A thread B
read deltaCreateCtx -> ctx
read deltaCreateCtx -> ctx
test != NULL -> ok
test != NULL -> ok
deltaCreateTeardown(ctx)
deltaCreateTeardown(ctx) <- a second time
deltaCreateCtx = NULL
deltaCreateCtx = NULL
deltaCreateTeardown frees the context and the properties hanging off it. Running it twice on the same context is a double-free and a reference-count underflow at once.
The second free is the one that matters. It frees a block the allocator may already have reallocated to something else. If you can then get a kernel object into that memory (from iOS 15 on, one sharing the freed block’s kalloc_type signature, not any object you like), you can reach it two ways: the legitimate one, and the stale pointer the driver is still holding. Working through the stale pointer is how a bug like this becomes a read and write of kernel memory.
That is the whole bug: nothing the check* fields could have caught, because selector 49 declares no arguments to check. The only thing wrong is the assumption that it runs one call at a time, the same bug you get from two threads in ioctl() on one file descriptor with no mutex. Apple fixed it by wrapping the body in IOLockLock/IOLockUnlock.
Reaching selector 49 takes work. It does nothing until methodDeltaCreatePrepare (selector 36) has left a context behind, which needs an unmounted volume, which normally means creating one with methodVolumeCreate (selector 0), which needs root. Apple’s own impact line is “An app with root privileges may be able to execute arbitrary code with kernel privileges”. So the entry cost is root, one volume you made yourself, one prepared delta context, and then two threads on one connection.
It was fixed in iOS 15.6, which is why the 15.0 kernelcache above still carries it, lock-free.
The surface in 2026
The IOKit surface has narrowed since that bug.
iOS 16 split many dispatch tables in two: a full one and a restricted one. A caller without the right entitlement is routed to the restricted table and gets a stub returning kIOReturnNotPermitted for the sensitive selectors; IOSurfaceRootUserClient and the GPU clients work this way. The practical effect: which selectors you can reach depends on your entitlements, so you have to work it out for your own process instead of reading it off one table.
Two more changes make a bug harder to use. kalloc_type segregates the kernel heap by object type, so freeing a victim and reallocating something you control in its place no longer works across types. On the newest hardware, MIE (Memory Integrity Enforcement, the A19 memory-tagging feature from 2025) makes most out-of-bounds and use-after-free accesses fault outright. Both raise the cost of the step after this one.
Even with a full kernel compromise through IOKit, you do not automatically own the page tables or the code-signing verdict. On A15 and later those sit behind separate monitors, SPTM (Secure Page Table Monitor) and TXM (Trusted Execution Monitor), that ordinary kernel code, IOKit included, cannot write to. A kernel read/write is no longer enough on its own.
Picking targets
Two variables rank every user client: can you reach it, and how weak is its validation.
- List what you can open. Parse your sandbox profile’s
iokit-openrules and cross-check the live registry withioreg. That intersection is your entire surface, and nothing else is worth a minute. - Recover each dispatch table from the kernelcache. Find the user client’s
externalMethod, identify the form (0x18 legacy or 0x28IOUserClient2022), and read the array: for each selector you get the handler and its declared counts. - Rank the selectors. Read first: any with a
0xFFFFFFFFvariable size (the handler validates itself, so the mistakes are there), anything taking a large or complex struct input, anything with no entitlement requirement that you can still reach.
A few traps:
- Restricted and unrestricted tables differ. Reverse the one your entitlements route you to, not the full one.
- Struct input over 4096 bytes arrives out-of-line, through a different code path than small input, and the two are often validated differently.
- The async variant of a method, called with a completion port so it returns before the handler finishes, is a separate and historically under-read path.
- A handler that takes no lock can be raced by two threads.
Where this leaves us
IOKit is one uniform entry path onto hundreds of independent drivers. The surface is wide because there are so many dispatch tables, and it stays productive because they were written by hand, one driver at a time. Find the clients you can reach, read their dispatch tables, and the surface stops being “the whole kernel” and becomes a short list of selectors you can actually audit.
That is one of the two local surfaces, and where this post stops. A dispatch table tells you what a driver accepts and where its checks are thin. The other half of what a sandboxed process can reach is the set of services it may send a Mach message to, and how you get one of them to do privileged work on your behalf. That is the next post: Mach messages, MIG, and XPC. Every IOConnectCall* above already crossed into the kernel through a MIG stub; the next post takes MIG apart.
Notes and sources
Everything here is drawn from open source, vendor documentation, published research, and a kernelcache anyone can download.
- Apple, XNU source:
iokit/IOKit/IOUserClient.h,iokit/Kernel/IOUserClient.cpp, andosfmk/device/device.defs, the primary reference forIOExternalMethodDispatch,IOExternalMethodArguments, and theio_connect_method/io_connect_async_methodMIG routines theIOConnectCall*family funnels through. - Tommy Muir (Muirey03), CVE-2022-32832 write-up and proof-of-concept, the source for the case study: selector 49,
methodDeltaCreateFinalize, thedelta_create_ctx_tdouble-free, the root precondition, and theIOLockLock/IOLockUnlockfix in iOS 15.6. - Karol Mazurek, “Mapping IOKit Methods Exposed to User Space on macOS”, Phrack 72:9 (2025), on recovering dispatch tables statically and the 0x18 versus 0x28 entry stride.
- Saar Amar, “IOUserClient2022 highlevel overview” and “iOS 16 restricted Userclients”, the reference for the 2022 dispatcher’s centralised checks and the restricted/unrestricted method-table split.
- Brandon Azad, “A survey of recent iOS kernel exploits” (Project Zero, 2020), the exploit-by-exploit catalogue the four bug classes here are drawn from.
- Ian Beer, “An analysis of an in-the-wild iOS Safari WebContent to GPU Process exploit” (Project Zero, 2023), for why a renderer no longer opens GPU user clients directly.
- Apple, “Memory Integrity Enforcement” (2025), the primary source for synchronous memory tagging on A19.
- Moritz Steffin and Jiska Classen, “Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves” (2025), for why IOKit running at EL1, the kernel’s ARM64 exception level, no longer implies control of the page tables.
- Jonathan Levin, *OS Internals, Volume II: Kernel Mode (newosxbook.com), for IOKit, the registry, and driver matching.
