In iOS 14 and 15 Apple shipped several iOS kernel mitigations that drastically changed iOS exploitation, and many researchers documented these mitigations publicly. In iOS 17 and 18, Apple introduced several interesting iOS userspace mitigations, however they were not discussed in as much detail. In this blog post we’ll discuss some of these mitigations by using the BLASTPASS exploit as a case study, and explore how relevant these exploit primitives are in iOS 18.
BLASTPASS has been documented thoroughly by Ian Beer in his P0 blogpost, and we recommend reading Ian’s blog post before continuing here.
BLASTPASS Recap
[ Huffman Table ] -> [ Heap Metadata ] -> [CFSet Backing Buffer]--XXX-> [ LEGIT CFObject ]
|
|
+----------------> [ FAKE CFReadStream ]
|
+----> 0x414141414
The above diagram is a simplified version of the exploit chain, where:
- The attackers used a 3 byte semi-controlled out-of-bounds (OOB) write vulnerability to corrupt heap metadata;
- Heap metadata corruption is used as a 2nd stage corruption to corrupt the backing buffer of a
CFSet
; - The
CFSet
backing buffer contains a pointer to a legitimateCFObject
, which is corrupted to point to a fakeCFReadStream
object; CFRelease
is called on the fakeCFReadStream
object to start a JOP chain;- Code execution is accomplished.
The two key primitives the attackers used are:
- The ability to corrupt heap metadata via an OOB write vulnerability;
- The ability to
CFRelease
a fakeCFObject
.
Let’s see how these hold up in iOS 18.
iOS Heap Primer
In iOS 16 the libmalloc sources contained multiple allocators, which included NanoV1
, NanoV2
, ScalableZone
, and some debug only allocators like SanitizerMalloc
. At the time, iOS devices only used NanoV2
and ScalableZone
.
NanoV2
The NanoV2 allocator was shipped in libmalloc around 2018 (iOS 12), and was only meant for allocations in the size range 16 – 256 bytes. Although it is a fairly recent allocator, it has always used a static heap base:
Platform | Heap Base |
---|---|
iOS 16 | 0x280000000 |
iOS 17 | 0x280000000 |
iOS 18 | 0x300000000 |
macOS | 0x600000000000 |
The NanoV2 allocator is outside of the scope of this post, however is worth mentioning as it is still used in iOS 18.
Scalable Zone Allocator
The Scalable Zone allocator is one of the main allocators used in some processes, even on iOS 18, and its foundational code base was published in 2009. This allocator splits allocations into tiny
, small
, and large
regions based on the size of the allocation.
The tiny
range is used for allocations of 512 (0x200)
bytes and smaller. In this range, each region is laid out as a heap, followed by a header block. These regions are a fixed size of 1MiB.
typedef struct tiny_region
{
tiny_block_t blocks[NUM_TINY_BLOCKS];
region_trailer_t trailer;
// The interleaved bit arrays comprising the header and inuse bitfields.
// The unused bits of each component in the last pair will be initialized to sentinel values.
tiny_header_inuse_pair_t pairs[CEIL_NUM_TINY_BLOCKS_WORDS];
uint8_t pad[TINY_REGION_SIZE - (NUM_TINY_BLOCKS * sizeof(tiny_block_t)) - TINY_METADATA_SIZE];
} *tiny_region_t;
The small
range is used for allocations of 15360 (0x3c00)
bytes and smaller. In this range, each region is laid out as a heap, followed by a metadata array. These regions are a fixed size of 8MiB.
typedef struct small_region
{
small_block_t blocks[NUM_SMALL_BLOCKS];
region_trailer_t trailer;
msize_t small_meta_words[NUM_SMALL_BLOCKS];
uint8_t pad[SMALL_REGION_SIZE - (NUM_SMALL_BLOCKS * sizeof(small_block_t)) - SMALL_METADATA_SIZE];
} *small_region_t;
The source code provides various helper macros to access different information via the allocation ptr
for each allocator size. For the scope of this write-up we are interested in the small
range, which provides macros to look up the region and metadata.
/*
* Locate the heap base for a pointer known to be within a small region.
*/
#define SMALL_REGION_FOR_PTR(_p) ((void *)((uintptr_t)(_p) & ~((1 << SMALL_BLOCKS_ALIGN) - 1)))
/*
* Locate the metadata base for a pointer known to be within a small region.
*/
#define SMALL_META_HEADER_FOR_PTR(_p) (((small_region_t)SMALL_REGION_FOR_PTR(_p))->small_meta_words)
/*
* Compute the metadata index for a pointer known to be within a small region.
*/
#define SMALL_META_INDEX_FOR_PTR(_p) (((uintptr_t)(_p) >> SHIFT_SMALL_QUANTUM) & (NUM_SMALL_CEIL_BLOCKS - 1))
/*
* Find the metadata word for a pointer known to be within a small region.
*/
#define SMALL_METADATA_FOR_PTR(_p) (SMALL_META_HEADER_FOR_PTR(_p) + SMALL_META_INDEX_FOR_PTR(_p))
If we dump the vmmap
output of any process using scalable allocator, we see that these memory regions are laid out adjacent to each other:
REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD
...
MALLOC_SMALL 133800000-134000000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 134000000-134800000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 134800000-135000000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 135000000-135800000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 135800000-136000000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 136000000-136800000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 136800000-137000000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
MALLOC_SMALL 137000000-137800000 [ 8192K 8192K 8192K 0K] rw-/rwx SM=PRV
If we consider the fact that there are no guard pages among these regions, the layout would look like so:
+-------------------------+
| |
+--------------------------------------------+ +----------------------------------------------+
... [block] [block] [block] [huffman table] | | [trailer] [meta words] ... [block] [block] ...
+--------------------------------------------+ +----------------------------------------------+
An example of the information retrieved from metadata is as follows:
/*
* Determine whether a pointer known to be within a small region points to memory which is free.
*/
#define SMALL_PTR_IS_FREE(_p) (*SMALL_METADATA_FOR_PTR(_p) & SMALL_IS_FREE)
/*
* Extract the msize value for a pointer known to be within a small region.
*/
#define SMALL_PTR_SIZE(_p) (*SMALL_METADATA_FOR_PTR(_p) & ~SMALL_IS_FREE)
Here we can see that attacking the heap metadata for each region (e.g. corrupting SMALL_IS_FREE
state or the msize
value) makes the scalable zone allocator a very interesting target; and that’s what the attackers did.
Next, we can investigate how well things hold up in terms of linear and semi-linear heap metadata corruption in iOS 18.
XZone Allocator
Apple introduced a new allocator in iOS 17.0 beta 1 called XZone Malloc (also abbreviated as XZM). Unlike the previous ancient allocator we discussed, this allocator was designed with security in mind. It’s not just meant to serve some of the most critical 0-click attack surfaces on iOS, but also Exclaves, DriverKit, and more. It seems to be heavily inspired by the kernel heap mitigations introduced in iOS 14 and 15. Unfortunately, xzone code is stripped from public libmalloc sources. Some of XZM’s key features include:
- Heap separation (
data
andpointers
); - OOL heap metadata;
- And type segregation.
From the perspective of BLASTDOOR exploitation, we only care about OOL heap metadata. However, we need to learn about some XZM terminology before continuing.
Segments Groups
XZM has four different kinds of segment groups: data
, data_large
, pointer_xzones
, and pointer_large
. As their name suggests. they seem to be classified among allocation types (pointer & data, very similar to kernel’s KHEAP_DEFAULT
and KHEAP_DATA_BUFFERS
heaps), along with some size based classification with regard to the _large
group prefix.
Note that macOS and iOS set up segment group ranges quite differently, as per xzm_main_malloc_zone_init_range_groups
.
Segments
XZM segments consists of two different VM maps: a segment body
, and a metadata slab
. The segment body on iOS seems to be 4MiB in size, whereas the metadata slab appears to be 512KiB.
Chunks
Chunks are groups of one or more memory pages (known as slices
in XZM terms). XZM defines at least 8 types of chunks:
chunk name | xzcb_kind | chunk size |
---|---|---|
XZM_SLICE_KIND_SINGLE_FREE | 1 | - |
XZM_SLICE_KIND_TINY_CHUNK | 2 | 0x4000 |
XZM_SLICE_KIND_MULTI_FREE | 3 | - |
XZM_SLICE_KIND_MULTI_BODY | 4 | - |
XZM_SLICE_KIND_SMALL_CHUNK | 5 | 0x10000 |
XZM_SLICE_KIND_LARGE_CHUNK | 6 | - |
XZM_SLICE_KIND_HUGE_CHUNK | 7 | - |
XZM_SLICE_KIND_GUARD | 8 | 0x4000 |
Chunk sizes are variable in some cases, ie. with large
and huge
chunks. Chunks are carved out of the segment body and follow a specific xzone ID, so a chunk can only have allocations (aka blocks
) of the same size and same bucket ID (discussed later). Whenever a tiny or small chunk is requested, the allocator randomly maps XZM_SLICE_KIND_GUARD
guard pages between these chunks.
OOL Heap Metadata
Using this completely new allocator, Apple moved all of the chunk metadata out of the segment body and into the metadata slabs. XZM uses a different mask
when mapping segment body and metadata slab allocations to keep them away from each other, preventing metadata corruption via heap OOB write vulnerabilities.
Going back to BLASTPASS, we see that heap metadata corruption using this bug is not possible anymore. We could consider a different corruption victim, however this is a bit harder now; thanks to type segregation provided by XZM.
Type Segregation
XZM introduces type segregation for allocations on the iOS heap by implementing memory bucketing. This bucketing works such that chunks
are tied to a specific bucket ID. The number of pointer buckets varies across devices, but can also be controlled by environment variables.
Type ID
Unlike the iOS Kernel, we don’t have dedicated kalloc_type
structures for each allocation. Instead, all of the information for a specific object is stored in an 8-byte malloc_type_id_t
, and is used at every *alloc()
call site. Here’s what a malloc_type
call site looks like:
MOV X1, #0x1010040539B097A ; type_id
MOV W0, #0x830 ; size
BL _malloc_type_malloc
Most of the type_id
bits mean something, but only the following bits seem to contribute in type segregation:
42:43 (type kind)
^
|
0x1010040539B097A
--- ----------> 0:31 (hash)
|
48:56 (data bits)
Bucketing
A type ID’s hash
, type kind
, and data bits
determine the bucket ID an allocation belongs to.
Bits 48:56 describe the structure of an allocation. If bit 56 is set and bits 48:55 are unset, it’s a data
allocation. Otherwise, it’s a pointer
allocation. data
allocations are meant to be tied down to data
segments, which are not supposed to contain any pointer
chunks. If a type ID is marked as a data
allocation, it’s supposed to be locked down to bucket ID 0.
Bits 42 and 43 define the type kind
of the allocation; ie. a C, C++, Obj-C or a Swift object. If a type ID describes the type kind
as Obj-C
, then the allocation is locked down to bucket ID 1. These two cases are the only scenarios where you can statically determine which xzone an allocation belongs to.
For other allocations, the bucket ID is determined at runtime using the hash
component of the type ID.
Note: when we talk about bucketing, we generally talk about small and tiny chunks. Type segregation works differently for large chunks, and is completely disabled for huge chunks.
Bucket Randomness
On app/daemon launch, the kernel computes a sha256 hash called the executable_boothash
(also known as bucketing key
in XZM terms). Based on the number of pointer buckets, the bucketing key is then computed with the hash
part of the type ID, and a bucket ID is determined. Thus, we can’t predict a specific type ID to always get assigned to a determined bucket ID, as executable_boothash
differs between device boots.
The kind of allocation the huffman table’s type ID defines is left as an exercise for the reader.
Malloc Type Weakness
If library developers are not completely aware of how to implement malloc type correctly, it could lead to malloc type not being as effective as intended. For example, the C++ operator new
is meant to allocate different C++ objects, but from the same call site. Therefore, Apple added dynamic type ID generation in the function:
// __Znwm / operator new(size_t __sz):
...
MOV X8, X30 // Use `LR` as diversifier
XPACI X8 // Strip down PAC
MOV X20, #0xC0000000000 // C++ type_kind bits
BFXIL X20, X8, #2, #0x20 // Generate dynamic type id
MOV X0, X19 ; size
MOV X1, X20 ; type_id
BL _malloc_type_malloc
...
However, for all of iOS 17 there was no such measure taken for other critical call sites like CoreFoundation’s CFAllocatorAllocate
API. Apple fixed this weakness in iOS 18.0 beta 5 by introducing CFAllocatorAllocateTyped
:
// _CFRuntimeCreateInstance calculating dynamic type id for every different CFTypeID
type_id = (403 * (cf_typeid ^ 0xC9DC5)) & 0xFFFFFLL | 0xCF000000LL;
if ((*v8 & 0x10) != 0)
{
v23 = malloc_default_zone();
v22 = malloc_type_zone_memalign(v23, v18, v20 & 0xFFFFFFFFFFFFFFF0LL, type_id);
}
else
{
// if ...
v22 = CFAllocatorAllocateTyped(v13, v20 & 0xFFFFFFFFFFFFFFF0LL, type_id);
}
Summarizing the Various Allocators
iOS version | Critical Processes | Non-Critical Processes | 3rd Party |
---|---|---|---|
iOS 16 | Scalable + NanoV2 | Scalable + NanoV2 | Scalable + NanoV2 |
iOS 17 | XZM | Scalable + NanoV2 | Scalable + NanoV2 |
iOS 18 | XZM | Scalable + NanoV2 | XZM + NanoV2 |
iOS 18.4 | XZM | ??? | XZM |
Note: Critical Processes mostly include processes that are frequently used in n-click chains.
There are a lot of exceptions among different processes influenced by feature flags, environment variables, hardware, or even blacklisting in libmalloc. Therefore, it’s best to check the allocator being used in your target process dynamically. In iOS 18.4, Apple made many changes to the libmalloc codebase, including features like thread caching for allocations sized <=0x100
(also out of the scope of this blog post). However, it appears to have side effects in multiple 3rd party apps, so at the time of writing Apple explicitly force these apps to use the older NanoV2 allocator (instead of XZM) for small allocations:
// __malloc_init
v47 = getprogname();
if (!_platform_strcmp(v47, "LetsGoClient")
|| (v48 = getprogname(), !_platform_strcmp(v48, "PESmobile"))
|| (v49 = getprogname(), !_platform_strcmp(v49, "DeltaForceClient"))
|| (v50 = getprogname(), !_platform_strcmp(v50, "MBS_PROD"))
|| (v51 = getprogname(), !_platform_strcmp(v51, "CitiAuthenticator"))
|| (v52 = getprogname(), !_platform_strcmp(v52, "Banco Galicia")))
{
if ((dyld_program_sdk_at_least(0x12040000000002LL) & 1) == 0)
malloc_nano_on_xzone_override = 2;
}
This concludes the heap internals part of this blog. Please note that all of the xzone terminology used in this blog is based on function names or error strings, so there may be inconsistencies once xzone sources are made public.
CoreFoundation Shenanigans
The attackers are able to start a JOP chain by calling CFRelease
on a fake CFReadStream
object. This JOP technique relies on the fact that whenever the reference count of a CFObject
is about to become 0, it tries to call the finalize
routine of that specific CFObject
. If you control the body of a CFReadStream
object, then CFReadStream
’s finalize
routine gives you access to a controlled BLRAAZ. The attackers used this primitive to gain code execution.
Let’s look at the structure of a CFObject
, and how they are able to fake it:
+0x0 +0x8 +0xC +0x10
+-----------------------+---------------+-------------+
| ISA | CFInfo | RC |
+-----------------------+---------------+-------------+
An important note here is that the ISA pointer is PAC-authenticated. How could the attackers fake it without a heap info leak? Unlike Obj-C objects, CF objects don’t necessarily use the ISA at +0x0
, so it was possible to set the ISA to zero and use CF APIs on these CFObjects without issue. The very next 8 bytes after the ISA (CFInfo
) describe the CFObject
, so forging a CFObject
out of thin air was possible.
Apple added an ISA check in CF_IS_OBJC
, which has over 300 cross-references in different CF APIs. We will simply crash if we try to use CFRelease
on a CFObject
with a NULL ISA:
...
Application Specific Information:
CF objects must have a non-zero isa
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 CoreFoundation 0x197dad128 CF_IS_OBJC.cold.1 + 16
1 CoreFoundation 0x197ce8504 CF_IS_OBJC + 296
2 CoreFoundation 0x197ce8394 CFRelease + 60
...
Things don’t end here, as Apple modified the __CFRuntimeBase
structure in 17.0 beta 5 to reduce the reference count field to just 2 bytes. This gives them enough space to place a 3 byte data PAC signature for the CFTypeID
during initialization of CFObjects:
+0x0 +0x8 +0xB +0xE +0x10
+-----------------------+----------+----------+-------+
| ISA | CFInfo | PAC | RC |
+-----------------------+----------+----------+-------+
The signature generation excludes the sub-type ID byte, which is used to define whether an object is mutable, non-mutable, etc. This signature is checked in a lot of places in CoreFoundation, including right before the finalize
call inside of CFRelease
. Therefore, it’s no longer possible to call the finalize
routine from a fake CFObject
.
That concludes our investigation of the two main exploit primitives used in the iOS 16 BLASTPASS chain. Due to Apple’s mitigations, both of these primitives are no longer viable.
Honorable Mention: Harder Sandboxing
Although we don’t know how the attackers proceeded with the exploit chain after getting code execution in MessagesBlastDoorService
, it is certain that any kind of iOS attack is done by researching the target daemon’s capabilities. This includes file system access, kernel attack surface access, userspace pivot access, and so forth. The sandbox profile (governed by the kernel’s Sandbox.kext
) dictates this access, and we saw in iOS 18.4 that Apple started shipping much stricter sandbox profiles with very selective access to kernel APIs.
Apple also made some major architectural changes to one of the most attacked sandbox escape daemons on iOS, mediaserverd
. It acted as a great victim for a usermode pivot because of how much kernel attack surface it exposed, whilst itself exposing a large attack surface (including some RCE surfaces). In iOS 17 and iOS 18, Apple split mediaserverd
across several other daemons including mediaplaybackd
, cameracaptured
, audiomxd
, AudioConvertorService
, airplayd
, et al. These daemons have a very strict sandbox, where Apple tried to minimise the kernel attack surface as much as possible. As such, an attacker would now need to look for vulnerabilities in very specific daemons, based on an existing n-stage userspace pivot or kernel exploit.
.