The previous post ended on a service doing its job for the wrong caller. That buys a more privileged userland context and never touches kernel memory. This one starts with a bug that does corrupt kernel memory, and with what such a bug is actually worth.
A heap overflow writes past the end of your object and into whatever the allocator put next; a use-after-free hands you an object that something else now owns. The bug is the cheap part in both cases. The work is deciding what the kernel puts next to it, or into it, and since iOS 15 the allocator has been redesigned to make that decision unavailable.
Everything here is public: Apple’s open-source XNU, Apple’s own advisories, and published exploits. The vulnerability walked in detail, CVE-2023-23536, was fixed in iOS 16.4 and macOS 13.3 in March 2023, and its exploit has been open source since. Nothing below is private or unpatched.
From a bug to a primitive
The vocabulary is worth fixing first, because every writeup uses it and few define it. A primitive is a repeatable operation with an interface: inputs you choose, an effect you can predict, and no panic afterwards. A bug is not a primitive. A bug is one event with an outcome you mostly do not control.
Three stages separate them. Limited control is what the bug itself hands you, usually a write of the wrong size in the wrong place, or a reference to memory that has been freed. An intermediate primitive is that bug applied to a victim object you chose, so that one field of that object now holds a value you picked. Arbitrary read and write, KRKW in kfd’s vocabulary, for kernel read and kernel write, is a pair of operations that fetch or store bytes at any kernel address you name, repeatedly, without disturbing anything else.
The third stage used to have a fixed shape and a name. tfp0, from task_for_pid(0), a send right to the kernel’s own task port, gave the kernel’s address space through the same Mach calls that work on any other process. Apple closed the userland route to it, chains moved to forging a fake ipc_port in memory they controlled, and the answer to that was a provenance check: zone_id_require_aligned(ZONE_ID_IPC_PORT, port), in osfmk/ipc/ipc_port.c, verifies that a pointer being treated as a port really came from the zone ports come from. You build a read and a write, and then you spend them on something specific, which is the last section of this post.
The zone allocator
Kernel heap objects come from zalloc. A zone owns a set of pages carved into equal-size elements and hands them out one at a time; kalloc(size) routes to the zone whose element size fits, or straight to the VM when the size is above the largest class. For a reader coming from Linux, a zone is a slab cache and kalloc.64 is kmalloc-64.
Two details decide what can be done with a freed element.
The first is where the free list lives, which is not inside the freed elements. Each run of zone pages has a struct zone_page_metadata, sixteen bytes, held in a separate array indexed by page, and the free elements are tracked in a bitmap field inside it (osfmk/kern/zalloc.c). There is no next pointer sitting in a freed element for an overflow to reach, which removes the entire family of free-list corruption techniques.
The second is sequestering. When zone garbage collection reclaims the physical pages under a chunk, the virtual address range stays assigned to that zone (z_pageq_va, zone_submap_is_sequestered()). Physical memory returns to the system; the address does not. A virtual address that has held an ipc_port will not later hold something else.
So the move is always the same: free the victim, get the kernel to allocate an object you control into the slot it left behind, and a field you can drive now sits where a field the kernel trusts used to be. Everything that follows is about which objects are allowed into that slot.
kalloc_type, or why the same size is not enough
Before iOS 15, the answer was “anything of the same size”. Every 64-byte kalloc() came from one kalloc.64 zone, so a freed 64-byte victim could be replaced by any 64-byte object you knew how to allocate on demand. That is the technique kalloc_type was built to end.
Each allocation site in XNU now compiles to a view: a record in the __DATA_CONST,__kalloc_type section naming the type, carrying its size, and carrying a signature computed at compile time by __builtin_xnu_type_signature. The signature describes the type’s layout one eight-byte granule at a time, and osfmk/kern/kalloc.h lists the alphabet:
KT_GRANULE_PADDING = 0,
KT_GRANULE_POINTER = 1,
KT_GRANULE_DATA = 2,
KT_GRANULE_DUAL = 4,
KT_GRANULE_PAC = 8
Two structures of identical size whose pointers sit in different places have different signatures, and that is the entire idea.
At boot, the views are sorted by signature, collapsed into groups, and spread across zones named by one line in osfmk/kern/kalloc.c:
snprintf(z_name, MAX_ZONE_NAME, "kalloc.type%u.%zu", i,
which is where a name like kalloc.type3.48 comes from: bucket 3 of the 48-byte size class. The distribution is shuffled with kmem_shuffle() and the hash seeded from early_random(), so a type’s zone is fixed for one boot and different on the next. Allocations that contain no pointers at all go to a separate heap, data.kalloc.N, which never mixes with anything holding a pointer.
The replacement object now has to match the victim on size class, on signature group, and on this boot’s bucket. Apple published the arithmetic using SockPuppet, Ned Williamson’s 2019 bug, as the worked example: in the boot they analysed, its victim object ip6_pktopts landed in a bucket with ten other types, and a single replacement type gives an 8% success rate. Roughly fifteen separate replacement strategies would be needed to reach 75%, and an exploit implementing all 26 candidate types is still capped near 92%, because on some boots the bucket contains nothing useful at all.
Hands-on: watching a spray land
None of that needs a device or a debugger to see. macOS ships zprint, which reads the live zone map, and sudo is enough. Everything below was run on macOS 26.4.1 (build 25E253) on Apple Silicon, with System Integrity Protection on.
The columns first, then every zone whose elements are 16 bytes:
$ zprint | head -3
elem cur max cur max cur alloc alloc
zone name size size size #elts #elts inuse size count
-------------------------------------------------------------------------------------------------------------
$ sudo zprint | awk '$1 ~ /^(kalloc\.type[0-9]+|data\.kalloc)\.16$/'
data.kalloc.16 16 10016K 10016K 641024 641024 491192 16K 1024 C
kalloc.type0.16 16 288K 288K 18432 18432 8031 16K 1024 C
kalloc.type1.16 16 32K 32K 2048 2048 1780 16K 1024 C
kalloc.type2.16 16 400K 432K 25600 27648 12851 16K 1024 C
kalloc.type3.16 16 16K 16K 1024 1024 6 16K 1024 C
kalloc.type4.16 16 144K 144K 9216 9216 8636 16K 1024 C
kalloc.type5.16 16 16K 16K 1024 1024 0 16K 1024 C
kalloc.type6.16 16 16K 16K 1024 1024 0 16K 1024 C
Seven zones, one element size, plus the pointer-free heap. Across all size classes this machine has 260 of them, and the flat kalloc.N zones are gone entirely:
$ awk '$1 ~ /^kalloc\.type[0-9]+\.[0-9]+$/' /tmp/z-before.txt | wc -l
260
$ awk '$1 ~ /^kalloc\.[0-9]+$/' /tmp/z-before.txt
$
Now the other half. bsd/kern/posix_sem.c allocates three types through kalloc_type() on the way through sem_open(), so a POSIX semaphore is a spray any process can drive with no privilege at all. Twenty lines:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <semaphore.h>
#include <unistd.h>
#include <sys/resource.h>
int main(int argc, char **argv)
{
int n = (argc > 1) ? atoi(argv[1]) : 4000;
struct rlimit rl;
getrlimit(RLIMIT_NOFILE, &rl);
rl.rlim_cur = rl.rlim_max;
if (setrlimit(RLIMIT_NOFILE, &rl) != 0) {
rl.rlim_cur = 10240;
setrlimit(RLIMIT_NOFILE, &rl);
}
int made = 0;
for (int i = 0; i < n; i++) {
char name[32];
snprintf(name, sizeof(name), "/kt%d", i);
sem_t *s = sem_open(name, O_CREAT | O_EXCL, 0600, 1);
if (s == SEM_FAILED) {
perror("sem_open");
break;
}
sem_unlink(name); /* release the name, keep the object */
made++;
}
printf("%d semaphores alive, holding for 30s\n", made);
fflush(stdout);
sleep(30);
return 0;
}
sem_unlink() immediately after sem_open() is the interesting line. It decrements the use count and drops the name, but the pseminfo survives as long as a descriptor refers to it, so the allocations stay alive while nothing is left behind in the global namespace.
Snapshot the zones, run it, snapshot again, print the zones whose line changed:
$ sudo zprint > /tmp/z-before.txt
$ /tmp/spray 4000 & sleep 3 && sudo zprint > /tmp/z-after.txt
4000 semaphores alive, holding for 30s
$ awk 'NR==FNR{a[$1]=$0;next} ($1 in a) && a[$1]!=$0 {print "- "a[$1]; print "+ "$0}' \
/tmp/z-before.txt /tmp/z-after.txt |
grep -E 'fileproc|kalloc\.type6\.16|kalloc\.type0\.96|kalloc\.type0\.16|kalloc\.type2\.16'
- fileproc 32 336K 368K 10752 11776 6330 16K 512 C
+ fileproc 32 336K 368K 10752 11776 10348 16K 512 C
- kalloc.type0.16 16 288K 288K 18432 18432 8031 16K 1024 C
+ kalloc.type0.16 16 288K 288K 18432 18432 8028 16K 1024 C
- kalloc.type2.16 16 400K 432K 25600 27648 12851 16K 1024 C
+ kalloc.type2.16 16 400K 432K 25600 27648 12829 16K 1024 C
- kalloc.type6.16 16 16K 16K 1024 1024 0 16K 1024 C
+ kalloc.type6.16 16 64K 64K 4096 4096 4000 16K 1024 C
- kalloc.type0.96 96 16K 944K 170 10069 76 16K 170 C
+ kalloc.type0.96 96 384K 944K 4096 10069 4076 16K 170 C
Read the cur inuse column, the seventh. kalloc.type6.16 went from 0 to 4000, and the zone had to grow from 16K to 64K to take them. kalloc.type0.96 went from 76 to 4076. fileproc, which has its own dedicated zone rather than a shared one, went up by just over 4000 as each semaphore took a descriptor. Those are struct psemnode, eight bytes and therefore in the 16-byte class, struct pseminfo in the 96-byte class, and the file object behind the descriptor.
The two neighbours are the point. kalloc.type0.16 and kalloc.type2.16 hold 16-byte elements exactly like kalloc.type6.16, they are busy zones with thousands of live objects, and across the spray they moved by three and by twenty-two, downward in both cases, which is ordinary system noise. Four thousand objects went into one zone and were invisible to its same-size neighbours. Before iOS 15 all three of those zones were one zone.
The output has two limits worth stating. First, zprint names a type only where the kernel asked it to: a view gets its own line, like kalloc.type7.512[site.struct coalition], when it was declared with KT_PRIV_ACCT for private accounting, and otherwise its allocations accumulate anonymously into the zone’s totals (osfmk/kern/kalloc.c). psemnode is not one of the named ones. Second, and for the same reason, what identified kalloc.type6.16 above was a measurement, not a lookup: allocate a known number of a known type, see which zone moves by that number. Recovering the full type-to-zone map means reading the __kalloc_type section out of a kernelcache, which is a later post. Either way the answer is only true until the next boot.
PhysPuppet: six steps to a dangling PTE
The other route stops arguing about which object may follow yours at a virtual address, and keeps the physical page after the kernel has stopped accounting for it.
The bug is CVE-2023-23536, found by Félix Poulin-Bélanger, fixed in iOS 16.4 and macOS 13.3, and credited by Apple to him and David Pan Ogea. Its preconditions are what make it worth walking: no root, no entitlement, no prior call to set anything up, and reachable from the ordinary App Sandbox. Apple’s advisory carries no privilege qualifier at all, only that an app may be able to execute arbitrary code with kernel privileges. It is not reachable from the WebContent sandbox, which matters for a browser chain and not for a local one. The exploit is public in the kfd repository, and the code below is XNU’s own, quoted from the writeup.
The kernel describes a process’s address space as a list of vm_map_entry structures, each a start address, an end address, and the object backing that range. Dozens of functions assert that both addresses are page-aligned, and assertions are compiled out of release builds. So: what happens if an entry is not?
Six steps get there, with P for the 16KB page size.
The first uses a MIG routine, mach_memory_object_memory_entry_64(), to create a named entry of size 2P+1. Sizes are not rounded on that path. The second maps that named entry with an initial size of ~0ULL, which overflows the page rounding to zero; the recovery path in vm_map_enter_mem_object_helper() then takes the size from the named entry instead, size = named_entry->size - offset, giving 1P+1. That leaves a live entry in the process’s map, running from a page-aligned A to A+1P+1. Faulting both of its pages, the third step, populates two page-table entries, at A and at A+1P, both readable and writable.
The fourth step is the bug. vm_deallocate() over that range calls pmap_remove_options(), which reaches this, in osfmk/arm/pmap/pmap.c:
bpte = &pte_p[pte_index(pt_attr, start)];
epte = bpte + ((end - start) >> pt_attr_leaf_shift(pt_attr));
end - start is 1P+1, and the shift discards the 1. The loop clears one page-table entry and stops. The second, at A+1P, survives with read and write permission. The alignment check that would have caught this does exist, inside a macro compiled only in development builds.
Releasing the port from the first step drops the last reference on the backing object, which returns both physical pages to the free list without disconnecting any mapping. That is step five, and it is where the two accounts of memory come apart: the process’s map no longer covers A+1P, the hardware page tables still do, and the process cannot now exit without a panic reading “Found inconsistent state in soon to be deleted L%d table”. The sixth step buys that back, allocating a fresh entry over the same range and faulting only its first page, which restores agreement without touching the entry that matters.
What is left is a page-table entry in a userland process, read/write, pointing at a physical page the kernel believes is free. That is a physical use-after-free, PUAF, and the kernel will hand that page to whatever asks next.
What kalloc_type does not cover
The mitigations from the first half of this post are about virtual addresses. This is not.
kalloc_type()is completely irrelevant for this technique as it only provides protection against virtual address reuse, as opposed to physical address reuse.
That is Félix’s own assessment, and the rest of his list is as blunt. Kernel address space layout randomisation does not matter, because nothing here needs a kernel address until much later. Privileged Access Never, PAN, does not matter either, because the attacker is not dereferencing a user pointer from the kernel; the kernel is writing into its own page, which happens to also be mapped in userland. zone_require() and pointer authentication on data pointers are the two he rates highest, and both are limits on what you do after the page is yours, not on getting it.
The Page Protection Layer, PPL, the privileged context that owned page tables through A14 and M1, does less here than expected. When it runs short of memory the kernel gives it pages from its own free queues, and PPL checks that each one has no mappings outside the physical aperture before taking ownership, panicking with “page still has mappings” if it does. One of your PUAF pages would fail that check. The exploit prevents the situation in advance, by filling PPL’s free list so that it never has to ask for more.
From a PUAF to read and write
The pages are free and mapped. Turning that into read and write takes four moves, the same four for every PUAF bug, which is why kfd keeps them in a file separate from the vulnerabilities.
First, reach the pages. Freed pages go on the tail of a free queue and userland cannot see where. So take free pages back a few at a time, with vm_copy() on a purgeable region, and after each one scan every dangling page for the content you just wrote. When it shows up, the kernel has started handing out your pages. kfd takes them in chunks of four until a quarter of the PUAF pages are consumed, and calls its own heuristic gross, which it is.
Then fill them with an object that has a field worth driving. This is the ordinary spray, and the object list should look familiar by now: psemnode from sem_open(), fileproc from dup(), kqworkloop from kqueue_workloop_ctl(). The sandbox profile you are running under limits that list. PhysPuppet is reachable from an app, so sockets are available; Smith, the bug Félix found next, is reachable from WebContent, where socket() is denied and the whole read/write had to be rebuilt from what that profile allows.
Then find your object among the pages, by a magic value you set through a syscall, and overwrite exactly one pointer in it by writing through the dangling page-table entry. That pointer has to be one the kernel does not authenticate, which is the constraint doing the most work in this design.
Finally, make a syscall that dereferences it. In kread_sem_open the object is a psemnode, the corrupted field is pinfo, and proc_info() returns eight bytes from wherever it points, believing them to be a semaphore’s uid and gid. In kwrite_dup the object is a fileproc, the field is fp_guard, and change_fdguard_np() writes eight bytes there. One kernel dereference each, at an address you chose.
Both have limits. The read pulls neighbouring fields in with the ones it wants, so an address at the very start of a page can fault on an unmapped predecessor. The write can neither store a zero nor overwrite a value that is already zero. kfd works around the second case in its own cleanup code and leaves the first as an exercise for the reader.
That is the reason a chain does not stop here. kfd spends its first read and write on a better read and write: it reads the file descriptor’s fg_ops->fo_kqfilter to recover the KASLR slide, then overwrites the device number in the file’s specinfo so an innocuous character device now indexes the performance-monitor driver, whose ioctl interface reads and writes arbitrary kernel memory by design. The initial primitive exists to buy a faster one.
Two things have to be undone. The device number and open count get restored on close, or the next open fails. And the process must not exit with a page-table entry the VM map does not know about, which is what the sixth step above was for.
What the write is for
Suppose all of that works, and you have arbitrary kernel read and write from an app on iOS 16. The old answer to what comes next was one sentence: patch your process’s credentials and you are root, patch the MACF label and you are out of the sandbox. That answer is dead, and the source says how dead.
ZONE_DEFINE_ID(ZONE_ID_KAUTH_CRED, "cred", struct ucred, ZC_READONLY | ZC_ZFREE_CLEARMEM);
struct ucred is allocated from a read-only zone (bsd/kern/kern_credential.c). The pointer to it moved out of struct proc into struct proc_ro, which is ZC_READONLY as well (bsd/sys/proc_ro.h, bsd/kern/kern_proc.c). And the MACF label those credentials point at, the one carrying AMFI’s verdict and the sandbox’s profile, is in a read-only zone too, with its slots written through zalloc_ro_update_field() (security/mac_label.c). All three landed in the same release, xnu-8792, which shipped as iOS 16 and macOS 13.
Read-only here means the kernel’s own mapping of that memory is read-only, enforced by PPL through A14 and M1, and from iOS 17 and macOS 14 by the Secure Page Table Monitor on A15 and M2 silicon upwards. Writes go through a small set of allocator entry points that run in the privileged context. An arbitrary write at EL1, which is where all of the above lives, does not reach any of it.
So the endgame splits in two. Either you call one of those entry points, which means control flow, which means defeating pointer authentication, or you stay in data and go after state that is still ordinary kernel memory. kfd’s device-number swap above is exactly the second kind: no pointer forged, no control flow diverted, one integer changed in a structure nobody thought of as security-relevant. That is what “data-only” means in practice: the write lands inside memory the kernel still owns, and the control flow never changes. Whether you can do the other thing, and call the function that is allowed to write a read-only zone, is a question about pointer authentication, and that is the next post.
State in 2026
kalloc_type (iOS 15, broadened through iOS 16). Everything in the first half of this post. Replacement is now a same-signature, same-bucket, per-boot problem.
SPTM (A15 and M2 and later, iOS 17 and macOS 14 and later). The Secure Page Table Monitor replaced PPL, and it changes the terms this post is written in. XNU at EL1 may no longer write a page-table entry at all: every mapping is a call to sptm_map_page(), which SPTM validates against a type it keeps for each physical frame and a rule set for which types XNU is allowed to map. Changing a frame’s type is a call into SPTM too, checked against what the calling domain may do. A PUAF is a user mapping that outlives the kernel’s own account of a frame, so all of it now sits inside bookkeeping that XNU no longer owns. The three bugs in kfd were fixed in iOS 16.4, 16.5.1 and 17.0.
MIE (A19 and A19 Pro in iPhone 17 and iPhone Air, September 2025, and the M5). Memory Integrity Enforcement tags allocations and checks the tag on every access, synchronously and on by default, over kalloc_type among other allocators. Linear overflows and ordinary use-after-free fault at the instruction that commits them instead of corrupting anything. The classes that survive are the ones that never cross a tag boundary: races, confusion between two types in one bucket, uninitialised reads, and writes that stay inside one correctly tagged object. The first public kernel memory-corruption exploit on hardware with MIE enabled, in May 2026, was exactly that: two bugs, normal system calls only, from an unprivileged local user to a root shell on macOS 26.4.1, the build the hands-on above was run on.
The direction is consistent. Each of these takes away a way of controlling where an object lands, and none of them takes away what you can change in it once it has landed.
Where this leaves us
A bug becomes an exploit when the attacker controls placement. The first half of this post is Apple making placement expensive, and the numbers are theirs: an 8% reallocation on a bug that used to be deterministic. The second half is the answer the public iOS exploits of 2022 and 2023 converged on, which was to stop competing for virtual addresses and to keep a physical page instead, where type segregation does not apply. Neither half required a memory-corruption bug in the classic sense. PhysPuppet is an arithmetic mistake in a page-table loop, and the exploit around it never overflows anything.
This post cannot answer why the ending changed. Credentials, labels and process state all moved into memory that a kernel write cannot touch, and reaching them now means making the kernel call the function that can. That is control flow, on a platform where the function pointers worth using are signed. Pointer authentication is the next post: what the hardware actually signs, what it does not, and why an exploit that owns all of kernel memory still cannot simply call a function.
Notes and sources
Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.
- Apple, XNU source:
osfmk/kern/kalloc.hfor the type-signature granules,osfmk/kern/kalloc.cfor the zone naming, the boot-time shuffle and the private-accounting rule,osfmk/kern/zalloc.cfor zone metadata and sequestering,bsd/kern/posix_sem.cfor the semaphore allocations used in the hands-on,bsd/kern/kern_credential.c,bsd/sys/proc_ro.handsecurity/mac_label.cfor the read-only zones. - Félix Poulin-Bélanger, kfd, the source of the whole second half: the writeup PhysPuppet for the six steps and the XNU code paths quoted above, and Exploiting PUAFs for the definition of the primitive, the page-grabbing heuristic, the
kread_sem_openandkwrite_dupmethods, the performance-monitor bootstrap, and the mitigation assessment quoted directly. - Apple, About the security content of iOS 16.4 and iPadOS 16.4, for CVE-2023-23536: impact, description, and credit to Félix Poulin-Bélanger and David Pan Ogea.
- Apple Security Research, Towards the next generation of XNU memory safety: kalloc_type, the design document for type segregation, signatures and per-boot randomisation.
- Apple Security Research, What if we had the SockPuppet vulnerability in iOS 16?, for the quantified impact on a real exploit: the bucket of eleven types, 8% for a single replacement type, and the 92% ceiling.
- Apple Security Research, Memory Integrity Enforcement, for what MIE covers on A19 and what it changes about linear overflows and use-after-free.
- Moritz Steffin and Jiska Classen, Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves, for SPTM’s frame typing and why it ends the physical use-after-free migration.
- Brandon Azad, A survey of recent iOS kernel exploits (Project Zero, 2020), for the fake-port lineage this post’s opening section summarises.
- Calif.io (Bruce Dang, Dion Blazakis, Josh Maine), First public macOS kernel memory corruption exploit on Apple M5 (May 2026), the data-only privilege escalation on hardware with MIE enabled.
