System Internals

Apple internals #5: IOKit up close

IOKit is the widest kernel surface a sandboxed iOS process can reach: hundreds of drivers, each vending a user client you open and call. Where the bugs live, and CVE-2022-32832 walked end to end.

In brief

The code driving the camera, the GPU and storage lives inside the iOS kernel, and an ordinary app is allowed to open a direct line to it and start calling. This post is about narrowing hundreds of those drivers down to the few worth reading closely.

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.

The AppleAPFSUserClient dispatch table in Ghidra, parsed as an array of IOExternalMethodDispatch: each 0x18-byte entry shows the handler pointer followed by its four declared argument counts

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:

Selector 49 decompiled in Ghidra: it tests the context pointer at this+0xf0, runs the teardown, and only then writes NULL back to the field

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.

  1. List what you can open. Parse your sandbox profile’s iokit-open rules and cross-check the live registry with ioreg. That intersection is your entire surface, and nothing else is worth a minute.
  2. Recover each dispatch table from the kernelcache. Find the user client’s externalMethod, identify the form (0x18 legacy or 0x28 IOUserClient2022), and read the array: for each selector you get the handler and its declared counts.
  3. Rank the selectors. Read first: any with a 0xFFFFFFFF variable 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:

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.

Work like this is what we do under engagement.

[email protected] Vulnerability Research

No spam, one click to unsubscribe. Privacy.