Snake&Apple X.NU
Introduction to macOS hybrid kernel XNU
INTRO
Welcome to another article in the series on macOS security internals!
Up to this point, we have focused on userland, examining mechanisms safeguarding macOS at the application layer, such as Code Signing or TCC.
Some of the described features, such as Quarantine, AMFI, or Sandbox, are implemented as kernel extensions that bridge userland and kernel operations.
In this article, we transition from userland to kernel space, focusing on XNU — the core of macOS. We will discuss why XNU is considered a hybrid kernel by examining its integration of the Mach and BSD components.
It was hard to describe everything here, so I split this article into pieces. I placed the links in the text, making it easy to follow while reading. Still, there is much to do, as it only briefly introduces the macOS kernel world!
The table below summarizes all of the subjects described in this article:
The Snake&Apple X. NU repository contains all of the code used.
X is Not Unix
XNU is the hybrid kernel of macOS. It combines elements from the Mach microkernel and BSD. Mach provides low-level functions such as memory management, thread scheduling, and inter-process communication, while BSD offers essential UNIX features, including networking and file systems.
Together, they form a cohesive kernel capable of efficiently managing macOS hardware and system software.
XNU’s source code can be found on Apple’s GitHub.
Darwin != Kernel
Some folks (and me in the past) mistakenly call macOS kernel a Darwin. This is not true, and the below graph explains it well with a Linux example:
Apple’s device (Mac) run OS (macOS) built on Darwin, whose core is XNU.
Architecture
Darwin itself is XNU plus system utilities. XNU combines Mach, BSD, and IOKit. Below, we can see the place of XNU in the Apple OS architecture:
Architecturally, XNU has three main components. Mach and BSD layers are written in C, while IOKit is written in embedded C++:
- Mach: low-level core, implements Traps, responsible for Inter-Process Communication, Tasks, Threads, and Virtual Memory Management.
- BSD: Built on top of Mach, it implements Unix Calls, provides POSIX compatibility, Network stack, Virtual File System, Process Management, Security (MAC & ACL). It interacts most with the userland.
- IOKit: An object-oriented framework for device drivers that handles driver and power management. Provides Plug-and-play functionality.
There is also libsa library for boot-time services and Platform Expert (pexpert) for Hardware Abstraction Layer (HAL) and low-level platform support.
User-Kernel Mode Transition
I wrote an article about Exception Levels and System Calls on macOS. I encourage you to read it before continuing with the current blog post:
The XNU manages transitions between user mode (EL0) and kernel mode (EL1) using an Exception Vectors Table when an exception occurs (trap).
The First Level Exception Handler (FLEH) delegates it to specific handlers based on exception types. Then, SLEH functions manage these transitions.
For instance, when an app in EL0 (user app) requests kernel service (makes a system call), the following sequence occurs:
- System Call Invocation: The
svc(Supervisor Call) instruction generates an exception and transitions the processor to EL1 (Kernel Mode). - Kernel Execution: The kernel identifies the requested service, processes it, and interacts with hardware or subsystems as necessary.
- Returning Control: Using the
eret(Exception Return) instruction, the kernel resumes the execution of the original process (back to EL0).
Examples of syscalls are described in the Exceptions on macOS article. We can also talk to the kernel through the driver interfaces and Mach ports.
Source Code
We can divide the source code into subsystems (red), libraries (green), and utilities (all others). This is not official, just my imagination :D
Most of them (except iokit subsystem) are introduced in the README.md:
I would add here the
sandirectory, which contains implementations of Kernel Address Sanitizer (KASAN) and aconfigconfiguration files for exported APIs.
Decompiling
For decompilation and binary analysis, it is best to use a decompressed kernel cache to load all kexts at once, as this will fill the memory gaps where one kernel extension depends on the functions of another.
While working with Kernel Cache, we can also use ipsw:
# Decompressing Kernel Cache
ipsw kernel dec $(ls /System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache) -o kernelcache
# Extract KEXT(s) from kernelcache
ipsw kernel extract $(ls kernelcache/System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache.decompressed) com.apple.driver.AppleMobileFileIntegrity
# Another way using CrimsonUroboros
CrimsonUroboros -p kernelcache.decompressed --dump_kext sandbox
# List all KEXTs
ipsw kernel kexts kernelcache_decompressed
# Get Symbols
ipsw kernel symbolsets kernelcache_decompressed
# Get CTF - Compact ANSI-C Type Format data - this works only with KDK
ipsw kernel ctfdump PATH_TO_KDKKernel Debugging
We cannot see what code is executed in the kernel from user mode. We must utilize hypervisor mode (EL2) and debug the kernel to do that.
Apple Silicon sadly does not support active kernel debugging, so we can only work on core dumps. I described it in the below article:
See also Debugging the XNU Kernel with IDA Pro and Corellium guide.
Kernel Mode Tracing
We can use dtrace and dtruss, but we need to disable SIP to do that:
# Boot into Recover Mode -> Utilities -> Terminal
csrutil disable
# Alternatively, we can use flag to disable just dtrace protection
csrutil enable --without dtraceTo learn more about various SIP flags and SIP in general, read this:
We can use dtruss to print details on process system calls:
# Trace all system calls made by a program and save to a log file
sudo dtruss -f YOUR_PROGRAM &> dtruss.log
# Trace a specific process ID
sudo dtruss -p PID
# Follow child processes with -f
sudo dtruss -f -p PID
# Track specific system calls using -n
sudo dtruss -n open,write,close YOUR_PROGRAMWe have much more flexibility with DTrace since it is the underlying tracing framework. We can run directly from the command line with -n:
# Tracing "open" syscalls in the
sudo dtrace -n 'syscall::open:entry { printf("%s %s",execname,copyinstr(arg0)); }'
# Tracing mach_traps in kernelmanagerd
sudo dtrace -n 'mach_trap::: /execname == "kernelmanagerd"/ { printf("%s %d\n", execname, arg0); }'We can also run an example script example below is for tracing hook calls behind the process execution. To run: dtrace -s script.d or ./script.d.
#!/usr/sbin/dtrace -s
#pragma D option flowindent
// Enable tracing when execve or __mac_execve syscalls are entered
syscall::execve:entry { self->tracing = 1; }
syscall::__mac_execve:entry { self->tracing = 1; }
// Disable tracing and exit when execve or __mac_execve syscalls return
syscall::execve:return { self->tracing = 0; exit(0); }
syscall::__mac_execve:return { self->tracing = 0; exit(0); }
// Print syscall arguments when tracing is active
fbt::: /self->tracing/ {
// Print the first three arguments of the syscall in hexadecimal format
printf("%x, %x, %x", arg0, arg1, arg2);
}For more ideas see DTrace One-Liners and also ktrace.
Security — MACF
This series taught us various security features, such as Code Signing, SIP, TCC, Gatekeeper, Sandbox, and more… All of them utilize Mac Policies.
They are enforced through the Mandatory Access Control Framework (security/ directory). I wrote a separate article about MACF:
Every function that operates on security objects is first routed through MACF, which evaluates security policies before allowing the operation to proceed.
Extending Kernel
XNU’s functionality can be extended through Kernel Extensions (kexts) — loadable modules that run in kernel mode and provide additional features.
We can find many of them on macOS, and to list them all, we can use a bit deprecated kextstat command (which is now interfacing kmutil):
It is possible to load custom kext. For a detailed analysis of the security and restrictions around loading kernel extensions in XNU, see this article:
Apple make it hard and encourages not to use kexts and introduced System Extensions, which operate in user mode and offer similar functionality.
Kernel Programming Interfaces (KPIs)
They allow KEXTs to interact with parts of the kernel in a controlled and safe manner. This is like API in user-mode apps. These are all available:
# KPI Subsystems:
com.apple.kpi.bsd # Core Unix operations like files, networking, and processes
com.apple.kpi.iokit # Device driver framework for hardware interactions
com.apple.kpi.libkern # Basic kernel utilities and data structures
com.apple.kpi.mach # Low-level kernel operations like tasks and memory
com.apple.kpi.dsep # Device security policy enforcement (MACF)
com.apple.kpi.unsupported # Available but unstable interfaces
com.apple.kpi.private # Apple-internal kernel interfaces
com.apple.kpi.kasan # Kernel memory error detection
com.apple.kpi.kcov # Kernel code coverage testingWhen developing a KEXT, we declare which KPIs we need in Info.plist under the OSBundleLibraries key. For example, a typical device driver
<key>OSBundleLibraries</key>
<dict>
<key>com.apple.kpi.iokit</key>
<string>8.0.0</string>
<key>com.apple.kpi.libkern</key>
<string>8.0.0</string>
</dict>Each KPI bundle provides access to a specific subsystem of the kernel. We can get a list of KPIs kext needs using
kmutil libraries -p KEXT_PATH.
Kernel Cache
It is also called Prelinked Kernel. It is a pre-linked binary containing the XNU kernel and approved extensions. When the system boots, instead of loading each kernel extension separately, it loads this unified cache:
# Location on macOS
ls /System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache
# Location on iOS:
/System/Library/Caches/com.apple.kernelcaches/kernelcacheWe can download the latest ipsw from https://ipsw.me/:
# Warning it can download a few versions
ipsw dl appledb --os macOS --latest --kernel
# Extract all kexts
ipsw kernel extract kernelcache.release.* --allKernel Cache consists of three Kernel Extension collections, but on macOS we have only boot collection and Auxiliary Kext Collection (AuxKC):
man kmutilThis offers significant performance benefits by having all symbols pre-resolved and dependencies pre-linked. It gets updated whenever the system installs new kernel extensions or during system updates.
XNU in the Boot process
This image shows the macOS secure boot process when we push the power button. It shows the signature validation steps (the chain of trust):
The chain of trust validation has three main steps in the context of XNU and kernel extension loading to ensure their integrity:
- Boot ROM validates LLB (Low-Level Bootloader)
- LLB validates LocalPolicy and iBoot stage 2 signature
- iBoot stage 2, according to LocalPolicy, validates BKC and AuxKC
Boot Kernel Collection (BKC) contains the XNU, system KEXTs, and boot drivers. The Auxiliary Kernel Collection (AuxKC) contains 3rd-party KEXTs.
XNU First Steps
The kernel is loaded into memory and executed after signature validations.
The very first entry point is LowResetVectorBase, which contains the reset vector that iBoot initially jumps to:
However, the actual initialization takes place in _start which branches to start_first_cpu if this is the first boot (cold boot):
The start_first_cpu sets the exception-handling (VBAR_EL1 to point to LowExceptionVectorBase), stack pointers, and page tables (both V=P and KVA) mappings and gets kernel memory parameters from boot args:
The start_first_cpu eventually branch to common_start which sets up MMU and passes control to the arm_init (lr was set here):
In the case of the warm boot there is a
arm_init_tramptrampoline, which also ends with passing control toarm_init().
ARM initialization
The arm_init() is the first C code executed. It performs platform and CPU initialization after the basic MMU(arm_vm_init) is set. Most importantly for security, it configures PAC, rebases the kernel (KASLR), and signs the JOP:
Then, control passes to
machine_startupwhich finally callskernel_bootstrap.
Kernel Bootstrap
The kernel_bootstrap among others, sets virtual memory, scheduler to manage tasks&threads, IPC, console, and (most notably for security) MACF:
It transitions a basic boot state to a point where the kernel can begin running normally with essential services operational. At the end, it stars the first thread.
Kernel First Thread
The kernel_bootstrap_thread creates the first “real” thread structure and kicks off a true multi-threaded operation on sched_startup. In this step, we load all MAC security policies and drivers. It also initializes IOKit and BSD.
Drivers are loaded in PE_init_iokit step by the StartIOKitMatching. To learn more about drivers and how we can call them from user mode, read:
A call to vm_pageout() transforms this thread into the system’s pageout daemon, which manages VM paging operations for its entire lifetime.
This function never returns as the kernel continues running after bootstrap.
Kernel Parallelism —Layers & Threads
After bootstrap, subsystems operate through multiple concurrent threads. The Mach layer manages kernel threads created via kernel_thread_create or kernel_thread_start but also those created by the BSD layer using pthread_create in user mode (mapped to Mach threads underneath).
Unix threads are mapped to Mach threads, but these are not kernel threads!
Kernel threads are named through thread_set_thread_name which calls BSD’s bsd_setthreadname. They are invisible to user mode, even from root.
It was possible to use
stack_snapshot_with_configsyscall, but it was patched.
First Processes
The kernel itself operates as PID 0 (kernel_task), containing all kernel threads, including the idle thread, VM pageout daemon, and interrupt threads. The first user process (PID 1) is launchd:
The BSD layer launches the PID 1 using
load_init_programinbsd_init.
Two-Layer Process Model
Unlike Windows or Linux, where a process is a single container for threads and resources, XNU splits process functionality across two layers:
- BSD Process provides UNIX compatibility, manages PIDs, handles file descriptors, access rights, and contains Unix thread metadata.
- Mach Task manages system resources, VM, IPC, thread scheduling, and contains Mach threads for actual execution.
If we want to inject shellcode into a process, we spawn a new Mach Thread. If we signal a process, we use the BSD layer to reach the Unix Thread.
The Unix thread
uthreadstructure has a pointer to its Machthread. The Machthreadhas a pointer back to itsuthread. BSD cannot directly control thread execution it makes requests to Mach when it needs thread operations.
Inter-Process Communication
Generally, there are two methods of communication between processes, and XNU implements them through the Mach layer:
Mach IPC is the foundation of XNU’s communication system and one of the message-passing IPC methods. I wrote a separate article about it:
On top of that, we have XPC built on Mach, which I described there:
There are also Unix-standard mechanisms, such as sockets, pipes, FIFOs, signals, message queues, semaphores, and shared memory (
shm_*).
Memory Management
The kernel creates an illusion for each application that it has exclusive access to the entire address space. This is achieved through the Memory Management Unit (MMU) that was set in arm_vm_init during arm_init.
MMU maps virtual addresses to physical addresses. The mapping unit is called a page, whose size is typically 16KB (can also be 4KB).
The system uses vm_map for Virtual Memory and pmap for Physical Memory management. This is a very, very high-level overview:
Each process gets its own set of page tables, enabling memory isolation.
Virtual Memory Layout
The Virtual Memory for each user process looks like this:
- Kernel Space (
0xFFFF800000000000–0xFFFFFFFFFFFFFFFF) - User Space (
0x0000000000000000–0x00007FFFFFFFFFFF) - Heap starts after program data and grows upward
- Stack starts at a high address and grows downward
- Pagezero (
0x0000000000000000–0x0000000100000000+ ASLR)
There are three possibilities for loading the Dyld Shared Cache. I described them in Snake&Apple V—Dyld. For main binary layout see Snake&Apple I — Mach-O.
Comm Page
It is a unique memory structure that is always located at the same address in all processes, and we can access this from the user process directly:
// clang a.c -o a_intel -arch x86_64
// clang a.c -o a_arm -arch arm64
#include <stdio.h>
#include <unistd.h>
#define COMM_PAGE_BASE_ADDRESS 0x7fffffe00000ULL
int main() {
printf("Comm Page: 0x%llx\n", *((uint64_t *)COMM_PAGE_BASE_ADDRESS));
return 0;
}However, the above works only for x86_64 programs. If we try to read it using COMM_PAGE_BASE_ADDRESS from arm64 process, we will end up with:
This is because the arm Comm Page is at 0x0000000FFFFFC000ULL:
The commpage_populate shows it is created using pmap_create_sharedpages:
We can see all Comm Page data in XNU here. I also made a Comm Page Parser.
Dyld Shared Cache
It is also mapped to every user process, but XNU only provides memory mapping support and related syscalls that dyld uses to map it to processes.
The actual cache creation, management, and usage are handled by dyld. We can see its inner workings while using, for instance getpid syscall:
#include <stdio.h>
#include <unistd.h>
int main() {
printf("My PID is: %d\n", getpid());
return 0;
}When a process starts, dyld makes a getpid syscall early in initialization:
br set -n getpidWhen the program calls getpid, the C library implementation first checks if the PID value is available in this cache. If not, then make a syscall:
After that, the PID value is cached in the RW part of the Dyld Shared Cache. So when we hit our getpid syscall in the main, we will not call it again:
Instead, we will use the cached value from the Comm Page in DSC.
Memory Security
The memory is a vast attack surface. It was thoroughly hardened by Apple using a series of mitigation (some of them are ARM specific) such as:
However, it does not mean there are no vulnerabilities, just more mitigations to overcome. A good source of knowledge is Ian Beer’s talk “Auditing and Exploiting XNU Virtual Memory”, this blog post from Sven Peter, and the Siguza Blog.
FINAL WORDS
Kernel Level Code execution is a holy grail, allowing for anything we want.
Remember that on macOS, besides syscalls, we also have Mach Ports and Driver Interfaces. Moreover, we can load custom code in form of KEXT.
Talking to the kernel from user mode always boils down to SVC and Mach IPC. However, various components utilize them in multiple ways. To find bugs, it is essential to see those components and enumerate how they are used first.
REFERENCES
Even if outdated, in my opinion, the best talk introducing XNU:
While working on this article, I read some very good shorter and longer pieces about XNU from many cool security researchers. Here they are:
- Behind the Scenes of iOS and Mac Security by Ivan Krstić
- Apple Silicon Hardware Secrets: SPRR and GXF by Sven Peter
- Auditing and Exploiting XNU Virtual Memory by Ian Beer
- XNU heap exploitation: From kernel bug to kernel control by Tihmstar
- Finding and exploiting XNU logic bug by Eloi Benoist-Vanderbeken
- XNU Memory Allocation — CSE598 AVR by Adam Doupé
- ipsw Walkthrough Part 1 AND Part 2 by 8ksec
I also used some of Apple’s official documentation:
The best book about XNU: *OS Internals Volume II by Jonathan Levin:
Also, check the articles in the repo under XNU.
What’s next?
Although it was the final article in my 11-part Snake&Apple series, there will be an expansion next year. This is not the end. However, at this point, CrimsonUroboros is a finished tool. Below is the last addition of XNU flags:
Still, if anyone wants to contribute, feel free to do so through GitHub. I will also add new functionalities if I come across something that would be nice to be semi-automated with an additional flag while researching.
In 2025, I will focus more on developing IDAPython scripts than terminal tools. I am also considering writing a book that includes articles from the Snake & Apple series, extended with additional comments from me.
Sssssssssstay tuned.