CoursesLinux hardeningSELinux in enforcing, calmly

SELinux in enforcing, calmly

Read denials, write targeted modules, never disable.

Advanced16 min · lesson 12 of 16

Regular Linux file permissions work like handing out keys to your house. You own a file, so you decide who gets a key: read for the group, write for you, nothing for anyone else. This is DAC (Discretionary Access Control), because access is left to the discretion of the owner. It has one weakness. If a program running as you gets tricked into doing something bad, it already holds every key you hold. A hacked web server, running as the web-server user, can read whatever that user can read.

MAC (Mandatory Access Control) adds a second lock the owner cannot open. It works like a security guard posted inside the building who follows one fixed rulebook. It does not matter that you own the office, or that you are the CEO. If the rulebook says the mailroom clerk does not enter the server room, the clerk does not enter, and no key changes that. SELinux (Security-Enhanced Linux) is that guard on RHEL (Red Hat Enterprise Linux) and its relatives (Rocky, AlmaLinux, Fedora, CentOS Stream). The kernel (the core of the operating system that talks directly to the hardware) checks every access against the rulebook, and even root cannot argue it into a yes.

So a web server that gets fully taken over can still only do what the web server's rulebook allows: read its own content and answer requests. It cannot read /etc/shadow, wander into people's home directories, or open a reverse shell (a hijacked command line the attacker drives from across the internet), because nothing in the policy permits its type to do those things. That confinement holds even against code the attacker completely controls. Your job as the operator is to keep it on, read what it blocks, and grant back exactly the one thing a service legitimately needs, no more.

Check what mode you are in

Before touching anything, find out whether the guard is on duty. There are three modes. Enforcing means the rulebook is applied and violations are blocked. Permissive means violations are logged but allowed through, which is a debugging setting, not a safe place to live. Disabled means SELinux is off and labels are not even maintained. getenforce gives you the one-word answer.

~/secopslog — bash
$ getenforce
Enforcing

sestatus gives the whole picture: which policy is loaded, the mode right now, and the mode the config file will boot into next time. Watch those last two. Someone can flip a box to permissive at runtime to unblock a problem and forget to note it, so 'Current mode' and 'Mode from config file' can disagree. Catching that gap is a real finding in a hardening review.

~/secopslog — bash
$ sestatus
SELinux status: enabled SELinuxfs mount: /sys/fs/selinux SELinux root directory: /etc/selinux Loaded policy name: targeted Current mode: enforcing Mode from config file: enforcing Policy MLS status: enabled Policy deny_unknown status: allowed Memory protection checking: actual (secure) Max kernel policy version: 33

'Loaded policy name: targeted' is the one to understand. On the targeted policy, most of the system, including your login shell and admin tools, runs unconfined and is barely touched by SELinux. The confinement is aimed at the processes that actually get attacked: the network-facing daemons (long-running background programs) like the web server, the SSH login service, and the DNS (Domain Name System) server. That is a deliberate trade. SELinux is not trying to referee everything on the box; it is boxing in the services that face the internet.

The boot-time state lives in one plain file. If a runbook ever tells you to set SELINUX=disabled here, that is the line that removes the guard on the next reboot.

/etc/selinux/config
# This file controls the state of SELinux on the system.
# SELINUX= can take one of these three values:
# enforcing - SELinux security policy is enforced.
# permissive - SELinux prints warnings instead of enforcing.
# disabled - No SELinux policy is loaded.
SELINUX=enforcing
# SELINUXTYPE= can take one of these values:
# targeted - Targeted processes are protected,
# minimum - Modification of targeted policy. Only selected processes are protected.
# mls - Multi Level Security protection.
SELINUXTYPE=targeted

Labels are the whole game

Every process and every file wears a wristband, the kind you get at a festival that says which areas you are allowed into. SELinux calls that wristband a context, and it has four fields separated by colons: user, role, type, and level. Ignore three of them for now. The type is what the rulebook is written about. This is called type enforcement: the policy is a long list of rules shaped like 'a process of type A may perform action B on an object of type C', and anything not on that list is denied. Look at a web page's label with ls -Z.

~/secopslog — bash
$ ls -Z /var/www/html/index.html
system_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html

The type here is httpd_sys_content_t, the label for content the web server is meant to serve. Now look at the running web server itself. Processes wear wristbands too, and you read them with ps -eZ.

~/secopslog — bash
$ ps -eZ | grep httpd
system_u:system_r:httpd_t:s0 1247 ? 00:00:00 httpd system_u:system_r:httpd_t:s0 1249 ? 00:00:01 httpd

The process runs as httpd_t. One rule in the policy says httpd_t may read httpd_sys_content_t, which is why the page loads. There is no rule letting httpd_t read shadow_t (the type on /etc/shadow) or ssh_home_t (the type on people's SSH keys). So if the web server is compromised and reaches for those files, the kernel says no and writes down exactly what it stopped. The attacker owns the process, but not the wristband, and the wristband is what the door checks.

When it breaks, read the denial

The guard keeps a logbook, and every time it turns someone away it records the who, the what, and the where. SELinux does the same. Every block becomes an AVC (Access Vector Cache) denial in the audit log. The name is internal plumbing; treat it as the guard's logbook entry. When something 'mysteriously' fails on an enforcing box, reading the last few entries is the entire diagnosis. Here is a denial you will meet in real life. You copied your site into place from a tarball (a .tar archive), or moved it with mv out of /root, so the files kept their old label. httpd_t is not allowed to read that old type, and the browser gets a 403 (the web server's Forbidden error). Pull the recent denials with ausearch.

~/secopslog — bash
$ sudo ausearch -m avc -ts recent
---- time->Wed Jul 15 14:22:31 2026 type=PROCTITLE msg=audit(1784125351.482:812): proctitle=2F7573722F7362696E2F6874747064202D44464F524547524F554E44 type=SYSCALL msg=audit(1784125351.482:812): arch=c000003e syscall=257 success=no exit=-13 a0=ffffff9c a1=7f3c1c01e2a0 a2=80000 a3=0 items=0 ppid=1 pid=1247 auid=4294967295 uid=48 gid=48 euid=48 comm="httpd" exe="/usr/sbin/httpd" subj=system_u:system_r:httpd_t:s0 key=(null) type=AVC msg=audit(1784125351.482:812): avc: denied { read } for pid=1247 comm="httpd" name="index.html" dev="dm-0" ino=1179651 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:admin_home_t:s0 tclass=file permissive=0

Read the AVC line like a sentence. 'denied { read }' is the action that was blocked. comm="httpd" is the program. scontext ends in httpd_t, the source, which is the process asking. tcontext ends in admin_home_t, the target, which is the file's label. tclass=file says the target is a file. permissive=0 means enforcing actually blocked it (a 1 there would mean permissive mode let it through but still logged it). The tell is right there: the file is labeled admin_home_t, the type on things out of /root, not httpd_sys_content_t. The web server is behaving perfectly. The file is mislabeled. If you would rather not parse that by eye, pipe it to audit2why for the plain-English reason.

~/secopslog — bash
$ sudo ausearch -m avc -ts recent | audit2why
type=AVC msg=audit(1784125351.482:812): avc: denied { read } for pid=1247 comm="httpd" name="index.html" dev="dm-0" ino=1179651 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:admin_home_t:s0 tclass=file permissive=0 Was caused by: Missing type enforcement (TE) allow rule. You can use audit2allow to generate a loadable module to allow this access.

Three fixes, in order of preference

That last line is a trap for the impatient. audit2allow will happily write a rule to permit anything, including the exact access SELinux just blocked to protect you. Before you generate any policy, ask which of three fixes actually fits, and try them in this order.

An AVC denial landed. What is the right fix?
SELinux denied an access. What now?
File is in the right place, wrong label
Relabel it
restorecon for a one-off, semanage fcontext for a permanent path. The common, correct fix.
Legit behavior a supported switch already covers
Flip a boolean
setsebool -P, no custom policy, vendor-tested
Genuinely new access nothing else covers
Write a targeted module
audit2allow -M, but read the rule before you load it

The first question is always 'is this a labeling mistake?', because most denials are. In our case the file needs its correct label back. restorecon reads the system's default labeling rules and stamps the right type onto the file it finds in that location.

~/secopslog — bash
$ sudo restorecon -Rv /var/www/html
Relabeled /var/www/html/index.html from unconfined_u:object_r:admin_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0

-R recurses into the directory, -v prints what changed. Reload the page and the 403 is gone, and you did not weaken the policy by an inch. If your content lives somewhere non-standard, say /srv/sites, teach the system that location's permanent label instead of fixing it by hand every deploy. semanage fcontext writes the rule; restorecon then applies it.

~/secopslog — bash
$ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/sites(/.*)?" sudo restorecon -Rv /srv/sites
Relabeled /srv/sites from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0

The second fix is for behavior that is legitimate but off by default. SELinux ships hundreds of on/off switches called booleans that toggle whole chunks of policy without any custom code. The classic case: your app server needs to reach a database on another host. By default httpd_t may not open outbound network connections, a sensible default that stops a hacked web app from scanning your network or phoning home. First list the network booleans and their current state.

~/secopslog — bash
$ getsebool -a | grep httpd_can_network
httpd_can_network_connect --> off httpd_can_network_connect_cobbler --> off httpd_can_network_connect_db --> off httpd_can_network_memcache --> off httpd_can_network_relay --> off

Then turn on exactly the one you need. The -P flag makes it persistent so it survives a reboot. Skip -P and the switch reverts on the next boot, and your service breaks at the worst possible moment with no obvious cause.

~/secopslog — bash
$ sudo setsebool -P httpd_can_network_connect_db on getsebool httpd_can_network_connect_db
httpd_can_network_connect_db --> on

Booleans are the safest fix because the distribution's engineers wrote and tested them. You are choosing from a supported menu, not writing new policy. Reach for a custom module only when the access is real, nothing is mislabeled, and no boolean covers it. audit2allow reads a denial and writes the allow rule that would have permitted it, then -M packages it under a name.

~/secopslog — bash
$ sudo ausearch -m avc -ts recent | audit2allow -M my-app-fix
******************** IMPORTANT *********************** To make this policy package active, execute: semodule -i my-app-fix.pp

That command writes two files: my-app-fix.te, the human-readable rule, and my-app-fix.pp, the compiled package that gets loaded. Always read the .te first. This is the step people skip, and it is the one that matters.

my-app-fix.te
module my-app-fix 1.0;
require {
type httpd_t;
type admin_home_t;
class file read;
}
#============= httpd_t ==============
allow httpd_t admin_home_t:file read;

Read that as an operator. It says 'let the web server read files labeled admin_home_t', which is files out of /root. That is almost certainly wrong. The file should have been relabeled, not the policy widened to let an internet-facing daemon read root's home directory. Loading this module would take the exact hole SELinux caught and make it permanent and official. Seeing the rule in plain text is what stops you from rubber-stamping it. When the rule genuinely is what you want, load it with semodule and confirm it registered.

~/secopslog — bash
$ sudo semodule -i my-app-fix.pp sudo semodule -l | grep my-app-fix
my-app-fix
A denial is the system working, not a switch to flip off
setenforce 0 and SELINUX=disabled make one denial disappear by removing the whole defensive layer for every service on the host. Disabling also stops label maintenance, so re-enabling later forces a slow full-disk relabel on the next boot. If one stubborn domain needs debugging, make only that domain permissive with 'semanage permissive -a httpd_t' so the rest of the host stays enforcing, then remove it with 'semanage permissive -d httpd_t' when you are done. Treat 'disable SELinux' in any runbook as a bug to fix, not a step to follow.

Verify, then move on

After any change, prove it worked instead of hoping. Reproduce the action that failed and check that the log stays quiet.

~/secopslog — bash
$ sudo ausearch -m avc -ts recent
<no matches>

No new denials plus the feature actually working is your green light. Two things fool people here. First, some noisy denials are hidden on purpose by dontaudit rules that ship with the policy, so a program can misbehave while the log looks empty. If you suspect that, reveal the hidden ones with 'semodule -DB', reproduce the problem, then restore them with 'semodule -B'. Second, confirm you are still enforcing and did not quietly land in permissive, where everything works and nothing is protected.

~/secopslog — bash
$ getenforce
Enforcing
Quick check
01An AVC denial shows httpd_t was denied read on a file in /var/www/html whose label is admin_home_t. What is the right first move?
Correct — admin_home_t on web content is a labeling mistake from a copy or move; relabeling fixes it without touching policy.
Incorrect — That drops confinement for every service on the host to fix one file; a denial is not a reason to disable the guard.
Incorrect — That permanently lets an internet-facing daemon read root-home files; you would be turning the caught hole into official policy.
Incorrect — That switch governs outbound network access and has nothing to do with a local file-read denial.
02You run setsebool httpd_can_network_connect_db on to let the web server reach a database, but you leave off the -P flag. What actually happens?
Incorrect — the toggle is accepted and takes effect immediately; -P only controls whether it survives a reboot.
Correct — without -P the change is runtime-only, and the lesson warns the service then fails after a reboot with no obvious cause.
Incorrect — booleans are pre-built, vendor-tested switches, not custom modules; audit2allow is what builds modules.
Incorrect — that is restorecon's job; a boolean toggles a chunk of policy and never touches file labels.
03During a hardening review, sestatus shows Current mode: permissive and Mode from config file: enforcing. What does this tell you?
Incorrect — the config value only decides the next boot; right now, in permissive, nothing is actually being blocked.
Incorrect — permissive still loads policy and maintains labels; a disabled system would report disabled, not permissive.
Correct — the lesson calls this gap a genuine review finding: a box quietly running unprotected until someone reboots it.
Incorrect — on a healthy enforcing box both lines read enforcing; a mismatch is exactly what you are looking for.

One habit catches these before your users do. restorecon takes a -n flag for a dry run: it reports what is mislabeled without changing a thing. Point it at your service directories after every deploy and you find the next 403 while it is still cheap to fix.

~/secopslog — bash
$ sudo restorecon -Rvn /var/www
Would relabel /var/www/html/report.pdf from unconfined_u:object_r:admin_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0

Try this

Work through “Verify, then move on” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a denial is the system working, not a switch to flip off. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related