SELinux Type Enforcement & Contexts
Mental Model
A building with two security systems. The first is a key card (Unix DAC): if the badge grants access to room 401, the door unlocks. The second is a guard station (SELinux MAC) behind the door. The guard checks a manifest: "Is an employee of type engineer allowed to access equipment of type server-rack in room 401?" If the manifest says no, the guard blocks entry even though the key card worked. The building owner cannot override the guard's manifest. Only the security administrator can update it. That is mandatory access control.
The Problem
A web server getting "Permission denied" reading files that have correct Unix permissions. The file is chmod 644, owned by the apache user. But the web server process runs as httpd_t and the file is labeled user_home_t. The SELinux policy has no allow rule for httpd_t reading user_home_t. Until the file is relabeled to httpd_sys_content_t or a policy module is added, the access is denied. The audit log shows "avc: denied { read } for scontext=httpd_t tcontext=user_home_t" but many administrators see only the application's "Permission denied" and start chasing Unix permission ghosts.
Architecture
A web server returns 403 Forbidden. The file exists, is owned by the right user, and has 644 permissions. strace confirms the openat() call fails with EACCES. Every Unix permission checklist comes back clean. The answer is not in Unix permissions at all. It is in the four-field label attached to every file and process on the system: the SELinux security context.
SELinux type enforcement is a mandatory access control system that operates independently of Unix permissions. Both must allow the access. When one denies it, the application sees the same "Permission denied" error, making it easy to chase the wrong cause.
What Actually Happens
Every process on an SELinux-enabled system runs in a domain (a type assigned to processes). Every file has a type (stored in the security.selinux extended attribute). The SELinux policy is a compiled database of rules specifying exactly which domains can perform which operations on which types.
When the Apache web server (running as domain httpd_t) tries to read a file, the kernel performs two checks in sequence:
- Unix DAC: Does the process UID/GID have read permission on the file? If no, EACCES.
- SELinux MAC: Does an allow rule exist for
httpd_ttoreada file of typeX? If no, EACCES.
Both checks must pass. This is what "mandatory" means: the policy cannot be overridden by file owners or even by root. Root is confined too.
The allow rules look like this:
allow httpd_t httpd_sys_content_t:file { read open getattr };
allow httpd_t httpd_sys_script_exec_t:file { read execute open getattr };
Files in /var/www/html/ are labeled httpd_sys_content_t. The policy permits httpd_t to read that type. Files in /home/user/ are labeled user_home_t. No allow rule exists for httpd_t reading user_home_t. Access denied.
Security Contexts in Detail
Every SELinux-aware object carries a security context with four fields:
user:role:type:level
system_u:system_r:httpd_t:s0
User (system_u, unconfined_u, staff_u): Maps Linux users to SELinux identities. In targeted policy, most users are unconfined_u (no restrictions) and daemons are system_u.
Role (system_r, unconfined_r, object_r): Constrains which types a user can enter. system_r is for system daemons. object_r is for files and other non-process objects. Roles are the bridge between users and types in Role-Based Access Control (RBAC).
Type (httpd_t, httpd_sys_content_t, user_home_t): The critical field. Type enforcement rules reference types. In targeted policy, 95% of access decisions come down to "does an allow rule exist for source_type to access target_type."
Level (s0, s0:c123,c456, s0-s15:c0.c1023): Used by MLS and MCS. In MCS mode (standard for containers), the level includes categories. A container running at s0:c123,c456 can only access objects at the same or lower category set.
To inspect contexts:
# File context
ls -Z /var/www/html/index.html
# Output: system_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html
# Process context
ps -eZ | grep httpd
# Output: system_u:system_r:httpd_t:s0 12345 ? 00:00:01 httpd
# Own process context
id -Z
# Output: unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
Domain Transitions
Daemons do not start in their confined domain. They transition into it. When systemd (init_t) executes /usr/sbin/httpd (labeled httpd_exec_t), three policy rules work together:
# Rule 1: init_t is allowed to execute httpd_exec_t files
allow init_t httpd_exec_t:file { execute open read };
# Rule 2: init_t is allowed to transition to httpd_t
allow init_t httpd_t:process { transition };
# Rule 3: the actual transition rule
type_transition init_t httpd_exec_t:process httpd_t;
The result: the new process runs as httpd_t instead of init_t. If the binary is not labeled httpd_exec_t (installed manually, not via RPM), the transition does not happen and httpd runs as init_t or unconfined_t, effectively unconfined.
This is a common issue with software installed from source. The binary needs a label:
semanage fcontext -a -t httpd_exec_t "/opt/nginx/sbin/nginx"
restorecon -v /opt/nginx/sbin/nginx
MCS: Container Isolation via Categories
Multi-Category Security is SELinux's mechanism for isolating containers on the same host. Each container gets a unique pair of categories:
Container A: system_u:system_r:container_t:s0:c10,c20
Container B: system_u:system_r:container_t:s0:c30,c40
Both are container_t, but the MCS categories differ. Container A's files are labeled s0:c10,c20. Container B cannot access them because its category set (c30,c40) does not dominate c10,c20.
The :Z and :z volume mount flags control this:
# :Z = private label (only this container's MCS categories)
podman run -v /data:/data:Z myimage
# :z = shared label (container_file_t, no MCS restriction)
podman run -v /data:/data:z myimage
# No flag = host label preserved (container_t likely cannot access it)
podman run -v /data:/data myimage # Probable "Permission denied"
Under the Hood
Extended attributes. SELinux contexts are stored in the security.selinux extended attribute on each inode. The getxattr() and setxattr() syscalls read and write them. On XFS and ext4, extended attributes live in the inode or in a dedicated xattr block. The kernel caches contexts in the inode's i_security field to avoid reading xattrs on every access check.
The AVC (Access Vector Cache). The kernel does not query the full policy on every access. The AVC caches recent decisions. A cache hit avoids the expensive policy lookup entirely. Cache misses go to the security server (security/selinux/ss/services.c), which walks the policy rules. The AVC is critical for performance: without it, SELinux would add measurable overhead to every file operation.
Policy compilation. The human-readable .te (type enforcement) files are compiled by checkpolicy into a binary policy loaded into the kernel at boot. The binary policy is a graph of types, rules, and constraints. The kernel policy engine evaluates access requests by walking this graph. On RHEL, the compiled policy is at /etc/selinux/targeted/policy/policy.33.
Audit subsystem integration. When the AVC denies an access, it sends an event to the audit subsystem (via audit_log()). The message format:
type=AVC msg=audit(1680000000.123:456): avc: denied { read } for
pid=12345 comm="httpd" name="index.html" dev="sda1" ino=67890
scontext=system_u:system_r:httpd_t:s0
tcontext=system_u:object_r:user_home_t:s0
tclass=file permissive=0
This single line contains everything needed to diagnose the issue: which process (httpd_t), which operation (read), which file type (user_home_t), and whether it was enforced or just logged (permissive=0 means enforced).
Common Questions
Why does mv cause SELinux problems but cp does not?
mv within the same filesystem is a rename operation. The inode stays the same, and its extended attributes (including the SELinux context) stay the same. Moving a file from /home to /var/www preserves user_home_t. cp creates a new inode in the destination directory, and the new file inherits the type defined by the parent directory's file context rules (httpd_sys_content_t for /var/www/html/). This distinction catches administrators constantly. The fix after mv is always restorecon.
How does audit2allow work and when is it dangerous?
audit2allow reads AVC denial messages and generates allow rules that would permit the denied operations. Run without filtering (audit2allow -a), it generates rules for every denial on the system, including denials that should remain blocked (brute force attempts, exploit probes). The safe workflow: filter by the specific command (ausearch -m avc -c httpd | audit2allow -M httpd_custom), review the generated rules, and load only after understanding each rule.
What is the performance impact of SELinux?
With the AVC warm, SELinux adds less than 2% overhead to typical file operations. The overhead comes from the initial policy lookup on AVC misses and the xattr read for file contexts. On workloads with high file creation rates (thousands of new files per second), the xattr writes for labeling new files can become measurable. Container workloads are largely unaffected because the AVC quickly caches the small set of types each container uses.
How does SELinux interact with user namespaces and rootless containers?
SELinux label checks happen at the kernel level, outside any namespace. A process in a user namespace still has its SELinux context checked against the system policy. There is no per-namespace SELinux policy. This means rootless containers on SELinux-enforcing hosts still need correctly labeled volumes and proper domain transitions. The container runtime handles label assignment regardless of whether user namespaces are in use.
How Technologies Use This
A RHEL 9 host running 40 Docker containers on a PCI-DSS-compliant payment processing platform has SELinux in enforcing mode. Every container process launched by Docker runs in the container_t SELinux domain by default. The container_t type can read and write container_file_t labeled files (the container's own filesystem layers), access container_runtime_t sockets (for communication with containerd), and use container_port_t labeled network ports. It cannot read host files labeled etc_t, var_log_t, or user_home_t, even if Unix permissions are 777.
When a container needs access to a host-mounted volume, SELinux requires the :z or :Z flag on the bind mount. The :z flag relabels the host directory to svirt_sandbox_file_t, making it accessible to all containers. The :Z flag applies a per-container MCS (Multi-Category Security) label like s0:c123,c456, restricting access to only that specific container. Without either flag, the host directory retains its original label (often default_t or user_home_t), and the container gets "Permission denied" from SELinux despite correct Unix ownership.
Privileged containers (--privileged) run as spc_t (super privileged container), which has access to almost all types on the system. This is why --privileged is dangerous on SELinux-enforcing hosts: it disables the type enforcement boundary that separates containers from the host. On PCI-DSS audited systems, running any container as spc_t triggers a compliance finding. The audit log at /var/log/audit/audit.log shows every SELinux denial as an "avc: denied" entry with the source context (container_t) and target context (the denied type), providing exact information for writing allow rules or fixing label mismatches.
A 120-node Kubernetes cluster on RHEL 9 with SELinux enforcing uses the pod securityContext.seLinuxOptions field to assign specific SELinux labels to each pod. A pod running a web frontend sets seLinuxOptions.type to container_t (the default), while a monitoring agent pod that needs to read host log files sets seLinuxOptions.type to spc_t. The kubelet passes these labels to the container runtime, which applies them via setexeccon() before calling execve() on the container entrypoint.
MCS (Multi-Category Security) labels provide per-pod isolation within the same SELinux type. Kubernetes assigns unique MCS labels like s0:c100,c200 to each pod. Two pods both running as container_t but with different MCS labels cannot read each other's files, even if they share the same node and the same host directory. This prevents a compromised pod from accessing another pod's volumes. The labels are written to the container's /proc/PID/attr/current file and enforced by the kernel on every file access, socket connection, and IPC operation.
On clusters that run both privileged infrastructure pods (CNI plugins, CSI drivers, log collectors) and unprivileged application pods, seLinuxOptions is critical for limiting blast radius. Infrastructure pods that must run as spc_t are confined to dedicated node pools with taints, while application pods on general-purpose nodes remain in container_t with unique MCS labels. The audit log on each node captures every denied access, which the security team feeds into a central SIEM for real-time detection of unexpected type transitions or access attempts.
Every Android device since version 5.0 runs SELinux in mandatory enforcing mode. A typical Android 14 device defines over 400 SELinux domains. Third-party apps from the Play Store run in the untrusted_app_t domain. System apps pre-installed by the OEM run as platform_app_t. Core system services (SurfaceFlinger, AudioFlinger, vold) each have dedicated domains. The type enforcement policy explicitly lists which Binder IPC endpoints, filesystem paths, and hardware HAL interfaces each domain can access. An untrusted_app_t process cannot open /data/system/ files labeled system_data_file_t, even if the app holds the same Linux UID as the system_server.
Android's SELinux policy is compiled into the device image at build time using the sepolicy tool chain in the AOSP build system. Device manufacturers add custom policy modules for hardware-specific daemons such as the camera HAL (hal_camera_default_t), fingerprint sensor (hal_fingerprint_t), and NFC controller. Each custom daemon runs in its own domain with type transitions defined so that when init spawns the daemon, it automatically transitions from init_t to the correct domain without any runtime configuration.
The Android Compatibility Test Suite (CTS) includes over 50 SELinux validation tests. A device that ships with SELinux in permissive mode fails CTS and cannot license Google Play Services. During development, "avc: denied" messages in dmesg identify policy violations, and the audit2allow tool generates candidate allow rules. However, Android's policy philosophy is "neverallow first": the AOSP base policy contains neverallow rules that block known-dangerous access patterns (such as untrusted_app_t writing to /system), and any custom policy that violates a neverallow rule fails the CTS sepolicy tests even if the device otherwise boots and runs correctly.
Same Concept Across Tech
| Technology | How it uses SELinux | Key gotcha |
|---|---|---|
| RHEL/CentOS | Targeted policy enforcing by default. ~200 confined daemon types | Files moved (not copied) retain source labels. restorecon fixes it |
| Fedora | Upstream testing ground for RHEL policy. Desktop apps (Firefox) confined | Flatpak apps get per-app SELinux domains (flatpak_app_t) |
| Android | Enforcing since 5.0. Per-app domains (untrusted_app, platform_app) | CTS requires policy compliance. Custom HAL daemons need sepolicy rules |
| OpenShift | Pods run with MCS categories. SCC (Security Context Constraints) control allowed labels | Volume mounts need correct MCS labels or :Z flag in volume spec |
| Podman | Containers labeled container_t with unique MCS categories | :Z relabels volume exclusively for one container, :z for shared access |
Stack layer mapping (web server "Permission denied" with correct Unix permissions):
| Layer | What to check | Tool |
|---|---|---|
| Application | Which file is being accessed and from which path? | Application error log, strace -e openat |
| Unix DAC | Are traditional permissions correct? | ls -la /path/to/file |
| SELinux type | Does the file's type match what the process domain can access? | ls -Z /path/to/file, sesearch --allow -s httpd_t -t file_type |
| SELinux boolean | Is there a boolean that enables the needed access? | getsebool -a |
| Audit log | What exactly did SELinux deny? | ausearch -m avc -ts recent |
| Policy | Does a custom module need to be created? | audit2allow -a, review before loading |
Design Rationale Unix DAC has a fundamental flaw: the file owner controls access. A compromised httpd process running as the apache user can read any file that user owns or that has world-read permissions. SELinux replaces this with mandatory access control: the policy administrator defines exactly which domains can access which types, regardless of file ownership. The type enforcement model is simple in concept (subject-verb-object: httpd_t read httpd_sys_content_t) but powerful enough to express fine-grained security policies for an entire operating system. The targeted policy approach, confining services while leaving user sessions unconfined, was a pragmatic compromise that made SELinux usable on production servers without requiring every administrator to become a MAC expert.
If You See This, Think This
| Symptom | Likely cause | First check |
|---|---|---|
| "Permission denied" with correct Unix permissions | SELinux type mismatch | ls -Z on the file, check type against process domain |
| Web server 403 after deploying new files | Files have wrong SELinux label (tmp_t, user_home_t) | restorecon -Rv /var/www/html/ |
| Container cannot write to mounted volume | Missing MCS categories on volume | Mount with :Z or :z flag, check ls -Z on mount point |
| Service starts but immediately fails | Domain transition not working, running as unconfined_t | ps -eZ, check binary label with ls -Z /usr/sbin/service |
| Custom daemon denied network access | No allow rule for the domain to use TCP/UDP sockets | ausearch -m avc -c daemon_name, check for network-related booleans |
| audit2allow generates "allow unconfined_t" rules | Process not transitioning to its proper confined domain | Check binary label and type_transition rules |
When to Use / Avoid
Relevant when:
- Debugging "Permission denied" errors that persist despite correct Unix permissions
- Deploying applications on RHEL, CentOS, or Fedora (SELinux enforcing by default)
- Running containers on OpenShift or Podman on SELinux-enforcing hosts
- Meeting compliance requirements that mandate MAC (PCI-DSS, HIPAA, DISA STIG)
Watch out for:
- mv vs cp: moving files preserves the wrong label, copying inherits the right one
- Container volume mounts need :Z or :z to get correct MCS categories
- audit2allow can generate overly permissive rules if applied to unfiltered audit logs
- Disabling SELinux is never the right answer in production
Try It Yourself
1 # Check SELinux mode
2
3 getenforce && sestatus
4
5 # View security contexts of files and processes
6
7 ls -Z /var/www/html/ && ps -eZ | grep httpd
8
9 # Find AVC denials for a specific service in the last hour
10
11 ausearch -m avc -ts recent -c httpd
12
13 # Fix labels after moving files
14
15 restorecon -Rv /var/www/html/
16
17 # Add a custom file context rule
18
19 semanage fcontext -a -t httpd_sys_content_t "/srv/webapp(/.*)?" && restorecon -Rv /srv/webapp/
20
21 # Toggle a boolean persistently
22
23 setsebool -P httpd_can_network_connect on
24
25 # Generate and load a policy module from recent denials
26
27 ausearch -m avc -ts recent -c myapp | audit2allow -M myapp_policy && semodule -i myapp_policy.pp
28
29 # List all allow rules for a domain
30
31 sesearch --allow -s httpd_t | head -30
32
33 # Check which file type a path should have
34
35 matchpathcon /var/www/html/index.html
36
37 # View all loaded policy modules
38
39 semodule -l | head -20
40
41 # Temporarily set a single domain to permissive (not the whole system)
42
43 semanage permissive -a httpd_tDebug Checklist
- 1
Check if SELinux is enforcing: getenforce - 2
View file label: ls -Z /path/to/file - 3
View process domain: ps -eZ | grep <process_name> - 4
Search for AVC denials: ausearch -m avc -ts recent - 5
Check for boolean that might allow the access: getsebool -a | grep <keyword> - 6
Fix file labels: restorecon -Rv /path - 7
Generate policy module from denials: ausearch -m avc -c <command> | audit2allow -M mypolicy - 8
Load custom policy module: semodule -i mypolicy.pp
Key Takeaways
- ✓SELinux operates after Unix DAC (Discretionary Access Control). Even if Unix permissions allow access, SELinux can still deny it. Both checks must pass. This is mandatory access control (MAC): the policy is set by the administrator, not by file owners.
- ✓The targeted policy confines specific daemons while leaving user sessions unconfined. This means most SELinux denials come from service processes (httpd_t, mysqld_t, named_t), not interactive shell sessions. The policy is conservative: services get the minimum access they need.
- ✓File labels are stored in extended attributes (security.selinux). Moving a file preserves its label. Copying a file inherits the label of the destination directory. This distinction is the root cause of many SELinux issues: "mv" from /home to /var/www preserves user_home_t labels, but "cp" would inherit httpd_sys_content_t.
- ✓Boolean switches (getsebool/setsebool) toggle optional policy rules without writing custom modules. Example: "setsebool -P httpd_can_network_connect on" allows httpd_t to make outbound TCP connections. There are hundreds of booleans; semanage boolean -l lists them all.
- ✓Permissive mode (setenforce 0) logs denials without blocking access. It is invaluable for debugging but should never stay on in production. The audit log in permissive mode shows exactly which allow rules are needed, and audit2allow can generate a policy module from those denials.
Common Pitfalls
- ✗Disabling SELinux instead of fixing the denial. Running "setenforce 0" or setting SELINUX=disabled in /etc/selinux/config removes an entire security layer. Most denials are fixed with a single restorecon, setsebool, or semanage command. Disabling SELinux to fix a permission error is like removing a lock because the key is in the wrong pocket.
- ✗Using "mv" instead of "cp" when deploying web content. "mv /home/user/index.html /var/www/html/" preserves the user_home_t label. The fix: "restorecon -Rv /var/www/html/" to reset labels based on the file_contexts policy, or use "cp" which inherits the destination directory's label.
- ✗Running audit2allow on the entire audit log without filtering. This generates an overly permissive policy module that allows everything that was denied. Instead, filter by the specific domain: "ausearch -m avc -ts recent -c httpd | audit2allow -M mypolicy" generates rules only for httpd denials.
- ✗Ignoring MCS categories on container volumes. Mounting a host directory into a container without :Z or :z leaves the host's SELinux label. The container (running as container_t with category s0:c123,c456) cannot access files labeled with a different category or with no category at all. The result is "Permission denied" that only manifests on SELinux-enforcing systems.
Reference
In One Line
SELinux type enforcement blocks access that Unix permissions allow, and the fix is almost always restorecon, setsebool, or a targeted policy module, never setenforce 0.