- Published on
xnusp: Learning the Modern XNU Kernel by Building a Kernel Introspection CLI
- Authors

- Name
- Varik Matevosyan
- @D4RK7ET
The best way I found to understand the modern XNU protection stack was to build a small tool that inspects a live kernel and grow it one module at a time. This post turns that into a set of exercises. Each one has a goal, a few hints, pseudocode and a way to check your result. The real code is left for you to write; that is the exercise.
My numbers come from a vphone VM running iOS 26.1 (xnu-12377.42.6). Offsets and addresses will differ on your build, so treat them as examples of what to look for.
| # | Command | You learn |
|---|---|---|
| 0 | setup | tfp0, a 30-second build/deploy loop |
| 1 | kread / kwrite | A safe read/write backend |
| 2 | vmmap | How kernel_map sees the kernel |
| 3 | slide | Deriving the KASLR slide from leaked pointers |
| 4 | kernproc | Finding structures in a stripped kernel |
| 5 | syscalls / traps | How svc #0 becomes a function call |
| 6 | ptwalk / ptmap / physmap | 16K page tables and the SPTM physical aperture |
| 7 | probe | PAC by hand: sign, strip, authenticate |
Exercise 0: Setup
The VM
Set up vphone-cli by following its README. It applies a jailbreak patch set to the kernel, including the task_for_pid patch you need. Then install the package bootstrap from the README so you get openssh-server and can ssh into the VM.
tfp0
mach_port_t kt;
kern_return_t kr = task_for_pid(mach_task_self(), 0, &kt);
For this call to succeed, all three of these must pass, and each one fails with the same KERN_FAILURE:
| Gate | Satisfied by |
|---|---|
if (pid == 0) return KERN_FAILURE; | the vphone kernel patch |
task_for_pid_posix_check() | running as root |
mac_proc_check_get_task() (AMFI) | the task_for_pid-allow entitlement |
Sign your binary with:
<dict>
<key>get-task-allow</key><true/>
<key>task_for_pid-allow</key><true/>
<key>platform-application</key><true/>
<key>com.apple.developer.arm64e.preview</key><true/>
</dict>
Hint: test the call with pid 1, not your own pid. Your own pid always succeeds through a special case, so it proves nothing.
Build loop
SDK_ROOT := $(shell xcrun --sdk iphoneos --show-sdk-path)
SSH ?= ssh -p 2222 root@127.0.0.1 # however you reach the VM
compile:
@mkdir -p build
@clang -isysroot $(SDK_ROOT) -arch arm64e -march=armv8.3-a \
-Xclang -fptrauth-calls -Xclang -fptrauth-returns \
-g -O0 -Wall -Wextra src/*.c -o build/xnusp
sign: compile
@codesign --force --sign - --entitlements entitlements.plist build/xnusp
run: sign
@cat build/xnusp | $(SSH) "cat > /tmp/xnusp && chmod +x /tmp/xnusp && /tmp/xnusp $(ARGS)"
The kernelcache
The kernelcache is fully stripped: no _kernproc, _sysent or _kernel_pmap. Every anchor has to be found from structure. Make sure your static reference is the kernelcache the VM actually boots (a research build). The release kernelcache shares the same base address but has a different segment layout.
ipsw kernel dec <kernelcache> # unwrap IM4P (ipsw macho info fails silently on IM4P)
ipsw macho info dev-kernelcache # segments and fileset entries
otool -l dev-kernelcache # exact section bounds
Write down the static base (__TEXT of the MH_FILESET container), the segment offsets and the container span. Also clone XNU at the closest published tag and keep tools/lldbmacros/ open; it is Apple's own answer key for most of what follows.
Exercise 1: A read/write backend
Goal: one interface that everything else is written against.
typedef struct {
int (*kread)(uint64_t va, void *out, size_t n);
int (*kwrite)(uint64_t va, const void *in, size_t n);
uint64_t base, slide; // Exercise 3
uint64_t pmap, tte, ttep; // Exercise 6
mach_port_name_t port;
} kctx_t;
init:
port = task_for_pid(self, 0)
kread = vm_read_overwrite(port, va, n, out); fail unless all n bytes were read
kwrite = vm_write(port, va, in, n)
kpage_present(va):
kr, disp = vm_map_page_query(port, va)
return kr == SUCCESS and (disp & VM_PAGE_QUERY_PAGE_PRESENT)
kread_safe(va, out, n):
for every 16K page in [va, va+n): if not kpage_present(page): return false
return kread(va, out, n)
Hints
- A small read of an unmapped kernel address does not return an error; it panics the kernel (it ends in
copyinmap()'s barememcpy). Writes have the same problem. Use the_safeversions for anything you are scanning. vm_map_page_queryreturnsKERN_SUCCESSfor holes too. Only the disposition bit answers the question.mach/mach_vm.his blocked in the iOS SDK, but libSystem still exports themach_vm_*functions. Declare the prototypes yourself.- Use
strtoullfor addresses; kernel VAs are larger thanLLONG_MAX.
Check: a kread matches the same bytes read through a kernel debugger. kwrite round-trips a pattern into a page you vm_allocate in kernel_map through the kernel task port.
Exercise 2: vmmap
Goal: list kernel_map regions with protections and tags.
cursor = VM_MIN_KERNEL_ADDRESS, depth = 0
loop:
addr, size, info = mach_vm_region_recurse(port, cursor, depth)
if kr == KERN_INVALID_ADDRESS: break // end of map
if info.is_submap: depth++; continue // same cursor, one level deeper
print(addr, size, info.protection, info.max_protection, info.user_tag, info.pages_resident)
cursor = addr + size; depth = 0 // advance on addr+size, never on addr
Hints
- Use
mach_vm_region_recurse, notvm_region_recurse_64. The latter goes throughvm_region_get_kern_return()invm_user.c, which turns every success intoKERN_INVALID_ADDRESSonceaddr + sizepassesVM_MAX_ADDRESS(0xF0000000on arm64). So it fails for every kernel address. infoCntis counted innatural_tunits, not bytes.
Check: the first row is one 64 GB PROT_NONE region with no object behind it. The running kernelcache lives inside it. kernel_map only reserves that range; the kernelcache was mapped by the bootstrap page tables and SPTM. Keep that in mind for Exercise 6.
Exercise 3: slide
Goal: compute the KASLR slide from pointers found in kernel heap memory, without kas_info.
// harvest
for each readable vmmap entry with a pointer-bearing tag (KALLOC, KALLOC_TYPE, ZONE, IPC, KEXT):
for a few present pages of it:
for each 8-byte word w:
if KERNEL_VA_MIN <= w < KERNEL_HEAP_START: candidates.add(w)
// cluster
sort and dedupe candidates
find the largest group that fits inside one kernelcache span
seed = lowest member, width = highest - lowest
if width < span / 2: give up // not an image, just one structure
// walk down to the header
for page = align_down(seed) downwards, at most (span - width) / 16K + 1 pages:
if magic == MH_MAGIC_64 and filetype == MH_FILESET: base = page; break
assert filetype(base + 0x4000) == MH_EXECUTE // com.apple.kernel
slide = base - static_base
Hints
- The window contains three separately slid images: the kernelcache, SPTM and TXM. The monitors are
MH_EXECUTE, so stop only onMH_FILESET. Otherwise your slide is off by 256 MB and still looks valid. - Cap how many candidates you take per region, or the first region in address order decides the cluster on its own.
Check: the slide is page-aligned, a different value on each boot, and agrees with a scan from a kernel debugger.
Exercise 4: kernproc and the process list
Goal: ps -ax, read from kernel memory.
What the source gives you
kernprocisSECURITY_READ_ONLY_LATE, so it lives in__DATA_CONST,__const, about 1 MB you can scan.process_name()writes"kernel_task"into bothp_commandp_name.kernprocis the tail ofallproc.p_listis the first field ofstruct proc, sole_prevpoints straight at the previous proc.
for each pointer p in __DATA_CONST,__const:
if p is a heap address and kread_safe(p, win, 0x1000):
a = find(win, "kernel_task"); b = find(win after a, "kernel_task")
if a and b: kernproc = p; comm_off = a; comm_size = b - a; break
// p_pid reads 1 in launchd (kernproc's predecessor) and 0 in kernproc
pid_off = first offset < comm_off where launchd[off] == 1 and kernproc[off] == 0
p = kernproc
loop:
print(p[pid_off], p[comm_off])
prev = p->le_prev
if prev points into the kernelcache: break // that is &allproc
if *prev != p: break // list invariant
p = prev
Check: pid 0 is kernel_task, pid 1 is launchd, and the list matches ps -ax.
Exercise 5: syscalls and traps
Goal: dump sysent (BSD, positive numbers) and mach_trap_table (Mach, negative numbers).
struct sysent { // 24 bytes on iOS
uint64_t sy_call; // PAC-signed
uint64_t sy_arg_munge32; // PAC-signed
int32_t sy_return_type;
int16_t sy_narg;
uint16_t sy_arg_bytes;
};
The handler pointers are PAC-signed fixups, so the classic "find a run of pointers into __TEXT_EXEC" scan doesn't work. Match the scalar fields instead:
expected = parse(bsd/kern/syscalls.master) // {name, narg, ret} per number
find off in __DATA_CONST where 32 consecutive 24-byte records
match expected (ret, narg, bytes) // gives exactly one hit
for i = 0..:
e = record(off + 24*i)
if not (0 <= ret <= 7 and narg <= 8 and narg*4 <= bytes <= narg*8): break
print(i, name[i], e.narg, strip(e.sy_call) - base)
strip(p) = (p & 0xffffffffff) | 0xfffffe0000000000 // trust only if it lands inside the image
Do the same for mach_trap_table (also 24 bytes per entry; its length is fixed at 128).
What you should see: 558 BSD syscalls. In the trap table, about half the slots point at one kern_invalid handler. A few sysent rows differ from the source because the feature is compiled out on iOS (SysV IPC, the NFS server).
On paper: trace one call: svc #0 with the number in x16 → sleh_synchronous → handle_svc (sign of x16 picks Mach or BSD) → unix_syscall → sysent[code] → sy_call(proc, args, rval). Errors come back with the carry flag set, which libc turns into errno.
Exercise 6: Page tables
Goal: answer "what are the permissions of this address, and who can change them" by walking the tables yourself.
Geometry
iOS uses 16K pages, so each level indexes 11 bits, not 9. The kernel's root table is a full 2048-entry L1.
L1 = (va >> 36) & 0x7FF // 64 GB per entry
L2 = (va >> 25) & 0x7FF // 32 MB per entry
L3 = (va >> 14) & 0x7FF // 16 KB per entry
offset = va & 0x3FFF
Decoder (pure function, test it on your Mac first)
decode(raw, level):
if !(raw & 1): INVALID
if (raw & 3) == 3:
level == 3 ? PAGE pa = raw & 0x0000ffffffffc000
: TABLE pa = raw & 0x0000ffffffffc000, APTable, PXNTable, UXNTable
else:
level == 2 ? BLOCK pa = raw & 0x0000fffffe000000 // 32 MB
: INVALID // no L1 blocks with a 16K granule (only with FEAT_LPA2)
leaf: AP = raw[7:6], AF = raw[10], PXN = raw[53], UXN = raw[54], AttrIndx = raw[4:2]
Find the root
You can't read TTBR1_EL1 from userspace, and _kernel_pmap is stripped. struct pmap begins with tte, ttep, min, max:
for off in __DATA step 8:
tte, ttep, min, max = words at off
if max == UINT64_MAX and -min is a power of two
and tte is a page-aligned kernel VA and ttep is a page-aligned PA:
kernel_pmap = off
Physical to virtual (SPTM)
Descriptors give physical addresses, but kread takes virtual ones. On SPTM devices this is not a single offset (gPhysBase/gVirtBase are gone). It is a lookup in libsptm_papt_ranges, as in lldbmacros' PhysToKVARM64:
phystokv(pa):
for r in papt_ranges: // {paddr_start, papt_start, num_mappings}
if r.paddr_start <= pa < r.paddr_start + r.num_mappings * 16K:
return pa - r.paddr_start + r.papt_start
return 0
Check: phystokv(ttep) == tte.
ptwalk <va>
tbl = tte; restrictions = none
for lvl in 1..3:
d = decode(kread(tbl + 8 * index(va, lvl)), lvl)
if d == INVALID: return unmapped // don't kread va after this
if d is a leaf:
pa = d.pa | (va & (level_size - 1))
ap = d.AP limited by every APTable above
pxn = d.PXN or any PXNTable above
return pa, ap, pxn, d.UXN
restrictions += d.APTable, d.PXNTable, d.UXNTable
tbl = phystokv(d.pa)
Check: walk an address in __TEXT_EXEC (rebased with this boot's slide). You should get kernel read-only, PXN=0, UXN=1. Then physmap <pa> should give you back the same VA.
ptmap
Recurse through every valid entry from the root, fold the table restrictions into each leaf, merge adjacent leaves with the same permissions into ranges, and label each range with its kernelcache segment. Block entries are common at L2, so handle them, not just L3 pages.
What you should see:
- Root slots 2015–2028 are in use: the physical aperture, the kernelcache, then the heap.
- Every heap table entry has PXNTable set.
- The summary line
no effective kernel RWXholds.
pmap-pid <pid>
proc -> p_proc_ro -> pr_task -> task.map -> vm_map.pmap -> {tte, ttep}
task.map and vm_map.pmap are PAC-signed, so strip them. Check that pid 0 resolves to the kernel pmap you found above.
Try writing a PTE
Read a leaf descriptor, then write the same 8 bytes back with kwrite. Nothing about the mapping changes, but the kernel panics with Unexpected fault in kernel physical aperture. Under SPTM the page tables are read-only to the kernel, and only the monitor can change them.
That's the main takeaway of the lab. Kernel code and page tables are protected below the kernel. Kernel data on the heap is not: it is protected only by in-kernel software like PAC. That is why modern exploits are data-only, and why kernel read/write is where the work starts, not where it ends.
Exercise 7: PAC by hand
Goal: see pointer authentication with your own eyes. You've already run into it twice: the signed sy_call slots in Exercise 5 and the signed task.map in Exercise 6. This exercise needs no kernel access; it runs in your own arm64e process.
Write tiny inline-asm wrappers so you can see each instruction separately:
static inline uint64_t pacia(uint64_t p, uint64_t disc) { __asm__ volatile("pacia %0, %1" : "+r"(p) : "r"(disc)); return p; }
static inline uint64_t autia(uint64_t p, uint64_t disc) { __asm__ volatile("autia %0, %1" : "+r"(p) : "r"(disc)); return p; }
static inline uint64_t xpaci(uint64_t p) { __asm__ volatile("xpaci %0" : "+r"(p)); return p; }
// same for pacdb / autdb (data pointers, DB key)
// code pointer, IA key
signed = &some_function // the compiler already signed it (IA, discriminator 0)
stripped = xpaci(signed) // drop the signature bits
resigned = pacia(stripped, 0) // sign it again ourselves
authed = autia(resigned, 0) // verify and strip
assert resigned == signed // same key + same pointer + same discriminator = same signature
assert authed == stripped
// data pointer, DB key
signed_d = pacdb(&local_var, disc)
authed_d = autdb(signed_d, disc) // print all three: authed_d should equal &local_var
Hints
- Print all four values and compare the high bits: the signature lives in the unused upper bits of the pointer.
- Compiling with
-arch arm64eand-fptrauth-callsis what makes the compiler sign function pointers for you (see the Makefile in Exercise 0). - In the kernel tables from Exercise 5, the same function always has the same signed value because the discriminator doesn't mix in the slot address (
addrDiv = 0). That's why grouping handlers by raw value still works there.
Check: both asserts pass, and your re-signed pointer is byte-identical to the one the compiler produced.
Checkpoint
Try to answer these without looking:
- Of the static base, runtime base and slide, which is on disk and which two are only known at runtime?
- Why are Mach traps negative, and how much of the trap table is dead?
- Why 11 bits per level? Which bit stops the kernel from executing a user page?
- With arbitrary kernel read and write, what can you still not do, and what stops you?
References
- XNU source and
tools/lldbmacros/ - vphone-cli
- ARM, AArch64 memory management
- Apple Platform Security guide
- Project Zero, Examining Pointer Authentication on the iPhone XS
- Brandon Azad's blog
- ipsw