The previous post ended at a handoff. AMFI had decided what a binary was allowed to be and stamped the answer, including its sandbox-exception entitlements, into cr_label, the Mandatory Access Control Framework label slot on a process’s credentials. AMFI is one policy module reading that slot. The sandbox is the other, and it answers the complementary question: now that the process is running, what is it allowed to touch?
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, and it carries the one idea this post is built on. The profile is a map. Read it and you are holding the exact list of what a confined process can reach, which is the same list as everything an attacker who lands in that process will try next. Escaping a sandbox is far more often a matter of reading that map than of corrupting anything.
The sandbox is AMFI’s twin: the same framework, the same kind of hooks, a sibling slot on the same label. So we move quickly through the mechanism, whose shape the last post already built, and spend the room where it pays: on the profile as an attack surface, on reading one off a running system, and on the handful of escape shapes that reading it exposes.
Everything here is public: Apple’s open-source XNU, the Apple Platform Security documentation, published research from Dionysus Blazakis, the SandBlaster authors, Brandon Azad and others, and the readable sandbox profiles shipped on every Mac. It contains no exploit, private detail, or 0day.
The sandbox is AMFI’s twin
Start with the shape, because it is the one the last post already built. The sandbox is Sandbox.kext (bundle id com.apple.security.sandbox), and it is a MACF policy module, structurally identical to AMFI. It registers a callback with the framework at each sensitive operation, a file open, a memory mapping, an IOKit user-client open, a raw syscall, a mach-lookup of a service name, and MACF invokes every registered policy at that chokepoint. A deny from any one of them denies the operation. That is the deny-wins property from the last post: AMFI and the sandbox each hold an independent veto, and neither disabling the other buys you the second.
Where AMFI runs once, at exec, and decides code identity, the sandbox runs continuously and filters every sensitive operation the process attempts for the rest of its life. It reads the same cr_label AMFI wrote to. AMFI keeps its verdict in one slot of that label; the sandbox keeps, in a sibling slot, a pointer to this process’s compiled profile. At each hook, the kernel pulls that profile out of the label slot and evaluates the operation against it.
The model is deny by default. A profile begins with (deny default), and every capability the process has is an explicit allow written on top of that floor. Nothing is permitted that the profile did not name.
Two profiles apply to essentially every process, and the operation is allowed only if both allow it, a logical AND:
- The platform profile is a single mandatory base policy compiled into the kext (
_platform_profile_data), evaluated for every process on the system, root daemons included. It is the iOS analogue of SIP enforced at the MAC layer: even uid-0 code is confined, and no per-process rule can raise the floor it sets. - The per-process profile is
containerfor third-party apps (every App Store app shares the identical container profile; the differentiation between them is entirely by Apple-signed entitlements) or a named service profile (com.apple.WebKit.WebContent,mediaserverd,quicklook-thumbnail, and the rest) applied at spawn.
The offensive consequence of the label is the one from last post, and it is worth restating because the 2026 section qualifies it. Once you have kernel read/write, what confines the process is a pointer in a label slot. Overwrite it, swap in an unrestricted profile or NULL, and the process is no longer confined, without ever touching the policy itself. So where does that per-process profile come from, and what is the kernel actually reading when it walks one?
Containers: every process in its own tree
Before the profile, the container, because a lot of what the profile enforces is expressed in terms of it. containermanagerd, itself a sandboxed daemon, creates each app’s data container at /var/mobile/Containers/Data/Application/<UUID>/ and records ownership out of the app’s reach. The profile carries a variable, HOME, bound at spawn to that container path, so a file-read* or file-write* rule written against (subpath (param "HOME")) resolves into the app’s private tree and nowhere else. An app cannot name another app’s container, because it never learns the other UUID and could not read it if it did.
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 profile is where the interesting half, the IPC and IOKit and syscall surface, is spelled out.
SBPL, and the profile the kernel actually reads
Profiles are authored in SBPL, the Sandbox Profile Language, a small Scheme-derived DSL. A rule is (action operation filter... modifier...):
- action is
allowordeny. - operation is hierarchical and wildcarded:
file-read*coversfile-read-data,file-read-metadata,file-read-xattr; alongside it sitfile-write*,mach-lookup,network*,iokit-open,process-exec*,sysctl-read,syscall-unix, and a few dozen more.defaultsets the base verdict. - filter narrows the rule to specific arguments: path filters (
literal,subpath,prefix,regex), the Machglobal-name/local-name,require-entitlement,iokit-user-client-class, networksocket-domain/remote. Filters combine withrequire-all/require-any/require-not. - modifier tweaks the outcome:
report, a specificerrnoto return on deny,send-signalto kill on violation, andno-sandbox, which lets a child run unconfined and is a red flag every time it appears.
So a single line like (allow mach-lookup (global-name "com.apple.tccd")) reads exactly as it looks: this process may resolve the name of the TCC daemon, and by omission may resolve no other name the profile does not separately allow.
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 the whole named-service collection ship pre-compiled inside the kext, sitting in memory the kernel is not allowed to rewrite, so there is no source on the device to read and no compiler to invoke.
What the kernel evaluates, then, is not text but a compiled decision graph. As recovered by SandBlaster, a compiled profile is a header plus an array of fixed 8-byte nodes forming a directed acyclic graph, indexed by an operation table: entry i of that table is the root node for operation i.
- A non-terminal node is
[type=0x00][filter_id][argument_id][match_offset][unmatch_offset]. It applies filterfilter_idwith argumentargument_id(an index into the string table, the regex table, or a numeric literal) against the operation’s context, and jumps tomatch_offseton a match orunmatch_offsetotherwise. Both offsets are node indices. - A terminal node is
[type=0x01][action...], carrying allow or deny in a low bit and modifier flags above it. Traversal from a root always ends at exactly one terminal, which is the verdict.
Walk it concretely. To decide a mach-lookup of com.apple.foo, the evaluator indexes the operation table to the mach-lookup root, a non-terminal whose filter is global-name and whose argument points at the string "com.apple.foo". If the requested name matches, it jumps one way; if not, the unmatch_offset threads to the next candidate name, and so on down a chain of allowed names whose final miss lands on the default terminal, a deny. The traversal is O(depth) and branch-free of attacker input beyond the argument being compared, which is exactly why bugs in the interpreter are scarce and the productive attack is on the content of the policy.
Hands-on: reading the map off a stock Mac
Here is the good news about learning this on a Mac: you do not have to decompile anything. 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
Those are the first ten, alphabetical, and the names already read like an attack-surface index: blastdoor.sb is the sandbox Apple built around iMessage parsing, application.sb the base every third-party app inherits. Pick one an attacker actually lands in. quicklook-thumbnail.sb is the profile for the QuickLook thumbnail generator, and that process is a genuinely hostile position: when a file arrives on the system, downloaded, AirDropped, or merely sitting in a folder you open in Finder, the OS parses it to render a thumbnail, with no user interaction, on bytes an attacker chose. 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.
Read its head first:
sed -n '1,12p' /System/Library/Sandbox/Profiles/quicklook-thumbnail.sb
;;;;;; QuickLook Thumbnail Profile
;;;;;;
;;;;;; Copyright (c) 2019 Apple Inc. All Rights reserved.
;;;;;;
;;;;;; WARNING: The sandbox rules in this file currently constitute
;;;;;; Apple System Private Interface and are subject to change at any time and
;;;;;; without notice. The contents of this file are also auto-generated and
;;;;;; not user editable; it may be overwritten at any time.
(version 1)
(deny default file-link)
(import "system.sb")
(import "appsandbox-common.sb")
Deny by default, then two imports. system.sb is the common base every process pulls in, and appsandbox-common.sb is the shared App Sandbox layer; the effective policy is this file plus whatever those two allow. Now the part that matters, 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 map. Twenty-odd names, each one a service this profile may resolve into a send right, and by omission every other name on the system is unreachable, bootstrap_look_up returns nothing. You are looking at the complete first-order attack surface of a thumbnailing bug. Read a few of the names and you can see where an escape would go: com.apple.tccd and com.apple.tccd.system are the Transparency, Consent, and Control daemon, the thing that decides 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, an old favorite because compiling is close to executing. The second block, the Purple* names (Purple is Apple’s internal codename for iOS) and com.apple.lskdd, is the iOS-flavored set, a reminder that these first-party profiles are shared source between iOS and macOS.
To watch a denial happen, author a profile that allows everything and then revokes 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 come up and run normally; the later (deny network*) wins for that one class, so the process starts and its very 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.
One honest caveat closes the hands-on, and it is the bridge back to iOS. 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 read the same map 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. That is more work than a cat, but the map is the same.
What the map tells an attacker
The map is a target list, and the reason it reads that way is that sandbox escapes are dominated by logic, not corruption. A logic bug that reasons around the policy needs no heap shaping, survives kalloc_type, is untouched by memory tagging, and is unaffected by the page-table monitor, for the same reason it did in the code-signing post: it never corrupts anything. The classes below are all visible from the map we just read.
Confused deputy via mach-lookup. This is the single most productive class, and it is the map read literally. Each allowed global-name is a service running with its own profile, its own entitlements, and often a higher uid. If you find a bug, memory-safety or logic, in a reachable service that is more privileged than you, you inherit its capabilities without ever attacking the sandbox itself. So the twenty names become a ranked target list: tccd because subverting it is a privacy bypass, SecurityServer because it fronts the keychain, coreservicesd because launching a process is a capability, WindowServer and cvmsServ because both have long histories as escape surfaces. The method is invariant: enumerate the allowed services, rank them by privilege and entitlements, and audit each endpoint’s message handlers. Two caveats keep it honest. Reachable is not the same as 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 rather than in unconfined code, so real chains stack escapes until they reach an unsandboxed or root deputy, or the kernel.
That last shape is exactly 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 simply handed over the kernel task port. No memory was corrupted; a service reachable over an allowed mach-lookup 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 PID-domain XPC services that auto-register when a framework loads and skip the entitlement checks their System-domain siblings enforce.
Bugs in the evaluation itself. Rare, but when they land they are total, because a flaw in the bytecode interpreter, the regex engine, filter-argument parsing, or the extension-token check applies to every profile at once. The realistic corners are the regex table (a pattern that matches a path it should not, through .., UTF-8, or case-folding surprises) and the extension HMAC path. The interpreter is small and evaluated on data you only indirectly control, which is why these are scarce.
Entitlement and exception widening. Entitlements do not only unlock capabilities in AMFI, they widen the profile, and the mechanism is right there 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))))
Read that literally: a process whose signature carries the com.apple.security.temporary-exception.mach-lookup.global-name entitlement gets to resolve any service name it lists, overriding the deny-default floor. This is the sanctioned bridge back to the entitlements from the last post, the signed credential that widens what a process may do. It is also 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. Last, the gaps: an operation nobody wrote a rule for and that inherits a too-generous default, or a path filter defeated by a symlink, a .., or a rename race slipped in between the mpo_vnode_check_* callout and the actual filesystem operation. These are the TOCTOU (time-of-check to time-of-use) bugs, and they live wherever a filter matches a name that a moment later points somewhere else.
State in 2026
The delta since roughly 2021 is that the escape got narrower and better instrumented, not that the model changed. The sandbox is still an in-XNU MACF kext, and unlike the code-signing verdict from the last post, which moved out to the Trusted Execution Monitor, sandbox evaluation did not migrate to TXM or the Exclaves. What changed is the surface around it.
- Per-syscall filtering matured.
syscall-unix,syscall-mach, and kernel MIG-routine filtering let a profile whitelist individual BSD syscalls, Mach traps, and kernel routines. WebContent and BlastDoor now run with aggressively trimmed lists, so a large chunk of the raw XNU trap surface that was implicitly reachable in 2021 is explicitly denied per profile. Reverse the syscall nodes before you assume a trap is even callable from where you stand. - The reachable-service set keeps shrinking. First-party profiles, WebContent above all, have had their
mach-lookuplists cut repeatedly. From the modern Safari renderer the direct reach is minimal, principally the WebKit GPU and Networking processes, which is why the current browser escape pivots through the GPU process rather than messaging a daemon straight from the renderer. Fewer deputies, and each survivor a harder audit. - Launch constraints and DER entitlements close adjacent attack paths. Launch constraints (iOS 16 and later) bind which process may spawn a given binary, killing the old trick of relaunching a privileged Apple binary in your own context to inherit its wider profile. DER-encoded, TXM-validated entitlements make forging one to widen a profile far harder than the plist era allowed.
- The policy itself is out of reach of a kernel write. The platform profile blob was always locked by KTRR/CTRR (the Kernel Text Readonly Region and its configurable successor). On A15 and M2 and later, the pages backing the compiled policy are, plausibly, owned by SPTM under its physical-frame retyping, so even full kernel read/write cannot patch the profile in place. This is why the canonical post-exploitation desandbox is still editing
cred->cr_label, swapping in an unrestricted sandbox orNULL, rather than rewriting policy bytes: the label is data the kernel still owns, the policy is not. - Sensitive resources are moving under Exclaves. Camera and microphone capture, and the recording indicator, are migrating to sensor and indicator Exclaves running under the Secure Kernel, reached only through SPTM-mediated paths. The effect on this card is precise: a
device-cameraordevice-microphoneallow in a profile is becoming necessary but not sufficient, because the actual capture path is gated below XNU. A sandbox escape, or even a kernel compromise, no longer silently disables the recording indicator, which used to be a thing you could do. - The next stage is harder even when the escape works.
kalloc_typeand MIE (Memory Integrity Enforcement, the synchronous memory tagging on A19 and the iPhone 17 line) do not touch sandbox logic, but a confused-deputy escape that lands you in a service still leaves you facing a hardened kernel on the far side.
What still works, and will keep working, is the logic. Confused deputies, extension-scoping bugs, an under-sandboxed daemon, a profile gap, a filter race: none of the memory-safety hardening touches any of them, and the entire pre-A15, pre-A19 installed base still runs the old flow where kernel read/write plus a 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 is the floor no process escapes; a per-process profile and its entitlements decide how far above that floor a given process reaches. Its richest bugs are not corruption but logic, and you find them by reading the profile: its allowed mach-lookup set is the list of everything the process can reach and therefore everything you can try.
Which is also the honest limit of this post. Reading the map tells you which services you may talk to. It says nothing about how to talk to them, or what goes wrong when you do. That is the next post: Mach messages, MIG, and XPC, the actual mechanics of the confused deputy, where a service that checks a caller’s PID instead of its audit_token is the whole difference between a hardened endpoint and an escape. The map names the deputies; talking to them is the next post.
Notes and sources
Everything here is drawn from open source, vendor documentation, published research, and the profiles shipped on any Mac.
- Dionysus Blazakis, “The Apple Sandbox” (Black Hat DC 2011), the original public reverse-engineering of the SBPL bytecode model, still the conceptual baseline.
- Răzvan Deaconescu et al. (malus-security), “SandBlaster: Reversing the Apple Sandbox” and the
sandblasterdecompiler (maintained fork at cellebrite-labs);reverse-sandbox/operation_node.pydocuments the 8-byte node layout, the0x00non-terminal /0x01terminal type byte, and the operation table of 16-bit offsets used above. - Patroklos Argyroudis (CENSUS), “vs com.apple.security.sandbox” (CanSecWest 2019), the hooks,
sandbox_check, and operation/filter internals from an offensive stance. - nsantoine, “A Worm’s Look Inside: Apple’s Sandboxing Security Measures” (2024), a modern account of
cred_sb_evaluate,label_get_sandbox, operation numbering, and the platform-profile-in-kext design. - Brandon Azad,
blanket(CVE-2018-4280), the canonicalmach-lookupconfused-deputy escape toReportCrash. - Moritz Steffin and Jiska Classen, “Modern iOS Security Features: A Deep Dive into SPTM, TXM, and Exclaves” (2025), for the Exclaves and sensor/indicator domains behind the 2026
device-*changes. - Apple, “Memory Integrity Enforcement” (2025), the primary source for synchronous memory tagging on A19 and the iPhone 17 line.
- Jonathan Levin, *OS Internals, Volume III: Security & Insecurity (newosxbook.com) and the
sbtoolutility, the reference for MACF, the sandbox internals, and querying a live process’s profile. - Apple, Apple Platform Security, for the app sandbox, data containers, and the privacy-indicator architecture at the vendor-documentation level.
