Gradient background with dark blue on the left fading to lighter blue on the right.
Table of Contents

Darwin’s new allocator, Xzone, is now part of libmalloc and in open source.In this post, we are providing an analysis of Xzone that is an expanded version of an excerpt from Jonathan Levin’s book “Disarming Code”. (6/2-3) . Get the book at https://NewDebuggingBook.com/

Darwin: libsystem_malloc.dylib and xzone

Apple introduced a new allocator called XZone malloc as far back as 2023, but kept its sources out of the libmalloc distribution, likely due to its support for MTE, which was not acknowledged until Darwin 25 and the A19/M5 chipsets. Early reverse engineering attempts include DataFlow Security's blog. This changed with libmalloc-792, which not only open sources the allocator, but also includes documentation.[xzd]

XZone malloc is derived for Microsoft's mimalloc,[mma] and the 'X' implies "XNU-Style", similar to the kernel's own Zone allocator, which is discussed later in 13/2.2.2. There are parallels in key features, including type isolation, separate metadata regions for allocations, probabilistic guard pages.

Prelude: libsystem_malloc

Darwin's libsystem_malloc.dylib (reexported by libSystem.B.dylib) provides the platform's heap implementation. A detailed discussion of the library (up to Darwin 19) can be found in M-I/9-15. The library uses malloc "zones" - malloc_zone_t structures - allowing multiple providere with a modular implementation of the user-mode memory management functions. The structure has continuously evolved over the years, and the following figure "catches up" with Darwin 25 (structure version 16, libmalloc version 792):

Figure 6/2-43: The libmalloc malloc_zone_t (libmalloc 792, D25) (cf. M-I/9-9)

Typed Memory Operations

Darwin 23 (libmalloc 521, structure version 16) added malloc_type_* variants. The interface (in <malloc/_malloc_type.h) was declared as private and preceded by an ominous warning, but it indicates the introduction of allocation subtypes, similar to what Apple started with XNU's zone allocator in Darwin 19. The zone functions were expanded with malloc_type_* variants of [m/c/re]alloc, memalign and malloc_with_options, which take another argument of malloc_type_id_t - a 64-bit, shown in Listing 6/2-44 (next page), adapted from a comment in libmalloc-792's sources.

Listing 6/2-44: The malloc_type_id_t (from libmalloc-792)

The typed allocations are contingent on the compiler's support, which can be detected using __has_feature(typed_memory_operations). As speculated in the initial, pre-Darwin 25 edition of this book, this indeed hinted at the adoption of ARMv8.5 MTE (and the XZone malloc allocator, discussed in this later edition).

Terminology

XZM divides the heap into mach_vm_allocate()d ranges known as segments. These are presently #defined to be XZM_SEGMENT_SIZE (= 4MB) in size. The smallest unit in a segment is a slice (XZM_SEGMENT_SLICE_SIZE = 16KB, per the page size). Contiguous slices can be grouped into a span. The documentation refers to a span with one or more blocks as a chunk. Chunks can hold up to 2 or 4 blocks, depending on the allocation strategy: Tiny blocks are up to 4KB in a 16KB chunk, Small blocks are up to 32KB in a 64KB chunk, Large chunks go up to 2MB blocks (and one block per chunk), and Huge ones span the entire segment.

XZM performs tiny and small allocations from xzones.* As with other slab allocators, each xzone serves an allocation of some given bin size. There are 40 zone bins, ranging in size from 16 bytes to 32K (preinitialized from _xzm_bin_sizes).

Each bin further supports 4 (iOS26) or 6 (macOS.2) buckets, which separate similar sized allocations, so they don't end up next to one another (similar to XNU's kalloc.type.varnn.size). The aforementioned malloc_type_descriptor_t is used to determine the bucket type (the documentation refers to this as "Type Isolation"). Zone '0' is unused (indexing starts at XZM_XZONE_INDEX_FIRST =1), so this yields 161 (iOS) or 241 (macOS) zones.

The xzm_main_zone structure

XZM interfaces with libmalloc through the malloc_zone_t structure (shown in Figure 2-43), which links its function implementations to the well known memory management APIs. The first zone created is an xzm_main_malloc_zone_s, which can be thought of as a subclass of an xzm_malloc_zone_s, itself a subclass of malloc_zone_t. Additional xzm_malloc_zone_ss may be created, in which case their xzz_main_ref field will hold the reference to the main zone.

The main zone holds the global allocation data for the entire process. There are dozens of fields in this combined structure, and Figure 6/2-45 attempts to highlight some of the important ones:

Figure 6/2-45: The xzm_main_alloc_zone_s, and some important fields

Segments Table(s) and Segment Groups

The Segment Table manages 64GB of virtual memory.** Recall, that each segment is 4MB, so this requires 16K entries. Each xzm_segment_table_entry_s is a mere 32-bits, whose most significant bit (xste_normal) indicates a normal/huge segment, and the lower 31-bits encode the pointer to a metadata pool.

In a manner similar to XNU's zone allocator "packed pointers", this 31-bit value can be resolved back to the actual pointer by _xzm_segment_table_entry_to_segment, which shifts it by XZM_METAPOOL_SEGMENT_BLOCK_SHIFT (14) to efficiently turn it into a pointer to the corresponding struct xzm_segment_s, as shown in the next figure:

* - not to be confused with the malloc_zone_ts, from Figure 6/2-43, referred to as 'mzones'.** - The segment table covers 64GB, but on macOS virtual memory can range far higher, into the theoretical 128TB limit. For this, a two-level xzmz_extended_segment_table is used, holding xzmz_extended_segment_table_entries.

Figure 6/2-46: Locating an xzm_segment_s corresponding to a given VA

The xzm_segment_s is a large structure, holding the segment's xzs_kind, its xzs_segment_body pointer, its count of slices, as well as arrays of XZM_SLICES_PER_SEGMENT (=256) xzm_slice_s structures and their metadata (reclaim identifiers or batch list pointers). The xzm_slice_s structures hold the chunk or free list data, as well as other important data, notably the xzone claiming this slice.

Figure 6/2-47: The xzm_segment_s and the xzm_slice_s

The segment also contains a pointer to a xzm_segment_group_s structure. Groups are typed (data, data_large (reclaimable), ptr_large/data_only, or ptr_xzones).The segment group maintains an array of 27 span queues. A span queue is a linked list of .xzsq_slice_count xzm_slice_s structures. Each segment group is also linked to one range group, but one range group can serve multiple segment groups. Range groups are also typed (data or ptr, with ptr_large apparently only in exclaves). Each range has a base and size (with an optional skip/skip size), and grows either up or down.

The xzone allocation paths

The xzm_malloc() routine is the routine which (eventually) gets called to serve the xzm_malloc_zone allocations, but it does not necessarily immediately use xzones. Figure 6/-48 provides a high-level view of its flow.

Figure 6/2-48: The high-level flow of xzm_malloc()

Allocations over the hard coded XZM_SMALL_BLOCK_SIZE_MAX (=32KB) instead get diverted to _xzm_malloc_large_huge. Such allocations will take the best fitting span, and - if there is a remainder - split it off and put it on a smaller queue. Conversely, deallocating will first check if adjacent free spans can be coalesced with the newly freed memory, potentially coalescing into a bigger span before enqueueing.

Smaller allocations - under 32K - require the zone index, according to the correct bin and bucket, as deduced by _xzm_zone_lookup(…) (expanded to the right in the Figure). This routine looks at the allocation size to find the bin, and the type descriptor to find the bucket. The "well-known" buckets of XZM_XZONE_BUCKET_DATA (0) and XZM_XZONE_BUCKET_OBJC (1) are used for their respective types. Other "generic" ones require one of the remaining (2 (iOS) or 4 (macOS)) buckets. The entropy seeding the choice of general bucket is in the mzone's xzmz_bucketing_keys field, which is populated from the apple[] array's executable_boothash (q.v. Output 6/1-3).

Once the bucket and bin can both be determined, they can be combined to find the zone index. The bin offsets are deduced dynamically and held in the mzone's xzmz_xzone_bin_offsets array, (as n + #buckets), (e.g. 1,5,9... (iOS, 4 buckets) or 1,7,13... (macOS, 6 buckets) for bins 0,1,2,... and so on). Adding the bin offset to the bucket chosen yields the correct zone index. It follows, that the total count of xzones is (1 + bin_count * buckets_per_bin), held in the mzone's xzz_xzone_count field. Recall, this will be (1 + 40 * 4) for iOS, or (1 + 40 * 6) for macOS.

The actual allocation path diverges at this point, in a call to _xzm_zone_malloc(…):

  • Allocations under 256 are potentially handled by a per-thread cache, if enabled.
  • Allocations within an "early_budget" go to the "Early Allocator", an MFM (Arena) based allocator. The documentation cites the rationale for this as mitigating fragmentation in the first allocations which are unlikely to be attacker controlled.
  • Tiny (<4k) allocations are served through _xzm_xzone_malloc_tiny. These use per-chunk free-lists. Because the lists are adjacent to the allocations and potentially corruptible, they are PAC-signed along with a sequence number (as an anti-replay measure).
  • All other allocations (which at this point can only be Small), may be tried through a similar freelist approach if the mzone was initialized with small_freelist. This is only settable on macOS (or in development builds, MallocSmallFreelist=1).
  • Otherwise, the allocations default to the regular small allocation path, which uses a bitmap-based approach to hold the free block list.

The xzones are maintained in the mzone's xzz_xzones field. This is an array of xzz_zone_count struct xzm_zone_s structures. Each structure occupies 0x180 bytes, somewhat resembling its alleged inspiration, (the XNU struct zone ,q.v. 13/2-12) with lists of partial/full/preallocated/empty chunks. Each zone maintains a relation (in xz_segment_group_id) to one of the segment groups, from which it can allocate more chunks and grow.

Xzone allocation lists are stored in the main zone's xzmz_xzone_allocation_slots, pointing to (xzz_slot_count * xzz_xzone_count) xzm_xzone_allocation_slot_s structures (that is, indexed first by slot number, then by zone index). The count is determined by a "slot configuration", which may be XZM_SLOT_SINGLE (only one slot), XZM_SLOT_CPU (by CPU number), or XZM_SLOT_CLUSTER (by CPU cluster), and may be set differently by process identity. It is commonly XZM_SLOT_CPU.

Looking up a pointer's xzone affiliation is straightforward, and the role of _xzm_ptr_lookup(…) (as also shown in the next Figure):

Figure 6/2-49: The high-level flow of xzm_ptr_lookup(…)

The Author's memento(j) tool can display metadata for a given pointer through the "meta" option:

Output 6/2-50: Displaying xzone metadata for a VA using memento(j)

morpheus@eM1nent(~)$ memento /tmp/core meta 0xb28800000
Malloc Zone@0x100f58000: Version 16 DefaultMallocZone
Segment Table: 0x4, Entry @0x100f7d0b0
Segment off @0xb28800000: 0xb288Segment table Entry@0xb288: 0x800403ea -> segment_s#14392@0x100fa8000
{ Body: 0xb28800000, Group: 0x100f7c3d0, Used: 1, 256 slices, 	
 { xzm_slice_s0@0x100fa8838: slice 0xb28800000 claimed by Zone 5},
}
Found in slice 0: 0x0, claimed ZONE: 5 (block_size 16)

Introspection

Somewhat annoyingly, XZone malloc zones (intentionally*) do not use different naming. The mzone's introspect's zone_type field (see Figure 6/2-43) is set to MALLOC_ZONE_TYPE_XZONE (1). The other introspection function pointers can be triggered by calling libmalloc's generic malloc_zone_print(3). The function emits copious information on the groups, spans and xzones in JSON format.**

This function can be injected into processes, but (as discussed throughout Chapter 10), code injection in Darwin is increasingly getting harder. Apple's heap(1) can force introspection in another process using the undocumented(!) -p switch, thanks to its entitlements. The Author's memento(j) provides another way to introspect the XZone heap when used on a PID (assuming task port permission) or a core dump:

Output 6/2-51: Walking the XZone heap with memento(j)

# Use Apple's crude (but entitled) gcore to dump the core of a process 
# (or, assuming task port permission, procexp(j) core)
#
morpheus@eM1nent(~)$ <mark>gcore pid -o /tmp/core</mark>
#
# Then use memento(j) to walk the heap
#
morpheus@eM1nent(~)$ <mark>memento /tmp/core heap</mark>
Analyzing Darwin (libsystem_malloc) heap
Malloc Zone@0x100dd8000: Version 16 DefaultMallocZone 	Introspection: 0x20d63a9a0
Total size: 0x370b0
Malloc XZone with 241 zones, 10 slots
Allocation Slots: 10@0x100dddca0 77120 bytes
241 XZones@0x100dd8240

  { ID: 1, MZone: 1 early_budget: 128, bucket: pure data, block_size: 16, segment_group_id: 0 --,}
  { ID: 2, MZone: 1 early_budget: 128, bucket: obj-c, block_size: 16, segment_group_id: 3 --,}
  ...
  { ID: 10, MZone: 1 early_budget: 0, bucket: general, block_size: 32, segment_group_id: 3 -MTE, 
   preallocated: 0x800100e20838, { allocationSlot 0@100dddde0: { chunk : 0x20004038820e0, } }
	..
  { ID: 240, MZone: 1 early_budget: 0, bucket: general, block_size: 32768, segment_group_id: 3 -MTE,}
"desc": "xzone malloc", 
"segment_size": 4194304, "slice_size": 16384, 
"max_list_config": 2, "initial_slot_config": 1, "slot_initial_threshold": 128, "max_slot_config": 2, 
"bucketing_key": "428bbfb0da2805c63da0595cb30e2177",
"segment_group_ids_count": 4, "segment_group_front_count": 5, "allocation_front_count": 2, 
 "segment_table@0x100dfd0b0" : {
  { Segment@6608(0x674000000):  0x4038b->0x100e2c000 { Body: 0x674000000, Group: 0x100dfb450, Used: 1, 320 slices }}
  { Segment@6609(0x674400000):  0x4038b->0x100e2c000 { Body: 0x674000000, Group: 0x100dfb450, Used: 1, 320 slices }}
  { Segment@11995(0xbb6c00000): 0x80040388->0x100e20000 { Body: 0xbb6c00000, Group: 0x100dfba20, Used: 1, 256 slices }}
  { Segment@11996(0xbb7000000): 0x80040389->0x100e24000 { Body: 0xbb7000000, Group: 0x100dfb830, Used: 1, 256 slices }}
  { Segment@11997(0xbb7400000): 0x8004038a->0x100e28000 { Body: 0xbb7400000, Group: 0x100dfb640, Used: 2, 256 slices }}
 }
"metapools:": 5@0x100dfcf70, 
   0: { @0x100dfcf70 "id" :  (0), "tag" : 1, "slab_size" : 524288, "block_size": 16384 }
	Slab@0x100e1c020 { next: 0x0, base : 0x100e20000 }
   1: { @0x100dfcfb0 "id" :  (1), "tag" : 1, "slab_size" : 262144, "block_size": 65536 }
   2: { @0x100dfcff0 "id" :  (2), "tag" : 1, "slab_size" : 16384, "block_size": 16 }
   3: { @0x100dfd030 "id" :  (3), "tag" : 11, "slab_size" : 32768, "block_size": 1792 }
	Slab@0x100e1c010 { next: 0x0, base : 0x100e14000 }
   4: { @0x100dfd070 "id" :  (4), "tag" : 1, "slab_size" : 16384, "block_size": 16 }
 	Slab@0x100e1c000 { next: 0x0, base : 0x100e1c000 }
"range_groups" : 4@0x100dfb120, 
   0@0x100dfb120:  { Base: 0x674000000-0x674000000 direction: "up" 	next: 0x0	remaining: 0 }
   2@0x100dfb1c0:  { Base: 0xbb7000000-0xdb6000000 direction: "up" 	next: 0xbb7800000	remaining: 1fe800000 }
   3@0x100dfb210:  { Base: 0xbb7000000-0xdb8000000 direction: "down" 	next: 0xbb6c00000	remaining: 200c00000 }
"segment_groups" : 15@0x100dfb260, 
   {"id" : "data" @0x100dfb260, 	"range_group" : 0x100dfb120,	"main_ref" : 0x100dd8000 }
   {"id" : "data_large" @0x100dfb450, 	"range_group" : 0x100dfb120,	"main_ref" : 0x100dd8000 }
   {"id" : "pointer_large"@0x100dfb640, "range_group" : 0x100dfb1c0,	xzsg_spans[26]: 0x100e28a78, 	"main_ref" : 0x100dd8000 }
	...
   {"id" : "pointer_xzones" @0x100dfcd80,  "range_group" : 0x100dfb210,	"main_ref" : 0x100dd8000 }
"mfm_address": 0x100f74000, 
"batch_size:" : 0x10, "ptr_bucket_count:"  : 0x4, "bin_count:"  : 0x40, 
"bin_sizes:" : 0x100dfa080, "bin_bucket_counts:"  : 0x100dfa1c0, "bin_bucket_offsets:"  : 0x100dfa1e8, 
	{ 0: "bin_size" : 16 , "offset" : 1, 6 Buckets }
	{ 1: "bin_size" : 32 , "offset" : 7, 6 Buckets }
...
	{ 38: "bin_size" : 28672 , "offset" : 153, 6 Buckets }
	{ 39: "bin_size" : 32768 , "offset" : 157, 6 Buckets }

* - A comment right after _malloc_initialize's call to xzm_main_malloc_zone_create() blames this on hard coding "DefaultMallocZone" into Apple's tools and codebase.

** - The introspection code (in 26.2) has a bug, when the format string "zu" is used without a "%", resulting in "zu" appearing for numerous properties of range groups and spans. memento(j) is compiled with -Wall, thus avoiding this format string snafu.

We at DFF Are constantly keeping abreast of developments in the world’s leading operating systems, seeing how they impact both exploitability and forensics capabilities. Our aim is to stay ahead of the curve , delivering the best possible means to detect and deal with ever evolving advanced persistent threats, in a constantly shifting landscape. We’re hiring! Join us and work alongside some of the best securityresearchers in the industry!

We are hiring!

We are hiring for multiple positions and encourage you to send us your CV - more details
Dark blue and black gradient background with smooth lighting effects.