HomeDocumentationAndroid Under The Surface
Android Under The Surface
14

The Sandbox: Permissions, SELinux, and Isolation

Android App Sandbox, Permissions, and SELinux for Flutter

March 28, 2026

Post 1 introduced the UID model: every app gets a unique Linux user ID, and the kernel enforces file permissions based on UIDs. That's the foundation of the sandbox. But it's not the whole sandbox.

Android's isolation model has multiple layers, each addressing a different class of threat. The UID sandbox prevents apps from reading each other's files. Runtime permissions gate access to sensitive capabilities. SELinux enforces mandatory access control policies that not even root can bypass in normal operation. seccomp filters restrict which syscalls a process can make. The combination creates a security model that's deeper than any single mechanism — and understanding the layers explains why certain operations work, why certain others fail with cryptic errors, and what the actual boundaries of your Flutter app's world are.

Layer 1: The UID sandbox

This is the layer from Post 1, but it's worth seeing the full picture.

At install time, Android assigns your app a UID — say, 10143. Your app's data directory (/data/data/your.package.name/) is created with owner UID 10143 and permissions rwx------ (owner read/write/execute, no access for group or others). Every file your app creates inside this directory inherits the same ownership.

This is standard Unix file permission enforcement, running in the kernel's VFS (Virtual File System) layer. When your Dart code (through the Dart VM, through libc) calls open("/data/data/other.app/databases/secrets.db", O_RDONLY), the kernel's sys_openat handler checks:

  1. What's the UID of the calling process? → 10143
  2. What's the owner UID of the file? → 10201 (some other app)
  3. Does the "others" permission include read? → No (------)
  4. Result: EACCES (Permission denied)

Your Dart code gets a FileSystemException: Permission denied. The sandbox worked.

This extends beyond files. Network sockets carry the creating process's UID. The kernel can apply per-UID network rules (Android uses this for per-app data usage tracking and per-app VPN routing). When your app creates a TCP socket, the kernel tags it with UID 10143, and iptables/nftables rules can match on this UID.

The UID sandbox is effective because it's enforced by the kernel at the syscall level. There's no user-space code to bypass. The only way around it is running as UID 0 (root), which is why rooting a device fundamentally compromises app isolation.

Layer 2: Runtime permissions

The UID sandbox controls file access. Permissions control access to system capabilities — things that aren't files but that have privacy or security implications: camera, microphone, location, contacts, phone state, body sensors.

Before Android 6.0 (API 23), all permissions were granted at install time. The user saw a list of requested permissions, accepted all of them (or didn't install the app), and the app had those permissions forever. This was widely recognised as inadequate — users didn't read permission lists, and apps had no incentive to request minimal permissions.

Since Android 6.0, dangerous permissions require runtime requests. The app asks, the user decides, and the decision can be revoked at any time.

The enforcement happens in the system services, via Binder (Post 4). When your Flutter plugin calls CameraManager.openCamera(), the request goes through Binder to CameraService in system_server. The service calls checkCallingPermission("android.permission.CAMERA"), which checks whether the calling UID (your app's UID, stamped on the Binder transaction by the kernel) has been granted the CAMERA permission.

If the permission hasn't been granted, the call fails. The system service returns an error, which propagates back through Binder to your plugin's Kotlin code, which propagates it to Dart as an exception or error state.

The important point: the enforcement is in the system service, not in your app. Your app doesn't decide whether it has a permission — the system does, based on the UID and the permission grant state stored in PackageManagerService. This is why you can't "fake" a permission grant by modifying your own process. The check happens in a different process, and the identity is kernel-verified.

For Flutter, the permission_handler package wraps the Android permission request flow. Under the surface, it:

  1. Sends a platform channel call to Kotlin code
  2. Kotlin code checks if the permission is granted (ContextCompat.checkSelfPermission)
  3. If not granted, calls ActivityCompat.requestPermissions, which triggers a system dialog
  4. The dialog runs in a system-owned Activity (not your process)
  5. The user's choice is recorded by PackageManagerService
  6. Your Activity's onRequestPermissionsResult callback fires (via Binder from AMS)
  7. The result propagates through the platform channel back to Dart

The system dialog runs in a different process specifically so the app can't influence or fake the user's choice.

Layer 3: SELinux

The UID sandbox and permissions are discretionary access control (DAC) — the owner of a resource can change its permissions. Root (UID 0) bypasses all DAC checks. This is a problem if any process running as root is compromised.

SELinux provides mandatory access control (MAC) — policies set by the system that even root cannot override (without disabling SELinux itself, which requires a kernel modification).

SELinux assigns a security context (label) to every process and every resource (file, socket, device, etc.). Policies define which contexts can access which other contexts, and in what ways.

bash
adb shell ps -Z | grep your.package.name
javascript
u:r:untrusted_app:s0:c143,c256,c512,c768  u0_a143  14823  812  ...  your.package.name

Your app runs in the untrusted_app SELinux context. The system services run in different contexts (system_server, platform_app). Kernel resources have their own labels.

The SELinux policy for untrusted_app defines exactly what your app can do:

  • Can: read/write its own data directory, access its own network sockets, communicate via Binder with allowed services, read certain system properties
  • Cannot: access /data/data/ directories of other apps (even if Unix permissions somehow allowed it), open raw device files (even with root), access system files outside the app sandbox, load kernel modules, modify system properties

This is defence in depth. Even if the UID sandbox were somehow bypassed (a kernel bug, an escalation exploit), SELinux provides a second enforcement layer. A process running as UID 0 but in the untrusted_app SELinux context still can't access most system resources, because the MAC policy blocks it.

For Flutter developers, SELinux is mostly invisible — until it isn't. If a native plugin tries to access a file path or device that SELinux blocks, the operation fails with a permission error even though Unix permissions might have allowed it. The diagnostic tool:

bash
adb logcat | grep "avc: denied"

SELinux denials show up as avc: denied in logcat, with the source context, target context, and the specific operation that was blocked. These messages are the clue that the failure is MAC (SELinux) rather than DAC (Unix permissions).

Layer 4: seccomp-bpf

Starting with Android 8.0, apps run under a seccomp-bpf filter — a kernel mechanism that restricts which system calls a process can make.

The filter is a BPF (Berkeley Packet Filter) program loaded into the kernel. Before each syscall executes, the kernel runs the filter to check if the syscall is allowed for this process. If the filter rejects it, the syscall fails or the process is killed.

Android's seccomp policy for apps blocks syscalls that normal apps should never need:

  • reboot() — an app should not be able to reboot the device
  • mount() / umount() — an app should not mount file systems
  • klogctl() — an app should not read kernel logs
  • ptrace() — an app should not trace/debug other processes
  • init_module() — an app should not load kernel modules
  • Various other administrative syscalls

This is a last line of defence against exploits. Even if an attacker achieves code execution inside your app's process (through a buffer overflow in a native library, for example), the seccomp filter limits what they can do with that execution.

For normal Flutter development, seccomp is invisible. The Dart VM and Flutter engine only use standard syscalls (open, read, write, mmap, ioctl, epoll — the ones from Post 3). But if you're integrating a native library that uses unusual syscalls (perhaps for debugging or low-level system interaction), seccomp can be the reason it works on desktop Linux but fails on Android.

Layer 5: App-specific storage

Android 10 (API 29) introduced Scoped Storage, which fundamentally changed how apps access files outside their sandbox.

Before scoped storage, an app with the READ_EXTERNAL_STORAGE permission could read any file on shared storage — photos, downloads, documents from other apps. This was a privacy problem: a weather app with storage permission could read your private photos.

With scoped storage:

  • App-specific storage (/data/data/your.package.name/) works as before — no permission needed, fully sandboxed.
  • Shared storage (photos, videos, audio, downloads) is accessed through MediaStore or the Storage Access Framework (SAF). The app doesn't get raw file paths — it gets content URIs that the system controls. Access is per-file, granted by the user through system UI (photo picker, file chooser).
  • `READ_EXTERNAL_STORAGE` no longer grants broad file access on Android 13+ — it's been replaced by granular permissions (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO).

For Flutter, this affects plugins like image_picker, file_picker, and path_provider. The path_provider plugin returns paths to app-specific directories (which don't need permissions). The image_picker plugin uses the system photo picker (which handles permissions internally). Plugins that try to access raw file paths outside the app sandbox may work on older Android versions but fail silently or with permission errors on newer ones.

Understanding that "the file system" isn't a single, uniform space — but rather a set of sandboxed regions with different access rules — makes these failures predictable rather than surprising.

The sandbox from the kernel's perspective

From the kernel's viewpoint, your Flutter app's process has:

  1. A UID (10143) that determines DAC file access
  2. An SELinux context (untrusted_app:s0:c143,...) that determines MAC access to labelled resources
  3. A seccomp filter that restricts allowed syscalls
  4. A set of capabilities (all dropped — untrusted_app has no Linux capabilities, meaning no privilege escalation even with setuid binaries)
  5. UID-based network rules (iptables/nftables for per-app traffic control and data usage tracking)
  6. A mount namespace where certain system paths are hidden or replaced

These are all kernel-enforced. No user-space code can override them without exploiting a kernel vulnerability. The combination creates a process that:

  • Can only read/write its own files
  • Can only communicate with other processes through Binder (not raw sockets to other app processes)
  • Can only access system services that accept untrusted_app Binder calls
  • Can only make a limited set of syscalls
  • Cannot escalate its privileges
  • Cannot access hardware directly

This is your Flutter app's world. Everything your Dart code does happens within these constraints. When a plugin opens the camera, it asks (via Binder) a system service (CameraService) that runs with higher privileges (the cameraserver SELinux context) to operate the camera hardware on the app's behalf. The app never touches the camera hardware directly.

Shared UID: the exception

By default, each app gets its own UID. But apps signed with the same key can declare a shared UID in their manifest, allowing them to run in the same sandbox — sharing file access, potentially sharing a process.

Google's own apps (GMS Core, Play Store, Play Services) use shared UIDs extensively. This is how Google Play Services can access data from multiple Google apps without explicit content providers.

For third-party developers, shared UIDs are strongly discouraged (and deprecated since Android 12 / API 31). They create security risks (a vulnerability in one app exposes the other's data) and complicate the installation/update lifecycle. For Flutter developers, shared UIDs are essentially irrelevant — each Flutter app is its own isolated process with its own UID.

The sandbox and debugging

The sandbox's enforcement has implications for debugging:

Debug builds vs release builds. Debug builds (installed via flutter run or adb install) get the debuggable flag, which relaxes certain restrictions: you can attach a debugger (ptrace is allowed for the specific debug connection), run-as works (you can access the app's data directory from the shell), and some SELinux restrictions are loosened.

Release builds run in the full sandbox. If your app works in debug but fails in release, the sandbox might be the reason — an operation that was permitted in the relaxed debug context is blocked in the strict release context.

`adb shell run-as your.package.name` drops you into a shell with your app's UID, letting you browse the app-specific data directory. This only works for debuggable builds.

Root access (adb root on userdebug builds, or rooted devices) bypasses UID-based DAC but does not bypass SELinux on enforcing builds. Even as root, avc: denied messages still appear. On userdebug builds, setenforce 0 disables SELinux enforcement temporarily — but production devices don't allow this.

The mental model

For day-to-day Flutter development, the sandbox is mostly invisible. Files read and write to the app's directory without issue. Plugins handle permission requests. System services respond to Binder calls. Everything works.

The sandbox becomes visible when something goes wrong — a permission denial, a file access failure on a new Android version, a native library that works in debug but not release. In those moments, knowing which layer is responsible is the difference between hours of debugging and minutes:

  • EACCES on a file → UID-based DAC. Check file ownership and permissions.
  • Permission denied on a system service → Runtime permission not granted. Check permission_handler state.
  • `avc: denied` in logcat → SELinux MAC. The operation is blocked by policy, not by Unix permissions.
  • SIGSYS (signal 31) → seccomp rejection. The process made a syscall the filter blocks.
  • FileNotFoundException on shared storage → Scoped storage. The app can't access that path directly; use MediaStore or SAF.

Each layer has its own diagnostic tool, its own error signature, and its own solution. They're not redundant — they defend against different attack vectors. Together, they make Android's app isolation one of the strongest in any consumer operating system.

The final post in this series traces the build pipeline: from flutter build apk on your machine to an installed, running app on the device, following every transformation the code undergoes.

This is Post 9 of the [Android Under the Surface](/blog/android-under-the-surface) series. Previous: [From Render Tree to Pixels: The Display Pipeline](/blog/android-surfaceflinger-display-pipeline-flutter). Next: [The Build Pipeline: From Source to Installed App](/blog/android-flutter-build-pipeline-apk).

Related Topics

android app sandboxandroid permissions flutterselinux androidandroid uid isolationflutter permissionsandroid security modelandroid app isolation flutter

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.