Mobile Computing · Mobile Platforms
Per-App UID Isolation and the Data Directory Contract
How assigning each installed app its own kernel user ID turns file permissions into an application boundary, what the private data directory guarantees, and where the model leaks.
A mobile platform needs two things that pull in opposite directions. Apps must be able to read and write files cheaply, using the ordinary file APIs they already know. And one app must not be able to read another’s files, even though both run on the same kernel with the same syscall table. The mechanism that reconciles these is older than any phone: give each app a distinct numeric user identity and let the kernel’s existing permission checks do the work.
The identity assigned at install time
When an app is installed, the package manager allocates an unused user ID from a reserved range and records the mapping in its database. The ID is stable for the lifetime of the installation and is not reused for a different package until the app is fully removed. At launch, the process that will host the app is switched to that ID before any app code runs, as described in Process Launch: From Launcher Tap to First Instruction.
The consequence is that every file the app creates carries that ID as its owner. A file created by app A has owner u0_a123; a file created by app B has owner u0_a124. When B opens a file owned by A with mode 0600, the kernel compares the caller’s effective UID against the inode owner, finds a mismatch, falls through to the group check, finds a mismatch, and applies the “other” bits, which are zero. The call returns EACCES. No platform-specific code ran; this is generic_permission in the kernel’s virtual filesystem layer.
Because the check is per-inode and per-syscall, it holds for every path into the filesystem: open, openat, stat, rename, unlink, mmap of a file descriptor, and the directory traversal that precedes them. There is no list of protected paths for the platform to maintain and no way for an app to reach a file by a route the kernel does not check.
/* Two apps, two identities, one filesystem. */
int fd = open("/data/data/com.example.a/notes.db", O_RDONLY);
/* For a process running as com.example.b, the kernel returns -1 / EACCES. */
/* For com.example.a itself, the owner bits of 0600 apply and the open succeeds. */
The error is EACCES rather than ENOENT because the path is traversable: the parent directories carry execute permission for all, so the lookup reaches the inode and fails at the permission check. Removing the execute bit on the parent would produce EACCES at the traversal instead, which is the behaviour described below under directory traversal.
The group list carries a second channel. An app’s process is started with a supplementary group list derived from the permissions it has been granted, and files representing hardware or shared resources are given group ownership accordingly. Access to the camera device node, to a Bluetooth socket, or to a specific mount point can then be expressed as a group permission rather than as a bespoke check in a system service. The kernel enforces group membership at the same point it enforces the owner check, so an app that was never granted the permission simply is not in the group and its open fails.
What the private directory actually promises
Each app is given a directory whose path is derived from its package name, owned by its UID, with mode 0700 on the directory itself. The parent directories are traversable but not listable by other apps, so an app can construct the path of a rival’s data directory and still cannot enumerate its contents or open files inside it.
Three distinct guarantees are often conflated here.
Confidentiality against other apps is a kernel guarantee, as above. It survives the app being buggy, because a bug in the app cannot change what the kernel will let it open.
Persistence across updates is a package-manager guarantee. Reinstalling an app with a higher version code leaves the data directory in place; uninstalling removes it. The manager distinguishes these by whether the removal is part of an upgrade transaction.
Backup and restore is a policy guarantee and the weakest of the three. The platform’s backup service reads app data through a privileged path that bypasses the UID check, subject to the app declaring what should be included. An app that opts in has its private files copied to external storage or to a remote service, where the UID boundary no longer applies. This is a deliberate hole, and it is why backup eligibility is a per-app declaration rather than a platform default.
The modes that do not fit the model
The UID model assumes one identity per app. Two features break that assumption and their history is instructive.
Shared user IDs let several packages declare the same identity so that they can read each other’s data and share a process. The feature existed to support apps split across multiple APKs before split APKs were natively supported, and because the packages then share one permission set, a vulnerability in the weakest of them grants access to all. New declarations of shared identity are restricted on current platform versions, and the mechanism survives mainly for compatibility with packages installed long ago.
Isolated processes are the inverse: a process started with a UID that has no access to anything, used to host code that must be treated as untrusted, such as a renderer or a third-party advertising component. The process can be given access to exactly one service through a binder handle passed at creation and nothing else. It has no data directory of its own and cannot read any app’s files, including the app that spawned it. This is the closest the model comes to a capability system, and it is implemented by taking away identity rather than by adding checks.
The remaining leak is the shared storage area. External storage is a single filesystem, historically mounted with a kernel-level filesystem driver that synthesised per-app views by rewriting paths based on the caller’s UID. That driver has been replaced by a FUSE-based volume managed by a system process, which performs the same path rewriting in user space and adds per-directory access rules for media collections. The UID check on the underlying filesystem is not sufficient there, because the whole point is that many apps read the same files. Access control for shared storage is therefore a policy decision made by a service, not a permission bit, and it is the part of the sandbox most likely to be reworked.
Permission bits are not the whole story
File modes answer “may this UID open this inode”. They do not answer “may this process perform this operation at all”, and modern platforms layer a mandatory policy on top of the discretionary one. On Android that policy is SELinux, which labels every process and every file with a security context and applies a domain-specific allowlist on top of the UID check. A process running in an app’s domain cannot read a file labelled as system data even if the mode bits would permit it, because the type enforcement rule denies the access before the discretionary check is consulted. The two checks compose: both must allow.
The reason for the second layer is that the first is too coarse for system services. system_server runs as a privileged UID and must reach into many apps’ state; a single UID-based rule cannot express which of its operations are legitimate. Type enforcement can. The cost is that the policy becomes a second, larger thing to maintain, and a mislabelled file fails with EACCES in a way that the mode bits alone cannot explain. This layering is the subject of Mandatory Access Control Above the UID Boundary.
Failure modes worth knowing
Renaming a file preserves its owner but not its mode. An app that writes a file with 0644 and renames it into a shared location has published it to every other app on the device, because the rename does not narrow the permissions. The platform’s file APIs set modes explicitly for this reason; code that creates files through lower-level calls inherits the process umask, which is permissive.
Directory traversal is checked component by component, so an app that is granted access to a subdirectory of another app’s data still cannot reach it unless it has execute permission on every ancestor. Granting access by adding a group to a single subdirectory therefore fails in a way that looks like the grant was ignored.
Removal is asynchronous with respect to the process. An app whose data directory has just been deleted can still hold open file descriptors to unlinked inodes, and writes through those descriptors succeed and consume space until the last descriptor closes. The space is not reclaimed at the moment of uninstall, which is visible as a delay in the storage accounting.
Finally, the numeric identity is recycled. An app uninstalled and a different app installed later may receive the same UID. Files that escaped deletion, for example on a filesystem that was not fully cleaned, become readable by the new app. The platform mitigates this by removing the data directory as part of the uninstall transaction, but the mitigation depends on the removal completing.
References
- chmod(2) and the inode permission bits — Linux manual page — Linux man-pages project
- Android Open Source Project — Application sandbox — AOSP documentation
- Apple Platform Security — Protecting app data — Apple Inc.