The single most common SELinux “fix” on the internet is setenforce 0, and it is almost always wrong. SELinux is not a misbehaving guest on your server; it is a mandatory access control (MAC) system that contains a compromised daemon to the handful of operations that daemon legitimately needs — so that a remote-code-execution bug in your web server cannot read /etc/shadow, write cron jobs, or pivot to the database socket. When a service fails and SELinux is involved, the kernel has already told you exactly what it refused and why. Disabling it throws that diagnostic away and leaves you with an unconfined, un-auditable process running with the full discretionary permissions of its UID.
This is the operator’s playbook for running SELinux enforcing in production and never reaching for setenforce 0 under pressure. You will learn to read an AVC (Access Vector Cache) denial field by field from /var/log/audit/audit.log, to triage the three fixable causes that account for the overwhelming majority of denials — a wrong file context, an unregistered port, or a boolean that is off — with semanage, restorecon and setsebool -P, and only then, when a service genuinely has no upstream policy, to author a clean, minimal custom policy module from .te, .if and .fc sources using reference-policy interfaces rather than a raw audit2allow dump. The centerpiece is a symptom-to-fix denial playbook you keep open at 02:00 when a daemon won’t start and journalctl is being unhelpful.
By the end you will treat a denial the way a senior engineer does: as data, not an obstacle. When httpd returns 503, when a container can’t bind its port, when a moved file 404s, when a third-party agent silently runs unconfined in init_t, you will know within ninety seconds whether you are looking at a mislabelled file, a missing port registration, an off boolean, or a real policy gap that warrants a module — and you will fix the right one, persistently, without weakening the machine.
What problem this solves
SELinux failures are maddening precisely because the surface symptom lies. A daemon exits with a generic error, systemctl status shows code=exited status=1 with no useful detail, and the application’s own logs say “permission denied” on a path the Unix mode bits clearly allow. The ls -l output shows rw-r--r-- root root on a file the root process cannot read — because the type label is wrong and discretionary permissions never entered into it. Nothing in the ordinary Linux toolchain (ls -l, strace at a glance, the app log) points at SELinux, so engineers who do not know to check ausearch conclude the platform is broken and disable the one control that was doing its job.
What breaks without this knowledge is worse than a single outage. An on-call engineer runs setenforce 0, the daemon starts, the incident closes, and the machine now runs permissive until the next reboot re-reads /etc/selinux/config — or, if someone “made it permanent,” forever. A compromised nginx on that box can now do everything nginx’s UID can do: read every world-readable secret, connect anywhere, write to any directory the mode bits allow. In a regulated environment (PCI-DSS, FedRAMP, CIS Level 2, many DoD STIGs) enforcing SELinux is a hard control; setenforce 0 is an audit finding on the spot. Meanwhile the actual cause — a file relabelled by mv, a service moved to a non-standard port, a boolean nobody flipped — sat there, perfectly diagnosable, ignored.
Who hits this: anyone running RHEL, Rocky, AlmaLinux, CentOS Stream, Fedora, or Oracle Linux with the default targeted policy; anyone shipping a third-party or in-house daemon that has no upstream policy module; anyone moving web or app content between directories; anyone running containers under Podman or CRI-O where MCS categories isolate each container; and anyone who has ever “fixed” an SELinux problem with chcon in a config-management role and been bitten weeks later by a routine relabel that silently reverted it. The fix is almost never to weaken SELinux — it is to read the denial, apply the right tool, and (when warranted) write twenty lines of policy that give the service its own confined domain.
To frame the whole field before the deep dive, here is every denial class this article covers, the question it forces, and the first place to look:
| Symptom class | What the kernel is telling you | First question to ask | First command to run | Most common single cause |
|---|---|---|---|---|
| Daemon won’t start | A domain was denied an operation at startup | Is it labelled/transitioned into a confined domain at all? | ps -eZ | grep <daemon>; ausearch -m AVC -ts recent |
Wrong file context, or missing port registration |
| File access denied (mode bits allow it) | tcontext type is not what the domain expects |
Was the file moved, restored, or created in the wrong place? | ls -Z <path>; restorecon -nv <path> |
mv preserved the old label; run restorecon |
| Port bind/connect refused | A tcp_socket/udp_socket name_bind/name_connect denied |
Is the port registered to the domain’s port type? | semanage port -l | grep <port> |
Non-standard port not registered with semanage port |
| Feature works only with a flag flipped | A boolean gates the operation | Is there a boolean for this? | getsebool -a | grep <service> |
Boolean off (e.g. httpd_can_network_connect) |
| Service runs but is unconfined | Binary is bin_t, no transition, stays in init_t |
Does it have an *_exec_t label + a transition? |
ps -eZ | grep <daemon> shows init_t |
No policy module → write one |
| Container denied on a mounted volume | MCS category mismatch (s0:c123,c456) |
Is the host path relabelled for container access? | ls -Z <hostpath>; podman inspect label |
Volume not mounted with :z/:Z |
| Nothing in the log, but it still fails | A dontaudit rule is silencing the denial |
Are dontaudit rules hiding it? | semodule -DB, reproduce, then semodule -B |
Silenced denial; disable dontaudit to see it |
Learning objectives
By the end of this article you can:
- Explain type enforcement precisely: the four-field security context (
user:role:type:level), why the type does the work, how files get types and processes get domains, and how a type_transition confines a daemon automatically. - Read a raw AVC denial from
audit.logfield by field —scontext,tcontext,tclass, the permission,permissive=,comm,pid— and turn it into an exact remediation. - Drive the audit toolchain fluently:
ausearchto find denials,audit2whyto explain them,sealert/setroubleshootfor guided remedies, andaudit2allowto scaffold (never blindly ship) rules. - Triage the three fixable causes in the right order — context → port → boolean — using
restorecon,semanage fcontext,semanage portandsetsebool -P, and know when each is authoritative. - Distinguish enforcing / permissive / disabled modes and per-domain permissive (
semanage permissive), and know why you never edit/etc/selinux/configto fix a symptom. - Distinguish confined vs unconfined domains, diagnose why a third-party daemon runs unconfined in
init_t, and give it a real domain. - Author a clean, minimal custom policy module —
.te,.if,.fc— that declares private types, uses reference-policy interfaces discovered withsepolicy, applies file transitions, and builds reproducibly with the devel Makefile; then roll it out safely via per-domain permissive before flipping to enforcing. - Reason about MCS/MLS categories and container-selinux — why containers get
s0:c<n>,c<m>labels, and how:z/:Zvolume relabelling works.
Prerequisites & where this fits
You should be comfortable on an enterprise Linux host: systemd service management (systemctl, journalctl), the Unix permission model (owners, groups, mode bits — SELinux is checked after these pass), reading /var/log, and installing packages with dnf. You should know what a daemon is, what a TCP port is, and roughly how nginx/httpd/postgresql are laid out on disk. No prior SELinux knowledge is assumed, but this is an Expert-level treatment: it moves fast through the model and spends its length on troubleshooting and policy authoring.
This sits in the Linux security hardening track. SELinux is one MAC implementation; the sibling article on Authoring AppArmor Profiles: Confining Services on Ubuntu and Debian covers the path-based alternative you meet on Ubuntu/Debian, and reading both makes the type-enforcement-versus-path-based trade-off concrete. Denials land in the audit subsystem, so it pairs tightly with Building a Linux Audit Trail with auditd and eBPF Runtime Visibility — audit.log is the same log both articles read. Because systemd is what transitions a daemon into its domain, Mastering systemd: Units, Timers, Resource Control, and Service Hardening is upstream of the confined-vs-unconfined discussion. And the container isolation section connects directly to Running Rootless Containers in Production with Podman and Quadlet, where MCS categories keep containers apart.
A quick map of who confirms what during an SELinux incident, so you reach for the right tool fast:
| Layer | What lives here | The tool that tells the truth | Failure classes it explains |
|---|---|---|---|
| Kernel LSM hook | The allow/deny decision itself | audit.log via ausearch |
Every denial; permissive= tells you if it blocked |
| File labels | security.selinux xattr on each inode |
ls -Z, restorecon -nv |
Wrong-context denials (the #1 cause) |
| Port types | Port→type mapping in the policy store | semanage port -l |
name_bind / name_connect on sockets |
| Booleans | Policy-author switches | getsebool -a, semanage boolean -l |
“works only with a flag on” |
| Policy modules | Loaded .pp modules in the store |
semodule -l, sesearch, seinfo |
Missing domain, missing rule → write policy |
| Process domains | The scontext of running processes |
ps -eZ, id -Z |
Unconfined-in-init_t, wrong domain |
The .te/.if/.fc sources |
Your custom policy under version control | sepolicy, the devel Makefile |
Gaps you fix by editing and rebuilding |
Core concepts
Five mental models make every later diagnosis obvious.
SELinux is default-deny type enforcement, checked after DAC. Traditional Unix permission (mode bits, owners, groups) is discretionary access control (DAC) — the file’s owner decides who may read it. SELinux adds mandatory access control on top: even if DAC allows an operation, the kernel consults the policy, and if no rule explicitly permits it, the operation is denied. Crucially, DAC is checked first — a denial you see in audit.log means DAC already passed and SELinux is the one saying no. Every process and object carries a security context of four fields — user:role:type:level — and the field that does the enforcement work is the type. The policy is one enormous allowlist of rules of the form “the domain (a process type) X may perform action Y on objects of type Z”. Everything not written is refused.
Files have types; processes have domains; a transition ties them together. A file’s type — sometimes called its label or context — comes from policy rules keyed on its path: /var/www/html is httpd_sys_content_t, /etc/shadow is shadow_t. That label lives in the security.selinux extended attribute on the inode, so it travels with the file on the same filesystem and is why mv (which preserves the xattr) behaves differently from cp (which lets the destination policy assign a fresh one). A process type is called a domain: httpd_t, sshd_t, postgresql_t. When systemd (itself running in init_t) executes a binary labelled httpd_exec_t, the policy contains a type_transition rule that moves the new process into httpd_t. This is why a confined daemon becomes confined automatically — the transition is declared in policy, not configured by you, and it is the single mechanism you must reproduce when you confine your own daemon.
A denial names the actor, the target, and the operation — that is the whole skill. When the kernel refuses, it emits an AVC record to the audit subsystem. Read three fields and you know the fix: scontext is the source domain (the actor — who tried), tcontext is the target context (what it tried to touch), and tclass plus the bracketed permission ({ read }, { name_bind }, { name_connect }) is the operation. A tcontext of default_t where you expected httpd_sys_content_t is a labelling problem. A tclass=tcp_socket with { name_bind } and a port in the record is a port-registration problem. A denial that a boolean would have permitted is a boolean problem. The permission and the target type route you to one of exactly three fixes before you ever consider writing policy.
Modes are global, but permissiveness can be per-domain. SELinux runs in one of three global modes: enforcing (denials block and log), permissive (denials log but do not block — invaluable for discovery), or disabled (the LSM is off; no labels maintained — avoid, and note that going from disabled back to enforcing needs a full relabel). But the sharp tool is per-domain permissive: semanage permissive -a <domain> makes a single type run permissive while the entire rest of the system stays enforcing. That is how you safely bring up a new confined daemon in production — the new domain logs what it needs without blocking, everything else stays protected — then you harvest the denials, fold them into policy, and remove the exception.
Most denials are not policy bugs — they are one of three fixable conditions. Before you ever write a module, three cheap checks resolve the overwhelming majority of tickets: the file is in the wrong place or was moved (fix with restorecon), the service listens on a non-standard port the policy doesn’t know about (fix with semanage port), or a boolean that gates the behaviour is off (fix with setsebool -P). The triage order is context, then port, then boolean. Only when all three are correct — the labels match, the port is registered, the relevant booleans are on — do you have a genuine policy gap that a custom module addresses.
The vocabulary in one table
Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the model side by side:
| Concept | One-line definition | Where it lives | Why it matters to a denial |
|---|---|---|---|
| Security context | The user:role:type:level 4-tuple on every process/object |
xattr (files) / kernel (processes) | The thing compared against policy |
| Type | The field enforcement is based on | 3rd field of the context | Wrong type on a file = denial |
| Domain | A process type | scontext of a running process |
The actor in every denial |
| Type enforcement (TE) | The rule model: domain→action→type | The loaded policy | The whole allowlist |
type_transition |
Rule that moves a new process into a domain | Policy module | Why daemons auto-confine |
| Label / file context | The type stored on a file | security.selinux xattr |
restorecon sets it from policy |
| AVC denial | Kernel record of a refused access | /var/log/audit/audit.log |
Your primary diagnostic |
| Boolean | Policy-author on/off switch | Policy + /sys/fs/selinux/booleans |
Off → common denials; setsebool -P |
| Port type | Type mapping a port to a domain’s allowed ports | Policy store (semanage port) |
Non-standard port → name_bind denial |
| Confined domain | A tightly boxed process type | httpd_t, postgresql_t, … |
The goal state |
| Unconfined domain | Barely restricted (unconfined_t, init_t) |
Default for unlabelled binaries | The trap: “works” but not protected |
| Policy module | A loadable .pp package of rules |
The policy store (semodule -l) |
What you write to fill a gap |
| MCS category | The level field’s categories (c0,c1,…) |
4th field of the context | Container/multi-tenant isolation |
dontaudit |
Rule that silences a harmless denial | Policy | Why a denial can be invisible |
To ground the abstractions, here are the confined domains you meet most on a real server, the binary that enters each, and the private types they own — this is the shape your custom module should imitate:
| Service | Domain | Executable type | Key private data types |
|---|---|---|---|
| Apache HTTP | httpd_t |
httpd_exec_t |
httpd_sys_content_t, httpd_log_t, httpd_sys_rw_content_t |
| nginx | httpd_t (shares Apache’s) |
httpd_exec_t |
same as httpd (nginx uses the httpd domain) |
| PostgreSQL | postgresql_t |
postgresql_exec_t |
postgresql_db_t, postgresql_log_t, postgresql_var_run_t |
| MariaDB/MySQL | mysqld_t |
mysqld_exec_t |
mysqld_db_t, mysqld_log_t |
| OpenSSH server | sshd_t |
sshd_exec_t |
sshd_key_t, sshd_var_run_t |
| systemd (PID 1) | init_t |
init_exec_t |
— (the untransitioned default for stray daemons) |
| Podman/CRI-O container | container_t |
container_runtime_exec_t |
container_file_t (+ MCS categories) |
| BIND DNS | named_t |
named_exec_t |
named_zone_t, named_cache_t |
| chronyd | chronyd_t |
chronyd_exec_t |
chronyd_var_run_t |
How type enforcement actually works
Start with the context. Inspect any process or object with the -Z flag, which nearly every core utility understands, and you will see the four fields:
id -Z
# unconfined_u:unconfined_r:unconfined_t:s0
ps -eZ | grep -E 'httpd|nginx'
# system_u:system_r:httpd_t:s0 1234 ? 00:00:01 httpd
ls -Z /var/www/html/index.html
# unconfined_u:object_r:httpd_sys_content_t:s0 index.html
ss -ltnpZ | grep :443
# LISTEN 0 511 *:443 users:(("httpd",pid=1234)) ... system_u:system_r:httpd_t:s0
The four fields, and how much each matters in the targeted policy that every mainstream distribution ships by default:
| Field | Name | Example | Role in targeted policy |
|---|---|---|---|
| 1 | SELinux user | system_u, unconfined_u |
Maps to a set of allowed roles; mostly background |
| 2 | Role | system_r, object_r |
Gates which types a user may enter; object_r for all files |
| 3 | Type / domain | httpd_t, httpd_sys_content_t |
The enforcement field — this is what you troubleshoot |
| 4 | Level (sensitivity:categories) | s0, s0:c1,c2 |
MLS sensitivity / MCS categories; container & multi-tenant isolation |
In targeted policy — the default on RHEL, Rocky, Alma, Fedora, Oracle Linux — the first two fields are largely fixed (system_u/system_r for system processes, object_r for files) and the type is where all the action is. Do not be distracted by user and role when troubleshooting a standard box; the denial you are chasing is almost always about the type.
The -Z flag is your window into contexts, and nearly every core utility accepts it. Commit these to reflex — you run them constantly during triage:
| Command | Shows the context of | Use during triage to answer |
|---|---|---|
id -Z |
Your current shell/user | What domain you are in (often unconfined_t) |
ps -eZ / ps -eZ | grep <d> |
Every process / one process | Is the daemon confined, or stuck in init_t? |
ls -Z <path> |
Files and directories | Is the target labelled the way policy expects? |
ls -Zd <dir> |
A directory itself (not its contents) | Is the parent dir’s type blocking traversal? |
ss -ltnpZ / netstat -Z |
Listening sockets and their processes | Which domain owns the socket on a port? |
stat -c '%C' <path> |
One file’s context, scriptably | Grab just the context for a comparison |
matchpathcon <path> |
What a path should be labelled | Diff expected vs actual before restorecon |
find / -context '*httpd*' |
Files matching a context pattern | Locate everything a domain can touch |
runcon -t <type> <cmd> |
(runs a command in a given context) | Test what a domain can do, deliberately |
Labelling: how a file gets its type
A file’s type is not stored in the policy — it is stored on the file, in the security.selinux xattr — but where it comes from is a policy rule keyed on the path. The semanage fcontext database maps path regexes to types; restorecon reads that database and stamps the correct label onto the inode. This split is the source of the single most common class of bug, so hold it clearly:
| Operation | What it does to the label | Persistent across relabel? | When to use |
|---|---|---|---|
restorecon <path> |
Sets the label policy says the path should have | Yes (reads the fcontext DB) | The default fix for a mislabelled file |
chcon -t <type> <path> |
Sets a label imperatively, right now | No — a relabel reverts it | Quick experiments only; never in automation |
semanage fcontext -a -t <type> '<regex>' |
Adds a rule to the fcontext DB (does not touch files) | Yes (it is the DB) | Register a path so restorecon is authoritative |
cp <src> <dst> |
Destination gets a fresh label from policy | N/A | Prefer over mv when placing content |
mv <src> <dst> |
Preserves the source’s existing label | N/A | The trap — moved content keeps its old type |
matchpathcon <path> / selabel_lookup |
Shows what label a path should have | N/A | Diff expected vs actual before restoring |
The workflow that makes labels correct forever: register the path with semanage fcontext -a, then apply with restorecon. chcon is for a ten-second experiment and nothing else, because a routine restorecon -R / or an /.autorelabel boot will silently undo it.
Domain transition: how a process gets confined
When you start systemctl start httpd, the sequence is: systemd runs in init_t; it execs /usr/sbin/httpd, which policy has labelled httpd_exec_t; a type_transition init_t httpd_exec_t : process httpd_t; rule fires; the new process lands in httpd_t. Three ingredients must all be present — the binary’s *_exec_t label, an entrypoint relationship (httpd_exec_t is declared the entrypoint of httpd_t), and the transition rule. Break any one and the daemon does not enter its domain:
| Ingredient | What provides it | Symptom if missing |
|---|---|---|
*_exec_t label on the binary |
.fc file + restorecon, or semanage fcontext |
Binary is bin_t → process stays in init_t (unconfined) |
| Entrypoint declaration | init_daemon_domain() / domain_entry_file() in the .te |
Transition can’t fire even if the label is right |
type_transition rule |
init_daemon_domain() macro |
No transition → runs in the caller’s domain |
Confirm the outcome in one command — this is the first thing to run when a daemon misbehaves:
ps -eZ | grep my-daemon
# GOOD: system_u:system_r:my_daemon_t:s0 ... my-daemon
# TRAP: system_u:system_r:init_t:s0 ... my-daemon <- unconfined
If your daemon shows init_t or unconfined_service_t, it has no real confinement and no policy module — that is your starting point for the authoring sections below.
Confirm your enforcing state first
Never begin troubleshooting without knowing the mode. A “denial” you cannot reproduce is often because the box is already permissive:
getenforce # Enforcing | Permissive | Disabled
sestatus # full status: policy name, store, mode, MLS/MCS status
sestatus output on a healthy targeted box reads SELinux status: enabled, Current mode: enforcing, Loaded policy name: targeted. If Current mode says permissive you are seeing logged-not-blocked denials and the app is not actually being stopped — a common source of “it works on staging” confusion.
Modes: enforcing, permissive, disabled — and per-domain permissive
The global mode is set at runtime with setenforce and at boot in /etc/selinux/config. Understand every combination and, critically, why disabling is the one you almost never want:
| Mode | Denials blocked? | Denials logged? | Labels maintained? | Return path | Use it for |
|---|---|---|---|---|---|
| Enforcing | Yes | Yes | Yes | — | Production. The only correct steady state |
| Permissive | No | Yes | Yes | Instant | Discovery: bring-up, harvesting a new domain’s needs |
| Disabled | No | No | No | Needs a full relabel on re-enable | Almost never — you lose auditing and xattrs drift |
| Per-domain permissive | No (that domain) / Yes (rest) | Yes | Yes | semanage permissive -d |
Safely bringing up ONE new daemon in prod |
Runtime toggles and the persistent setting:
setenforce 0 # Permissive (momentary diagnostic ONLY — set it straight back)
setenforce 1 # Enforcing
getenforce # confirm
# Persistent mode lives here — set enforcing and LEAVE IT:
grep '^SELINUX=' /etc/selinux/config # SELINUX=enforcing
The rule that saves you an audit finding: do not edit /etc/selinux/config to “fix” a problem. Keep SELINUX=enforcing permanently. setenforce 0 is acceptable for a few seconds to prove SELinux is the cause of a symptom (if it starts working permissive, SELinux is involved), but you flip it straight back and then diagnose properly. The reason disabling is genuinely dangerous, not merely frowned upon: while disabled, new and modified files never get labelled, so the on-disk labels drift out of sync with reality; when you eventually re-enable, everything is mislabelled and you must trigger a full filesystem relabel (fixfiles onboot or touch /.autorelabel; reboot), which on a large host is a long maintenance window.
Per-domain permissive is the professional’s tool and deserves emphasis. It lets you observe exactly what one new type needs without exposing the machine:
semanage permissive -a my_daemon_t # ONLY my_daemon_t runs permissive
semanage permissive -l # list current permissive domains
semanage permissive -d my_daemon_t # remove the exception → back to enforcing for it
There is also a related distinction worth knowing: a type can be declared permissive in a module (permissive my_daemon_t; in a .te) — the same effect, shipped as policy. Prefer semanage permissive for temporary bring-up because it is trivially reversible without a rebuild.
Reading AVC denials — the core skill
When the kernel refuses an action it writes an AVC (and sometimes USER_AVC) record to /var/log/audit/audit.log. Do not grep that file raw. Use ausearch, which parses records, resolves timestamps from epoch, and lets you filter by message type, time, and process. audit.log is line-noise; ausearch is readable:
ausearch -m AVC,USER_AVC -ts recent # AVCs in the last ~10 minutes
ausearch -m AVC -ts today | tail -n 40 # today's denials
ausearch -m AVC -c httpd -ts recent # only comm=httpd
ausearch -m AVC -su httpd_t -ts today # only scontext user/type httpd_t
ausearch -m AVC --start 09:00 --end 09:30 # a specific window
The ausearch flags you will actually use, so you scope a denial fast instead of scrolling audit.log:
| Flag | Meaning | Example |
|---|---|---|
-m AVC,USER_AVC |
Filter by message type (AVC denials) | ausearch -m AVC -ts recent |
-ts recent / -ts today |
Start time: last ~10 min / since midnight | ausearch -m AVC -ts today |
--start / --end |
An explicit time window | ausearch -m AVC --start 09:00 --end 09:30 |
-c <comm> |
Filter by process command name | ausearch -m AVC -c httpd |
-su <type> |
Filter by source (scontext) user/type | ausearch -m AVC -su httpd_t |
-tc <class> |
Filter by target object class | ausearch -m AVC -tc tcp_socket |
--raw |
Unformatted output (feed to audit2allow) |
ausearch -m AVC -c X --raw | audit2allow -M X |
-i |
Interpret numeric fields (nicer output) | ausearch -m AVC -ts recent -i |
-if <file> |
Read a specific log file, not the live one | ausearch -if /var/log/audit/audit.log.1 -m AVC |
A raw denial — the thing you are learning to read:
type=AVC msg=audit(1715520000.123:456): avc: denied { name_connect } for
pid=2310 comm="httpd" dest=8080 scontext=system_u:system_r:httpd_t:s0
tcontext=system_u:object_r:http_cache_port_t:s0 tclass=tcp_socket permissive=0
Read it field by field. This table is the diagnostic method — internalise it and you can decode any denial:
| Field | Value in the example | Meaning | What it tells you to check |
|---|---|---|---|
avc: denied { ... } |
{ name_connect } |
The permission refused | Which operation to allow (or which fix maps to it) |
pid / comm |
2310 / httpd |
The process that tried | Which service; correlate with journalctl |
scontext |
...:httpd_t:s0 |
Source domain (the actor) | The domain that needs the access |
tcontext |
...:http_cache_port_t:s0 |
Target context | The type being touched — often the whole clue |
tclass |
tcp_socket |
The object class | file / dir / tcp_socket / udp_socket / etc. |
dest / name / path |
dest=8080 |
Class-specific detail | Port number, file name, or path |
permissive |
0 |
Was it actually blocked? | 0 = blocked (real), 1 = logged only |
The permissive= field is the one people miss. permissive=1 means the record was logged but the operation was allowed — you are on a permissive box or a per-domain-permissive type, and this “denial” did not actually stop anything. permissive=0 is a real block. When someone says “SELinux is denying X but disabling it doesn’t help,” check whether they were reading permissive=1 records the whole time.
Common permissions and the object classes they appear on, so a bracketed verb immediately suggests a category of fix:
| Permission | Typical tclass |
Usually means | First-line fix to consider |
|---|---|---|---|
read, open, getattr |
file, dir, lnk_file |
Wrong file label | restorecon / semanage fcontext |
write, append, create |
file, dir |
Wrong label, or genuinely needs write to a private type | restorecon; or private type in a module |
name_bind |
tcp_socket, udp_socket |
Listening on an unregistered port | semanage port -a |
name_connect |
tcp_socket |
Outbound connect to an unregistered/blocked port | semanage port; often a boolean too |
execute, execute_no_trans |
file |
Executing something in the wrong domain | Label as *_exec_t; add a transition |
execmem, execstack, execheap |
process |
App maps writable+executable memory | Almost always fix the app, not policy |
connectto |
unix_stream_socket |
Talking to another domain’s socket | A named interface between the two domains |
signal, sigkill |
process |
Sending signals across domains | An interface; or run in the same domain |
search, add_name |
dir |
Traversing/adding into a directory | Label the directory; list_dirs_pattern |
Explaining a denial: audit2why, sealert, and setroubleshoot
ausearch shows you the denial; audit2why tells you why it happened and often what class of fix it needs — wrong label, missing boolean, or a genuine gap:
ausearch -m AVC -ts recent | audit2why
# ... prints, per denial, one of:
# "Was caused by: Missing type enforcement (TE) allow rule." -> real gap
# "... one of the following booleans: httpd_can_network_connect" -> flip a boolean
# "... you may need to relabel ... restorecon -R -v /path" -> a labelling fix
For a plain-English explanation with a suggested exact command, install setroubleshoot-server and use sealert. It correlates related denials and frequently names the precise setsebool or semanage line to run — and it is what fires the desktop/journal “SELinux is preventing…” notifications:
dnf install -y setroubleshoot-server
sealert -a /var/log/audit/audit.log # analyse the whole log
journalctl -t setroubleshoot --since "10 min ago" # the notifications it raised
The three-tool division of labour, so you pick the right one:
| Tool | What it gives you | Speed | When to reach for it |
|---|---|---|---|
ausearch |
The raw, filtered, timestamped denials | Instant | Always first — find and scope the denials |
audit2why |
Why each denial happened (label/boolean/gap) | Instant | To classify: is this a boolean, a label, or a real gap? |
sealert (setroubleshoot) |
Human explanation + a suggested exact command | Seconds | To get a concrete remediation, and for the notifications |
audit2allow |
allow rules / a loadable module that would permit it |
Instant | To scaffold a module — then read it, never ship blind |
The dontaudit trap: when a denial is invisible
Some denials are noisy but harmless — a daemon probing for a capability it does not really need, a library stat-ing paths it may not read. Policy authors silence these with dontaudit rules so they never reach the log. That is usually a kindness, but it becomes a trap when a service genuinely misbehaves and you see nothing in ausearch. If a daemon fails and the audit log is silent, temporarily disable all dontaudit rules, reproduce, and re-enable:
semodule -DB # -D disable dontaudit, -B rebuild/reload the policy
# ... reproduce the failure now; the previously-silenced denials will appear ...
ausearch -m AVC -ts recent
semodule -B # rebuild WITHOUT -D → dontaudit rules back on (ALWAYS do this)
Leaving dontaudit disabled floods the log with harmless noise and can hide real signal, so the semodule -B at the end is not optional. This is the answer to “SELinux is definitely the problem but there are no denials.”
Fix the common cases first — context, then port, then boolean
The overwhelming majority of denials are one of three fixable conditions. Rule them out in this order before you consider a module; a custom policy for what is actually a mislabelled file is a self-inflicted maintenance burden.
| Order | Cause | Tell-tale in the denial | The fix | Persistence |
|---|---|---|---|---|
| 1 | Wrong file context | tcontext file type is default_t/user_home_t/mismatched; tclass=file/dir |
restorecon; register with semanage fcontext |
semanage fcontext + restorecon survives relabel |
| 2 | Unregistered port | { name_bind }/{ name_connect }, tclass=*_socket, a dest= port |
semanage port -a -t <type> -p tcp <port> |
Persistent (stored in policy store) |
| 3 | Boolean off | audit2why names a boolean; behaviour is a known variation |
setsebool -P <bool> on |
-P makes it persistent |
Cause 1 — Wrong file context (the number-one cause)
You put content where policy does not expect it, or — far more often — you mvd a file (which preserves its old label) instead of cping it. The denial shows a tcontext type that does not match what the domain wants: default_t or user_home_t where httpd_sys_content_t belongs.
Confirm what the label is versus what it should be, without changing anything:
ls -Z /var/www/html/index.html
# unconfined_u:object_r:user_home_t:s0 index.html <- wrong; came from a home dir via mv
restorecon -nv /var/www/html/index.html
# would relabel .../index.html from user_home_t to httpd_sys_content_t <- -n = dry run
Fix. If the path is already covered by policy (as /var/www/html is), just restore it:
restorecon -Rv /var/www/html # -R recursive, -v verbose (shows every change)
If the path is not a standard one — say you serve content from /srv/webdata — register a rule first, then restore. This is what makes the label authoritative forever:
semanage fcontext -a -t httpd_sys_content_t '/srv/webdata(/.*)?' # add the rule
restorecon -Rv /srv/webdata # apply it
semanage fcontext -l | grep webdata # verify the rule exists
chcon exists and works, but reserve it for a ten-second experiment. Putting chcon in Ansible/Puppet/a systemd ExecStartPre is a latent outage: the next restorecon -R /, fixfiles, or /.autorelabel reverts it and the service breaks weeks later with no obvious cause. The equivalence between mv/cp and label behaviour is worth committing to muscle memory:
| You did | Resulting label | Result under enforcing |
|---|---|---|
cp /home/u/site.html /var/www/html/ |
Fresh httpd_sys_content_t from policy |
Works |
mv /home/u/site.html /var/www/html/ |
Kept user_home_t |
Denied — run restorecon |
tar xf preserving xattrs (--selinux) |
Whatever the archive stored | Depends — often wrong; restorecon after |
rsync -X (preserve xattrs) |
Source labels copied | Often wrong on the target; restorecon after |
| Wrote the file in place with the app | Inherits parent dir’s transition/type | Usually correct |
Cause 2 — Unregistered port
Network types are guarded too. Policy knows which port types a domain may bind or connect to; if you run a service on a non-standard port, the policy must be told that port belongs to the right type. Run nginx on 8443 and you get { name_bind } denied on tcp_socket because 8443 is not (by default) an http_port_t.
Confirm and fix:
semanage port -l | grep -w http_port_t # what ports http_port_t already covers
# http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000
semanage port -a -t http_port_t -p tcp 8443 # ADD a port to a type
semanage port -m -t http_port_t -p tcp 8443 # MODIFY if the port already maps elsewhere
semanage port -d -t http_port_t -p tcp 8443 # DELETE the mapping
If -a errors with “port already defined,” the port belongs to a different type — one protocol/port pair maps to exactly one type — and you must move it with -m. Common services and their port types (so you register the right one, not a generic one):
| Service / domain | Port type to add to | Default ports policy already allows |
|---|---|---|
Apache / nginx (httpd_t) |
http_port_t |
80, 81, 443, 488, 8008, 8009, 8443, 9000 |
SSH (sshd_t) |
ssh_port_t |
22 |
PostgreSQL (postgresql_t) |
postgresql_port_t |
5432 |
MySQL/MariaDB (mysqld_t) |
mysqld_port_t |
1186, 3306, 63132–63164 |
| Node/app servers behind a proxy | http_port_t (if httpd talks to it) |
as above |
| Custom TCP daemon (your own domain) | your own *_port_t (declare in .te) |
none — you define it |
Cause 3 — Boolean off
Booleans are switches the policy author exposes for common variations that would otherwise require different policies: “may httpd make outbound network connections,” “may it read user home directories,” “may Samba share home directories.” When audit2why names a boolean, flipping it is the fix — and the -P flag makes it persist across reboots. Omit -P and the change evaporates on the next restart, producing the classic “it worked until we rebooted” bug.
getsebool -a | grep httpd # every httpd boolean and its current state
semanage boolean -l | grep httpd_can_network # includes the human-readable description
setsebool -P httpd_can_network_connect on # PERSISTENT (writes to policy store)
setsebool httpd_can_network_connect on # runtime-only — reverts on reboot (avoid)
getsebool httpd_can_network_connect # confirm: ... --> on
High-value booleans you will actually reach for, and exactly what each unlocks:
| Boolean | Default | Turning it on allows | Typical trigger |
|---|---|---|---|
httpd_can_network_connect |
off | httpd to make any outbound TCP connection | App calls an API/backend over the network |
httpd_can_network_connect_db |
off | httpd to connect to database ports specifically | PHP/app talking to a DB (narrower than the above) |
httpd_can_network_relay |
off | httpd to act as a forward/reverse proxy | nginx/httpd reverse-proxying upstreams |
httpd_read_user_content |
off | httpd to read user_home_t content |
Serving ~user/public_html |
httpd_enable_homedirs |
off | httpd to access home directories at all | UserDir setups |
httpd_use_nfs / httpd_use_cifs |
off | httpd to serve content from NFS/CIFS mounts | Web root on a network share |
httpd_execmem |
off | httpd to map writable+executable memory | Certain PHP/JIT modules (weakens W^X — avoid) |
samba_enable_home_dirs |
off | Samba to share home directories | Home-share file servers |
nfs_export_all_rw |
off | NFS to export filesystems read-write | NFS servers |
ftpd_full_access |
off | vsftpd broad filesystem access | FTP servers needing more than the default |
container_manage_cgroup |
off | Containers to manage cgroups | Some nested/systemd-in-container setups |
selinuxuser_execmod |
on/off (varies) | User content with execmod | Certain interpreters |
The triage order that resolves most tickets: context, then port, then boolean. Run
restoreconfirst, checksemanage port -l, thengetsebool -a. Only when all three are correct do you have a real policy gap.
Confined vs. unconfined — and why your daemon won’t start (or worse, “just works”)
Whether SELinux confines a service at all depends on labelling and a transition rule, and the failure modes here are the ones that trip up experienced admins.
- Confined services have an
*_exec_tlabel on their binary and atype_transitioninto a dedicated domain (httpd_t,postgresql_t). Tightly boxed — this is the goal. - Unconfined processes run in
unconfined_t(interactive user sessions) orinit_t(thingssystemdlaunched with no transition) and are barely restricted. A binary labelled onlybin_tthatsystemdstarts typically stays ininit_t— effectively unconfined.
So when you drop in a third-party daemon, one of two things happens, and the worse outcome is the one that looks like success:
| Outcome | What you observe | Reality | Why it’s dangerous or annoying |
|---|---|---|---|
Binary is bin_t, stays in init_t |
Daemon “just works” | Unconfined — SELinux protects nothing | You think you’re protected; a compromise runs wild |
| Picks up a transition to a confined domain | Daemon fails to start with denials | Stock policy never anticipated its needs | Annoying but honest — you write policy to fix it |
Lands in unconfined_service_t |
Works but loosely confined | Minimal restriction | Better than init_t, still not a real domain |
The “just works because it’s unconfined” case is the trap. You gain a false sense of security and, in an MCS/MLS environment, the daemon can both bypass confinement and still trip category denials on labelled files — worst of both worlds. Diagnose with the process domain, then read both the OS-level failure and the SELinux reason:
ps -eZ | grep my-daemon # init_t/unconfined_service_t = your red flag
journalctl -u my-daemon -b | tail -n 50 # the OS-level failure (exit code, stderr)
ausearch -m AVC -c my-daemon -ts recent # the SELinux reason, if any
For a real daemon you want a dedicated confined domain — which means writing policy. That is the rest of this article.
Generate a starter with audit2allow — then stop and read it
audit2allow reads denials and emits the allow rules that would have permitted them. It is the fastest way to understand what a service needs and to scaffold a module. It is also the fastest way to punch a hole clean through your security posture if you ship its output unreviewed. Treat it as a checklist of needs, never as the artifact you load.
Generate a candidate module straight from the audit log. -M builds a named module — a human-readable mydaemon.te and a compiled mydaemon.pp in one step:
ausearch -m AVC -c my-daemon --raw | audit2allow -M mydaemon
# produces: mydaemon.te (source you MUST read) and mydaemon.pp (compiled package)
# or pipe from a saved log:
audit2allow -a -M mydaemon # -a reads all of audit.log
Now read mydaemon.te before doing anything else. audit2allow is mechanical; it will happily generate rules that defeat confinement entirely. Treat these as red flags and, wherever you see one, ask “what is the right fix?” — which is almost always a private type or a proper interface, not the raw rule:
Red flag in generated .te |
What it really means | The right fix instead |
|---|---|---|
allow X self:capability { sys_admin }; |
Daemon wants near-root kernel powers | Almost never needed — find the specific operation; drop the capability |
allow X self:capability { dac_override, dac_read_search }; |
Bypass Unix permissions entirely | Fix the file ownership/labels so DAC/SELinux both pass |
allow X etc_t:file write; |
Wants to rewrite /etc broadly |
Relabel the daemon’s own config to a private type and write to that |
allow X var_log_t:file write; |
Wants to write the shared log type | Give it a private *_log_t and a log file transition |
allow X ... execmem; / execstack; |
Maps writable+executable memory | Fix the app (a memory bug/JIT); granting it weakens W^X |
| One rule allowing a whole class vs a broad type | A relabelling problem in disguise | Relabel the target to a specific type, then allow narrowly |
allow X unconfined_t:... |
Interacting with unconfined processes | Rethink the design; interface to a specific confined domain |
The mental model: audit2allow answers “what would unblock this?” — it never answers “what should this service be allowed to do?” You answer the second question. Use the generated .te as a list of the operations the daemon attempted, then express each with the right construct (a private type, a named interface, a narrow allow) in a module you actually maintain.
Write a clean .te module with proper interfaces
A maintainable module uses the reference policy interfaces and macros instead of raw allow rules wherever possible. Interfaces — defined in .if files under /usr/share/selinux/devel/include — encapsulate the correct, complete set of rules for an operation, so you do not hand-roll fragile permission lists that break on the next policy update. Discover them with sepolicy:
dnf install -y selinux-policy-devel policycoreutils-devel # provides the interfaces + Makefile
sepolicy interface -l | grep -i 'read.*log' # discover interfaces by keyword
sepolicy interface -v logging_read_generic_logs # show exactly what one expands to
sepolicy generate --init /usr/sbin/mydaemon # scaffold a full .te/.if/.fc skeleton for a daemon
sepolicy generate --init is the right scaffolder (as opposed to audit2allow): it produces a .te, .if, and .fc skeleton with the domain declaration, the init_daemon_domain transition, and common interface calls already wired — a far better starting point than a pile of raw allows. The three source-file types you will write:
| File | Purpose | Key contents |
|---|---|---|
.te (Type Enforcement) |
The policy logic | policy_module(), type declarations, allow rules, interface calls, transitions |
.fc (File Contexts) |
Path→label mapping | Regex-to-gen_context() lines that restorecon and the module install honour |
.if (Interface) |
Reusable interfaces your module exports | interface(...) definitions so other modules can call into your types |
Here is a minimal but correct module for a hypothetical daemon mydaemon that listens on a TCP port, reads its config under /etc/mydaemon, and writes logs and a pidfile. Note the structure: a private domain, a transition from init, private data types, and named interfaces instead of bare rules.
policy_module(mydaemon, 1.0.0)
########################################
# Declarations
require {
type init_t;
}
# The daemon's own domain, its executable label, and the init transition
type mydaemon_t;
type mydaemon_exec_t;
init_daemon_domain(mydaemon_t, mydaemon_exec_t) # binary -> domain transition + entrypoint
# A PRIVATE port type this daemon (and nothing else) may bind
type mydaemon_port_t;
corenet_port(mydaemon_port_t)
# PRIVATE data types — the whole point of confinement
type mydaemon_conf_t;
files_config_file(mydaemon_conf_t) # marks it a config file type
type mydaemon_log_t;
logging_log_file(mydaemon_log_t) # marks it a log file type
type mydaemon_var_run_t;
files_pid_file(mydaemon_var_run_t) # marks it a runtime/pid file type
########################################
# Local policy
# Minimal capabilities — setgid/setuid to drop privileges after binding
allow mydaemon_t self:capability { setgid setuid };
allow mydaemon_t self:tcp_socket create_stream_socket_perms;
# Bind ONLY its own port type
allow mydaemon_t mydaemon_port_t:tcp_socket name_bind;
# Read its own configuration (pattern macros pull in dir search + file read)
read_files_pattern(mydaemon_t, mydaemon_conf_t, mydaemon_conf_t)
list_dirs_pattern(mydaemon_t, mydaemon_conf_t, mydaemon_conf_t)
# Manage its own logs, with a file transition so NEW logs get the right label
manage_files_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
manage_dirs_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
logging_log_filetrans(mydaemon_t, mydaemon_log_t, { file dir })
# Manage its own pidfile under /run, with a file transition
manage_files_pattern(mydaemon_t, mydaemon_var_run_t, mydaemon_var_run_t)
files_pid_filetrans(mydaemon_t, mydaemon_var_run_t, file)
# Standard plumbing almost every daemon needs
sysnet_dns_name_resolve(mydaemon_t) # resolve hostnames
logging_send_syslog_msg(mydaemon_t) # write to the syslog socket
Pair it with a file context (.fc) file so the labels are applied by path — this is what makes restorecon (and the module install) do the right thing forever:
/usr/sbin/mydaemon -- gen_context(system_u:object_r:mydaemon_exec_t,s0)
/etc/mydaemon(/.*)? gen_context(system_u:object_r:mydaemon_conf_t,s0)
/var/log/mydaemon(/.*)? gen_context(system_u:object_r:mydaemon_log_t,s0)
/run/mydaemon(/.*)? gen_context(system_u:object_r:mydaemon_var_run_t,s0)
Why this beats the raw audit2allow dump: the daemon writes to its own private types, not to shared system types like var_log_t or etc_t. A compromise of mydaemon cannot touch other services’ logs or config, cannot bind other services’ ports, cannot read arbitrary /etc. That isolation is the entire value proposition, and you only get it by declaring private types and using file transitions. The macros doing the heavy lifting, decoded:
| Macro / interface | What it does | Why you use it over raw rules |
|---|---|---|
policy_module(name, ver) |
Declares the module and pulls in base requires | Boilerplate every module needs |
init_daemon_domain(t, exec_t) |
Domain + entrypoint + init_t→domain transition |
The one call that confines a systemd daemon correctly |
files_config_file(t) |
Marks a type as a config file | Gets the right attributes for /etc-style files |
logging_log_file(t) |
Marks a type as a log file | Correct attributes for /var/log files |
files_pid_file(t) |
Marks a type as a runtime/pid file | Correct attributes for /run files |
corenet_port(t) |
Declares a bindable port type | Lets you own a private port type |
read_files_pattern(d, t, t) |
Domain d reads files of type t in dirs of t |
Bundles dir-search + file-read correctly |
manage_files_pattern(d, t, t) |
Full create/read/write/delete on type t |
One call for complete file management |
logging_log_filetrans(d, t, cls) |
New files the domain makes in log dirs get type t |
So rotated/new logs are labelled automatically |
files_pid_filetrans(d, t, file) |
Pidfiles the domain creates get type t |
So /run/mydaemon/… is labelled on creation |
sysnet_dns_name_resolve(d) |
Everything needed to resolve DNS | Dozens of correct rules in one line |
logging_send_syslog_msg(d) |
Write to the syslog/journal socket | Correct socket access without hand-rolling |
Build, load, and test safely
You have two compilation paths. The devel Makefile is the clean one for a real .te/.fc/.if set — it knows where the interfaces live and links them:
# requires selinux-policy-devel; run from the directory holding mydaemon.{te,fc,if}
make -f /usr/share/selinux/devel/Makefile mydaemon.pp
The manual path is useful when you have only a .te with no interface dependencies and want to see each step:
checkmodule -M -m -o mydaemon.mod mydaemon.te # compile the module (-M enables MLS/MCS)
semodule_package -o mydaemon.pp -m mydaemon.mod \
-f mydaemon.fc # package .mod + .fc into a loadable .pp
Now the safe rollout. Never load a fresh module straight into enforcing on a production node. Install it, make only its domain permissive, relabel its files, and start it:
semodule -i mydaemon.pp # install (upgrade in place if re-run)
semanage permissive -a mydaemon_t # make ONLY this domain permissive
restorecon -Rv /usr/sbin/mydaemon /etc/mydaemon /var/log/mydaemon /run/mydaemon
systemctl restart mydaemon
Exercise every code path — start, reload, rotate logs, hit every endpoint, trigger error paths — then harvest any residual denials the domain logged while permissive. audit2allow -R suggests interfaces (the -R = reference policy) rather than raw allows, which is exactly what you want to fold back in:
ausearch -m AVC -c mydaemon -ts recent | audit2allow -R
# review the suggested interfaces, add the legitimate ones to mydaemon.te, rebuild
Fold the legitimate gaps into the .te (using the suggested interfaces, after review), rebuild, reinstall, then flip the domain to enforcing and confirm the log is clean:
make -f /usr/share/selinux/devel/Makefile mydaemon.pp
semodule -i mydaemon.pp
semanage permissive -d mydaemon_t # remove the exception → enforcing for it
systemctl restart mydaemon
ausearch -m AVC -c mydaemon -ts recent # should now be EMPTY
The full lifecycle of module commands, so you never guess the flag:
| Command | Action | Note |
|---|---|---|
semodule -i mydaemon.pp |
Install (or upgrade) a module | Idempotent; re-run to update |
semodule -l |
List installed modules | Confirm yours is loaded |
semodule -l | grep mydaemon |
Check one module | Should print its name |
semodule -r mydaemon |
Remove a module | Reference by name, not .pp |
semodule -B |
Rebuild/reload the active policy | After -D, re-enables dontaudit |
semodule -DB |
Rebuild with dontaudit disabled | For seeing silenced denials; undo with -B |
semodule -e mydaemon / -d mydaemon |
Enable / disable a module without removing | Toggle without uninstalling |
sesearch -A -s mydaemon_t |
Show the live allow rules for the domain | Verify what actually loaded |
seinfo -t | grep mydaemon |
Confirm your private types exist | Sanity-check declarations |
To audit the loaded policy — verifying containment, diffing before/after a change, answering “can this domain really do X?” — sesearch and seinfo (from setools-console) are the query tools. Keep this reference for policy review and CI diffs:
| Command | Answers the question | Typical use |
|---|---|---|
sesearch -A -s <domain> |
What may this domain do? | Prove containment: “it can do only this” |
sesearch -A -t <type> |
Who may act on this type? | Find every domain that can touch a resource |
sesearch -A -s <d> -t <t> -c file |
May domain d do anything to files of type t? |
Confirm a specific allow exists |
sesearch -T -s <domain> |
What type_transitions apply? | Verify the daemon transitions correctly |
sesearch --neverallow |
Any neverallow rules relevant? | Catch a rule that will fail the build |
seinfo -t | grep <x> |
Does this type exist in loaded policy? | Sanity-check your declarations loaded |
seinfo -aportcon / seinfo --portcon |
Which ports map to which types? | Cross-check semanage port -l |
seinfo -b | grep <x> |
Which booleans exist? | Discover a gating boolean |
seinfo --stats |
How big is the loaded policy? | Baseline; spot a huge unexpected change |
sediff base.pp new.pp |
What changed between two policies? | CI diff / review a policy change like code |
MCS, MLS, and multi-level: the fourth field earns its keep
The level field — s0, s0:c1,c2, s0-s3:c0.c1023 — is the sensitivity-and-categories part of the context, and two schemes use it:
- MCS (Multi-Category Security) — the one you meet daily, because it is how containers are isolated. Each object gets categories (
c0,c1, …). A process may access an object only if the process’s category set dominates the object’s. Podman/CRI-O assign each container a unique random pair likes0:c123,c456; a process inc123,c456cannot read a file labelledc789,c012, so two containers on the same host, even running as the same UID with the same type (container_t), cannot read each other’s files. This is category isolation, and it is transparent until a volume mount trips it. - MLS (Multi-Level Security) — the full Bell-LaPadula lattice (Unclassified < Secret < Top Secret), used in government/defence deployments running the
mlspolicy rather thantargeted. “No read up, no write down.” Rarely seen outside those contexts, but the mechanism is the same field.
The scheme comparison:
| Scheme | Field usage | Where you meet it | Enforcement rule |
|---|---|---|---|
| Plain targeted | s0 only |
Every default RHEL/Fedora box | Type enforcement only; level is inert |
| MCS | s0:c<n>,c<m> categories |
Containers (Podman/CRI-O), some multi-tenant | Category set must dominate to access |
| MLS | s0–s15 sensitivities + categories |
Defence/government (mls policy) |
Bell-LaPadula: no read-up, no write-down |
For containers, the practical knowledge is volume relabelling. When you bind-mount a host directory into a container, the host path has a normal type (say default_t or user_home_t) that container_t cannot read. Podman/Docker relabel on request:
# :z -> relabel the volume as SHARED among containers (type svirt_sandbox_file_t / container_file_t)
# :Z -> relabel as PRIVATE to THIS container (adds its unique MCS categories)
podman run -v /srv/appdata:/data:Z myimage # private, per-container categories
podman run -v /srv/shared:/data:z myimage # shared across containers
ls -Z /srv/appdata # after :Z -> ...:container_file_t:s0:c123,c456
The :z/:Z distinction and its gotcha:
| Volume flag | Relabels to | Category behaviour | Use when | Gotcha |
|---|---|---|---|---|
:z (lowercase) |
container_file_t |
s0 (no unique categories) |
Multiple containers share the data | Any container can read it — less isolation |
:Z (uppercase) |
container_file_t |
The container’s unique c<n>,c<m> |
One container owns the data | Only that container instance reads it |
| (no flag) | unchanged | host type kept | The path is already container-labelled | container_t likely can’t read it → denial |
The classic container SELinux denial is a bind mount with no :z/:Z — the container gets Permission denied on /data even though the host mode bits are 0777, because the type is wrong. ls -Z on the host path plus ausearch -m AVC -ts recent (look for scontext=...container_t... and tcontext=...default_t...) confirms it, and adding :Z fixes it. There is also a boolean, container_use_cephfs and friends, and for the rare case where you must let containers touch host files broadly, setsebool -P container_manage_cgroup on and related switches — but reach for :Z first.
Architecture at a glance
There is no diagram for this topic — the model is a decision flow you run in your head, so picture it as a pipeline from symptom to fix. A request arrives at a confined daemon; the kernel’s LSM hook checks the DAC bits first, and only if those pass does it consult the SELinux policy. If the policy has an allow rule for scontext → operation → tcontext, the access proceeds silently. If not, the kernel writes an AVC record to audit.log and — in enforcing mode — returns EACCES to the process, which surfaces as a failed daemon, a 503, a “permission denied,” or a container that cannot read its volume.
Now trace the diagnostic path the way you would walk a flowchart. Every incident starts at one question: did SELinux actually block this? Run getenforce (are we even enforcing?) and ausearch -m AVC -ts recent (is there a denial, and is permissive=0?). A real denial routes down one of four branches by reading three fields. If the tcontext is a file type that looks wrong (default_t, user_home_t), you are on the labelling branch — restorecon, and register with semanage fcontext if the path is non-standard. If the permission is { name_bind }/{ name_connect } on a *_socket with a dest= port, you are on the port branch — semanage port -a. If audit2why names a boolean, you are on the boolean branch — setsebool -P. Only if all three are ruled out — labels correct, port registered, booleans on — do you reach the fourth branch: a genuine policy gap, where you scaffold with sepolicy generate/audit2allow -M, rewrite it cleanly with private types and interfaces, and roll it out under semanage permissive before flipping to enforcing.
The convergence point of every branch is the same instrument set: ps -eZ (is the actor in the domain you think?), ls -Z (is the target labelled the way you think?), ausearch/audit2why (what exactly was refused and why?), and sesearch/seinfo (what does the loaded policy actually say?). That is the whole method — localise the symptom to a branch by reading scontext, tcontext, and the permission; apply the branch’s fix; confirm with a clean ausearch. The mental picture is not a network diagram; it is this four-way fork, and the fastest engineers are the ones who read the three fields and pick the branch in seconds.
Real-world scenario
Meridian Pay, a fictional but representative payments processor, ran a fleet of about 40 RHEL 8 hosts under a hard PCI-DSS requirement: SELinux stays enforcing, no exceptions, audited quarterly by an external QSA. The platform team was six engineers; the constraint on the wall read “setenforce 0 is a reportable incident.”
The trouble started when a vendor shipped a new log-shipping agent — a closed-source Go binary dropped at /opt/vendor/bin/shipper, started by a systemd unit — that refused to work correctly after install. systemctl status shipper showed the process running, so the on-call engineer nearly closed the ticket, but downstream the central log platform showed no data arriving. journalctl -u shipper was unhelpful (a generic “failed to open source” line). The vendor’s documentation, predictably, said: “If logs are not shipping, run setenforce 0.” On this fleet that instruction was a non-starter.
The breakthrough came from asking the right first question. ps -eZ | grep shipper revealed the real problem: the agent was running in init_t — completely unconfined — because its binary was labelled bin_t and there was no policy module to transition it. So SELinux was not confining it at all, yet it was still failing, which seemed contradictory. ausearch -m AVC -c shipper -ts recent explained it: the agent needed to read every other service’s logs under /var/log to ship them, and even an unconfined process in init_t trips category and type denials when it reaches into files labelled for other confined domains (httpd_log_t, postgresql_log_t, and MCS-categorised files). The agent was getting { read } denied on those precise files — the worst-of-both-worlds case: unconfined enough to be a security hole, still confined enough to fail.
The team refused both the vendor’s setenforce 0 and the lazy audit2allow -a -M shipper dump (which would have granted dac_read_search and broad var_log_t writes — a QSA red flag). Instead they gave the agent a real domain and exactly one tightly scoped read interface:
policy_module(shipper, 1.0.0)
require { type init_t; }
type shipper_t;
type shipper_exec_t;
init_daemon_domain(shipper_t, shipper_exec_t)
# read existing logs only — no write, no create, no delete
logging_read_all_logs(shipper_t)
allow shipper_t self:capability { dac_read_search }; # to traverse other services' log dirs
sysnet_dns_name_resolve(shipper_t) # resolve the log platform's hostname
allow shipper_t self:tcp_socket create_stream_socket_perms;
They labelled the binary and rolled out safely rather than straight to enforcing:
semanage fcontext -a -t shipper_exec_t '/opt/vendor/bin/shipper'
restorecon -v /opt/vendor/bin/shipper
make -f /usr/share/selinux/devel/Makefile shipper.pp
semodule -i shipper.pp
semanage permissive -a shipper_t # observe first, block nothing
systemctl restart shipper # now runs in shipper_t, confined
# ... exercise a full shipping cycle across all log sources, harvest residual denials ...
ausearch -m AVC -c shipper -ts recent | audit2allow -R
# ... one extra interface for the syslog socket added and reviewed ...
semanage permissive -d shipper_t # flip shipper_t to enforcing
The outcome, as numbers: the agent moved from init_t (unconfined) to shipper_t (confined to read-only log access plus DNS and one outbound socket), logs flowed to the platform, and the residual ausearch -m AVC -c shipper was clean. The quarterly QSA review passed with no finding — and, tellingly, the security team could now point at sesearch -A -s shipper_t and prove the agent could read logs but not write anywhere, connect only outbound, and touch nothing else. The lesson written on the runbook: “A denial on an unconfined daemon means it needs a domain, not setenforce 0. Give it the smallest domain that works.”
The incident as a timeline, because the order of moves is the lesson:
| Step | Observation | Action taken | Effect | What it should have been |
|---|---|---|---|---|
| 1 | systemctl status = running |
(nearly closed the ticket) | — | Check the data, not just the process |
| 2 | No logs at the platform | journalctl -u shipper |
Generic “failed to open source” | Correct next step |
| 3 | Cause unclear | ps -eZ | grep shipper → init_t |
Found it runs unconfined | The breakthrough |
| 4 | Why fail if unconfined? | ausearch -m AVC -c shipper |
{ read } denied on other domains’ logs |
— |
| 5 | Vendor says disable SELinux | (refused) | — | Never on a PCI fleet |
| 6 | Tempting shortcut | (rejected audit2allow -a -M) |
Would grant broad caps/writes | Read the .te; write it clean |
| 7 | Real fix | Dedicated domain + logging_read_all_logs |
Confined, read-only, working | The actual fix |
| 8 | Safe rollout | semanage permissive -a, harvest, then enforce |
Clean ausearch; QSA passes |
— |
Advantages and disadvantages
Type enforcement is powerful precisely because it is mandatory and default-deny, and those same properties are what make it feel obstructive until you learn to read it. Weigh it honestly:
| Advantages (why enforcing SELinux is worth it) | Disadvantages (why it bites) |
|---|---|
Contains a compromised daemon to only what it legitimately needs — an RCE in httpd can’t read /etc/shadow or pivot |
The surface symptom lies: a generic app error, not “SELinux denied X”; you must know to check ausearch |
| Default-deny: anything not written is refused, so unforeseen operations are blocked, not silently allowed | Labelling is a second permission system layered on DAC; mv vs cp and relabels create non-obvious failures |
| Every denial is logged with the exact actor, target, and operation — precise, machine-readable diagnostics | The dontaudit mechanism can make a real denial invisible until you semodule -DB |
| Booleans let you tune common variations without writing policy — one persistent flag flips a whole behaviour | Forgetting -P on setsebool gives the classic “works until reboot” bug |
| Per-domain permissive lets you bring up a new daemon in prod without exposing the machine | Third-party daemons often ship no policy and run unconfined in init_t — a false sense of security |
| Private types + interfaces give a compromised service zero reach into other services’ data | Writing good policy is a real skill; raw audit2allow output actively teaches bad habits |
| MCS isolates containers on a shared host for free — two containers can’t read each other’s files | Volume mounts without :z/:Z produce confusing “permission denied” on 0777 paths |
| Meets hard compliance controls (PCI-DSS, STIG, CIS L2, FedRAMP) that mandate MAC | setenforce 0 under pressure is a real, common, and reportable failure mode |
The model is right for any internet-facing or multi-tenant host, any regulated workload, and anywhere a single daemon compromise must not become a full host compromise. It bites hardest on teams that treat every denial as an obstacle rather than data, on shops shipping in-house daemons with no policy, and on anyone who has normalised chcon-in-automation or setenforce 0-in-incidents. Every one of the disadvantages is manageable — but only if you know it exists, which is the entire point of this article.
Hands-on lab
Reproduce three classic denials (a mislabelled file, an unregistered port, an off boolean), then build and confine a tiny custom daemon end to end. Run on a RHEL 9 / Rocky 9 / AlmaLinux 9 VM you can throw away. Everything is reversible; teardown is at the end.
Step 0 — Confirm enforcing and install the tooling.
getenforce # must print: Enforcing
sudo dnf install -y policycoreutils policycoreutils-devel \
setroubleshoot-server selinux-policy-devel httpd
Expected: Enforcing, and the packages install (they provide semanage, sepolicy, audit2allow, sealert, and the devel Makefile).
Step 1 — Reproduce a wrong-file-context denial with httpd. Serve content from a non-standard directory and watch it fail:
sudo mkdir -p /srv/webdemo
echo '<h1>hello</h1>' | sudo tee /srv/webdemo/index.html
sudo sed -i 's#^DocumentRoot .*#DocumentRoot "/srv/webdemo"#' /etc/httpd/conf/httpd.conf
sudo systemctl restart httpd
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.html # expect 403
Confirm it is SELinux, not Apache config:
sudo ausearch -m AVC -c httpd -ts recent | sudo audit2why | tail -n 8
# ... "you may need to relabel ..." OR a name_bind/read denial on default_t
ls -Z /srv/webdemo/index.html # ...default_t... (wrong for httpd)
Step 2 — Fix it the right way: register the path, then restore.
sudo semanage fcontext -a -t httpd_sys_content_t '/srv/webdemo(/.*)?'
sudo restorecon -Rv /srv/webdemo
ls -Z /srv/webdemo/index.html # now ...httpd_sys_content_t...
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.html # now 200
Validation: the curl returns 200, ls -Z shows httpd_sys_content_t, and a fresh ausearch -m AVC -c httpd -ts recent is empty.
Step 3 — Reproduce and fix an unregistered-port denial. Make httpd listen on 8443 (not in http_port_t by default? it is — so use 8888 which is not):
sudo sed -i 's/^Listen 80$/Listen 8888/' /etc/httpd/conf/httpd.conf
sudo systemctl restart httpd # FAILS to bind
sudo ausearch -m AVC -c httpd -ts recent | tail -n 6
# ... avc: denied { name_bind } ... tclass=tcp_socket ... dest=8888 ...
sudo semanage port -a -t http_port_t -p tcp 8888 # register the port
sudo systemctl restart httpd # now starts
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8888/index.html # 200
Validation: the service starts after semanage port -a, and semanage port -l | grep 8888 shows http_port_t tcp ... 8888.
Step 4 — Reproduce and fix a boolean denial. Have httpd make an outbound connection (simulate with a reverse-proxy or a script); the boolean httpd_can_network_connect gates it. Demonstrate the boolean directly:
getsebool httpd_can_network_connect # httpd_can_network_connect --> off
sudo setsebool -P httpd_can_network_connect on
getsebool httpd_can_network_connect # --> on (and PERSISTENT thanks to -P)
Validation: the boolean reads on, and because you used -P it survives sudo reboot (the -P-less form would revert).
Step 5 — Build and confine a tiny custom daemon. Create a trivial daemon, give it a domain, and confine it. First the daemon and its unit:
sudo tee /usr/local/sbin/mydaemon >/dev/null <<'EOF'
#!/usr/bin/env bash
# a trivial "daemon": listen on a TCP port, log to its own log dir
mkdir -p /var/log/mydaemon
exec 2>>/var/log/mydaemon/mydaemon.log
echo "$(date) starting on 9099" >>/var/log/mydaemon/mydaemon.log
exec /usr/bin/ncat -lk -p 9099 -c 'echo ok'
EOF
sudo chmod 0755 /usr/local/sbin/mydaemon
sudo dnf install -y nmap-ncat
sudo tee /etc/systemd/system/mydaemon.service >/dev/null <<'EOF'
[Unit]
Description=mydaemon lab
[Service]
ExecStart=/usr/local/sbin/mydaemon
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start mydaemon
ps -eZ | grep mydaemon # note: runs in init_t (UNCONFINED) — the trap
Now author the policy. Create mydaemon.te and mydaemon.fc:
cat > /tmp/mydaemon.te <<'EOF'
policy_module(mydaemon, 1.0.0)
require { type init_t; }
type mydaemon_t;
type mydaemon_exec_t;
init_daemon_domain(mydaemon_t, mydaemon_exec_t)
type mydaemon_port_t;
corenet_port(mydaemon_port_t)
type mydaemon_log_t;
logging_log_file(mydaemon_log_t)
allow mydaemon_t self:capability { setgid setuid };
allow mydaemon_t self:tcp_socket create_stream_socket_perms;
allow mydaemon_t mydaemon_port_t:tcp_socket name_bind;
manage_files_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
manage_dirs_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
logging_log_filetrans(mydaemon_t, mydaemon_log_t, { file dir })
EOF
cat > /tmp/mydaemon.fc <<'EOF'
/usr/local/sbin/mydaemon -- gen_context(system_u:object_r:mydaemon_exec_t,s0)
/var/log/mydaemon(/.*)? gen_context(system_u:object_r:mydaemon_log_t,s0)
EOF
Build, register the port and labels, install, and roll out permissive-first:
cd /tmp && make -f /usr/share/selinux/devel/Makefile mydaemon.pp
sudo semodule -i mydaemon.pp
sudo semanage port -a -t mydaemon_port_t -p tcp 9099
sudo restorecon -Rv /usr/local/sbin/mydaemon /var/log/mydaemon
sudo semanage permissive -a mydaemon_t # bring up permissive, block nothing
sudo systemctl restart mydaemon
ps -eZ | grep mydaemon # now in mydaemon_t (confined)
Exercise it, harvest residual denials, then go enforcing:
printf '' | ncat localhost 9099 # hit the port a few times
sudo ausearch -m AVC -c mydaemon -ts recent | audit2allow -R # review any gaps
sudo semanage permissive -d mydaemon_t # flip mydaemon_t to enforcing
sudo systemctl restart mydaemon
sudo ausearch -m AVC -c mydaemon -ts recent # should be empty
Step 6 — Validate the end state.
getenforce # Enforcing
ps -eZ | grep mydaemon # mydaemon_t (NOT init_t)
sudo semodule -l | grep mydaemon # module loaded
sudo semanage permissive -l | grep mydaemon # NO output = enforcing for this domain
sudo sesearch -A -s mydaemon_t | head # inspect the live allow rules
sudo seinfo -t | grep mydaemon # your private types exist
Validation checklist: the process is in mydaemon_t, the module is listed, the permissive list does not mention mydaemon_t, and ausearch is clean.
Step 7 — Teardown. Reverse everything so the box is pristine:
sudo systemctl disable --now mydaemon
sudo rm -f /etc/systemd/system/mydaemon.service /usr/local/sbin/mydaemon
sudo systemctl daemon-reload
sudo semodule -r mydaemon # remove by NAME, not .pp
sudo semanage port -d -t mydaemon_port_t -p tcp 9099 2>/dev/null || true
sudo semanage port -d -t http_port_t -p tcp 8888 2>/dev/null || true
sudo semanage fcontext -d '/srv/webdemo(/.*)?' 2>/dev/null || true
sudo rm -rf /srv/webdemo /var/log/mydaemon
sudo setsebool -P httpd_can_network_connect off
sudo dnf remove -y httpd nmap-ncat 2>/dev/null || true
rm -f /tmp/mydaemon.*
Common mistakes & troubleshooting — the AVC-denial playbook
This is the reference you keep open at 02:00. Each row is a symptom you actually see, the root cause, the exact command to confirm it, and the fix. Read the prose once; keep the table open during the incident. The columns are deliberately in confirm-then-fix order — never fix before you have confirmed with ausearch/ls -Z/ps -eZ.
| # | Symptom | Root cause | Confirm (exact command) | Fix |
|---|---|---|---|---|
| 1 | httpd 403s on content it should serve | File has wrong type after mv |
ls -Z /path; restorecon -nv /path |
restorecon -Rv /path |
| 2 | Content in a non-standard dir won’t serve | Path not in fcontext DB | semanage fcontext -l | grep /srv/... (empty) |
semanage fcontext -a -t httpd_sys_content_t '/srv/x(/.*)?'; restorecon -Rv /srv/x |
| 3 | Daemon won’t bind a non-standard port | Port not registered to the domain’s port type | ausearch -m AVC -ts recent → { name_bind } ... dest=<port> |
semanage port -a -t <type>_port_t -p tcp <port> |
| 4 | “already defined” when adding a port | Port belongs to another type | semanage port -l | grep <port> |
semanage port -m -t <type> -p tcp <port> (modify, not add) |
| 5 | Feature works only after setenforce 0 |
A boolean gates it | ausearch -m AVC -ts recent | audit2why (names the boolean) |
setsebool -P <boolean> on |
| 6 | Fix reverts after reboot | setsebool/chcon used without persistence |
getsebool <b> shows off again; ls -Z shows old label |
Use setsebool -P / semanage fcontext + restorecon |
| 7 | Third-party daemon “runs” but isn’t protected | Binary bin_t, no transition → init_t |
ps -eZ | grep <daemon> shows init_t |
Write a module: *_exec_t + init_daemon_domain() |
| 8 | Daemon fails at startup, generic exit | Confined domain denied a startup op | journalctl -u X; ausearch -m AVC -c X -ts recent |
Read the denial; apply the matching branch (label/port/boolean/module) |
| 9 | Denial mentions execmem/execstack |
App maps writable+executable memory | ausearch -m AVC -ts recent → { execmem } |
Fix the app (or, last resort, the specific *_execmem boolean) |
| 10 | Container gets “permission denied” on a 0777 mount |
Volume not relabelled for containers | ls -Z /hostpath (host type, not container_file_t) |
Mount with :Z (private) or :z (shared) |
| 11 | Two containers can/can’t see each other’s files unexpectedly | MCS categories (:Z isolates, :z shares) |
ls -Z /hostpath → s0 vs s0:c<n>,c<m> |
Choose :z for shared, :Z for isolated |
| 12 | Service definitely broken by SELinux, but no denials logged | A dontaudit rule silences it |
semodule -DB; reproduce; ausearch -m AVC -ts recent; semodule -B |
Read the now-visible denial; then fix; re-enable dontaudit |
| 13 | “Denial” appears but disabling SELinux doesn’t help | You’re reading permissive=1 records |
ausearch -m AVC -ts recent → check permissive= field |
It wasn’t blocking — look elsewhere for the real cause |
| 14 | Denials on a mount that has correct labels on local disk | NFS/CIFS mount; xattrs not used | ls -Z /mnt shows one generic type (nfs_t) |
Use the mount context or the httpd_use_nfs/use_nfs_home_dirs boolean |
| 15 | Log rotation breaks the service | Rotated logs got the wrong type | ls -Z /var/log/app/* (mismatch on new files) |
Add a logging_log_filetrans (or restorecon post-rotate) |
| 16 | App reads/writes an unexpected /tmp path denied |
Private tmp (PrivateTmp=yes) or wrong tmp type |
ausearch → tcontext=...tmp_t; check the unit |
Use manage_files_pattern on the domain’s tmp type / files_tmp_filetrans |
| 17 | restorecon “works” but the label reverts on next boot |
chcon in automation racing restorecon |
grep -r chcon /etc/ansible /etc/puppet ... |
Replace chcon with semanage fcontext -a + restorecon |
| 18 | Whole app breaks after enabling SELinux from Disabled | On-disk labels drifted while disabled | getenforce history; mislabels everywhere |
Full relabel: fixfiles onboot; reboot (or touch /.autorelabel) |
| 19 | Sudden mass denials after a package update | Vendor policy changed a type/interface | rpm -q --changelog selinux-policy | head; ausearch |
Rebuild/adjust your module against the new interfaces |
| 20 | Cron/systemd job denied though the command works by hand | Different domain when run by cron/systemd vs your shell | ps -eZ of the job vs id -Z in your shell |
Add rules for the service domain, not unconfined_t |
| 21 | sealert suggests a module — is it safe to run it? |
The suggestion is raw audit2allow, may be too broad |
Read the proposed .te it prints |
Rewrite with private types/interfaces; don’t blind-load |
| 22 | Denial { connectto } unix_stream_socket between two daemons |
One domain talks to another’s socket | ausearch → tcontext is the other domain |
Use the correct <other>_stream_connect() interface |
A few deserve a sentence beyond the table. Row 12 (invisible denials) is the one that makes people think SELinux breaks things “randomly”: the denial is real but dontaudit-silenced, and until you semodule -DB you debug blind — always try this when a service is clearly SELinux-affected yet ausearch is empty. Row 13 (permissive=1) is its inverse: chasing a “denial” that never blocked anything because that domain is permissive. Row 18 (relabel after Disabled) is why the standing advice is “permissive if worried, never disabled” — permissive keeps labels current, disabled lets them rot. And row 7 (unconfined init_t) is the quiet one: nothing fails, so no ticket is filed, yet the daemon has no protection at all — sweep for it proactively with ps -eZ.
Best practices
- Never
setenforce 0to fix a problem. Use it for at most a few seconds to prove SELinux is the cause, then flip it back and diagnose withausearch. KeepSELINUX=enforcingin/etc/selinux/configpermanently. - Triage in order: context → port → boolean. Run
restorecon -nv(dry run) andls -Zfirst, thensemanage port -l, thengetsebool -a | audit2why. Reach for a custom module only after all three are correct. - Always use the persistent form.
setsebool -P,semanage fcontext -a(notchcon),semanage port -a. Anything runtime-only (setseboolwithout-P,chcon) is a reboot-or-relabel timebomb. restorecon, notchcon, in automation. Register the path withsemanage fcontext -aand apply withrestorecon.chconbelongs only in a throwaway experiment.- Read every
audit2allow.tebefore loading it. Treatexecmem,dac_override,dac_read_search,sys_admin, and writes to shared system types (etc_t,var_log_t) as red flags demanding a private-type or interface fix instead. - Give real daemons a real domain. A binary that lands in
init_tis unconfined; write a module withinit_daemon_domain()and a*_exec_tlabel so it transitions into a dedicated, minimal domain. - Use private types, not shared ones. Your daemon writes to
mydaemon_log_t, notvar_log_t, and bindsmydaemon_port_t, not a shared port type. Isolation is the whole point. - Prefer reference-policy interfaces over raw
allow. Discover them withsepolicy interface -l/-vandaudit2allow -R. Interfaces stay correct across policy updates; hand-rolled permission lists rot. - Roll out permissive-first, per domain.
semanage permissive -a <domain>, exercise every code path, harvest residual denials, fold them in, thensemanage permissive -d. Never load a fresh module straight into enforcing on production. - Keep policy in version control and build it in CI. Treat
.te/.fc/.iflike code:make -f /usr/share/selinux/devel/Makefile, diff the loaded rules withsesearch, and review changes like any other patch. - Re-enable dontaudit rules. If you ever
semodule -DBto see silenced denials, alwayssemodule -Bafterwards, or you flood the log and can mask real signal. - Prefer permissive over disabled, always. If you must relax SELinux temporarily, use permissive (labels stay current) — never disabled (labels rot and force a full relabel to recover).
Security notes
The security value of SELinux is containment, and every practice above either preserves or squanders it. Least privilege is the governing principle: a custom domain should be able to do exactly what the daemon needs and nothing more, which is why private types and narrow interfaces beat broad allows — a compromise of the daemon then reaches only its own config, its own logs, and its own port. Auditability is the second pillar: because every denial is logged with the exact actor and target, a confined, enforcing host produces a forensic trail that an unconfined one cannot; this is why the audit log this article reads pairs directly with Building a Linux Audit Trail with auditd and eBPF Runtime Visibility.
Specific hardening points that matter for this topic:
| Concern | The SELinux control | Why it matters |
|---|---|---|
| A daemon compromise reading secrets | Private types; no dac_override/dac_read_search |
Contains RCE to the daemon’s own files, not /etc/shadow |
| Writable+executable memory (exploit staging) | Deny execmem/execstack; never grant to “fix” an app |
Preserves W^X; a granted execmem is a gift to an attacker |
| Lateral movement between services | Type enforcement + private port types | httpd can’t connect to the DB socket unless policy says so |
| Multi-tenant/container isolation | MCS categories (:Z volumes) |
Two containers as the same UID still can’t read each other’s data |
| Privilege escalation via mislabelled setuid | Correct *_exec_t labels + entrypoints |
Prevents a domain executing something in the wrong context |
| Bypassing the whole control | Enforcing mode; no setenforce 0 |
Permissive/disabled removes the mandatory guarantee |
Two anti-patterns are security-relevant enough to restate: shipping raw audit2allow output trains your team to grant exactly the permissions a real exploit wants — execmem, dac_override, writes to system types — so read the .te every time; and running a daemon unconfined in init_t gives a false sense of protection worse than knowing you have none, because you will not compensate elsewhere. When in doubt, prove containment with sesearch -A -s <domain> — if you cannot point at the rules and say “it can do only this,” the module is not done.
Cost & sizing
SELinux has no licence cost — it ships in the kernel and the base OS on every RHEL-family and Fedora distribution — so the “cost” is entirely operational: engineering time to read denials, author and maintain policy, and a small, usually negligible, runtime overhead. Budget in INR/USD terms is about people, not infrastructure.
| Cost dimension | Rough magnitude | What drives it | How to reduce it |
|---|---|---|---|
| Runtime CPU overhead | ~1–3% typical; higher on syscall-heavy workloads | LSM hook on every access decision | Usually not worth optimising; profile only if extreme |
| Memory | A few tens of MB for the loaded policy | Policy size (targeted is modest) | Not a concern on server-class RAM |
| Labelling / relabel time | Seconds to many minutes on a full relabel | Filesystem size and inode count | Avoid Disabled (which forces relabels); use restorecon -R on subtrees |
| Engineer time — triage | Minutes per denial once you know the method | Team’s SELinux fluency | This article; runbooks; sealert for suggestions |
| Engineer time — policy authoring | Hours per new custom domain | Daemon complexity; interface familiarity | sepolicy generate scaffolding; reuse interfaces; CI |
Engineer time — the setenforce 0 tax |
Potentially enormous (a breach, an audit finding) | Disabling instead of diagnosing | Culture: treat denials as data; forbid disabling |
The sizing guidance is really a staffing observation: the marginal cost of keeping SELinux enforcing, once your team is fluent, is small — a few minutes per denial and a few hours per genuinely new daemon. The cost of not being fluent is where the money hides: a single setenforce 0 that turns a containable RCE into a full host compromise, or a failed audit, dwarfs any policy-authoring time. On the relabel front, size maintenance windows for a full fixfiles on your largest hosts and parallelise with restorecon -Rp where you can — but the best cost control is never going to Disabled, so a relabel is never forced on you.
Interview & exam questions
Q1. What is the difference between DAC and MAC, and which is checked first?
DAC (discretionary access control) is the traditional Unix owner/group/mode model, where the resource owner decides access. MAC (mandatory access control) is SELinux’s system-wide policy that the owner cannot override. DAC is checked first; a SELinux denial in audit.log means DAC already passed and SELinux is the one refusing. Maps to RHCE/RHCSA and CIS hardening objectives.
Q2. In the context system_u:system_r:httpd_t:s0, which field does enforcement work, and what is it called for a process versus a file?
The third field, the type. For a process it is called the domain (httpd_t); for a file it is the type or label (httpd_sys_content_t). In default targeted policy the user and role fields are largely inert; the type is what you troubleshoot.
Q3. Read this denial: avc: denied { name_bind } ... tclass=tcp_socket ... dest=8888. What’s wrong and how do you fix it?
A domain tried to bind TCP port 8888, which is not registered to that domain’s port type. Confirm with semanage port -l | grep 8888 (absent), fix with semanage port -a -t <type>_port_t -p tcp 8888. This is the “port” branch of triage.
Q4. A file has -rw-r--r-- root root but a root process can’t read it. Why, and what do you run?
The Unix mode bits allow it, so the block is SELinux: the file’s type is wrong for the reading domain (commonly after a mv preserved a foreign label). Confirm with ls -Z and ausearch -m AVC, fix with restorecon -Rv <path> (register with semanage fcontext first if the path is non-standard).
Q5. Why is chcon dangerous in a configuration-management role?
chcon sets a label imperatively and does not register it in the fcontext database, so any subsequent restorecon -R, fixfiles, or /.autorelabel reverts it and the service breaks with no obvious cause. Use semanage fcontext -a + restorecon, which survives relabels.
Q6. What does setsebool -P do that setsebool alone does not?
-P writes the boolean change to the policy store so it persists across reboots. Without -P the change is runtime-only and reverts on the next boot — the classic “it worked until we rebooted” bug.
Q7. A third-party daemon “works fine” but ps -eZ shows it in init_t. Is that good?
No — init_t means it is effectively unconfined: its binary is only bin_t, so no transition fired and SELinux protects nothing. The fix is to write a module with an *_exec_t label and init_daemon_domain() so it transitions into a dedicated, minimal domain.
Q8. Why should you never blindly load audit2allow output?
audit2allow answers “what would unblock this?”, not “what should this be allowed to do?” It will happily generate execmem, dac_override, and writes to shared system types — exactly the permissions an exploit wants. Read the .te, then express each need with private types and reference-policy interfaces.
Q9. How do you bring up a new confined daemon in production without risking an outage?
Install the module, semanage permissive -a <domain> so only that domain logs-but-doesn’t-block, exercise every code path, harvest residual denials with ausearch ... | audit2allow -R, fold the legitimate ones in, rebuild, then semanage permissive -d <domain> to flip it to enforcing. The rest of the system stays enforcing throughout.
Q10. A container gets “permission denied” on a bind-mounted host directory that is chmod 0777. Why, and what’s the fix?
The host path’s type is wrong for container_t (SELinux, not DAC, is refusing). Confirm with ls -Z on the host path (it shows a non-container type) and ausearch. Fix by mounting with :Z (relabel private to this container) or :z (relabel shared). This is MCS/container-selinux in action.
Q11. What does dontaudit do, and how can it mislead you during troubleshooting?
dontaudit rules silence specific denials so they never reach the audit log. If a service is clearly broken by SELinux but ausearch shows nothing, a dontaudit rule may be hiding the denial. Disable them with semodule -DB, reproduce, then re-enable with semodule -B.
Q12. Why is going to Disabled worse than Permissive, even temporarily?
In Disabled mode the kernel maintains no labels, so new and modified files never get a correct security.selinux xattr and the on-disk labels drift. Re-enabling then requires a full filesystem relabel (fixfiles onboot / /.autorelabel), which can be a long maintenance window. Permissive keeps labels current and is instantly reversible.
Quick check
- In
user:role:type:level, which field does the enforcement, and what is it called for a process? - You moved a web file with
mvand now get a 403. What’s the one command that fixes the label, and why didmvcause it? - A denial reads
{ name_connect } ... tclass=tcp_socket. Which of the three triage branches (context/port/boolean) is this, and what’s a likely boolean too? - What flag makes a
setseboolchange survive a reboot, and what’s the failure mode without it? - Your custom daemon shows
init_tinps -eZ. What does that mean for its security, and what must a policy module provide to fix it?
Answers
- The third field, the type; for a process it is called the domain (e.g.
httpd_t). The user and role fields are largely inert in default targeted policy. restorecon -Rv <path>.mvpreserves the file’s existingsecurity.selinuxxattr (its old label, e.g.user_home_t), whereascplets the destination policy assign a fresh one (httpd_sys_content_t);restoreconresets it to what policy says the path should have.- It is the port branch — a service connecting/binding to an unregistered port; fix with
semanage port -a -t <type> -p tcp <port>. For an outbound httpd connection the booleanhttpd_can_network_connect(or the narrowerhttpd_can_network_connect_db) is frequently also required. -P(persistent). Without it the change is runtime-only and reverts on the next reboot — the “works until reboot” bug.init_tmeans the daemon is effectively unconfined — its binary isbin_t, no transition fired, and SELinux is protecting nothing. A module must provide an*_exec_tlabel on the binary and an entrypoint +type_transition(both frominit_daemon_domain()) so it transitions into a dedicated confined domain.
Glossary
- AVC (Access Vector Cache) denial — the kernel’s log record of a refused access, written to
/var/log/audit/audit.log; your primary diagnostic, read viaausearch. - Boolean — a policy-author-provided on/off switch (e.g.
httpd_can_network_connect) that toggles a common variation without writing policy; make persistent withsetsebool -P. - Context (security context) — the four-field
user:role:type:levellabel carried by every process and object. - DAC / MAC — discretionary access control (Unix owner/mode, checked first) versus mandatory access control (SELinux policy, checked after DAC passes).
- Domain — a process type (e.g.
httpd_t); the actor (scontext) in every denial. dontaudit— a policy rule that silences a specific harmless denial so it never reaches the log; disable temporarily withsemodule -DB.- Enforcing / Permissive / Disabled — the three global SELinux modes: block+log, log-only, and off (no labels maintained).
- File context (
.fc) — a policy source file mapping path regexes to types viagen_context(); what makesrestoreconauthoritative. init_daemon_domain(t, exec_t)— the reference-policy macro that declares a daemon domain, its entrypoint, and theinit_t→domain transition in one call.- Interface (
.if) — a reusable, correct bundle of rules for an operation, defined under/usr/share/selinux/devel/include; call these instead of rawallow. - Label — informally, a file’s type; stored in the
security.selinuxextended attribute on the inode. - MCS (Multi-Category Security) — the categories part of the level field (
s0:c1,c2) used to isolate containers and tenants on a shared host. - MLS (Multi-Level Security) — the Bell-LaPadula sensitivity lattice (no read-up, no write-down) used by the
mlspolicy in defence/government contexts. - Per-domain permissive — making a single domain permissive with
semanage permissive -a <domain>while the rest of the system stays enforcing; the safe way to bring up a new daemon. - Policy module — a loadable
.pppackage built from.te/.fc/.ifsources and installed withsemodule -i. - Port type — a type (e.g.
http_port_t) that maps ports to the domains allowed to bind/connect them; managed withsemanage port. restorecon— resets a file’s label to what the fcontext database says it should be; the default fix for a mislabelled file.scontext/tcontext/tclass— the source domain, target context, and object class fields of a denial; the three you read first.semanage— the tool that edits the persistent policy store:fcontext,port,boolean,permissive, and more.sepolicy— the tool to discover interfaces (interface -l/-v) and scaffold policy (generate --init).- Type enforcement (TE) — the core model: an allowlist of “domain X may do Y to type Z” rules; default-deny.
type_transition— the rule that moves a newly executed process into a new domain; why confined daemons auto-confine.- Unconfined domain — a barely-restricted type (
unconfined_t,init_t); a daemon that lands here has no real SELinux protection.
Next steps
- Authoring AppArmor Profiles: Confining Services on Ubuntu and Debian — the path-based MAC alternative you meet on Ubuntu/Debian; reading both makes the type-enforcement-versus-path trade-off concrete.
- Building a Linux Audit Trail with auditd and eBPF Runtime Visibility — go deeper on the same
audit.logthese denials land in, and correlate SELinux events with broader runtime visibility. - Mastering systemd: Units, Timers, Resource Control, and Service Hardening — the layer that transitions daemons into their domains, plus complementary sandboxing (
ProtectSystem,PrivateTmp, capability bounding). - Running Rootless Containers in Production with Podman and Quadlet — where MCS categories keep containers isolated; apply the
:z/:Zvolume-labelling knowledge from this article. - Designing Stateful Linux Firewalls with native nftables Rulesets and NAT — pair network-layer controls with SELinux’s port-type enforcement for defence in depth.