Most of what makes macOS hard to attack is not a single mitigation. It is the shape of the system: a hybrid kernel wearing a BSD coat, a filesystem that lies to you about where files actually live, a read-only system volume sealed with a cryptographic hash, and an executable format that carries its own code-signing receipt. You cannot reason about a sandbox escape or a TCC bypass until you can read that shape.

This is the first post in a series on macOS security internals and the techniques used to bypass them. The plan is to build up from the ground floor (architecture, filesystem, the Mach-O format, Objective-C) and end at full exploit chains: XPC privilege escalation, sandbox escapes, TCC bypasses, and unsigned kernel extension loads. None of that lands without the map, so we start with the map.

The goal here is not to reproduce a macOS internals course. It is to load exactly the mental model an attacker needs and nothing more: where the trust boundaries are, what enforces them, and which assumptions later posts are going to break.

The Stack: XNU, Darwin, and Everything Above

macOS is the descendant of two operating systems that should never have merged cleanly. NeXTSTEP contributed the kernel and runtime; classic Mac OS contributed the interface, rewritten from scratch. The result shipped in 2001 as OS X 10.0 and was renamed macOS in 2016. The lineage matters because the seams between those worlds are exactly where bugs live.

At the bottom is XNU, a hybrid kernel. Not a microkernel, not a monolith: both, bolted together. Three pieces share the address space:

  • Mach handles the primitive machinery: scheduling, threads, virtual memory, and inter-process message passing. Mach’s ports and messages are the substrate for a huge amount of macOS IPC, and a later post in this series turns Mach task ports into a process-injection primitive.
  • BSD layers the familiar POSIX world on top: processes, users, the filesystem, sockets, signals. When you call fork() or open(), you are talking to the BSD half.
  • IOKit is the driver framework, written in a restricted C++ dialect. Device drivers subclass Apple’s I/O classes instead of reimplementing them.

Kernel extensions (KEXTs) bolt additional code into the kernel. This is not a footnote: Sandbox.kext is the sandbox. The enforcement that confines an exploited Safari renderer is a kernel extension, and getting unsigned code into that ring is the subject of one of the hardest topics in this series.

Stack the kernel, the core libraries (libmalloc, libxpc, and friends), and Apple’s open-source userland together and you get Darwin. Apple publishes much of Darwin, so you can download, compile, and boot a working XNU. What you cannot get this way is the proprietary security glue: Sandbox, and AppleMobileFileIntegrity (AMFI), the component behind most code signing enforcement. The parts Apple keeps closed are, predictably, the parts that stop you.

Above Darwin sit the runtimes (Objective-C and Swift), then Apple’s frameworks: public ones for developers, and private ones meant only for Apple. That “meant only for Apple” qualifier is load-bearing. Several exploits in this series work precisely because a private API does something the public API politely refuses to do. At the very top are the applications, including non-obvious ones like WindowServer, which renders the entire UI and runs with privileges that make it a recurring target.

When you see a security feature described as “implemented in the kernel,” check whether it lives in core XNU or in a closed KEXT. The closed ones (Sandbox, AMFI) are where Apple hides the logic it does not want you to study, and therefore where the most interesting bugs hide.

APFS: A Filesystem That Shares and Seals

With macOS 10.13, Apple replaced HFS+ with the Apple File System (APFS), and the change is not cosmetic. On a traditional disk, a partition holds exactly one volume that owns the partition’s entire space. Resize one and you risk the others; the boundaries are rigid.

APFS inverts this. A partition holds a container, and a container holds many volumes that all draw from the same shared free space. You can carve the system into logically separate filesystems while letting each grow dynamically. diskutil list shows the layout:

# filename: diskutil-list.txt
/dev/disk1 (synthesized):
   #:  TYPE NAME                       SIZE       IDENTIFIER
   0:  APFS Container Scheme -         +85.7 GB   disk1
       Physical Store disk0s2
   1:  APFS Volume  Untitled - Data    4.7 GB     disk1s1
   2:  APFS Volume  Preboot            281.1 MB   disk1s2
   3:  APFS Volume  Recovery           652.6 MB   disk1s3
   4:  APFS Volume  VM                 1.1 MB     disk1s4
   5:  APFS Volume  Untitled           14.9 GB    disk1s5
   6:  APFS Snapshot com.apple.os...   14.9 GB    disk1s5s1

Two of those volumes carry the real security story.

The Signed System Volume

Starting in Big Sur, the operating system itself lives on a read-only, cryptographically sealed volume. APFS computes a hash tree over the entire system volume and stores a single root hash: the seal. At boot, the OS verifies that the on-disk content still matches the seal. Modify one byte of any system file and the seal breaks; in the diskutil apfs list output that shows up bluntly as Sealed: Broken.

This is System Integrity Protection (SIP) taken to its logical conclusion. SIP, also called rootless, was originally about denying even root the ability to modify protected files. The Signed System Volume goes further: the protection is no longer a permission check that privileged code might bypass, it is a hash that has to match. The user’s files live on a separate Data volume that is writable. The system you boot and the data you own are physically different volumes.

SIP-protected paths are marked with filesystem flags you can read directly:

# filename: ls-lO-root.txt
% ls -lO /
drwxr-xr-x@  9 root  wheel  restricted          288 System
drwxr-xr-x   8 root  admin  sunlnk              256 Applications
drwxr-xr-x@ 38 root  wheel  restricted,hidden  1216 bin
lrwxr-xr-x@  1 root  wheel  restricted,hidden    11 etc -> private/etc

The restricted flag is the SIP marker. sunlnk (System No Unlink) is weaker: it means the directory itself cannot be deleted, but its contents can still be created and removed. The distinction matters later: restricted and sunlnk fail differently under attack, and several privilege-escalation bugs in this series hinge on finding a writable, non-restricted directory sitting inside an otherwise protected tree.

Splitting system and data onto two volumes creates an obvious problem. A program expects /Users, /Applications, and /usr/local to sit alongside /System and /bin in one coherent tree. Physically they do not: system content is read-only, user content is elsewhere.

Apple’s answer is the firmlink, a bidirectional bridge between the two volumes that is not a symlink. A symlink is a visible indirection that points elsewhere; a firmlink is invisible plumbing that makes two directories on two volumes appear as one. The set of firmlinks is fixed, defined in /usr/share/firmlinks, and not something an application can create. The takeaway for an attacker: the path you see is not necessarily the volume you are writing to, and the rules for symlinks, hardlinks, and firmlinks are all different. A whole post later in this series is built on confusing privileged code about which file a path really resolves to.

Bundles and Property Lists: Structure as Attack Surface

macOS does not ship applications as bare executables. It ships bundles: directories with a fixed internal layout that the system treats as a single opaque object. A typical .app:

# filename: app-bundle.txt
Calculator.app/
└── Contents/
    ├── Info.plist          # identity, entitlements hints, executable name
    ├── MacOS/
    │   └── Calculator       # the actual Mach-O binary
    ├── Resources/           # icons, nibs, localized strings
    └── _CodeSignature/      # the code-signing seal over the bundle

Info.plist is the bundle’s identity card: it names the executable inside MacOS/, declares the bundle identifier, and carries configuration the loader reads before running a single instruction. Property lists, plists, are macOS’s universal serialization format, used for everything from app metadata to launch-daemon definitions to the TCC consent records that decide whether an app may use your microphone. They come in XML and binary flavors; plutil converts between them. Because plists configure so much privileged behavior, “what happens if I control this plist” is a question that recurs through every later post.

Other bundle types follow the same pattern: .framework for shared libraries with versioned headers, .bundle for loadable plugins (QuickLook generators and Spotlight importers among them, both of which become sandbox-escape vectors later), and .kext for kernel extensions.

The dyld Shared Cache

One more structural detail that trips up newcomers reversing macOS. The system frameworks and libraries are not individual files on disk in any useful sense: they are pre-linked together into the dyld shared cache, one enormous blob mapped into every process at launch. Reach for /usr/lib/libsystem.dylib expecting a standalone file and you will not find it. To statically analyze a system library you first extract it from the cache. It is a performance optimization that doubles as a reverse-engineering speed bump, and knowing it exists saves an hour of confusion the first time otool comes up empty.

Mach-O: The Executable That Carries Its Own Receipt

Every native macOS program (executable, library, KEXT, dyld cache entry) is a Mach-O file. Two features set it apart from ELF and PE.

First, universal (fat) binaries. A single file can bundle multiple architecture slices, say x86_64 and arm64, behind a small fat header that the loader reads to pick the right one. This is how the same .app runs natively on Intel and Apple Silicon. When you reverse a binary, step one is checking which slices are present and choosing the one you care about.

Second, the structure itself: a header, a list of load commands, and the data.

# filename: mach-o-layout.txt
┌─────────────────────────┐
│ Mach-O Header           │  magic, CPU type, file type, #load commands
├─────────────────────────┤
│ Load Commands           │  a script telling dyld how to build the process
│   LC_SEGMENT_64 __TEXT  │
│   LC_SEGMENT_64 __DATA  │
│   LC_LOAD_DYLIB         │  every library this binary depends on
│   LC_MAIN               │  the entry point
│   LC_CODE_SIGNATURE     │  offset to the embedded signature
│   ...                   │
├─────────────────────────┤
│ Data (segments/sections)│  __TEXT.__text (code), __DATA, etc.
└─────────────────────────┘

The header announces the magic number, CPU type, and file type. The load commands are the interesting part: they are effectively a recipe the dynamic loader executes to assemble a running process. LC_SEGMENT_64 maps a chunk of the file into memory with given permissions. LC_LOAD_DYLIB names a dependency, and there is one such command for every library the binary imports, which is precisely the list an attacker inspects when hunting for a hijackable dylib. LC_MAIN sets the entry point. LC_CODE_SIGNATURE points at the embedded signature blob.

That last command is the structural reason macOS code signing works the way it does. The signature is not a detached file the OS might forget to check: it is a load command inside the binary, validated as the binary is mapped. Tampering with the code changes the hashes; the signature no longer matches; AMFI refuses to run it. Every “bypass code signing” trick later in this series is, at root, a way to make that validation either not happen or check the wrong thing.

Objective-C: Why Hooking Is Easy Here

The last piece of the map is the language. Despite Swift’s rise, enormous amounts of macOS (and nearly all of its mature, interesting attack surface) are Objective-C. Its defining trait, from an attacker’s seat, is that method calls are not direct jumps. They are messages, dispatched at runtime by name.

When you write [object doSomething], the compiler emits a call to objc_msgSend(object, @selector(doSomething)). At runtime, objc_msgSend looks up the selector doSomething in the object’s class and invokes whatever it finds. Nothing is resolved at compile time; everything flows through one dispatch function consulting tables that are mutable while the program runs.

// filename: dispatch.m
// What you write:
[keychain unlockWithPassword:pw];

// What actually executes:
objc_msgSend(keychain, sel_registerName("unlockWithPassword:"), pw);

Hold onto that. A dispatch mechanism that resolves methods by name, through tables you can edit at runtime, is an open invitation. A later post uses the Objective-C runtime API to swizzle a method, swap the implementation behind a selector, and walks a worked example sniffing a KeePass master password out of a target’s memory. The same runtime that gives Objective-C its flexibility hands an attacker a clean, supported way to rewrite a program’s behavior from the inside.

The basics worth carrying forward: classes define methods and instance variables; objects are instances; @protocol is an interface; methods can be auto-generated getters/setters; and NSString, NSArray, NSDictionary are the workhorse types you will see everywhere in a disassembler. You do not need to write Objective-C fluently to attack macOS. You need to read it in Hopper and know that every call you see is a runtime-resolved message.

Why This Matters

Every control this series eventually bypasses is anchored in something on this map. SIP and the Signed System Volume turn “modify a system file” from a permission check into a hash mismatch. The two-volume, firmlink-bridged filesystem means a path never simply equals a location. Mach-O’s LC_CODE_SIGNATURE is the hinge that code-signing enforcement swings on, and its LC_LOAD_DYLIB list is a hijacking menu. Mach ports are the IPC substrate behind injection. Objective-C’s runtime dispatch is the reason hooking is a supported feature rather than a fight.

None of these is a vulnerability by itself. They are the terrain. The next post picks up the tools (codesign, jtool2, Hopper, LLDB, and DTrace) that let us actually read this terrain on a live system, because a map is only useful once you can survey the ground.

References

  1. Apple. System Integrity Protection Guide. developer.apple.com.
  2. Apple. Open Source: Darwin / XNU. opensource.apple.com.
  3. Oakley, H. Big Sur Boot Volume Layout. The Eclectic Light Company, 2021.