SELinux is the last line of defense in Android's security model. When UID-based Discretionary Access Control fails — and it inevitably does — Mandatory Access Control ensures a compromised mediaserver or browser renderer cannot pivot to exfiltrate keystore keys or remount system partitions.
Since Android 5.0, SELinux has run in enforcing mode for all domains. Yet most developers treat sepolicy as a black box of opaque denials. Understanding its Type Enforcement architecture, security context labeling, and neverallow guarantees is essential for anyone building HALs, system apps, or custom ROMs.
1. Why DAC Is Insufficient on Android
Traditional Linux DAC assigns ownership and rwx bits to users and groups. On Android, every app gets a unique UID (e.g., u0_a123) and runs in its own sandbox. This isolates apps from each other, but DAC has two fundamental flaws for mobile:
- Root is omnipotent: Any process running as UID 0 bypasses DAC checks entirely. A single exploited setuid binary or kernel flaw grants full device control.
- Permission is discretionary: A compromised app can chmod its own files to be world-readable, leaking sensitive data to colluding malware.
Mandatory Access Control inverts this. The policy is centrally defined, compiled into a binary blob, loaded by the kernel at boot, and cannot be overridden by any userspace process — not even root. This principle aligns closely with modern privacy and tracking prevention models where system-enforced guarantees matter more than app-declared permissions.
2. SELinux Core: Type Enforcement and Beyond
Android SELinux is a Type Enforcement (TE) system with Role-Based Access Control (RBAC) and Multi-Level Security (MLS) largely vestigial. Four concepts govern every decision:
| Feature | 5G NR | Wi-Fi 7 | Bluetooth 5.4 |
|---|---|---|---|
| Peak Throughput | 20 Gbps | 46 Gbps | 2 Mbps |
| Latency | 1–4 ms | 1–2 ms | 3–6 ms |
| Range | Several km | 30–50 m | 100–400 m |
| Power Draw | High | Medium | Ultra-low |
| Primary Use | Cellular data | Local networking | Peripherals/IoT |
The Security Context
Every subject (process) and object (file, socket, Binder endpoint, property) carries a context: user:role:type:level. On Android this collapses to:
u:object_r:camera_device:s0 # /dev/video0 context
u:object_r:system_file:s0 # /system/bin/cameraserver
u is always SELinux user, r is role (usually r), s0 is MLS sensitivity. The type (e.g., hal_camera_default, system_file) is the sole driver of TE decisions.
The Allow Rule
Policy is a set of allow rules evaluated by the kernel's LSM hook. Syntax:
# cameraserver can read camera device node
allow hal_camera_default camera_device:chr_file { read write open ioctl map };
# system_server can call into hal_camera via binder
allow system_server hal_camera_service:service_manager { find };
allow system_server hal_camera_default:binder { call transfer };
If no allow rule matches, access is denied by default — even if DAC would permit it. This default-deny posture is why zero-day exploits like Stagefright were contained: mediaserver was confined to mediaserver domain and could not execute su or write to /data despite running as a privileged UID.
Android uses only s0 for most domains, but leverages MLS categories (c0-c1023) to isolate apps. Each app gets unique categories like s0:c512,c768 via seapp_contexts, preventing one untrusted_app from reading another's /data/data directory even within the same TE type.
3. Android Enforcement Architecture
All access decisions flow through LSM hooks; userspace cannot bypass the kernel AVC, unlike pure DAC checks.
Every system call that touches a protected object — open(), ioctl(), binder_transaction — is intercepted. The hook extracts source and target contexts, queries the Access Vector Cache (AVC), and on miss performs a full policy DB lookup. This synchronous check adds ~2–3% overhead, mitigated by the AVC's hashed caching, which is critical for Binder's high-throughput IPC.
4. AOSP Policy Structure in Practice
Since Android 8.0's Treble, policy is split to enforce vendor separation:
- Platform policy —
system/sepolicy/public/(exported types/attributes) andprivate/(platform-only rules). - Vendor policy —
device/[vendor]/sepolicy/may only use types exposed viapublicor its ownvendor_types. Vendor cannot modifysystem_serverpolicy directly.
Build-time attributes group types for scalable rules:
type hal_camera, domain;
hal_attribute camera;
# private construct: auto-allow binder to hal services
type hal_camera_default, domain;
hal_server_domain(hal_camera_default, hal_camera)
# one rule covers all hal clients
allow hal_client hal_camera_service:service_manager find;
Attributes like domain, hal_client, netdomain, and appdomain prevent rule explosion. Instead of 80 duplicate allows, you write one allow appdomain hal_camera_service:service_manager find; and all 60+ app domains inherit it. This is similar to how modular 5G hardware abstractions reuse interfaces — policy reuse reduces audit surface.
File Labeling Pipeline
Labels are not stored in xattrs on most partitions at build time; they are assigned via file_contexts:
/dev/video[0-9]* u:object_r:camera_device:s0
/vendor/bin/hw/android\.hardware\.camera@.* u:object_r:hal_camera_default_exec:s0
/data/vendor/camera(/.*)? u:object_r:camera_data_file:s0
# hwservice_contexts (for hwbinder / AIDL)
android.hardware.camera.provider@2.6::ICameraProvider u:object_r:hal_camera_hwservice:s0
At runtime, init restores contexts via restorecon, and each daemon transitions to its domain via type_transition when execve() matches a _exec type.
5. Security Context Assignment: Where Theory Meets init
Context transitions are explicit. A process starts as init (u:r:init:s0). When it executes a binary labeled hal_camera_default_exec, the kernel checks:
allow init hal_camera_default:process transition;
allow hal_camera_default hal_camera_default_exec:file { entrypoint read execute };
For apps, zygote forks and seapp_contexts selects context based on UID, seinfo, and package name:
user=_app seinfo=platform domain=platform_app type=app_data_file
user=_app domain=untrusted_app type=app_data_file levelFrom=user
This is why two apps sharing untrusted_app TE type remain isolated: MLS categories differentiate them. Removing categories (a common vendor mistake when porting) silently breaks isolation while passing naive CTS tests.
6. Comparison: DAC vs. SELinux MAC on Android
| Dimension | Discretionary Access Control | SELinux Mandatory Access Control |
|---|---|---|
| Policy Owner | File owner / app (chmod, ACL) | System policy builder (sepolicy binary) |
| Root Override | Yes — UID 0 bypasses checks | No — root still confined (e.g., shell → su domain) |
| Default Mode | Allow unless restricted | Deny unless explicitly allowed |
| Granularity | User/group/other + capabilities | Type × Class × Permission (e.g., binder:call) |
| Persistence | Mutable at runtime | Immutable after boot (requires rebuild to change) |
| Failure Mode | Over-permissive files leak data | Denial logged via AVC; fails closed |
| Use Case | App sandbox isolation (UID) | System service confinement, HAL isolation, exploit mitigation |
The two systems are complementary, not competing. DAC handles user consent — “allow this app to read photos.” MAC handles system integrity — “even if compromised, mediaserver cannot load a kernel module.” For data path protection, see our analysis of VPN protocol security boundaries, which assumes MAC is correctly enforcing tunnel isolation.
7. Neverallow: The Policy About Policy
Android's most powerful innovation is neverallow — assertions that must never become true, checked at compile time with secilc and checkpolicy:
neverallow domain noblock_class:blk_file { open read write };
neverallow { domain -init } proc_security:file write;
neverallow untrusted_app kmem_device:chr_file *;
neverallow { appdomain -bluetooth } bluetooth_data_file:file *;
If a vendor adds allow untrusted_app kmem_device:chr_file read;, the build fails. Google's Compatibility Test Suite (CTS) and selinux_policy tests run ~300+ neverallow rules, ensuring Treble compliance. This compile-time guarantee is stronger than any runtime audit — it mathematically prevents whole classes of privilege escalation.
To create a custom HAL safely, replicate the pattern:
type hal_custom_default, domain;
type hal_custom_hwservice, hwservice_manager_type;
type custom_device, dev_type;
allow hal_custom_default custom_device:chr_file rw_file_perms;
allow hal_custom_default hal_custom_hwservice:hwservice_manager { add find };
# Explicitly forbid network access
neverallow hal_custom_default self:tcp_socket *;
8. Debugging Without Disabling
On userdebug builds, denials appear in dmesg and logcat:
scontext=u:r:hal_camera_default:s0 tcontext=u:object_r:unlabeled:s0 tclass=chr_file permissive=0
This tells you: source needed read on chr_file labeled unlabeled (missing file_contexts), not camera_device. Fix the label, not the rule.
Workflow for engineers:
adb shell dmesg | grep avc: | audit2allow -p out/target/product/*/obj/ETC/sepolicy_intermediates/sepolicyto generate candidate allows.- Manually verify each allow — audit2allow often suggests over-broad
rwxwhereread opensuffices. - Test with
adb shell setenforce 1and rebuild; never ship a permissive domain — it violates neverallow and fails CTS. - Add
userdebug_plat_sepolicy.cilexceptions viapermissive hal_camera_default;only temporarily.
Remember that permissive mode is diagnostic, not a fix. A permissive domain logs but does not block, hiding real breakage until production. The hardware analogy is leaving coil shielding disabled during testing: you see better efficiency numbers but ship a device that overheats.
Conclusion
Android SELinux succeeds because it makes security architecture explicit. Every Binder call, every /dev node, every property read must justify itself in policy. For embedded engineers, the payoff is a system where a compromised HAL cannot pivot to modem interfaces, and an exploited app cannot dump credentials — not because exploits are impossible, but because the mandatory policy makes post-exploitation lateral movement computationally prohibited.
Treat sepolicy as code: review it, test it with neverallow, and version it with your hardware. The DAC sandbox gets you app isolation; SELinux MAC gets you device integrity.
Frequently Asked Questions
Is SELinux the same as Android app permissions?
No. App permissions (CAMERA, LOCATION) are DAC-like, user-granted Runtime Permissions checked by the framework. SELinux MAC operates below that, in the kernel, confining what processes can do regardless of app permissions. An app with CAMERA permission still runs as untrusted_app and cannot access camera_device directly — only cameraserver and hal_camera_default can.
Can I set SELinux to permissive to fix denials on a production device?
Never on user builds. Permissive domains are blocked by neverallow and CTS; setenforce 0 requires userdebug or eng builds and a permissive build flag. On user builds enforcing=1 is forced by the kernel cmdline and Verified Boot will reject a tampered sepolicy. Fix labeling and allow rules instead.
Where do vendor SELinux policies live after Android 8.0 Treble?
Vendor policies are in /vendor/etc/selinux/ (CIL files) and compiled together with platform policy at boot into /sys/fs/selinux/policy. Sources belong in device/[vendor]/[device]/sepolicy/. Vendor types must use vendor_ prefix or be explicitly declared in system/sepolicy/public/ to maintain Treble separation.
How do I correctly label a new hardware device node?
Add an entry to file_contexts (e.g., /dev/my_sensor u:object_r:sensor_device:s0), declare the type in a .te file with type sensor_device, dev_type;, and use genfs_contexts if the node is created dynamically via sysfs. After flashing, verify with ls -Z /dev/my_sensor — if it shows unlabeled, the pattern did not match.