System Internals

Apple internals #4: The iOS sandbox

AMFI decides what may run; the sandbox decides what a running process may touch. The profile is the exact list of what it can reach, and escaping is more often reading that list than corrupting memory.

In brief

Even once iOS agrees to run a process, the sandbox decides what it may touch. This post takes apart the permission list that a profile is, and shows why getting out of the sandbox more often means reading what that list allows than corrupting memory.

The previous post ended on cr_label, the Mandatory Access Control Framework label on a process’s credentials. AMFI, Apple Mobile File Integrity, writes its verdict there at exec time, entitlements and sandbox exceptions included. A second policy module, the sandbox, reads the same slot and asks the question that follows: with the process now running, what may it reach?

The answer is not a check scattered through the kernel. It is a single compiled document, one per process, that the kernel consults on every sensitive operation: open this file, resolve that Mach service name, call this IOKit method. That document is the process’s sandbox profile: the exact list of what a confined process can reach, and therefore the list of everything an attacker who lands in it will try next. Escaping a sandbox is far more often a matter of reading the profile than of corrupting anything.

Everything here is public: Apple’s open-source XNU, the Apple Platform Security documentation, published research from Dionysus Blazakis, the SandBlaster authors and Brandon Azad, and the sandbox profiles shipped on every Mac. It contains no exploit, private detail, or 0day.

The sandbox is built like AMFI

The sandbox is Sandbox.kext, a kernel extension (bundle id com.apple.security.sandbox) and a MACF policy module structurally identical to AMFI. It registers a callback at each sensitive operation: a file open, a memory mapping, an IOKit user-client open, a raw syscall. MACF invokes every registered policy there, and a deny from any one denies the operation. That is the deny-wins property from the last post: a single refusal from either module ends the operation, so switching one off buys nothing from the other.

A mach-lookup, resolving a Mach service name to a port, takes a different route. There is no MACF hook for it: launchd asks the sandbox through the policy syscall before it hands out the send right, and the answer comes out of the same profile.

AMFI’s central verdict is taken at exec and is about code identity. The sandbox runs for the rest of the process’s life and filters every sensitive operation it attempts. Both read the same cr_label: AMFI’s verdict sits in one slot, and a pointer to this process’s compiled profile sits in a second slot, which the kernel pulls out and evaluates against at each hook.

The model is deny by default. A profile begins with (deny default), and every capability the process has is an explicit allow written against it. Nothing the profile did not name is permitted.

Two profiles apply to essentially every process, and an operation is allowed only if both allow it:

The offensive consequence is the one from the last post: once you have kernel read/write, what confines the process is that pointer. Overwrite it, swap in an unrestricted profile or NULL, and the process is no longer confined, without ever touching the policy itself.

Containers: every process in its own tree

Much of what the profile enforces is expressed in terms of the container. containermanagerd, itself a sandboxed daemon, creates each app’s data container at /var/mobile/Containers/Data/Application/<UUID>/, where the UUID is a random per-install identifier no other app ever learns, and records ownership out of the app’s reach. The profile binds a variable, HOME, to that path at spawn, so a file-read* or file-write* rule written against (subpath (param "HOME")) resolves into the app’s private tree and nowhere else.

The sanctioned way out of that isolation is an App Group: a shared directory under /var/mobile/Containers/Shared/AppGroup/<UUID>/, gated by the com.apple.security.application-groups entitlement, that several of a developer’s apps can share. On macOS the same machinery puts each sandboxed app under ~/Library/Containers/<bundle-id>/. The container is the filesystem half of confinement; the IPC, IOKit, and syscall half is in the profile.

SBPL, and the profile the kernel actually reads

Profiles are authored in SBPL, the Sandbox Profile Language, a small dialect of Scheme. A rule is (action operation filter... modifier...):

So a single line like (allow mach-lookup (global-name "com.apple.tccd")) reads as it looks: this process may resolve the name of TCC, the Transparency, Consent, and Control daemon, and no other name.

On macOS, libsandbox compiles SBPL to bytecode at spawn, and a dynamic profile can even run Scheme to generate its rules from the process’s entitlements. On iOS none of that happens at runtime: the platform profile and every named-service profile ship pre-compiled inside the kext, in memory the kernel is not allowed to rewrite. No source on the device to read, no compiler to invoke.

The kernel never sees SBPL text; it walks a compiled decision graph. As recovered by SandBlaster, a compiled profile is a header plus an array of fixed 8-byte nodes, indexed by an operation table: entry i is the root node for operation i. A node is either non-terminal, testing one filter against the operation’s context with an argument taken from the profile’s string, regex, or literal tables, and jumping to one of two other nodes on match or mismatch; or terminal, carrying the verdict and its modifier flags.

To decide a mach-lookup of com.apple.foo, the evaluator starts at the mach-lookup root and tests global-name against the first allowed name. A miss threads to the next candidate, and so on down the chain of allowed names, until a match reaches an allow terminal or the last mismatch lands on the default terminal, a deny. Traversal is O(depth) and the only attacker input is the argument being compared, which is why bugs in the interpreter are scarce and the productive attack is on the content of the policy.

Hands-on: reading a profile off a stock Mac

You do not have to decompile anything to learn this on a Mac. Unlike iOS, macOS ships a large set of first-party profiles as readable SBPL text, right in the filesystem.

ls /System/Library/Sandbox/Profiles/*.sb | head
/System/Library/Sandbox/Profiles/accessorysensormgrd.sb
/System/Library/Sandbox/Profiles/airlock.sb
/System/Library/Sandbox/Profiles/application.sb
/System/Library/Sandbox/Profiles/appsandbox-common.sb
/System/Library/Sandbox/Profiles/apsd.sb
/System/Library/Sandbox/Profiles/ASPCarryLog.sb
/System/Library/Sandbox/Profiles/AudioAccessoryAssetManagementXPCService.sb
/System/Library/Sandbox/Profiles/betaenrollmentagent.sb
/System/Library/Sandbox/Profiles/betaenrollmentd.sb
/System/Library/Sandbox/Profiles/blastdoor.sb

Two of those first ten already matter: blastdoor.sb is BlastDoor, the sandbox Apple built around iMessage parsing, and application.sb the base every third-party app inherits. quicklook-thumbnail.sb covers the QuickLook thumbnail generator, which is where an attacker lands for free: the OS parses a file to draw a thumbnail, with no user interaction, on bytes an attacker chose, as soon as it is downloaded, AirDropped, or opened in a Finder window. A memory-safety bug in one of those parsers gives you code execution inside this profile, so its allowed set is exactly what that bug can reach.

Skip the Apple banner at the top, which warns that these rules are private interface and auto-generated, and read the head of the policy:

sed -n '9,12p' /System/Library/Sandbox/Profiles/quicklook-thumbnail.sb
(version 1)
(deny default file-link)
(import "system.sb")
(import "appsandbox-common.sb")

Deny by default, then two imports: system.sb, the common base every process pulls in, and appsandbox-common.sb, the shared App Sandbox layer. The effective policy is this file plus whatever those two allow. Now the allowed Mach services:

grep -nE 'global-name|mach-lookup' /System/Library/Sandbox/Profiles/quicklook-thumbnail.sb
153:(allow mach-lookup
154:       (global-name "com.apple.containermanagerd")
155:       (global-name "com.apple.CoreServices.coreservicesd")
156:       (global-name "com.apple.coreservices.quarantine-resolver")
157:       (global-name "com.apple.cvmsServ")
158:       (global-name "com.apple.distributed_notifications@1v3")
159:       (global-name "com.apple.distributed_notifications@Uv3")
160:       (global-name "com.apple.FileCoordination")
161:       (global-name "com.apple.FontObjectsServer")
162:       (global-name "com.apple.fonts")
163:       (global-name "com.apple.gputools.service")
164:       (global-name "com.apple.mobileassetd")
165:       (global-name "com.apple.ocspd")
166:       (global-name "com.apple.securityd.xpc")
167:       (global-name "com.apple.SecurityServer")
168:       (global-name "com.apple.spindump")
169:       (global-name "com.apple.SystemConfiguration.configd")
170:       (global-name "com.apple.tailspind")
171:       (global-name "com.apple.tccd")
172:       (global-name "com.apple.tccd.system")
173:       (global-name "com.apple.TrustEvaluationAgent")
174:       (global-name "com.apple.windowserver.active"))
175:(allow mach-lookup
176:       (global-name "PurplePPTServer")
177:       (global-name "PurpleSystemEventPort")
178:       (global-name "com.apple.awdd")
179:       (global-name "com.apple.itunesstored.xpc")
180:       (global-name "com.apple.lskdd"))

That is the allowed set. Twenty-odd names, each one a service this profile may resolve into a send right; every other name on the system is unreachable, and bootstrap_look_up, the call that turns a service name into a port, returns nothing. It is the complete first-order attack surface of a thumbnailing bug, and the escape routes are visible in it: com.apple.tccd and com.apple.tccd.system decide whether code may reach your camera, microphone, and private files; com.apple.SecurityServer and com.apple.securityd.xpc front the keychain; com.apple.windowserver.active is WindowServer, historically one of the deepest escape surfaces on the platform; com.apple.CoreServices.coreservicesd is Launch Services, which can start other programs; com.apple.cvmsServ is the shader-compilation service, a long-standing target because it parses and compiles attacker-supplied shaders. The second block, the Purple* names (Purple is Apple’s internal codename for iOS), shows that these first-party profiles are shared source between iOS and macOS.

To watch a denial happen, write a profile that allows everything except one class, the network, and run a program under it:

cat > /tmp/nonet.sb <<'EOF'
(version 1)
(allow default)
(deny network* (with message "nonet-demo"))
EOF
sandbox-exec -f /tmp/nonet.sb /usr/bin/curl -sI https://www.apple.com ; echo "exit: $?"
exit: 6

(allow default) lets curl start normally; the later (deny network*) wins for that one class, so its first network move, resolving the hostname, is refused. curl exits 6, CURLE_COULDNT_RESOLVE_HOST: the DNS query never left the sandbox. Keep log stream --predicate 'sender == "Sandbox"' open in another terminal and the violation surfaces as the nonet-demo message, the same way the kernel logged the AMFI kill in the last post.

You can read this profile because it is a Mac, and macOS ships the sources. On the device there is no quicklook-thumbnail.sb to cat: the profile is compiled into Sandbox.kext and locked in memory. To get the same list on iOS you query it at runtime with sandbox_check or Levin’s sbtool against a live process, or you pull the kext out of the kernelcache and run it through SandBlaster to recover the SBPL. More work than a cat, same answer.

What the profile tells an attacker

The allowed set is a target list, because sandbox escapes are dominated by logic bugs. A bug that reasons around the policy is unaffected by the kernel’s memory-safety hardening, for the reason the code-signing post gave: it never corrupts anything. The classes below are all visible from the profile we just read.

Confused deputy via mach-lookup. The most productive class by far. Each allowed global-name is a service running with its own profile, its own entitlements, and often a higher uid. Find a bug in one of them, memory-safety or logic, and you inherit its capabilities without ever attacking the sandbox itself. The method is invariant: enumerate the allowed services, rank them by privilege and entitlements, and audit each endpoint’s message handlers. Reachable is not exploitable, since a service may have its own tight profile and hardened handlers. And escaping into a service usually lands you in another, often wider, sandbox, so real chains stack escapes until they reach an unsandboxed or root deputy, or the kernel.

That last case is what Brandon Azad’s blanket (CVE-2018-4280) did on iOS: a Mach-service bug chained through reachable services to ReportCrash, which was unsandboxed, ran as root, and held task_for_pid-allow, so the confused deputy handed over the task port of any process on the device, amfid included. No memory was corrupted; the deputy did the privileged work on the attacker’s behalf.

Unsandboxed or under-sandboxed services. A reachable process with no profile at all, an (allow default), or a no-sandbox grant on its children is an escape by construction, and the first thing to grep the decompiled policies for. ReportCrash was the classic; the macOS analogue today is the tail of XPC services (XPC is Apple’s IPC layer over Mach, the subject of the next post) that auto-register in one process’s launchd domain when a framework loads and skip the entitlement checks their system-wide siblings enforce.

Bugs in the evaluation itself. Rare, because the interpreter is small and sees data you only indirectly control, and total when they land: a flaw in the interpreter, the regex engine, or the check on extension tokens (the signed grants a broker hands a client for one specific path) applies to every profile at once. The realistic corners are the regex table, where a pattern matches a path it should not through .., UTF-8, or case-folding, and the HMAC (keyed hash) that authenticates those tokens.

Entitlement and exception widening. Entitlements widen the profile as well as unlocking AMFI capabilities, and the mechanism is at the bottom of the QuickLook file:

217:  "com.apple.security.temporary-exception.mach-lookup.global-name"
218:  (lambda (name) (allow mach-lookup (global-name name))))
220:  "com.apple.security.temporary-exception.mach-lookup.local-name"
221:  (lambda (name) (allow mach-lookup (local-name name))))

A process whose signature carries that entitlement gets to resolve any service name it lists, overriding its own profile’s deny-default, though still only up to what the platform profile allows. It is an escape class the moment an exception is over-broad, or a broker issues one on a client’s say-so without checking that the client should have it.

Uncovered operations and filter races. The gaps: an operation nobody wrote a rule for, which inherits a too-generous default, or a path filter defeated by a symlink, a .., or a rename race slipped between the mpo_vnode_check_* callout and the filesystem operation itself. These are TOCTOU (time-of-check to time-of-use) bugs, and they appear wherever a filter matches a name that a moment later points somewhere else.

State in 2026

The model has not changed since 2021; the escape got narrower and better instrumented. The sandbox is still an in-XNU MACF kext, and unlike the code-signing verdict from the last post, which moved out to TXM (the Trusted Execution Monitor), sandbox evaluation did not migrate to TXM or to the Exclaves, the isolated domains that run outside XNU under the Secure Kernel. What changed is the surface around it.

Sandbox logic is untouched by all of it: confused deputies, extension-scoping bugs, an under-sandboxed daemon, a profile gap, a filter race. kalloc_type and MIE (Memory Integrity Enforcement, the synchronous memory tagging on A19 and the iPhone 17 line) only make the stage after the escape harder, and it is only on anything still running iOS 15 or older that the old flow survives, where kernel read/write plus a plain label edit is the whole desandbox.

Where this leaves us

The sandbox is one compiled document per process, deny-by-default, walked by a small interpreter over a decision graph. The platform profile sets the maximum any process gets; a per-process profile and its entitlements narrow it further. You find its bugs by reading the profile: the allowed mach-lookup set is everything the process can reach, and therefore everything you can try.

Which is also the honest limit of this post. The profile tells you which drivers and services you may reach. It says nothing about how to talk to them, or what goes wrong when you do. The next post picks one iokit-open allowance and follows the user client behind it into the driver, down to the table that decides which function your call lands on.

Notes and sources

Everything here is drawn from open source, vendor documentation, published research, and the profiles shipped on any Mac.

Work like this is what we do under engagement.

[email protected] Vulnerability Research

No spam, one click to unsubscribe. Privacy.