After I took a couple months in early 2020 to learn iOS kernel exploit development, I began to try and find my first kernel bug in the middle of April and gave myself before classes started in the fall to find one. I quickly realized that I knew almost nothing about the kernel’s attack surface — I mean, I was aware of IOKit, user clients, external methods, and IOConnectCallMethod, but that was pretty much the extent of my knowledge. I didn’t really understand how each of those related to each other.

IOKit scared me off initially because it was closed source, so I spent a couple days reading the open source parts of XNU. Reading C code got boring very fast, and I knew that open source == more eyes, so after a couple days I bit the bullet and shifted over to IOKit.

I’m going to attempt to walk you through the four months I spent in the spring/summer of 2020 going from zero –> a sandbox reachable information leak inside IOGPU. Hopefully, if you are currently on the edge about learning about IOKit, this will provide some guidance.

IOKit Basics

There’s a lot of information already out there about IOKit that I have used to learn with and I’ll be linking those resources at the end of this. I don’t want to repeat what is already out there, so I’ll briefly go over what IOKit offers to kernelspace and userspace, external methods, and little things I initially overlooked that helped me find my bug.

In the kernel, IOKit encompasses all the kernel extensions (kexts) present inside an iOS kernelcache. Some of the kexts provide APIs to communicate with the hardware in the phone (like the Secure Enclave) and others serve only as a slight annoyance to jailbreakers (looking at you, CoreTrust). The kexts are written in a limited subset of C++.

Userspace communicates with IOKit kexts through user clients. There are two steps to opening a user client from userspace: you obtain a handle to the service it is registered under with IOServiceGetMatchingService, then use that handle with IOServiceOpen. IOServiceOpen takes four parameters, one of which is the user client type. Upon success, it will spit out a Mach port that represents a user client. That port is your process’s gateway to interacting with the kernel. Most user clients offer external methods, which are implemented in the kernel, that userspace can invoke with IOConnectCallMethod (or any of its variants). IOConnectCallMethod has parameters for input/output data. An external method can take input data from userspace, send output data back to userspace, do both, or do neither. Thus, external methods can be loosely thought of as system calls, because both deal with untrusted user input.

All services inherit from IOService and all user clients inherit from IOUserClient.

To open a user client, it must be listed under the com.apple.security.iokit-user-client-class entitlement. However, a couple user clients are explicitly encoded in the app sandbox profile, so a UI process (e.g. an Xcode application) can open those without issue.

User Client Types

To find the types of user clients a service offers, you have to reverse enginner (or look at Hex Rays output, whatever you want to call it) that service’s overridden newUserClient method. For example, this is IOGPU’s newUserClient method:

kern_return_t __fastcall IOGPU::newUserClient_task___void___unsigned_int__IOUserClient(IOGPU *a1, task *a2, void *a3, unsigned int type, IOUserClient **a5){
  v6 = 0xE00002BELL;
  result = 0xE00002C2LL;
  if ( a2 )
  {
    *a5 = 0LL;
    *&a1->m.__pad4[235] = 1;
    if ( type == 33 )
    {
      v13 = (*(IOGPUMemoryInfoUserClient::gMetaClass + 104))();
      if ( v13 )
      {
        v12 = v13;
        if ( (v13->init(v13) & 1) != 0 )
          goto LABEL_9;
        (v12->release_0)(v12);
      }
    }
    else
    {
      if ( type != 1 )
        return result;
      v11 = (a1->newDeviceUserClient)(a1);
      if ( v11 )
      {
        v12 = v11;
        (v11->retain)(v11);
        if ( (IOGPUDeviceUserClient::init(v12, 0LL, a2, HIWORD(type)) & 1) == 0 )
        {
          (v12->release_0)(v12);
LABEL_17:
          (v12->release_0)(v12);
          return v6;
        }
        (v12->release_0)(v12);
LABEL_9:
        if ( (v12->attach)(v12, a1) )
        {
          if ( v12->start(v12, a1) )
          {
            (v12->retain)(v12);
            result = 0LL;
            *a5 = v12;
            return result;
          }
          (v12->detach)(v12, a1);
        }
        v6 = 0xE00002C9LL;
        goto LABEL_17;
      }
    }
    return 0xE00002BELL;
  }
  return result;
}

As you can see, there’s only two types of user clients for IOGPU: 1 (AGXDeviceUserClient) and 33 (IOGPUMemoryInfoUserClient). User client type numbers don’t really follow any sort of pattern so always check newUserClient to figure them out.

External Methods

Each user client usually has a table of external methods in constant memory. I say usually because some user clients don’t have external methods and some employ a gigantic abomination of a switch statement instead, like AppleKeyStoreUserClient.

For those which have a table of external methods, there are many functions which could lead to it. In my experience, though, it almost always ends up being getTargetAndMethodForIndex and/or externalMethod. The other functions to check out are getExternalMethodForIndex, getExternalAsyncMethodForIndex, getAsyncTargetAndMethodForIndex, or getTargetAndTrapForIndex. Whatever function it is, it’ll likely reference a global array of structures that represent any number of external methods.

There are a couple different structures which can represent an external method. It seems that externalMethod will always reference an table of IOExternalMethodDispatch structures, while getTargetAndMethodForIndex will always reference a table of IOExternalMethod structures. The functions with Async in their name may reference a table of IOExternalAsyncMethod structures. There is also IOExternalTrap, but I have never seen this used. Every structure that represents an external method can be found in iokit/IOKit/IOUserClient.h inside the XNU source.

The many different ways external methods are represented was one of the biggest things that messed me up when I first started because I expected it to be consistent across user clients. The biggest piece of advice I can give to anyone starting IOKit is to expect a lack of consistency and to just roll with it rather than wasting time questioning yourself/why “x” is one way while “y” is another when both “x” and “y” accomplish the same task.

IOConnectCall*Method

As said before, this is how you call external methods from userspace.

kern_return_t IOConnectCallMethod(mach_port_t connection, uint32_t selector, const uint64_t *input, uint32_t inputCnt, const void *inputStruct, size_t inputStructCnt, uint64_t *output, uint32_t *outputCnt, void *outputStruct, size_t *outputStructCnt);

connection is the user client Mach port you got back from IOServiceOpen and selector is the number that an external method corresponds to. If there’s an external method table, this is usually just an index into that. Otherwise, you need to reverse engineer the function(s) which are responsible for external method dispatch to figure out how this number is used.

The rest of the parameters deal with user input/external method output. There are two types: “scalar” and “structure”. Scalar input is represented as an array of 64-bit integers. Its “cnt” parameter is the length of that array, not its total size in bytes. Structure input is for raw bytes. Its “cnt” parameter is its total size in bytes. The same concept applies to the different output parameters as well, but you still need to initialize those parameters to the sizes listed in the structure that represents the external method you’re calling. This is detailed later.

Some external methods only take in scalar input or structure input instead of a mix of both. For this case, Apple provides IOConnectCallScalarMethod and IOConnectCallStructMethod which only take scalar and structure input, respectively. This saves a lot of typing.

IOConnectCallAsyncMethod takes a couple additional parameters:

kern_return_t IOConnectCallAsyncMethod(mach_port_t connection, uint32_t selector, mach_port_t wake_port, uint64_t *reference, uint32_t referenceCnt, const uint64_t *input, uint32_t inputCnt, const void *inputStruct, size_t inputStructCnt, uint64_t *output, uint32_t *outputCnt, void *outputStruct, size_t *outputStructCnt);

How wake_port, reference, and referenceCnt are used depends on the external method, but the input/output parameters are the same as the non-async versions. Like IOConnectCallMethod, some async external methods take only one type of input/output, which IOConnectCallAsyncScalarMethod and IOConnectCallAsyncStructMethod are for.

The only difference between async and non-async external methods is that the async ones take some additional arguments. Don’t get too caught up on it.

The fields of an external method structure are the blueprints for calling IOConnectCall*Method. Let’s dive into the two most common ones, IOExternalMethodDispatch and IOExternalMethod.

IOExternalMethodDispatch

This is the most common structure used for external methods and is straightforward:

struct IOExternalMethodDispatch {
	IOExternalMethodAction function;
	uint32_t               checkScalarInputCount;
	uint32_t               checkStructureInputSize;
	uint32_t               checkScalarOutputCount;
	uint32_t               checkStructureOutputSize;
};

xnu-7195.50.7.100.1/iokit/IOKit/IOUserClient.h

function is a pointer to the external method, and the following four give us info about the different cnt parameters to IOConnectCall*Method. If a user client has a table of these structures, then the first field will always be a kernel function. This is how you distinguish this structure from IOExternalMethod, because the first field for that will always be NULL, or eight zero bytes.

Figuring out the parameters to IOConnectCall*Method is pretty simple. For example, here is the structure that represents the s_rotate_surface external method for IOMobileFramebufferUserClient from IDA:

IOExternalMethodDispatch <__ZN29IOMobileFramebufferUserClient16s_rotate_surfaceEPS_PvP25IOExternalMethodArguments, 3, 0, 0, 0>

This tells us that IOMobileFramebufferUserClient::s_rotate_surface takes three scalars and no structure input. Zero means “unused”. The unused arguments to IOConnectCall*Method are denoted as 0 or NULL. It also tells us that this external method has no scalar or structure output, so the call to IOConnectCallMethod would look like this (IOMobileFramebufferUserClient::s_rotate_surface is external method 0):

uint64_t scalar_input[3];
IOConnectCallMethod(connection, 0, scalar_input, 3, NULL, 0, NULL, 0, NULL, NULL, NULL, NULL);

Here is the structure that represents IOMobileFramebufferUserClient’s external method 74:

IOExternalMethodDispatch <__ZN29IOMobileFramebufferUserClient22s_supported_frame_infoEPS_PvP25IOExternalMethodArguments, 1, 0, 1, 0x40>

There’s one scalar input, one scalar output, and 0x40 bytes of structure output. The IOConnectCallMethod call would look like this:

uint64_t scalar_input;
uint64_t scalar_output;
size_t scalar_output_sz = 1;
uint8_t struct_output[0x40];
size_t struct_output_sz = sizeof(struct_output);

IOConnectCallMethod(connection, 74, &scalar_input, 1, NULL, 0, &scalar_output, &scalar_output_sz, struct_output, &struct_output_sz);

External methods can also have variable-sized input. This is indicated by kIOUCVariableStructureSize, or 0xffffffff:

IOExternalMethodDispatch <__ZN29IOMobileFramebufferUserClient13s_swap_submitEPS_PvP25IOExternalMethodArguments, 0, 0xFFFFFFFF, 0, 0>

In this case, IOMobileFramebufferUserClient::s_swap_submit takes in a variable amount of structure input. You’ll need to reverse engineer this function to figure out the upper bound of how much input it wants, but after that, calling this external method is the same as the others.

The really nice thing about external methods described by IOExternalMethodDispatch is that they have to conform to the straight-forward IOExternalMethodAction typedef:

typedef IOReturn (*IOExternalMethodAction)(OSObject * target, void * reference,
    IOExternalMethodArguments * arguments)

The arguments you pass to IOConnectCall*Method will be put into an IOExternalMethodArguments structure in the third argument, which makes it really easy to spot the places in the external method that deal with your input. It also makes it easy to figure out if an external method is asynchronous or not: if it is, then you’ll see references to the Mach port at *(arguments+0x8), or the array of 64-bit integers at *(arguments+0x10).

IOExternalMethod (and IOExternalAsyncMethod)

These are a bit more complicated than the IOExternalMethodDispatch structure:

struct IOExternalMethod {
	IOService *         object;
	IOMethod            func;
	IOOptionBits        flags;
	IOByteCount         count0;
	IOByteCount         count1;
};

struct IOExternalAsyncMethod {
	IOService *         object;
	IOAsyncMethod       func;
	IOOptionBits        flags;
	IOByteCount         count0;
	IOByteCount         count1;
};

count0 and count1 are for IOConnectCall*Method parameters. Since there’s only two of these this time around, the flags field tells us what they are for. Here are the flags and how count0 and count1 relate to them:

enum {
	/* ... */
    
         count0  count1
           |       |
           |       |
           V       V
	kIOUCScalarIScalarO = 0,
	kIOUCScalarIStructO = 2,
	kIOUCStructIStructO = 3,
	kIOUCScalarIStructI = 4,
    
    /* ... */
};

Obviously, this is pretty restrictive, which is why I think most user clients use IOExternalMethodDispatch instead.

For example, this is the IOExternalMethod structure for AppleJPEGDriverUserClient::startDecoder (external method 1):

__DATA_CONST:__const:FFFFFFF00784BF40                 DCQ 0                   ; object ; 1
__DATA_CONST:__const:FFFFFFF00784BF40                 DCQ __ZN25AppleJPEGDriverUserClient12startDecoderEP24_AppleJPEGDriverIOStructS1_; func.ptr
__DATA_CONST:__const:FFFFFFF00784BF40                 DCQ 0                   ; func.adj
__DATA_CONST:__const:FFFFFFF00784BF40                 DCD 3                   ; flags
__DATA_CONST:__const:FFFFFFF00784BF40                 DCB 0, 0, 0, 0
__DATA_CONST:__const:FFFFFFF00784BF40                 DCQ 0x58                ; count0
__DATA_CONST:__const:FFFFFFF00784BF40                 DCQ 0x58                ; count1

Here, flags is 3, so this external method takes 0x58 bytes of structure input and outputs 0x58 bytes of structure output, so the IOConnectCallMethod call would look like this:

uint8_t struct_input[0x58];
uint8_t struct_output[0x58];
size_t struct_output_sz = sizeof(struct_output);

IOConnectCallMethod(connection, 1, NULL, 0, struct_input, sizeof(struct_input), NULL, NULL, struct_output, &struct_output_sz);

Things get a little trickier regarding the arguments passed to the external method because they are not neatly packed into a structure. Instead, your input and output is passed as separate parameters. From what I can tell, for non-async external methods, your input is always the second parameter and your output is always the third parameter. For async external methods, there will be a parameter right before your input, making your input the third parameter and output the fourth parameter. This makes it a bit more difficult to tell if an external method is async, so take a look at how the second parameter is used to figure it out.

Vtable Offsets as External Methods

This deserves to be talked about because it messed with me when I was learning. For some reason, some user clients will have offsets from the start of their vtable instead of an actual function pointer to an external method. UPDATE 8/20/2021: Brandon Azad contacted me and explained that the vtable offsets come about because the external method field in the IOExternalMethod and IOExternalAsyncMethod structures is a C++ method pointer, not a function pointer. When method pointers point to a virtual method, clang encodes them as offsets from the start of a vtable, since figuring out the function to call is determined by the runtime type.

Off the top of my head, I know that AGXDeviceUserClient does this for all but one of its external methods (from iPhone 8, 14.6):

__DATA_CONST:__const:FFFFFFF007A29DB0 stru_FFFFFFF007A29DB0 DCQ 0                   ; [0].object
__DATA_CONST:__const:FFFFFFF007A29DB0                                         ; DATA XREF: AGXDeviceUserClient__getTargetAndMethodForIndex_IOService____unsigned_int+10↓o
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x5E0               ; [0].func.ptr
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 1                   ; [0].func.adj
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCD 3                   ; [0].flags
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [0].count0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x50                ; [0].count1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [1].object
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x5E8               ; [1].func.ptr
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 1                   ; [1].func.adj
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCD 3                   ; [1].flags
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [1].count0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x58                ; [1].count1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [2].object
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x5F0               ; [2].func.ptr
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 1                   ; [2].func.adj
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCD 3                   ; [2].flags
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 2
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 2
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 2
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 2
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [2].count0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x1C8               ; [2].count1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [3].object
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x5F8               ; [3].func.ptr
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 1                   ; [3].func.adj
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCD 3                   ; [3].flags
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 3
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 3
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 3
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 3
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [3].count0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x20                ; [3].count1
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [4].object
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x600               ; [4].func.ptr
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 1                   ; [4].func.adj
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCD 3                   ; [4].flags
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 4
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 4
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 4
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCB 0                   ; 4
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0                   ; [4].count0
__DATA_CONST:__const:FFFFFFF007A29DB0                 DCQ 0x10                ; [4].count1
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCQ 0                   ; object
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCQ sub_FFFFFFF00917F954; func.ptr
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCQ 0                   ; func.adj
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCD 3                   ; flags
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCB 0, 0, 0, 0
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCQ 0x20                ; count0
__DATA_CONST:__const:FFFFFFF007A29EA0                 DCQ 0x20                ; count1

So to figure out AGXDeviceUserClient’s external method 0, take its vtable and add 0x5e0 to it.

IOGPU

Since my bug was inside IOGPU, I decided to introduce its newUserClient method early on. Given that we have to match against IOGPU inside IOServiceGetMatchingService, for the longest time I just assumed that the service I got back was an IOGPU object and the user client I was opening was an IOGPUDeviceUserClient. IOGPUDeviceUserClient has a good amount of external methods but I wasn’t able to find any bugs in them. When I came back to this a month later I found out that the service I got back from matching against IOGPU is actually an AGXAccelerator object! AGXAccelerator inherits from IOGPU, but doesn’t override newUserClient, so calling IOServiceOpen from userspace still lands us inside IOGPU::newUserClient. Therefore, IOGPU::newUserClient is called with a this pointer that points to an AGXAccelerator object, not an IOGPU object. This means that this:

v11 = (a1->newDeviceUserClient)(a1);

lands us inside AGXAccelerator::newDeviceUserClient instead of IOGPU::newDeviceUserClient, because newDeviceUserClient is overridden by AGXAccelerator. Here is IOGPU::newDeviceUserClient:

IOGPUDeviceUserClient *__fastcall IOGPU::newDeviceUserClient(IOGPU *this)
{
  IOGPUDeviceUserClient *v1; // x0

  v1 = IOGPUDeviceUserClient::operator new(0x130uLL);
  return IOGPUDeviceUserClient::IOGPUDeviceUserClient(v1);
}

And here is AGXAccelerator::newDeviceUserClient:

AGXDeviceUserClient *__fastcall AGXAccelerator::newDeviceUserClient(AGXAccelerator *this)
{
  IOGPUDeviceUserClient *v1; // x19

  v1 = OSObject::operator new(0x130uLL);
  IOGPUDeviceUserClient::IOGPUDeviceUserClient(v1, &AGXDeviceUserClient::gMetaClass)->__vftable = off_FFFFFFF00798AC50;
  OSMetaClass::instanceConstructed(&AGXDeviceUserClient::gMetaClass);
  return v1;
}

That entire time I was actually opening AGXDeviceUserClient, not IOGPUDeviceUserClient! AGXDeviceUserClient overrides externalMethod and has six external methods of its own, so that’s a pretty nicely-sized unexplored attack surface.

Unfortunately, the first five external methods are not interesting and just return data.

The sixth one, AGXDeviceUserClient::performanceCounterSamplerControl, will tail call AGXShared::performanceCounterSamplerControl. This is a gigantic function that performs different commands based on the first uint32_t of the structure input. To perform other commands, the first thing that you must do is “lock access” to the performance sampler, which is done via command 0:

case 0:
  v13 = a1->m.accelerator->m.perf_sampler->lockAccess(
          a1->m.accelerator->m.perf_sampler,
          *(structureInputp + 4) != 0,
          a1);

On success, AGXDeviceUserClient::performanceCounterSamplerControl will copy the contents of the structure input to the structure output before returning:

kret = 0;
if ( structureOutputp )
  {
    v11 = *(structureInputp + 16);
    *structureOutputp = *structureInputp;
    *(structureOutputp + 16) = v11;
  }

Now we can get to the good parts.

AGXDeviceUserClient::performanceCounterSamplerControl will sometimes call out to the performance sampler’s processControlCommand function, which has its own set of commands. The only argument to it is a buffer, which is presumably meant for command data. Some of those commands require kernel pointers to different data structures to operate.

The problem was, and continues to be, that the structure input buffer is used for this data buffer. This means that for commands which require kernel pointers inside the performance sampler’s processControlCommand method, that kernel pointer is saved to the structure input buffer. This is pre-patch command 1:

case 1:
    if ( *(structureInputp + 8) )
    {
      v18 = *a1->m.gap48;
      v19 = sub_FFFFFFF009164FC4(a1->m.accelerator);
      v20 = *(structureInputp + 8);
      v21 = v19;
      v22 = a1->m.accelerator;
      if ( v20 )
        MappedBuffer = AGXShared::createMappedBuffer(v22, v18, a1, v20, *(structureInputp + 4));
      else
        MappedBuffer = sub_FFFFFFF00916A398(
                         v22,
                         v18,
                         a1,
                         0LL,
                         *&v22->m.gapEC8[1116] | 0x23u,
                         *(structureInputp + 4));
      v39 = MappedBuffer;
      if ( MappedBuffer )
      {
        sub_FFFFFFF00916AAA4(MappedBuffer);
        v40 = (*(v39->__vftable + 18))(v39, v21, 393223LL);
        (*(v39->__vftable + 5))(v39);
        if ( v40 )
        {
          a1->setFWBufferMap(a1, v40);
          *(structureInputp + 8) = v40;       <----------------
          v52 = a1->m.accelerator->m.perf_sampler->processControlCommand(
                  a1->m.accelerator->m.perf_sampler,
                  structureInputp);
        }
      }
    }

Here, processControlCommand would succeed and return, which means that the structure input buffer would be copied to the structure output buffer, granting userspace a free kernel pointer to a kernel object. I don’t remember what the object was, but if you already have an arbitrary kernel read, you could read out its vtable and derive the kernel slide.

This is the bug that CVE-2021-30656 was assigned to. It was fixed by zeroing *(structureInputp + 8) after processControlCommand returned. Here is the proof of concept for iOS 13.6.1 and here is the proof of concept for iOS 14.0 - iOS 14.4.2. I’m not really sure when this bug was introduced.

The funny thing is that I found this info leak 15 minutes before my first lecture of my Operating Systems class, so in the end, I did meet my goal of finding a kernel bug before my first class of the Fall 2020 semester. I was beyond excited, and I think that’s why I forgot to mention the other, currently-broken info leak in this same function that I had found a while later in my initial report. I’ve since notified them of it, but it hasn’t been “fixed”.

Would-be info leak: AGXShared::performanceCounterSamplerControl command 12

case 12:
    v24 = *(structureInputp + 8);
    if ( v24 )
    {
      v25 = *(structureInputp + 4);
      if ( v25 >= 2 )
      {
        v26 = *&a1->m.gap48[8];
        v27 = AGXShared::createMappedBuffer(a1->m.accelerator, *a1->m.gap48, a1, v24, v25);
        if ( v27 )
        {
          v28 = v27;
          sub_FFFFFFF00916AAA4(v27);
          v29 = (*(*v28 + 144LL))(v28, v26, 262151LL);
          (*(*v28 + 40LL))(v28);
          if ( v29 )
          {
            v30 = *a1->m.gap48;
            v46 = sub_FFFFFFF009164FC4(a1->m.accelerator);
            v47 = a1->m.accelerator;
            if ( *(structureInputp + 8) + *(structureInputp + 4) == 1LL )
              v48 = sub_FFFFFFF00916A398(v47, v30, a1, 0LL, *&v47->m.gapEC8[1116] | 0x23u, 1uLL);
            else
              v48 = AGXShared::createMappedBuffer(
                      v47,
                      v30,
                      a1,
                      *(structureInputp + 8) + *(structureInputp + 4) - 1LL,
                      1uLL);
            v50 = v48;
            if ( v48
              && (sub_FFFFFFF00916AAA4(v48),
                  v51 = (*(*v50 + 144LL))(v50, v46, 393223LL),
                  (*(*v50 + 40LL))(v50),
                  v51) )
            {
              *(structureInputp + 16) = v51;       <----------------
              a1->setRDEBufferMap(a1, v29);
              *(structureInputp + 8) = v29;        <----------------
              v52 = a1->m.accelerator->m.perf_sampler->processControlCommand(
                      a1->m.accelerator->m.perf_sampler,
                      structureInputp);
              (*(*v51 + 40LL))(v51);

The command that corresponds to 12 inside the performance sampler’s processControlCommand method is stubbed to return an error code. However, if code is added for command 12 inside processControlCommand that allows it to return non-zero, then two kernel pointers will be sent back to userspace.

Here’s the proof of concept for that. By using xnuspy to hook AGXShared::performanceCounterSamplerControl to dump the contents of the structure input buffer after it returns, we can see the two kernel pointers that don’t reach userspace:

0xfffffff5b2d4bd90: 0C 00 00 00 00 C0 00 00  A0 7E 7B B1 F5 FF FF FF  |  .........~{.....
0xfffffff5b2d4bda0: 00 15 7B B1 F5 FF FF FF  00 00 00 00 00 00 00 00  |  ..{.............

If you have any questions, please contact me on Twitter or Discord (preferred, Justin#6010). I also want to thank Siguza for proofreading.

Resources

Here’s a list of resources I used to learn about IOKit, but nothing beats experimenting and talking to other people.