27 Nov 24

Sandboxing on macOS

Background

This is an overview of macOS's built-in support for application sandboxing. It covers how sandboxing behaves from an application's perspective, how sandbox policies are expressed, and how they're enforced by the macOS kernel. The goal is to help developers for non-Mac platforms understand what sandboxing entails on the Mac, and to provide macOS developers with a deeper understanding of how sandboxing works under the hood.

While I discuss sandboxing here in the context of macOS, much of the implementation and resulting behavior are shared with Apple's other platforms (iOS, iPadOS, tvOS, etc). The most significant differences are that on those other platforms, sandboxing is mandatory for third-party applications, and there is no support for using custom sandbox policies in third-party applications.

What is sandboxing?

Sandboxing a process is a means of placing hard limits on the operations it can perform. An application may be sandboxed for one of two reasons:

  1. Security hardening

    In this context, the goal is to limit the impact of an attacker gaining code execution within a sandboxed process. It places additional barriers between the initial code execution and the ability for the attacker to execute other applications or access resources such as user data on disk. This is the motiviation for sandboxing processes like web browser rendering engines and other applications that process complex data from from the internet.

  2. User privacy

    For many third-party applications, sandboxing is a demonstration that they take user privacy seriously rather than a security mitigation. This is particularly true of applications that use the App Sandbox policy as it places strict limits on which parts of the filesystem an application can access without the user explicitly granting them access via the system Open dialog.

Real-world sandbox policies block most operations by default and contain an allow-list of permitted operations. In a perfect world, the sandbox for a process is designed such that only the resources or operations needed during normal execution of the process are available to it. In practice, the large amount of code used in a typical process, both within the application itself and provided by operating system libraries, make it difficult to tailor such a tight sandbox. As a result most sandboxes evolve over time as the resources accessed by an application change or the resource usage by system libraries becomes better understood.

Sandboxing on macOS

Sandboxing on macOS is implemented via the Sandbox kernel extension and controlled via the sandbox(7) family of userspace APIs.

Sandboxing from an application's perspective

Types of application sandbox

There are two main ways that an application can run in a sandbox:

  1. It can explicitly apply a sandbox to itself using the sandbox(7) family of APIs, most of which are undocumented. These APIs provide full control over the policy that is applied.

  2. It can opt into the App Sandbox via an entitlement. The App Sandbox is a predefined sandbox policy provided by macOS that uses process attributes, such as the presence of specific entitlements, to determine what resources should be accessible within the sandbox. Use of the App Sandbox is required for third-party applications distributed via the Mac App Store.

The opaque and inflexible nature of the App Sandbox means that many large, third-party applications that are distributed outside of the Mac App Store choose to use custom sandox policies rather than the App Sandbox.

How does an application become sandboxed?

An application using the App Sandbox entitlement will be sandboxed automatically by initializers in libSystem that run very early during an application's launch.

An application not using the App Sandbox entitlement must explicitly apply a sandbox policy to itself using an API such as sandbox_init_with_parameters.

It is not possible to sandbox an existing process from the outside. The process must apply the sandbox to itself.

Once a process has a sandbox applied to it, it is not possible for it to disable or remove the sandbox, nor is it possible for it to apply additional sandbox policies. Sandboxes can be inherited when a subproces is spawned by a sandboxed process.

What happens if the sandbox policy is violated?

By default, violating a sandbox policy results in the system call, or other operation that triggered the violation, failing with EPERM (operation not permitted). It is up to the calling code to handle this error gracefully.

The sandbox kernel extension also logs a message to the system console, and sandboxd (a userspace helper) may generate a violation report that includes a backtrace of the thread that triggered the violation. This can be used to track down what code was responsible for the violation.

Sandbox policies can control this behavior via action modifiers. See Sandbox policy evaluation for more detail.

What does a sandbox policy look like?

Sandbox policies are written in a dialect of Scheme known as SBPL. The policies are interpreted via an interpreter within libsandbox, based on TinyScheme, which generates a compiled representation of the policy that it passes to the Sandbox kernel extension.

;; All operations not explicitly allowed will be denied.
(default deny)
;; Allow reading from /tmp/foo and the directory /tmp/bar
;; and any files below it.
(allow file-read* (path "/tmp/foo") (subpath "/tmp/bar"))
;; Allow sysctl kern.hostname, but do it noisily.
(allow (with report) sysctl (sysctl-name "kern.hostname"))
;; Allow creating new files below /tmp/no-symlinks as long
;; as they aren't symlinks.
(allow file-write-create*
  (require-all
  (require-not
    (vnode-type SYMLINK))
    (subpath "/tmp/no-symlinks")))

This simple policy shows the basics of how a sandbox policy is expressed. The policy for individual operations is specified via an allow or deny function call that takes the name of the operation and any predicates that must be matched for the specified action (allow or deny) to be applied. A default action is provided via the default function for cases not matched via explicit allow or deny actions.

Being a Scheme dialect, the usual programming language constructs are available: conditionals, loops, lambdas, and even macros!

Additionally, SBPL supports passing string values as parameters to the policy. These parameters are available via the param function during policy evaluation. This combination of features makes it possible to express a policy that the application can configure:

(define SHINY_NEW_DOWNLOADS_ENABLED (param "SHINY_NEW_DOWNLOADS_ENABLED"))
(if (equal? SHINY_NEW_DOWNLOADS_ENABLED "YES")
  (allow file-read* file-write* (subpath "${any_user_homedir}/Downloads")))

Sandbox extensions

The evaluation-time configurability that parameters and conditional logic provides is applicable to policy decisions that affect the lifetime of the process. However, they are not sufficient to handle cases where the possible resources that may be accessed are not known in advance. This requires the ability to dynamically extend the sandbox after a policy has been applied.

For example, a service's sandbox policy may be configured to allow it to access data that it owns that lives in a fixed location. Clients of the service may need it to perform work on data that they own that lives outside of that location. Rather than having a broad sandbox policy that makes the client's data available, the sandbox policy can be limited to only the service's data, and be extended dynamically to access the client's data via a sandbox extension.

Using a sandbox extension requires cooperation from both the sandbox policy, the sandboxed process, and and the process interacting with the sandboxed process.

The process interacting with the sandboxed process issues an extension of a given class (represented as a reverse-DNS style dotted string) to a given resource that it has access to (typically a path or Mach bootstrap name). This results in a sandbox token, represented as an opaque string.

The client sends this token to the sandboxed process which consumes the extension token. The effect that the extension has on the sandboxed process is controlled by logic within its sandbox policy. Once the work that requires the extended sandbox is finished, the sandboxed process can release the extension. This revokes access to the resources that the extension covered.

As an example of how the sandbox policy controls the effect an extension has on it, consider the following:

;; Allow reading any file for which we have consumed an
;; extension of class com.apple.app-sandbox.read
(allow file-read* (extension "com.apple.app-sandbox.read"))
;; In contrast, the com.example.sandbox.read-downloads
;; extension will only permit reading a file if the extension
;; was issued for a path under ~/Downloads.
(allow file-read*
  (require-all
    (extension "com.example.sandbox.read-downloads")
    (subpath "${any_user_homedir}/Downloads")))

In the second example, the policy only permits the com.example.sandbox.read-downloads sandbox extension class to be used to extend access to a specific subdirectory.

Sandbox APIs

All of the sandbox APIs described here are private APIs. Apple considers sandboxing at this level to be deprecated in favor of the App Sandbox. In practice, the App Sandbox is not a usable replacement for many large applications. Apple continues to make use of these lower-level APIs for sandboxing its first-party applications and helper tools.

sandbox_init_with_parameters takes SBPL source as a string and an array of parameters that the policy can access. It evaluates the SBPL and compiles the resulting state to bytecode (described below). It then asks the Sandbox kernel extension to apply the sandbox to the current process.

sandbox_compile and sandbox_apply split the work of compiling the policy to bytecode and applying it to the process into two separate calls. This can avoid the overhead of repeatedly evaluating the same SBPL source if you ever launch more than one process with the same policy. Instead you can cache the compiled bytecode and the process can then use the bytecode when applying its sandbox.

sandbox_extension_issue_file, sandbox_extension_consume, sandbox_extension_release are used for issuing, consuming, and releasing extensions respectively.

MAC in the macOS kernel

The macOS kernel (XNU) provides a Mandatory Access Control Framework (MACF) that exposes around 300 policy hooks that can be used to approve or deny specific operations at a fine-grained level. Most of the policy hooks correspond to specific system calls or operations on the kernel's file system abstraction (VFS). As the name implies, these policy hooks are mandatory and are applied to all clients that use the system calls or perform file system operations.

The Sandbox kernel extension is a client of the MACF and implements many of the policy hooks exposed by the kernel. When a system call is made that the Sandbox kernel extension provides a policy hook for, XNU calls the corresponding policy hook early in the handling of the system call. The policy hook is provided with context about the operation being performed (i.e., arguments for the system call being made). This gives the Sandbox kernel extension an opportunity to deny the operation if its policies state that it should not be permitted.

Sandbox kernel extension

Sandbox policies can be applied to a process at two main levels[1]:

  1. The platform sandbox policy is applied to all processes.
  2. User-space applications can opt into being sandboxed.

When a user-space application opts into being sandboxed, its sandbox policy is passed to the Sandbox kernel extension via the __mac_syscall system call (often via the __sandbox_ms wrapper function). The kernel routes this system call to the appropriate MACF client based on its arguments. The Sandbox kernel extension performs some basic validation and then associates the policy with the kernel proc structure for the process using a MAC label.

When the kernel calls a MACF policy hook in the Sandbox kernel extension, the policy hook is mapped to a sandbox operation type that describes the operation being attempted. This provides a layer of indirection from the exact MACF hooks exposed in the kernel. You can see the complete list of sandbox operations here.

The operation is then evaluated against the platform sandbox policy. If the system sandbox policy denies the operation, the denial is propagated back through the MACF hook to the kernel and an error is returned via the system call.

If the platform sandbox policy permits the operation, it will next be evaluated against the process sandbox policy. The Sandbox kernel extension retrieves the sandbox policy associated with the process performing the hooked operation. If a sandbox policy is found, it is evaluated to determine whether the operation should be permitted. If the process is not sandboxed, the operation is permitted.

Sandbox policy evaluation

The sandbox policy consists of a list of bytecode instructions, and a mapping from sandbox operation to the initial bytecode instruction to be evaluated for that operation. Bytecode instructions take two forms:

  1. Filter instructions

    These consist of a predicate, an if match instruction index, and an if not match instruction index. If the predicate evaluates to true, execution continues from the if match instruction index. Otherwise it jumps to the if not match instruction index.

  2. Action instructions

    Actions may be either allow or deny, and they may have one or more modifiers associated with them. When evaluation reaches an action, the modifiers are applied and evaluation of the sandbox policy terminates with the action as the result.

In the policies I have analyzed, all jumps are forward jumps. As a result the bytecode forms a disconnected directed graph from entry points, through zero or more filters, to actions.

Filters

There are around 90 different filter predicates supported as of macOS 15. You can see the complete list of filter predicates here.

Most predicates match information about the operation being performed. For instance, file system operations can be filtered via the path and vnode-type filters. sysctl system calls can be filtered using the name of the operation via the sysctl-name predicate. Mach bootstrap lookup operations can be matched via the global-name or local-name filters.

The remainder of the predicates deal with attributes of the process performing the operation, such as signing-identifier or entitlement-is-present, or the state of the operating system system (csr for information about configurable security restrictions such System Integrity Protection, or system-attribute for other attributes).

These last two groups of predicates are mostly of use to policies that apply to more than one application, such as the platform sandbox policy or the App Sandbox, rather than application-specific policies.

Action modifiers

Modifiers can be applied on both allow or deny actions in order to modify the default behavior. The permitted set of modifiers differs for allow and deny actions as some modifiers only make sense in one context. You can see the complete list of action modifiers here.

Examples of modifiers supported on deny actions include:

  • overriding the errno value that is returned from the denied operation: (with EBADEXEC)
  • suppressing reporting of sandbox violations: (with no-report)
  • sending a signal to the user-space thread that performed the system call: (with send-signal SIGUSR1)
  • triggering Apple-internal telemetry related to the violation: (with telemetry)

Modifiers supported on allow actions include:

  • generating a violation report: (with report)
  • logging a message to the system console: (with message "…")
  • triggering Apple-internal telemetry related to the operation: (with telemetry)

  1. I have come across references to a few other types of policies (autobox, bastion, and delegated policies), but I have not yet investigated what role they play.