Mobile Computing · Mobile Platforms
Mandatory Access Control Above the UID Boundary
Why a per-app user ID is not sufficient authorisation, how type enforcement composes with discretionary permissions, and what the policy costs to maintain.
A user ID per app gives isolation between apps, but it gives nothing between an app and the system. A privileged service runs as its own UID and can reach every app’s data, because a single discretionary rule cannot express which of its many operations are legitimate and which are not. The layer that expresses that is mandatory access control: a policy evaluated by the kernel on every operation, independent of the object’s permission bits, and impossible for a process to relax.
The two checks compose
A discretionary check answers whether the caller’s identity is listed on the object. A mandatory check answers whether the caller’s label is permitted to perform this operation on the object’s label. The kernel consults both, and both must allow. Removing a permission bit denies an access that the policy would allow; adding a restrictive policy denies an access that the bits would allow.
The composition is what makes the model usable. The discretionary layer handles the common case cheaply: most denials are caught by comparing the caller’s UID against the inode owner, without touching the policy. The mandatory layer handles the cases the discretionary layer cannot express: a process that must read one file in another app’s directory but not the rest, a service that may connect to one socket family and not another, an app that may write to its own data directory but not to the system partition.
Because the mandatory check is evaluated after the discretionary one in the common path, a policy that is too permissive is still bounded by the bits, and a policy that is too restrictive produces denials that look identical to permission errors from the caller’s perspective. Distinguishing them requires the audit log, which records the label pair and the operation that was denied. That log is the primary tool for diagnosing policy problems, and its absence on a production device is why policy development happens against a build with auditing enabled.
Labels, domains, and transitions
Every process carries a security label. Every file, socket, and device node carries one too. The label has the form of a type, optionally with a role and a level; the platform uses the type and leaves the multi-level component unused for the ordinary app case.
A process’s type is its domain. The domain determines what the process may do, and the policy is a set of rules of the form “domain D may perform operation O on type T”. The rules are compiled into a binary policy loaded at boot and held in the kernel; there is no runtime path for a process to add a rule, which is the property that distinguishes mandatory from discretionary control.
The transition from one domain to another happens at defined points. A process that executes a file labelled as a domain-entry point enters the domain associated with that file, subject to a rule permitting the transition. This is how the zygote’s child becomes an app domain rather than remaining in the zygote’s domain: the child is forked in a general domain, and the transition to the app’s specific domain happens when the runtime sets its security context, which is a privileged operation available only to the process that manages apps.
# Type enforcement, in the policy language's rule form.
# An app domain may read and write only its own data type.
allow untrusted_app app_data_file:file { read write create unlink };
allow untrusted_app app_data_file:dir { search write add_name remove_name };
# No rule grants access to system_data_file, so the access is denied.
# Per-app separation is added by an MLS constraint on the category, not by
# a rule per app, so the rule count stays fixed as apps are installed.
The absence of a rule is the denial. There is no default allow, no wildcard that covers types not named, and no runtime path for the process to request the missing permission. That asymmetry is what distinguishes this layer from the discretionary checks: a file mode that is too permissive can be fixed by the file’s owner, while a policy that is too permissive can only be fixed by rebuilding and reloading it.
Android’s policy does not generate a separate domain per app. Apps installed from the same channel share a domain, and the per-app separation is expressed through categories: each app’s data directory is labelled with the shared data type plus a category derived from the app’s identity, and the policy constrains access so that a process may only reach files whose category matches its own. The result is that two apps in the same domain cannot read each other’s data, because the constraint applies to the pair of categories rather than to the type alone.
The redundancy with the UID check is deliberate. A bug in UID assignment, or a shared user ID, does not collapse the isolation, because the category constraint is computed from a different source of identity. It also means that the number of domains stays bounded as apps are installed, which keeps the policy compilable and the kernel’s rule lookup small; a domain per app would grow the policy with every install.
What the policy expresses that bits cannot
Four classes of rule justify the layer.
Per-operation restriction on a shared resource. The system’s data directory contains files that many services read and files that only one may write. A mode bit cannot distinguish the two without a group per file, and groups are limited in number and awkward to manage. A type per file class and a rule per domain can.
Restriction on operations that have no file object. A process may be permitted to ptrace another, to mount a filesystem, to load a kernel module, or to set a scheduling policy. These have no inode and no mode bits; the kernel’s capability check is the discretionary mechanism, and it is all-or-nothing per capability. Type enforcement can restrict them per domain, so a service that needs one capability does not receive the others.
Restriction on sockets and IPC. A domain may be permitted to connect to a specific socket type, or to send binder transactions to a specific service, and denied all others. The binder driver consults the policy on every transaction, using the sender’s and receiver’s labels, which is what prevents an app from calling a system service it was never granted access to even if it obtains a handle. The transport itself is described in Binder IPC: Parcels, Handles, and One-Way Calls.
Restriction that survives a compromised process. If an app is exploited and runs arbitrary code inside its own process, it remains in its own domain. The code can do anything the domain allows and nothing more. This is the property that makes the layer worth its cost: it converts a full compromise of a process into a compromise bounded by the policy, and the policy for an app domain is deliberately narrow.
The cost of the layer
Policy is code, and it has the properties of code: it must be written, reviewed, and maintained. Three costs are structural.
The policy is global. A rule added for one domain is visible to the analysis of every other domain, and a rule that is too broad weakens the guarantee for all of them. The platform’s policy is therefore organised to make each app domain’s rules derivable from a template rather than written individually, with the per-app differences expressed as attributes rather than as new rules.
Denials are failures, and failures are user-visible. A file that is created without the correct label, for example by a process that was itself mislabelled, is inaccessible to the domain that should read it. The symptom is an app that cannot open its own data after an update, and the cause is a labelling rule that did not cover a new path. Relabelling on first boot after an update is a standard part of the update process for this reason.
Policy changes cannot be validated by testing alone. A rule that is too permissive produces no failure, so it is invisible to a test suite. The platform’s defence is a build-time check that compares the policy against a set of never-allowed rules and refuses to build if a domain has been granted something on the list. The check is a lint, not a proof, and it is the reason policy changes are reviewed as security changes rather than as configuration.
The sandbox-profile analogue
Apple’s platforms implement the same idea with a different mechanism. The sandbox is expressed as a policy attached to the process at launch, written in a declarative language and compiled into a profile that the kernel consults. The profile grants access to specific resources: a set of paths, a set of mach services, a set of device capabilities. Anything not granted is denied.
The structural difference is granularity. The Apple profile is per-process and per-app, derived from entitlements and from the app’s declared capabilities, and it is evaluated by the kernel’s sandbox extension mechanism. Android’s policy is per-domain and global, evaluated by the kernel’s security module against labels. Both achieve the same end: a process that has been exploited cannot exceed the resources its profile allows.
The practical difference is in how access is extended. A sandboxed process that needs to read a file outside its profile is given an extension: a token that the kernel consults, issued by a process that holds the authority to issue it. The extension is per-file and can be revoked. The Android equivalent is a domain transition or a file descriptor passed over binder, and it has no per-file token. The Apple model is finer-grained and requires more machinery to distribute access; the Android model is coarser and relies on labels being assigned correctly.
Denials that resemble something else
A process that is not transitioned into its domain runs in the domain it inherited, which is the general domain with broader access. The transition is performed by the process manager at launch, and a launch path that bypasses it produces a process with the wrong policy. This is why the launch sequence described in Process Launch: From Launcher Tap to First Instruction includes the security context step explicitly.
A file created in a directory with a default label inherits that label, not the label the creating process intended. A process that writes to a shared directory therefore produces files with the directory’s type, which may be readable by domains it did not intend. The mitigation is to set the label explicitly after creation, which requires the creating domain to be permitted to do so.
A policy that permits a domain to write to a type used by the system’s configuration allows the domain to change its own policy on the next boot. The never-allowed list exists to catch this class of rule, and its incompleteness is the reason policy review matters more than policy testing.
And the audit log is finite. A denial storm, caused by a mislabelled file accessed in a loop, fills the log and drops records, which is exactly when the log is most needed. Rate limiting the audit records is the standard mitigation, at the cost of an incomplete picture of the denial.
References
- SELinux Project — Type Enforcement — SELinux Project
- Android Open Source Project — Security-Enhanced Linux in Android — AOSP documentation
- Apple Platform Security — Sandbox and resource access — Apple Inc.