Writing an iOS Kernel Exploit from Scratch

Posted on July 20, 2020 by K³

Introduction

In August 2019, Ian Beer and Samuel Groß of Google Project Zero published a comprehensive article series about several exploit chains of a professional threat actor who targets iOS users. As it describes the full details of all such exploit chains, the series is a good starting point for writing a custom exploit for the mentioned vulnerabilities.

Writing a full exploit without experience on iOS can be an overwhelming task, though. While the actual vulnerabilities are well described, there are many obstacles in the way. For example, how exactly can the vulnerable functions be triggered? Or even, how to deploy and debug the exploit code? Moreover, not only the described vulnerabilities have to be exploited, but several techniques need to be chained together in order to get full access to the system.

The required information for the iOS exploit developer aspirant is scattered over many articles, books and presentations. Therefore, the aim of this blog post is to take one of the described kernel vulnerabilities, write an exploit from scratch for it and collect all background information of the development process in a cohesive article such that it could act as a reference for beginners. The article will be structured as follows:

In a very first step, the steps required to set up a test environment will be outlined.

Subsequently, the vulnerability itself and the trigger will be analysed in detail. This also includes a short introduction to IOKit drivers along with the accompanying reverse engineering process.

After that, an initial trigger for the vulnerability is described. The remainder of the article shows how to develop a full exploit using common techniques used in iOS exploits, step-by-step.

In particular, this article will investigate chain #3 of the original article series with a focus on the kernel exploit. The vulnerability was introduced in iOS 11 and was mitigated in iOS 11.4.1, the last version of this release. Also, as it is a double-free vulnerability, it seems like a good choice to get in first contact with iOS without knowing to much details of the several subsystems.

The sandbox escape of the original chain is not investigated. Instead, the exploit uses a technique that was revealed recently by Siguza1. Moreover, it should be noted that this is neither a full jailbreak nor does it implement the post-exploitation techniques of the in-the-wild exploit chain.

Due to the nature of this article - discussing an exploit already described by Ian Beer - some content will be overlapping with the original articles. However, the intention of this article is to fill possible potential gaps that might occur to exploit developers who are new to iOS.

Many of the described techniques should be applicable to current iOS versions as well with some being mitigated currently, though. We hope that this article is a good starting point for other iOS exploit developer aspirants nevertheless.

You can find the source code here or on GitHub.

One last thing before diving into the exploit: if you enjoy stuff like this, you might want to check our jobs page. At the time of publishing this, we’re hiring ;)

Test Environment

Researching iOS requires the XNU open source part of the kernel as well as the iOS kernelcaches. The former is found at https://opensource.apple.com/ while the latter could be found at https://ipsw.me/.

For the development of this exploit an iPhone 5S with iOS 11.2.5 was used. Any iPhone using the vulnerable driver and an iOS version below 11.4.1 should work.

It is suggested to use a Mac with Xcode installed as a build system. In Xcode, a free signing identity for iPhone development is needed. Execute security find-identity in a terminal and find the fingerprint of that identity. Copy the fingerprint into Makefile as the value for the variable SIGNING_ID.

It is possible to use a jailbroken iPhone2 for development, but this is not a strict requirement. A jailbroken has the advantage that no sandbox escape is needed.

In order to run the exploit code delivered with this article, simple remove the application overhead and turn the function exploit() in the source code to the main() function. Copy the correct entitlements via ldid to the binary on the iPhone and copy the binary to /Applications/. Start the app via SSH.

If no jailbreak is available, the following approach may be used: Export your certificate identities to a file that can be read with openssl, using the following commands on a terminal:

security export -t identities -f pkcs12 -o certs -k login.keychain
openssl pkcs12 -in certs

Search for your iPhone development certificate and copy the value of OU. This is the team identifier. Copy that value to all XXX in the file entitlements.plist. The file has already the format that is needed to exploit Siguza’s sandbox escape3.

The exploit is now ready for building and installation.

Build the exploit with make and install it with ideviceinstaller -i chain3.ipa from the same directory. In case this fails, you might want to build and install some demo application via Xcode and trust the developer certificate via the Settings app of the iPhone. Then it should be possible to install the exploit application as well.

To run and debug it, the device support files for the correct iOS version are needed. These can be found for instance on Github4. After obtaining the files, open up two terminal windows. In the first window run idevicesyslog | grep chain3. This command will print the debug messages of the exploit. In the second window run ideviceimagemounter DeveloperDiskImage.dmg and then idevicedebug -d foxhound.chain3. The exploit should start on the iPhone and debug messages should appear in the first terminal windows.

Bug Analysis

As already described in the introduction, Ian Beer covered all important details of the bug in depth in his article series. However, someone new to iOS exploit development might have several questions about the actual reverse engineering and exploitation process of this vulnerability.

As the aim of this post is to explain the exploit development process from scratch, this section of the article not only addresses the kernel vulnerability itself but also covers several fundamentals that are important.

IOKit

The vulnerability is present in an IOKit driver. IOKit is a collection of several resources such as frameworks and libraries to streamline the process of device driver development. IOKit drivers are developed in a subset of C++. This complicates the reverse engineering process but leads to specific vulnerabilities and exploitation techniques, too.

IOKit drivers are so-called kernel extensions to the iOS kernel itself. They are prelinked in the kernelcache binary5. Information about these extensions is available in the __PRELINK_INFO segment of the kernelcache. Modern disassemblers like IDA Pro and Ghidra are aware of this.

For this article, the important aspect is that an IOKit driver gives the attacker an interface to the kernel space. Every IOKit driver provides one ore more so-called user clients, which provide external methods for user space processes, comparable to system calls. It seems natural to research these drivers in order to exploit the kernel as many researchers did in the past.

There is a snag to it, though. Many user clients are not accessible from a sandboxed process. Hence, some kind of sandbox escape is often required first. This is true for the bug exploited in this article as well. Ian Beer’s write-up discusses an escape that leverages a vulnerability accessible from the web browser’s process. As already mentioned in the introduction, the exploit described in this blog post will utilise a technique published by Siguza later on6.

Reversing IOKit Drivers

Before the bug itself can be discussed, it has to be identified in the kernelcache. Ian Beer used a kernelcache image of iOS 12 Beta 1 that is not available to the public anymore. Because Apple incidentally published a fully symbolized kernelcache (which actually should have the symbols stripped), they removed the download completely. Therefore, this article will give a short introduction about manually reversing IOKit drivers.

Like it was stated in the previous section, IOKit user clients provide external methods for user space processes. Comparable to C++ vtables, function pointers to these methods are defined in a table in the __const section of the __TEXT segment. This table is not just an array of function pointers, though, but of IOExternalMethodDispatch structs. This struct is defined in IOUserClient.h of the open-sourced XNU kernel:

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

Besides the actual function pointer IOExternalMethodAction function, there are several size fields in this struct. These size fields serve the purpose of describing the sizes of input and output values of the respective IOExternalMethodAction. A more in-depth description of these values will be provided later on in this article.

In order to identify externals methods of an IOKit driver, one can use the following approach: Every user client implements the virtual method externalMethod(), which will take the function pointer from the table and call the actual method via a dispatch method. Because the external method table is stored in the __const section, externalMethod() contains a hard-coded pointer to the table. Therefore, the aim is to first identify the externalMethod() implementation of the respective IOKit driver and from there to inspect the external method table.

Actually, it is possible to reconstruct the complete IOKit user client tree as is described in the “iOS Hacker’s Handbook” or in a presentation by Stefan Esser. However, it is also possible to easily find externalMethod() of a specific IOKit driver manually.

Every IOKit user client class has what’s called a meta class. This meta class is constructed when an object instance of the user client is constructed. The constructor of the meta class object expects the name of the actual class as an argument. Therefore, by searching the IOKit user client’s class name, it is possible to find the method where the meta class for the user client is constructed. For this article the corresponding class name isAppleVXD393UserClient. The method can be found by searching all references to that string.

After the meta class constructor OSMetaClass() was called, the pointer to the method table of the meta class is stored in the object itself. Because the vtable of the actual IOKit user client object is located before the meta class vtable, following this pointer reveals the IOKit user client’s vtable, too.

As was already mentioned, every IOKit user client implements externalMethod(). This is done by overwriting this method from the parent class IOKitUserClient. All that is needed now is to compare the vtable of AppleVXD393UserClient to the one of IOKitUserClient, which should already have been identified by the disassembler, because IOKitUserClient and its methods are some of the remaining symbols of a iOS kernelcache.

All methods that have different function pointers in the vtable of AppleVXD393UserClient are overwritten methods. One of these is externalMethod().

externalMethod() itself contains a pointer to the external method table of the user client as can be seen in the following image.

That is all that is needed to identify the vulnerable functions which are exploited in this article.

The Bug in AppleVXD393

This little primer should be enough to research the bug itself. The vulnerability is present in one of the external methods that can be found in the identified external method table of AppleVXD393UserClient 7. This discussion is a recap of the explanations given by Ian Beer in his write-up.

The first method in the method table is called CreateDecoder(), and the second one is called DestroyDecoder(). This could be identified through reverse engineering of the methods or by simply using the symbolized iOS 12 Beta 1 kernelcache. The actual usage of this methods is not of interest here (the driver has something to do with video decoding acceleration). What is of interested, though, is that the former method allocates a small memory chunk and stores a pointer to it in the user client. The DestroyDecoder() method deallocates this chunk, but fails to null-out the pointer. A classical double-free vulnerability occurs that can be turned into a use-after-free vulnerability.

The allocation can be observed in one of the calls made by the CreateDecoder() function. This is shown in the following code excerpt8:

/* AppleVXD393UserClient::AllocateMemory(_AppleVXD393AllocateMemoryInStruct*,
   _AppleVXD393AllocateMemoryOutStruct*) */

 1 undefined8 __thiscall
 2 AllocateMemory(AppleVXD393UserClient *this,_AppleVXD393AllocateMemoryInStruct *alloc_instruct,
 3               _AppleVXD393AllocateMemoryOutStruct *alloc_outstruct)
 4 
 5 {
 6   kern_return_t ret;
 7   _kern_mem_info *tmp_mem;
 8   _kern_mem_info *addr_of_old_0x38_byte_struct;
 9   
10   if ((alloc_instruct->hardcoded_1 == 0) &&
11      (_SMDLog(" %s %d assert broken \n"), alloc_instruct->hardcoded_1 == 0)) {
12     return 0xe00002c2;
13   }
14   ___bzero(alloc_outstruct,0x68);
15   tmp_mem = (_kern_mem_info *)_IOMalloc(0x38);
16   ___bzero(tmp_mem,0x38);
17   alloc_outstruct->addr_of_0x38_byte_struct = tmp_mem;
18   _ret = allocateKernelMemory
19                    (this->apple_vxd393,alloc_instruct->surface_id,(ulong)alloc_instruct->hardcoded_1
20                     ,alloc_instruct->hardcoded_memtype_3,alloc_instruct->hardcoded_maptype_3,
21                     (task *)this->field_0xe0,(_msvdx_client_mem_info *)alloc_outstruct);
22   addr_of_old_0x38_byte_struct = this->addr_of_0x38_byte_struct;
23   tmp_mem->prev = addr_of_old_0x38_byte_struct;
24   if (addr_of_old_0x38_byte_struct != (_kern_mem_info *)0x0) {
25     addr_of_old_0x38_byte_struct->next = tmp_mem;
26   }
27   this->addr_of_0x38_byte_struct = tmp_mem;
28   return _ret;
29 }

AllocateMemory() is directly called by CreateDecoder(). In line 15 a 56 bytes chunk is allocated. In line 27 the pointer to this chunk is saved in the user client object. In the lines before line 27 any old pointers to such a struct are saved in a double linked list.

One would assume that DestroyDecoder() would simply free the allocated chunk that is associated with the former created decoder. And indeed, this can be observed in the next excerpt:

/* AppleVXD393UserClient::DeallocateMemory(_AppleVXD393DeallocateMemoryInStruct*,
   _AppleVXD393DeallocateMemoryOutStruct*) */
 1 longlong DeallocateMemory(AppleVXD393UserClient *this,
 2                          uint8_t *tmp_buf)
 3 {
 4   longlong ret_value;
 5   _kern_mem_info *prev;
 6   _kern_mem_info *tmp_buf_+_0x20;
 7   _kern_mem_info **next's_prev;
 8   
 9   tmp_buf_+_0x20 = *(_kern_mem_info **)(tmp_buf + 0x20);
10   if (tmp_buf_+_0x20 == (_kern_mem_info *)0x0) {
11     ret_value = 0;
12   }
13   else {
14     ret_value = deallocateKernelMemory(this->apple_vxd393,tmp_buf_+_0x20);
15     prev = tmp_buf_+_0x20->prev;
16     if (prev != (_kern_mem_info *)0x0) {
17       prev->next = tmp_buf_+_0x20->next;
18     }
19                     /* head->last OR
20                        &next->prev IF next != NULL */
21     next's_prev = &this->list_head_prev;
22     if (tmp_buf_+_0x20->next != (_kern_mem_info *)0x0) {
23       next's_prev = &tmp_buf_+_0x20->next->prev;
24     }
25     *next's_prev = prev;
26     _IOFree(tmp_buf_+_0x20,0x38);
27   }
28   return ret_value;
29 }

The free of the 56 bytes chunk can be seen in line 26. tmp_buf is a memory region that was previously copied from the user client’s struct. tmp_buf + 0x20 then points to the 56 bytes chunk. Before the free, the unlinking of the chunk from the double linked list is executed. However, the chunk pointer is never deleted from the user client. It does neither happen in this function were it would be expected nor in any other function that is called by DestroyDecoder() or subsequent calls. Therefore, the check in line 10 is never true and it is possible to free the chunk multiple times.

This can be verified by creating a small proof-of-concept program that calls DestroyDecoder() twice in a row. How this is done is shown in the next section, which will discuss how to exploit this bug in detail.

Exploitation

This section will discuss how to exploit the bug that was shown in the previous section.

Triggering the Bug

Before the exploitability of the bug can be discussed, a trigger has to be built. The method table of AppleVXD393UserClient has to be reviewed again in order to find out how CreateDecoder() and DestroyDecoder() are called. The following excerpt shows the entries of the two methods in the table9.

 1                              **************************************************************
 2                              * AppleVXD393UserClient::sMethods                            *
 3                              **************************************************************
 4      ff00754a970 20 a7 59 08 f0      IOExternalMethodDispatch
 5                  ff ff ff 00 00 
 6                  00 00 f0 00 00
 7         ff00754a970 20 a7 59 08 f0  addr         AppleVXD393UserClient::_CreateDecoder  functio
 8                     ff ff ff
 9         ff00754a978 00 00 00 00     uint32_t     0h                                     checkScalarInputCount
10         ff00754a97c f0 00 00 00     uint32_t     F0h                                    checkStructureInputSize
11         ff00754a980 00 00 00 00     uint32_t     0h                                     checkScalarOutputCount
12         ff00754a984 40 00 00 00     uint32_t     40h                                    checkStructureOutputSize
13      ff00754a988 2c a7 59 08 f0      IOExternalMethodDispatch
14                  ff ff ff 00 00 
15                  00 00 04 00 00
16         ff00754a988 2c a7 59 08 f0  addr         AppleVXD393UserClient::_DestroyDecoder function
17                     ff ff ff
18         ff00754a990 00 00 00 00     uint32_t     0h                                     checkScalarInputCount
19         ff00754a994 04 00 00 00     uint32_t     4h                                     checkStructureInputSize
20         ff00754a998 00 00 00 00     uint32_t     0h                                     checkScalarOutputCount
21         ff00754a99c 84 00 00 00     uint32_t     84h                                    checkStructureOutputSize

There are two different kind of arguments that can be passed to an external method of an IOKit driver. One kind are scalar arguments, i.e., normal arguments like integers. The other one are structure arguments. The exact definition of these structures has to be reverse engineered. However, the size of that structure is given by checkStructureInputSize and checkStructureOutputSize. Dynamic length arguments are possible. This would be indicated by a value of 0xffffffff (all bits high) of the respective field.

There are different ways to call such an external method. Because only checkStructureInputSize and checkStructureOutputSize have a non-zero value in the method table for CreateDecoder() and DetroyDecoder(), it is suitable to use IOConnectCallStructMethod() - we’re not passing scalar arguments but only struct arguments. The full function prototype of IOConnectCallStructMethod is as follows:

kern_return_t IOConnectCallStructMethod(mach_port_t connection, 
                                        uint32_t selector, 
										const void *inputStruct, 
										size_t inputStructCnt, 
										void *outputStruct, 
										size_t *outputStructCnt);

selector is the index of the method in the method table. Hence, it is 0 for CreateDecoder() and 1 for DestroyDecoder.

inputStruct and outputStruct are pointers to structs that are allocated in user space and are copied into the kernel space by the user client. inputStructCnt and outputStructCnt have to be equal to the size values that are defined in the method table. If a dynamic length was needed (which would be indicated by a size of 0xffffffff in the method table), the size arguments have to be set to the actual size of the struct in user space.

The remaining argument is connection. Before an external method call can be performed, the user’s process has to establish a connection to the IOKit driver respectively the driver’s user client. Without going to much into the internal details of IOKit: Every IOKit driver inherits from IOService. IOServices are registered on creation which allows to query them. Moreover, an IOService can create user client connections which are internally represented by so-called mach ports, a macOS and iOS specific IPC mechanism which is discussed later in this article. This connection establishment is implemented by the function IOServiceOpen() after the driver’s IOService has been found via the mentioned registry. An implementation of this process can be found in, e.g., the file applevxd393.m from the finished exploit that is published with this article.

After understanding how to use IOConnectCallStructMethod(), it has to be reverse engineered how the input struct has to be filled for CreateDecoder() and DestroyDecoder().

First of all, it is suspicious that during the call to CreateDecoder(), a 32-bit value at an offset of 144 of the input struct is passed to allocateKernelMemoryInternal() via AllocateMemory(). It is passed along to a virtual method. If the call to this method fails, the error message that is logged and can be viewed via dmesg on a jailbroken device is:

AppleVXD393::allocateKernelMemory kAllocMapTypeIOSurface - lookupSurface failed

Apparently this 32-bit value has to be the ID of an IOSurface. These are frame buffer objects that are shareable between processes. So, an IOSurface has to be created first. This is implemented by another IOService which is called IOSurfaceRoot. Similarly, the method table of this service’s user client has to be found and reviewed first.

 1                              **************************************************************
 2                              * IOSurfaceRootUserClient::sMethodDescs                      *
 3                              **************************************************************
 4      ff0074a7ad0 78 aa 10 08 f0      IOExternalMethodDispatch
 5                  ff ff ff 00 00 
 6                  00 00 ff ff ff
 7         ff0074a7ad0 78 aa 10 08 f0  addr      IOSurfaceRootUserClient::s_create_surface function
 8                     ff ff ff
 9         ff0074a7ad8 00 00 00 00     uint32_t  0h                                       checkScalarInputCount
10         ff0074a7adc ff ff ff ff     uint32_t  FFFFFFFFh                                checkStructureInputSize
11         ff0074a7ae0 00 00 00 00     uint32_t  0h                                       checkScalarOutputCount
12         ff0074a7ae4 d0 0d 00 00     uint32_t  BC8h                                     checkStructureOutputSize

It can be seen that s_create_surface is the first external method which will create an IOSurface. Similar to CreateDecoder() it is called with struct arguments but the first has dynamic length while the output count is fixed10.

The input to the s_create_surface function is a dictionary in XML format. This dictionary contains details about the frame buffer that has to be allocated. However, it is enough to just provide the key IOSurfaceAllocSize with some value. Therefore it is enough to give as an “input struct” the following string:

<dict><key>IOSurfaceAllocSize</key><integer>32</integer></dict>

The output struct contains an ID of this IOSurface. This ID can then be used as the field value for the input struct of CreateDecoder().

After CreateDecoder() was called, all that is needed is to call DestroyDecoder() twice. This is done similarly. The content of the input struct is irrelevant as it is not used.

To test this trigger, the exploit code that is shipped with this article may be used. All that has to be done is to modify the exploit() function in exploit.m. After the call to trigger_bug() a call to trigger_free() will crash iOS.

How this primitive can be turned into a use-after-free vulnerability and subsequently into a privilege escalation is shown in the next sections.

Exploitation

After the bug was analysed in the previous section, this section will discuss a possible exploitation path. The general idea is to turn the double-free into a use-after-free condition and to leverage this condition in order to obtain an arbitrary memory write primitive. The ultimate goal for the exploit will be to create a kernel task port. A Mach task port will allow full access to the respective Mach task; access to the kernel task port hence provides convenient means to perform arbitrary modifications, such as patching kernel code.

In order to create such a kernel task port, several things are required: * A way to read kernel memory, so that the addresses required in the kernel task port can be faked * A way to trick the kernel into using user-provided data as a task port * This is best achieved in a use-after-free setting involving Mach ports, so turning the double-free issue into a use-after-free will be required

Kalloc Zone Spraying Via IOSurface Objects

In order to find a way to turn our double-free into a use-after-free vulnerability, it is worthwhile to take a quick glance at the dynamic memory management of the iOS kernel. iOS and macOS implement a zone allocator similar to the one in FreeBSD.

In general, each zone is associated with some struct that is defined in the kernel. Each allocation from a zone therefore has a fixed size. For example, there is a zone called ipc.ports to allocate struct ipc_port objects that are used to implement the mach IPC mechanism mentioned in the last section. If such a struct is allocated, the allocator first looks for a memory page that is already associated with that zone. If the page still has unallocated space big enough to hold the struct, one chunk of the page is allocated and the pointer to the chunk is returned. If the page is full, a whole new page is allocated and exclusively given to that zone.

More details on this mechanism could be found in the presentations of Stefan Esser, e.g., in this HITB presentation.

A special case are anonymous allocations, which are done by IOMalloc(). Note that IOMalloc() is used in the AllocateMemory() function called by CreateDecoder(). There are several zones that are named kalloc.x where x is a power of 2. For an anonymous allocation, the allocator takes the zone with the smallest chunk size that fits the requested allocation. In the case of the discussed vulnerability, 56 bytes are allocated. Hence, the allocation is a chunk from the zone kalloc.64. Further allocations to exploit this vulnerability , therefore, must come from that zone.

The exploit discussed in this article achieves this in a way that is similar to the one discussed in Ian Beer’s write-up with some modifications.

First, a memory spray technique is used that was discussed in the already mentioned presentation by Stefan Esser and expanded by Adam Donenfeld in his exploit named ziVA11. The IOSurface objects introduced in the previous section have an interesting feature: It is possible to associate properties with an IOSurface, which consist of OSDictionary objects. OSDictionary objects are key-value stores where the values can be other objects, e.g., OSNumber or OSData objects. The interesting part of this feature is that properties are given into the kernel space (remember that IOSurface objects are created in kernel space by a special IOService) in an XML format. This is parsed internally by the function OSUnserializeXML()12. If data, i.e., binary data, is associated with a key in the dictionary, an OSData object is allocated. This comes handy because OSData objects internally allocate space for the stored data from one of the anonymous kalloc.x zones. Hence, if 56 bytes of binary data are associated with a key, the data of the OSData object is stored in kalloc.64. By associating multiple OSData objects as properties with an IOSurface, it is possible to spray into the kalloc.64 zone and to eventually allocate the chunk that was previously freed by a call to DestroyDecoder().

Properties are set via the external method s_set_value() of the IOSurfaceRoot user client which has the selector 9. The method wants a dynamical length input struct. The content has to follow a specific scheme that must be reverse engineered. It can reviewed in the following code excerpt of set_value() which is called by s_set_value().

/* IOSurfaceRootUserClient::set_value(IOSurfaceValueArgs*, unsigned int, IOSurfaceValueResultArgs*)
    */

 1 ulonglong set_value(IOSurfaceRootUserClient *param_1,void *structureInput,uint inputSize,
 2                    void *structureOutput)
 3 
 4 {
 5   uint surface_id;
 6   OSObject *unserialized_obj;
 7   OSArray *array;
 8   OSObject *value;
 9   OSObject *key;
10   OSString *key_string;
11   ulonglong ret;
12   IOSurfaceClient *surface_client;
13   
14   ret = 0xe00002c2;
15   surface_id = *(uint *)structureInput;
16   _IOLockLock(param_1->lock);
17   if (surface_id < *(uint *)&param_1->n_surfaces) {
18     surface_client = param_1->IOSurfaceClientArray[surface_id];
19     surface_client._0_4_ = (int)surface_client;
20     if ((surface_client != (IOSurfaceClient *)0x0) &&
21        (unserialized_obj =
22              OSUnserializeXML((char *)((longlong)structureInput + 8),
23                               ((int)structureInput + inputSize) -
24                               (int)(char *)((longlong)structureInput + 8),(OSString **)0x0),
25        unserialized_obj != (OSObject *)0x0)) {
26       array = (OSArray *)
27               safeMetaCast((OSMetaClassBase *)unserialized_obj,(OSMetaClass *)&gMetaClass);
28       if (array != (OSArray *)0x0) {
29         value = (OSObject *)(*(code *)array->vtable->OSArray::getObject)(array,0);
30         key = (OSObject *)(*(code *)array->vtable->OSArray::getObject)(array,1);
31         if (key == (OSObject *)0x0) {
32           key_string = (OSString *)0x0;
33         }
34         else {
35           key_string = (OSString *)safeMetaCast((OSMetaClassBase *)key,(OSMetaClass *)&gMetaClass);
36         }
37         if (value != (OSObject *)0x0 && (key == (OSObject *)0x0 || key_string != (OSString *)0x0)) {
38           setValue(surface_client,key_string,(uint *)value,structureOutput);
39           surface_id = 0;
40           if ((int)surface_client == 0) {
41             surface_id = 0xe00002c9;
42           }
43           ret = (ulonglong)surface_id;
44         }
45       }

First, the IOSurface’s ID is parsed as can be read in line 15 as a 32-bit value. The actual property encoding is located at offset 8 as shown in line 22, where the call to OSUnserializeXML() occurs. Line 25 and 26 shows that an OSArray is expected. This could be deduced by following the gMetaClass pointer which points to the meta class of OSArray. If an OSArray is found, two virtual method calls occur in line 29 and 30. These are reversed like it was shown in the previous section. This way it is found that OSArray::getObject() is called twice. The second value has to be an OSString while the first one has to be a OSObject instance. Because it is known that a property is an OSDictionary object, it could be assumed that the OSString is the key while the other object is the actual data. By following the call of setValue() in line 38, this could be confirmed.

The spray will therefore do the following: Encode a property that consists of an array of 1024 OSData objects, each of which contains 56 bytes of zeroes. This should lead to enough allocations such that one of the OSData objects allocates the freed chunk from DestroyDecoder(). There is a small catch that can be read in Stefan Esser’s presentation: The data has to be encoded in base64. Luckily, because the exploit is actually written in Objective-C, there are methods to easily do the conversion from a normal C-string to a base64-encoded one.

From the reverse engineering of set_value() it is known that the input struct has to be constructed the following way:

 0-31  IOSurface ID
31-63  0..0
64-..  <array>
       <array>
	   <data>(56 0s base64-encoded)</data> -+
	   ...                                  |-> 1024 times
	   <data>(56 0s base64-encoded)</data> -+
	   </array>
	   <string>
	   KEY
	   </string
	   </array>

Calling s_set_value() via IOConnectCallStructMethod() should eventually allocate the 56 byte chunk. This is implemented in the function spray_kernel_heap_with_zeroes() of the accompanied exploit.

Creating an Information Leak from the Spray

What could be achieved by this? Actually, not much at this point. We have freed a 56 byte chunk using DestroyDecoder() and then re-allocated this chunk using a heap spraying approach.

The ultimate goal of obtaining access to a kernel task port requires an information leakage primitive, so that a fake kernel task port can be built containing the correct kernel pointers.

To this end, IOSurface properties have another interesting feature: By using the external method s_get_value() which is called via the selector 10, the properties of an IOSurface object could be read out from userspace! So, if it is possible to leak some interesting value into that chunk, it could be read from the exploit process. That is exactly, what is done next. Remember, that there are currently two pointers to the same chunk: one in the decoder and one in the properties we sprayed.

One can now free this chunk again by calling DestroyDecoder() via the function trigger_free(). This will not lead to a crash, because the chunk is allocated - albeit that this allocation happened while performing the heap spray. Now the two pointers (in the decoder and in the properties) point to a free 56 byte chunk. So whatever data is stored in this chunk in the future can be read using s_get_value().

But what could be leaked to the chunk and how? A technique that was often used in the past and is used to the current point, is to leak pointers to struct ipc_port objects of the port IPC mechanism that was already mentioned several times in this article. It is not the aim to get too much into this mechanism. However, ports have some features that come in handy in iOS exploitation.

One feature is that every so-called mach message that is sent to a port has an associated type that describes the content of the message. One type, called MACH_MSG_OOL_PORTS_DESCRIPTOR allows to send ports to other process via another port. In that case, the data of the message consists of an array of the port names to be sent, which are saved in a variable of type mach_port_t when the port is allocated.

What allows to exploit this feature via the achieved information leak, is the following property. Every port name that is contained in the message data is converted into a pointer to the struct ipc_port of the port. The array of these pointers is stored in the kalloc.x zone with the smallest chunks that fit the array of pointers. Hence, if 7 ports are sent, a chunk from kalloc.64 is allocated for the port pointers because a pointer is 8 bytes big on a 64-bit system and 7 * 8 == 56. By sending a lot of such mach messages, one message should allocate the freed 56 byte chunk. Simply using s_get_value() as outlined above now allows to read the kernel pointers to the struct ipc_port of any port the exploit process sends.

Moreover, if a message is not received by the port to which the MACH_MSG_OOL_PORTS_DESCRIPTOR message is sent, it stays in a port-internal message queue. As long as this is the case, the 56 byte chunk stays allocated containing the port pointers. To increase the chance to allocate the target 56 bytes chunk, the zone kalloc.64 is sprayed via sending 400 such messages. This spray is implemented in the function ool_ports_descriptor_spray() of the exploit.

Different from the original exploit described in Ian Beer’s write-up, the exploit code actually leaks three port pointers in the function leak_port_pointer(): Two pointers of two ports that are associated with the exploit process, called target_port and own_task_port, and another one which points to the host name port. The last one is a special port owned by the kernel’s process. The other four ports are set to MACH_PORT_NULL. Internally, this is represented by a NULL pointer.

The reason for these leaks is described later in the article. To create the exploitable use-after-free condition, the leak of target_port would be already sufficient as is described in the remainder of this section.

Now that three port pointers are contained in the 56 bytes chunk, these could be read out by using s_get_value() from the IOSurface object. s_get_value() is called like the other external methods before and similar to s_set_value(). The first 4 bytes of the input struct contain the IOSurface ID. At offset 8 follows the key of the property. Because the OSData objects were allocated containing zeroes only, it is easy to identify the pointers by using memmem() like in the original exploit in the output struct. That search is implemented in the function search_for_pointer_leak().

Later on, when creating a fake kernel task port, the information obtained via this leak will be required.

Dangling Port Pointers

The next step in the exploit will be to turn the vulnerability into a use-after-free condition on mach ports. This is what will allow for tricking the kernel into treating user-provided data as a Mach port - which in turn will enable the exploit to craft a fake kernel task port.

After leveraging the information leak, DestroyDecoder() is called one last time via trigger_free() and the IOSurface properties trick is used again in the function spray_kalloc_64_with_port_pointers(). This explains why the usage of MACH_PORT_NULL is important: Because DestroyDecoder() attempts to unlink the 56 chunk from the double-linked list, it is important that prev and next is set to NULL as well as the pointer at the beginning of the chunk. The latter is important because otherwise deallocateKernelMemoryInternal() which is called eventually by DestroyDecoder() would try to call a virtual method.

This time, the data string of the property contains the three leaked port pointers. The pointer of the port saved in target_port is stored twice in the OSData objects, though. The remainder of the 56 bytes is filled with zeroes.

In order to understand how to achieve a use-after-free condition on Mach ports, the following observation is important: When the MACH_MSG_OOL_PORTS_DESCRIPTOR message is sent, eventually ipc_right_copyin() is called. This function increases a reference counter for every port contained in the message via a call to ip_reference().

kern_return_t
ipc_right_copyin(
	ipc_space_t		space,
	mach_port_t		name,
	ipc_entry_t		entry,
	mach_msg_type_name_t	msgt_name,
	boolean_t		deadok,
	ipc_object_t		*objectp,
	ipc_port_t		*sorightp)
{
...
    case MACH_MSG_TYPE_COPY_SEND: {
...
        port->ip_srights++;
		ip_reference(port);
		ip_unlock(port);

When a port is destroyed via the user space function mach_port_destroy(), the queued messages for the port are destroyed as well. In the case of a MACH_MSG_OOL_PORTS_DESCRIPTOR message, ipc_object_destroy() is called for every port.

static unsigned int _ipc_kmsg_clean_invalid_desc = 0;
void
ipc_kmsg_clean_body(
        __unused ipc_kmsg_t     kmsg,  
        mach_msg_type_number_t  number,
        mach_msg_descriptor_t   *saddr)
{

                    case MACH_MSG_OOL_PORTS_DESCRIPTOR: {
                        /* destroy port rights carried in the message */

                        for (j = 0; j < dsc->count; j++) {
                                ipc_object_t object = objects[j];

                                if (!IO_VALID(object)) {
                                        continue;
                                }

                                ipc_object_destroy(object, dsc->disposition);
                        }

ipc_object_destroy() will finally release a reference to the port via a call to ip_release().

That means, while the reference counter for the message was increased by 1 for every MACH_MSG_OOL_PORTS_DESCRIPTOR sent during the spray, it is decreased by 2 for the message contained in the 56 byte chunk. Before the destruction of the port, there were 401 references to target_port: 1 because every process has a reference to allocated ports in a port table and 1 for every of the 400 messages during the spray via the MACH_MSG_OOL_PORTS_DESCRIPTOR messages. That means, destroying the receiving port will lead to a free of target_port because the reference counter is decreased to 0. The process’ port table still contains a dangling reference to the port though. Therefore, it can still be used.

Eventually, the double-free of a chunk in kalloc.64 was turned into a use-after-free vulnerability involving ports.

Zone Garbage Collection

The dangling port pointer is not useful in itself. What iOS exploits try to achieve typically is to turn a controlled port into a so-called kernel task port. This is due to the capabilities of such a port: A kernel task port allows to write and read memory anywhere in kernel and user space. This will eventually be used to get root rights in the exploit process.

To fulfill this aim, the exploit developer has to find a write-primitive for an attacker-controlled port. This exploit will draw on another well-established technique that triggers a zone garbage collection for the ipc.ports zone. A garbage collection in the context of memory kernel zones is something different as it is maybe known by the reader from managed programming languages. For iOS it means the following: If a page, that is associated with a memory zone, is completely empty because all chunks were freed, it stays associated with the zone. Hence, new chunks would be allocated from that page before a completely new page is associated with the zone. However, iOS has a mechanism, called garbage collection, that would de-associate empty pages from zones under memory-pressure, i.e., if a lot of new chunks are allocated via the zone allocator.

This can be reviewed in the function zalloc_internal(), where the following 3 lines are taken from:

if (is_zone_map_nearing_exhaustion()) {
    thread_wakeup((event_t) &vm_pageout_garbage_collect);
 }

is_zone_map_nearing_exhaustion() tests if the zone memory allocator is under pressure:

 1 boolean_t
 2 is_zone_map_nearing_exhaustion(void)
 3 {
 4         uint64_t size = zone_map->size;
 5         uint64_t capacity = vm_map_max(zone_map) - vm_map_min(zone_map);
 6         if (size > ((capacity * zone_map_jetsam_limit) / 100)) {
 7                 return TRUE;
 8         }
 9         return FALSE;   
10 }
11 

size in line 4 is the memory amount currently allocated in all zones. capacity on the other hand in line 5 is the memory amount that is available for allocations from the zone allocator. The if-clause in line 6 will test if size is more than 95% of the available capacity. If that is the case, the following function is eventually called due to thread_wakeup()

void
vm_pageout_garbage_collect(int collect)
{
        if (collect) {
                if (is_zone_map_nearing_exhaustion()) {
                        /*
                         * Woken up by the zone allocator for zone-map-exhaustion jetsams.
                         *
                         * Bail out after calling zone_gc (which triggers the
                         * zone-map-exhaustion jetsams). If we fall through, the subsequent
                         * operations that clear out a bunch of caches might allocate zone
                         * memory themselves (for eg. vm_map operations would need VM map
                         * entries). Since the zone map is almost full at this point, we
                         * could end up with a panic. We just need to quickly jetsam a
                         * process and exit here.
                         *
                         * It could so happen that we were woken up to relieve memory
                         * pressure and the zone map also happened to be near its limit at
                         * the time, in which case we'll skip out early. But that should be
                         * ok; if memory pressure persists, the thread will simply be woken
                         * up again.
                         */
                        consider_zone_gc(TRUE);
...

consider_zone_gc() will call eventually call zone_gc() that in turn calls drop_free_elements() on all zones. The latter is a function that goes through all pages that are associated with a zone and drops them, so that they are available for reallocation.

Therefore, the idea is the following: If the page from ipc.ports that holds the struct ipc_port object of target_port is completely empty, a garbage collection should allow a reallocation of this particular page. Next, a reallocation via some method that allows writing to a kernel page should give enough opportunity in order to get more control of target_port’s struct ipc_port - two methods were already described in this article but there are better options.

Hence, two conditions must be fulfilled to trigger a zone garbage collection: Not only must target_port be freed using the primitive described in the previous sections but also a primitive to put the zone memory allocator under pressure.

The first condition can be fulfilled by applying some kind of “heap feng shui”: If the page containing target_port is only filled with struct ipc_port objects that belong to the exploit process, it should be possible to free the other ports, too, after target_port is freed. This can be achieved by allocating a big array of 10240 ports, called before_ports in the exploit code. This will fill up holes in pages already associated to the zone ipc.ports and will hopefully lead to the allocation of fresh pages to the zone that are filled up with before_ports. Next, target_port is allocated, followed by another port array called after_ports, containing 5120 ports. The latter is necessary because the remainder of the page containing target_port and some of the ports from before_ports is still free and could be filled with ports from other processes.

This way, the complete page should be filled with target_port and some ports from before_ports and after_ports.

Deallocating target_port via the primitive and destroying all ports in before_ports and after_ports should leave the whole page empty. Moreover, because the exploit probably filled a lot more pages with ports than just the one containing target_port, the probability that the page is filled with ports from other processes should be low.

Next, a method to trigger the garbage collection is needed. While Ian Beer has described the method used in the original exploit, the method used in the exploit described here in the function force_GC2() is derived from Ben Sparkes’s (aka iBSparkes) Machswap exploit: Like it is possible to send ports via mach messages to another port, it is also possible to send bigger chunks of memory via mach messages, which is implemented in ool_descriptor_spray(). Such messages are of the type MACH_MSG_OOL_DESCRIPTOR. By sending 200 MACH_MSG_OOL_DESCRIPTOR messages, each sending 4096 - 24 bytes of data13, 800 MB are allocated. The substraction of 24 bytes is necessary, because a MACH_MSG_OOL_DESCRIPTOR message copies a header in front of the actual data. This can be seen from the following struct definition:

 struct vm_map_copy {
  int type;
  vm_object_offset_t offset;
  vm_map_size_t size;
  union {
    struct vm_map_header hdr;      /* ENTRY_LIST */
    vm_object_t          object; /* OBJECT */
    uint8_t              kdata[0]; /* KERNEL_BUFFER */
  } c_u;
};

Due to struct alignment, the first three fields consist of 24 bytes in size. c_u.kdata is the beginning of the actual data from the message. If one traces the code to the function that actually copies the message data to kernel space, they will eventually find a call to vm_map_copyin_kernel_buffer().

 1 static kern_return_t
 2 vm_map_copyin_kernel_buffer( 
 3         vm_map_t        src_map,
 4         vm_map_offset_t src_addr, 
 5         vm_map_size_t   len,
 6         boolean_t       src_destroy,
 7         vm_map_copy_t   *copy_result)
 8 {
 9         kern_return_t kr;
10         vm_map_copy_t copy;
11         vm_size_t kalloc_size;
12 ...
13         kalloc_size = (vm_size_t)(cpy_kdata_hdr_sz + len);
14 
15         copy = (vm_map_copy_t)kalloc(kalloc_size);
16 ...		 

That function is called if the data size is small enough, i.e., smaller than 8 KB, and the copy type defined in the message body by the copy field is MACH_MSG_PHYSICAL_COPY. It copies the message data, that is already copied into the kernel space into a special area of the kernel’s memory map that is used exclusively for physically copied data from MACH_MSG_OOL_DESCRIPTOR messages.

Line 15 shows that kalloc() is called with kalloc_size bytes. kalloc_size is len, which is the size of the data in the message, plus cpy_kdata_hdr_sz. cpy_kdata_hdr_sz is defined as the offset to the c_u.kdata field in struct vm_map_copy as can be seen in the file vm_map.h of the XNU kernel, i.e., the mentioned 24 bytes.

800 MB is 80% of the memory available on an iPhone 5S and triggers a garbage collection reliable. Similar to MACH_MSG_OOL_PORTS_DESCRIPTOR messages, the memory to queue MACH_MSG_OOL_DESCRIPTOR messages is allocated from the anonymous zones due to the kalloc() call, hence from kalloc.4096 in this case. To monitor if a garbage collection was executed, the time needed to send one MACH_MSG_OOL_DESCRIPTOR message is measured. A length of one millisecond clearly shows an occured garbage collection.

Afterwards, the ports used to trigger the garbage collection are destroyed in order to free the allocated chunks from kalloc.4096. Hence, the page containing the dangling port target_port is now associated with the zone kalloc.4096.

The next subsections describe how this can be used to escalate the process’ privileges to root.

Controlling target_port

After the garbage collection, the page containing the port target_port is associated with the zone kalloc.4096. However, the page is free and must be allocated to gain control over target_port. Moreover, the actual page containing target_port must be identified because probably much more were allocated during the garbage collection.

This is done in the function spray_kalloc_4096() by using MACH_MSG_OOL_DESCRIPTOR messages again. However, this time each sent message contains an individual magic value at a specific offset. Each port has a field in its associated struct ipc_port object called ip_context at offset 0x9014. This value can be retrieved via the function mach_port_get_context() in user space. Hence, if the ip_context field is overwritten with an unique value, the page is identifiable by calling that function.

In the original exploit, this was a little more complicated because the ip_context value of every port in the page is overwritten, too. However, the offset of the port in the page itself is known from the previous port pointer leak via the IOSurface object. For example, if the port pointer ends on 0x...1680, the offset of the port in the page would be 0x680 because each page is 0x1000 == 4096 bytes in size. The ip_context field of target_port therefore is at the following offset:

<port offset in page> + <ip_context offset> - <vm_map_copy header>

The subtraction is necessary due to the 24 bytes header that is copied before the message data as was explained in the previous section.

In the exploit, a loop is executed that sends one MACH_MSG_OOL_DESCRIPTOR message at a time, each via a unique port from an array called spray_ports, and sets the ip_context value to 0xdeadf007 + i where i is the loop counter resp. the index to the spray_ports array.

After all messages are sent, the ip_context value of target_port is retrieved via mach_port_get_context(). The port from spray_ports that was used to send the specific MACH_MSG_OOL_DESCRIPTOR message that has overwritten the ip_context value of target_port is then simply calculated via ip_context - 0xdeadf007.

After that, the port from spray_ports is directly destroyed, freeing the page again because the MACH_MSG_OOL_DESCRIPTOR message is dropped, too. Now, the aim is to get control over the port’s struct ipc_port structure via pipes, done by the function control_port_via_pipe(). The reason for that is, that pipes enable to write into the page without freeing it before a new write can happen. This is possible because the pipe will use the buffer as a cache if data, that was written to the pipe is not read out.

The pipe buffer is allocated in the function pipespace():

static int
pipespace(struct pipe *cpipe, int size)
{
        vm_offset_t buffer;

        if (size <= 0) {
                return EINVAL;
        }
 
        if ((buffer = (vm_offset_t)kalloc(size)) == 0) {
                return ENOMEM;
        }

size is taken from the following array:

static const unsigned int pipesize_blocks[] = {512, 1024, 2048, 4096, 4096 * 2, PIPE_SIZE, PIPE_SIZE * 4 };

Initially 512 bytes are allocated. The buffer is increased if needed. By writing 4096 bytes into the pipe, the buffer therefore is increased to 4096 bytes. Reading all 4096 bytes out before writing new content will preserve the buffer size.

Sometimes another page from kalloc.4096 is freed after the free of the page containing target_port happened and before the pipe write occurs. Hence, the exploit creates more than one pipe and writes to all of them 4096 bytes, identifying the pipe which has overwritten ip_context with the magic value trick.

This technique gives reliable control of target_port’s fields. The next subsections will discuss how this is used to turn target_port into a kernel task port and how this can be used to elevate the privileges of the process.

It is left as an exercise for the reader to implement a way to directly control target_port via pipes without the detour using MACH_MSG_OOL_DESCRIPTOR messages.

Creating a Kernel Task Port

Complete control over target_port is all that is needed to create a fake kernel task port via the function create_kernel_task_port(). The exploit code will use different techniques to traverse a couple kernel structs. From these, the necessary information is collected and finally a kernel task port is forged. Because the exploit relies only on the port pointer leaks and the offsets in the kernel structs are fixed15 in each iOS version, no direct KASLR bypass is required16.

To read out the information, a well-known arbitrary read technique is used, which leverages the function pid_for_task(). Like the name suggests, the function will read the PID of a given task port, i.e., a port that belongs to the exploit process17. How this is done, can be reviewed in the following code excerpt.

 1 kern_return_t
 2 pid_for_task(
 3         struct pid_for_task_args *args)
 4 {
 5         mach_port_name_t        t = args->t;
 6         user_addr_t             pid_addr  = args->pid;
 7         proc_t p;
 8         task_t t1;
...
15         t1 = port_name_to_task_inspect(t);
16 
17         if (t1 == TASK_NULL) {
18                 err = KERN_FAILURE;
19                 goto pftout;
20         } else {
21                 p = get_bsdtask_info(t1);
22                 if (p) {
23                         pid  = proc_pid(p);
24                         err = KERN_SUCCESS;
25                 } else if (is_corpsetask(t1)) {
26                         pid = task_pid(t1);
27                         err = KERN_SUCCESS;
28                 } else {
29                         err = KERN_FAILURE;
30                 }
31         }
...
35         (void) copyout((char *) &pid, pid_addr, sizeof(int));

First, the struct task pointer t1 of a given port, which will be target_port, is read in line 15. This is done by reading the kobject pointer from the struct ipc_port of the target_port. Next, get_bsdtask_info() will just return the bsd_info pointer of t1 in line 21. In fact, this is a pointer to a struct proc which has a field pid which has the PID of the process. Last, proc_pid() will read the PID from a given bsd_info by just the bsd_info pointer. pid is finally copied to user space in line 35.

However, this is a perfect read primitive to read 32 bit from kernel space. Because target_port is completely controlled, kobject can point anywhere. In the exploit it was chosen to use an unused part of the page in kalloc.4096 that contains target_port and is big enough to hold a struct task object. Hence, struct task is completely controlled. By setting bsd_info to an arbitrary kernel space address minus the offset of pid in struct proc (0x10 in iOS 11.2.5) the content of that address is read in via proc_pid() and copied to user space18. Doing two consecutive reads will allow to read 64 bit values, too.

Using this primitive allows to read all necessary information to forge the kernel task port. First, the ip_receiver field of the former leaked host port is read. ip_receiver points to a struct that stores all IPC messages that have not been received by the host port. Host ports are a special port type that share by coincidence the ip_receiver with the kernel task port.

The other necessary field to read out is the memory map, stored in the field vm_map of the kernel task struct. This is read from the actual kernel task object. To achieve this, several other structs are traversed. Here, the second task port own_task_port of which the pointer was leaked comes into play. First, the ip_receiver field of this port is read. ip_receiver points to a struct ipc_space object. This contains a pointer to the task that is associated with the process. Like before, the task object which is of type struct task contains a field bsd_info. This points to the struct proc object of the process, i.e., the actual object that stores all information of the exploit process. Each struct proc has a linked list at the beginning that contains a pointer to the former created process. Traversing these pointers will lead eventually to the struct proc of the kernel’s process which is recognized by a PID value of 0. struct proc contains a pointer to the kernel task , too. From this object, the vm_map field is read and copied to the fake kernel task port.

That is all what is needed to create a forged kernel task port. This can be used to read and write arbitrary locations in memory which is implemented via the functions kernel_read() and kernel_write(). They are used to elevate the process’ privileges in a last step.

Elevate Privileges

The last bit of the exploit is a piece of cake. elevate_privileges() calls patch_credentials() which traverses the process list again. This time the PID stored in each struct proc object is read. If it is the PID of the exploit process, the pointer of the struct proc is saved. If it is the PID of launchd, the init process of iOS and hence PID 1, the ucred field is read. ucred is a pointer to a struct cred object which holds information about the process like the UID of user running the process but also a MAC label. The latter defines the capabilities of a process. By overwriting the ucred field in the struct proc object of the exploit process with the ucred field value of launchd, the exploit gains the same capabilities as launchd which is more or less the same as having a process running with full root-privileges. Hence, it is possible to call setuid(0) and gain a process running as root.

Conclusion and Further Steps

The exploit could be improved in many ways. First, it is not a full jailbreak. This would need more work, e.g., in order to circumvent the mandatory code signing of apps. Moreover, the sandbox exploit used needs to sign and install an app. To turn this exploit into a 1-click or 0-click exploit, another sandbox escape, e.g., from Safari, is needed.

Furthermore, the exploit is pretty unstable. At least on an iPhone 5S it runs sucessfully around 70%. The reason is partly that there are many frees and dangling pointers. Reallocation by another process can happen and will eventually crash the system. At least the dangling pointer into kalloc.64 will certainly lead to a crash after the app is closed. Also, at least the detour using {MACH_MSG_OOL_DESCRIPTOR} instead of pipes directly when allocating the page in kalloc.4096 is a factor that could be improved easily.

The original threat exploited a user space process ({mediaserverd) first to break out of the sandbox. As this should not crash or stop as long as the iPhone is on, the dangling pointer shouldn’t be a problem then because it would be associated with this process. It would be interesting to know how reliable the original exploit code is.

Any feedback on this or how to make the exploit more reliable in general is very welcome!

There are some mitigations on more modern versions of iOS, too. At least the garbage collection technique works not anymore on iOS 13. The reason is a new mitigation called zone_require. Basically, it checks if the address of a port is mapped in the zone ipc.ports and not another one like kalloc.4096. The technique itself, to overwrite a port with certain values in order to forge a kernel task port, should work nevertheless. A better write-primitive is needed, though.

There are other mitigations like SMAP, too, and bypassing them would need some more work.

However, at this point a full kernel privilege escalation exploit was developed from scratch - but with a lot of knowledge that is scattered over many articles and presentations. There are many more already developed techniques and many more parts of iOS that could be researched from an attacker’s perspective. But again, it is hoped that this article can establish some kind of starting point for iOS exploit developer novices.

Acknowledgements

A lot of publicly available information significantly eased the development of this exploit. In no particular order:


  1. https://siguza.github.io/psychicpaper/

  2. unc0ver and checkra1n work well. The latter is not available for this iOS version, though.

  3. https://siguza.github.io/psychicpaper/

  4. https://github.com/filsv/iPhoneOSDeviceSupport

  5. Information about the acquisition of kernelcache binaries can be found in the appendix.

  6. https://siguza.github.io/psychicpaper/

  7. Please note that this driver is called AppleD5500 in some versions of iOS.

  8. The decompiled code is taken from Ghidra as are all disassembler screenshots in this article

  9. In order to get such a view in Ghidra a struct IOExternelMethodDispatch with the definition from the header file has to be created.

  10. The output size varies between iOS versions. It seems like a good idea to script the finding of that size value…

  11. It was mentioned in a Twitter thread, which the author cannot find anymore, that this technique was already known before Adam presented it. However, ZiVA was were the author has seen it first.

  12. Which was exploited in the infamous Pegasus exploit-chain.

  13. One page on the iPhone 5S is 4 KB big. Newer iPhone models have a page size of 16 KB.

  14. Speaking for iOS 11.2.5. It is different on other iOS versions.

  15. Offsets are found by researching public exploits for the concrete iOS version, by calculating them from the source code, or by writing a small program that prints them out.

  16. A technique using so-called clock ports is implemented in the exploit source code, though. Maybe it’s of use for later post-exploitation or other exploits.

  17. Tasks and processes are not exactly the same thing but this article will not lay out the details. Just know that processes come from the BSD part of the kernel while Tasks come from Mach.

  18. Note, that the exploit assumes that the iPhone does not implement SMAP. It should be no problem to implement the read primitive in a way that circumvents SMAP, though, as all needed information are available.