Sitemap

Snake&Apple X.NU

Introduction to macOS hybrid kernel XNU

16 min readDec 30, 2024

--

Press enter or click to view image in full size

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!

Press enter or click to view image in full size

The table below summarizes all of the subjects described in this article:

Press enter or click to view image in full size

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:

Press enter or click to view image in full size
Source

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:

Press enter or click to view image in full size
Source

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.

Press enter or click to view image in full size

For instance, when an app in EL0 (user app) requests kernel service (makes a system call), the following sequence occurs:

  1. System Call Invocation: The svc (Supervisor Call) instruction generates an exception and transitions the processor to EL1 (Kernel Mode).
  2. Kernel Execution: The kernel identifies the requested service, processes it, and interacts with hardware or subsystems as necessary.
  3. 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

Press enter or click to view image in full size

Most of them (except iokit subsystem) are introduced in the README.md:

Press enter or click to view image in full size

I would add here the san directory, which contains implementations of Kernel Address Sanitizer (KASAN) and a config configuration 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.

Press enter or click to view image in full size
ida64 kernelcache.decompressed

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_KDK

For full guide on IPSW see Part 1 and Part 2 from 8ksec.

Kernel 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 dtrace

To 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_PROGRAM

We 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);
}
Press enter or click to view image in full size

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):

Press enter or click to view image in full size

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:

Press enter or click to view image in full size
# 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 testing

When 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/kernelcache

We 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.* --all

Kernel Cache consists of three Kernel Extension collections, but on macOS we have only boot collection and Auxiliary Kext Collection (AuxKC):

Press enter or click to view image in full size
man kmutil

This 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):

Press enter or click to view image in full size
Source
Press enter or click to view image in full size
Source

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.

Press enter or click to view image in full size

The very first entry point is LowResetVectorBase, which contains the reset vector that iBoot initially jumps to:

Press enter or click to view image in full size
osfmk/arm64/start.s#L104

However, the actual initialization takes place in _start which branches to start_first_cpu if this is the first boot (cold boot):

Press enter or click to view image in full size
osfmk/arm64/start.s#L231C1-L231C13

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:

Press enter or click to view image in full size
osfmk/arm64/start.s#L466

The start_first_cpu eventually branch to common_start which sets up MMU and passes control to the arm_init (lr was set here):

Press enter or click to view image in full size
osfmk/arm64/start.s#L643

In the case of the warm boot there is a arm_init_tramp trampoline, which also ends with passing control to arm_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:

Press enter or click to view image in full size

Then, control passes to machine_startup which finally calls kernel_bootstrap.

Kernel Bootstrap

The kernel_bootstrap among others, sets virtual memory, scheduler to manage tasks&threads, IPC, console, and (most notably for security) MACF:

Press enter or click to view image in full size

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.

Press enter or click to view image in full size

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_config syscall, 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:

Press enter or click to view image in full size

The BSD layer launches the PID 1 using load_init_program in bsd_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:

Press enter or click to view image in full size
  • 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 uthread structure has a pointer to its Mach thread. The Mach thread has a pointer back to its uthread. 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:

Press enter or click to view image in full size
Source

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.

Press enter or click to view image in full size
osfmk/arm/arm_init.c#L566

MMU maps virtual addresses to physical addresses. The mapping unit is called a page, whose size is typically 16KB (can also be 4KB).

Press enter or click to view image in full size

The system uses vm_map for Virtual Memory and pmap for Physical Memory management. This is a very, very high-level overview:

Press enter or click to view image in full size

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:

Press enter or click to view image in full size
  • Kernel Space (0xFFFF8000000000000xFFFFFFFFFFFFFFFF)
  • User Space (0x00000000000000000x00007FFFFFFFFFFF)
  • Heap starts after program data and grows upward
  • Stack starts at a high address and grows downward
  • Pagezero (0x00000000000000000x0000000100000000 + 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;
}
Press enter or click to view image in full size

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:

Press enter or click to view image in full size

This is because the arm Comm Page is at 0x0000000FFFFFC000ULL:

Press enter or click to view image in full size
osfmk/arm/cpu_capabilities.h#L165

The commpage_populate shows it is created using pmap_create_sharedpages:

Press enter or click to view image in full size
osfmk/arm/commpage/commpage.c#L93

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.

Press enter or click to view image in full size

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:

Press enter or click to view image in full size
br set -n getpid

When 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:

Press enter or click to view image in full size

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:

Press enter or click to view image in full size

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:

Press enter or click to view image in full size

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:

Source

While working on this article, I read some very good shorter and longer pieces about XNU from many cool security researchers. Here they are:

I also used some of Apple’s official documentation:

The best book about XNU: *OS Internals Volume II by Jonathan Levin:

Press enter or click to view image in full size

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:

Press enter or click to view image in full size
Source

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.

--

--

No responses yet