The sandbox is macOS’s answer to “what happens when an attacker gets code execution inside a normal application.” A sandboxed process can run arbitrary code and still be unable to read your documents, talk to arbitrary network hosts, or launch other programs: because every one of those operations is checked against a profile, in the kernel, on the way out. Every Mac App Store app runs this way, and so does much of Apple’s own software.

This post covers how the sandbox is actually enforced (the kernel extension, the profile language, the path a process takes into confinement) and then the two ways out: escaping through a permissive profile, and the more interesting move of getting your code to run as a different, less-confined process entirely. It ends on two real escapes: a QuickLook plugin and Microsoft Word. The hooking post warned that the real boundaries on macOS are between processes, not inside them; the sandbox is the strongest of those boundaries an attacker meets early, and learning where it leaks is the point.

How the Sandbox Is Enforced

Sandboxing is implemented in Sandbox.kext, a kernel extension hooking the Mandatory Access Control framework (MACF). Enforcement is in the kernel, which is why a compromised userland process cannot simply turn it off: the checks happen below the code you control.

Originally an app entered the sandbox by calling sandbox_init from its own code. Today the trigger is an entitlement: sign a binary with com.apple.security.app-sandbox set to true and the system forces it into the sandbox automatically at launch. For App Store distribution this entitlement is mandatory, so every store app is sandboxed; for direct distribution it is the developer’s choice.

A sandboxed app gets a container: a private directory under ~/Library/Containers/<bundle-id>/ that stands in for its home directory. Its real reach beyond that container is governed entirely by its profile:

# filename: containers.txt
% ls -l ~/Library/Containers
drwx------  com.barebones.bbedit
% ls -l ~/Library/Containers/com.barebones.bbedit/Data
# symlinks back out to ~/Desktop, ~/Documents, etc. — but the profile decides
# whether the app may actually follow them.

Those symlinks are a trap for the unwary. The container links back to real locations, but the presence of a symlink does not mean the app is permitted to traverse it: the profile is the authority, not the filesystem layout.

The entry sequence is worth knowing because it shows where confinement actually clicks shut. At launch the app contacts the secinitd daemon, registering itself along with its entitlements. secinitd replies with the container path and the sandbox profile. Then the app issues a __mac_syscall into Sandbox.kext with the policy name "Sandbox", and that call is what puts the process under enforcement.

# filename: mac-syscall.txt
(lldb) breakpoint set --name __mac_syscall --condition '($rsi == 0)'
# stops right where the process is about to confine itself

Because activation is a userland-issued __mac_syscall, an attacker who controls the process before that call can interfere with it. One escape demonstrates interposing __mac_syscall (the technique from the function-hooking post) to skip the activation entirely, so the process never enters the sandbox. This is not a profile bug: it is a reminder that the sandbox you opt into from userland can be opted out of from userland, if you are there first.

The Sandbox Profile Language

Profiles are written in SBPL, a Scheme-like language. A profile starts with a version, sets a default stance, and then lists exceptions. The two default stances define everything:

; filename: allow-default.sb
(version 1)
(allow default)                                  ; permit everything...
(deny file* (literal "/private/tmp/secret.txt")) ; ...except this
; filename: deny-default.sb
(version 1)
(deny default)                                   ; permit nothing...
(allow process* (literal "/bin/cat"))            ; ...except what you list
(allow file* (literal "/private/tmp/secret.txt"))
(allow file* (regex "/usr/lib/*"))               ; including the libs cat needs

deny default is the secure posture, and the second example shows its sharp edge: deny-by-default means you must enumerate everything the allowed action transitively needs. Forget that /bin/cat loads libraries from /usr/lib and the command fails: not because the file rule is wrong, but because you under-specified the dependencies. Rules combine operations (file-read*, file-write*, network-outbound, mach-lookup, process-exec*, signal,…) with filters (literal, regex, remote ip, global-name). Apple’s own system profiles live in /usr/share/sandbox/ and /System and are dense, real-world examples of exactly this enumeration.

The takeaway for an attacker reading a profile: every allow is a potential exit. The whole audit is finding an allowed operation broad enough to reach something useful outside the container.

Two Ways Out

With the obvious non-bug case set aside (a profile so permissive it never confined anything), there are two genuine escape strategies.

Escape through a permissive profile. The profile allows an operation the attacker can leverage to reach outside the container. The two classic moves both end in code running unsandboxed:

  • Drop a launch agent plist in ~/Library/LaunchAgents. launchd later runs it, and launchd’s children are not under your sandbox. Your code is reborn outside the box.
  • Drop a payload that some other, unsandboxed process will pick up and execute.

The unifying idea is getting your code into a less-confined process. You do not break the sandbox’s enforcement; you arrange for execution to happen somewhere the enforcement does not apply. When that target process is also more privileged, you escape and escalate in one move.

Exploit a bug in a process that runs your input. Find a vulnerability in a more privileged or less-sandboxed process that handles attacker-controlled data, gain code execution in its context, and inherit its weaker confinement. The two case studies are exactly this.

QuickLook: A Plugin That Runs Outside Your Box

QuickLook is the Finder preview feature: select a file, press space, see a preview. The system matches the file type to a generator plugin (.qlgenerator bundle) and invokes it to render the preview. System plugins live in /System/Library/QuickLook and take precedence, but user plugins are auto-loaded from /Library/QuickLook, ~/Library/QuickLook, or inside an app bundle’s Contents/Library/QuickLook.

The crucial detail: QuickLook plugins do not run in your app’s sandbox. They run inside the system preview process, ExternalQuickLookSatellite, under its profile (/usr/share/sandbox/quicklook-satellite-legacy.sb). That profile, until Big Sur, was permissive enough to allow the launch-agent escape described above. So the chain for an App Store app escaping its own sandbox:

  1. Embed a QuickLook plugin in your bundle’s Contents/Library/QuickLook. A plugin’s job is to implement GeneratePreviewForURL: your code, packaged as a preview generator.
  2. Trigger preview generation so the system loads your plugin into ExternalQuickLookSatellite. Your code now runs in that process, under the satellite’s more permissive profile rather than your app’s restrictive one.
  3. Use the satellite’s allowed operations to escape: write a plist into ~/Library/LaunchAgents and let launchd execute your payload entirely outside any sandbox.

You never defeated the sandbox’s kernel enforcement. You moved your code into a process whose profile let it write the file that bootstraps a fully unconfined payload. Apple tightened the satellite profile in Big Sur, which is the usual end state for this class: these escapes are treated as vulnerabilities and patched by narrowing the permissive profile.

Microsoft Word: Sandboxed App, Unsandboxed Children

The Word case study is the same idea via a different door. Word is sandboxed (App Store distribution), but the bug lets Word’s confinement be sidestepped so that an attacker-controlled operation results in execution outside the box: again, frequently by planting something a privileged or unsandboxed process will later run. This post walks the vulnerability, the escape, and then the patch, which is the recurring methodology this series keeps returning to: the fix shows you exactly which operation was the escape, and studying it teaches you what the permissive edge was.

The pairing of QuickLook and Word is deliberate. Both are real, both are the same shape, a sandboxed context reaching code execution in a less-sandboxed one, and both got patched the same way, by tightening a profile after the fact. Two instances of one pattern is how you learn to recognize the pattern in the wild.

Why This Matters

The macOS sandbox is strong precisely where people expect it to be weak: enforcement is in the kernel, so userland compromise alone does not lift it. The escapes do not contradict that. They work by relocating execution (into a plugin host, into a launch agent, into any process whose profile is broader than yours) rather than by breaking the kernel check. Read that way, every sandbox audit becomes a search for an allow rule that reaches a more privileged execution context, and every patch is Apple deleting one such rule.

That framing (confinement as a property of the process you happen to be running in, escapable by becoming a different process) carries straight into the next post. TCC is the privacy layer that gates access to your camera, microphone, and files, and like the sandbox it is enforced per-process and per-identity. Which means the bypasses rhyme: find a process that already has the permission, and get it to act for you. That is where the series goes next.

References

  1. Apple. App Sandbox Design Guide; com.apple.security.app-sandbox. developer.apple.com.
  2. Apple. System sandbox profiles: /usr/share/sandbox, /System/Library/Sandbox.
  3. Apple. QuickLook: Generating Previews (GeneratePreviewForURL). developer.apple.com.