System Internals

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.

The previous post left you holding a map: the sandbox profile of a confined process, and on it the exact set of Mach services, files, and IOKit user clients that process is allowed to reach. This post takes one entry off that map 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 escalations begin: LPE, 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; the earlier posts in this series took it apart from the boot chain down.) 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. That is a very large number of entry points, most of them 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
matching on IOKitPersonalities MODULE_DEVICE_TABLE
retain() / release() on OSObject kref / 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, and it is where this post spends most of its time.

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. One consequence matters. 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, the first of the bug classes we come back to.

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 on modern hardware, a mitigation this series comes back to later. And it is reference counted: free an object while something still points at it and you have a use-after-free, the second bug class.

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. Everything below is about reaching one and what happens when you call it.

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 driver runs its sandbox and entitlement checks here, and if they pass it hands 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);

Three things here are yours to choose: the selector (which method), and two payloads, an array of 64-bit scalars and an opaque struct blob, each with a count or size you also supply. 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.

(All of the IOConnectCall* functions cross into the kernel as a single MIG routine, io_connect_method. MIG, the Mach Interface Generator, is the kernel’s RPC-stub compiler; its generated stubs are a bug surface of their own, which is why they get their own post, the next one in this series.)

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. That is the whole defence, and for years it was left to each driver to get right. Forgetting one of these checks, or setting it wrong, is the oldest IOKit bug there is: the handler indexes or copies using a count the caller controls, and writes out of bounds.

One sentinel is worth memorising. 0xFFFFFFFF in a size field means “variable, do not check”: the caller may send any size, and the handler promises 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.

Since iOS 16 most drivers use a newer form, IOUserClient2022, whose entries are 0x28 bytes: the same five fields plus a flag for whether async is allowed and an optional entitlement string the caller must hold. The counts are now checked in one place in the framework instead of in every driver, and the handler is reached through a PAC-signed pointer. 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, which is what our case study is.

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, decided by your sandbox. A confined process’s profile lists the user-client classes it may open (the iokit-open rules from the sandbox post); that list is your target set. So the first thing you do is look at what is live.

Count them. One iOS 15 kernelcache for the iPhone X carries 240 prelinked kexts:

$ 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 counts live instances, so each of those lines is a connection open on this machine while it idles. IOSurfaceRootUserClient at 88 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 showing up at all is the newer dispatch base class from the previous section, in service 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

Three results, three different things. 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.

Nothing crashed, and that is the point. 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, and reaching it with DTrace needs an fbt probe, which SIP 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
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

Read those four registers against the call from earlier and the whole argument model is sitting in them. 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 is obliged to check, and kIOReturnBadArgument above is what it looks like when it does.

The first instruction is worth a second look. pacibsp signs the return address before the function does anything else: PAC is already in force on the userland side of this call, which is the mitigation a later post is about.

The bug classes

Everything above tells you where the bugs are. 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, and the reason the 0xFFFFFFFF “variable” handlers are worth reading first.

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 handed out, 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. This is the class our case study lands in.

One bug, start to finish: CVE-2022-32832

Everything so far is enough to go and find a real bug in a real kernelcache. 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 are one long hole, 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. Which is what makes selector 49 worth reading, because of 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

Tidied into C, with the field and function names the public write-up confirms:

IOReturn methodDeltaCreateFinalize(AppleAPFSUserClient *this)
{
    if (this->deltaCreateCtx == NULL)          // at this + 0xf0
        return kIOReturnNotReady;

    finalize_delta_ctx(this->deltaCreateCtx);
    this->deltaCreateCtx = NULL;               // cleared after the teardown, not before
    return 0;
}

The pointer is tested, the teardown runs, and only then is the field cleared. 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
finalize_delta_ctx(ctx)
                                  finalize_delta_ctx(ctx)    <- a second time
deltaCreateCtx = NULL
                                  deltaCreateCtx = NULL

finalize_delta_ctx decrements an atomic counter and frees the context’s allocations, including a 0x20-byte block. Running it twice on the same context is a double-free and a reference-count underflow at once.

Said without the jargon: 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. The code does those two steps in that order and takes no lock, so a second thread can arrive between them and tear the same operation down a second time. The same block of kernel memory ends up freed twice.

The second free is the one that matters. It gives the allocator back a block the allocator may already have handed out to something else. If you can then get a kernel object of your choosing allocated into that memory, you can reach that object 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 from an ordinary app, which is the whole reason anyone cares about a double-free.

That is the whole bug. No bad count, no overflow, 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. When the fix for a kernel CVE is “add a mutex”, you are looking at a race.

Building that into a stable kernel read/write is a later post’s job. What this one shows is the entry cost: two threads and one connection.

(Muirey03 published the analysis and a proof-of-concept. 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, in ways that change what you should look at.

IOUserClient2022 moved the count checks into the framework, so the plain “driver forgot a bounds check” bug is largely gone from new code. What is left is the variable-size handlers and logic bugs like the race.

iOS 16 also 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 is that which selectors you can reach now depends on your entitlements, so reachability has to be computed for your exact position, not read off a single table.

Two more changes make a bug harder to use, and a later post covers both. 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. Neither fixes the bugs; both raise the cost of the step after this one.

One structural point matters most. 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 and TXM, that ordinary kernel code, IOKit included, cannot write to. A kernel read/write is no longer enough on its own. The hardening posts cover what else you need.

Picking targets

Two variables rank every user client: can you reach it, and how weak is its validation. The method follows from that.

  1. List what you can open. Parse your sandbox profile’s iokit-open rules (the sandbox post) and cross-check the live registry with ioreg. That intersection, the clients that exist and that your process may open, is your entire surface. 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 are worth naming:

Where this leaves us

IOKit is one uniform entry path onto hundreds of independent drivers. IOServiceOpen returns a Mach port, IOConnectCallMethod carries a selector and two payloads whose sizes you choose, and a table inside the driver turns that selector into a function with a declared calling contract. The surface is wide because there are so many of those tables, and it stays productive because the tables and the handlers behind them 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 the honest limit of this post. A dispatch table tells you what a driver accepts and where its checks are thin. It says nothing about the services a confined process is allowed to talk to, or how you get one of them to do privileged work on your behalf. That is the next post: Mach messages, MIG, and XPC, where the confused deputy that the sandbox post named finally gets its mechanics.

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.