SELinux on RHEL
Labels, denials and the right fix.
SELinux is the mandatory access control system that RHEL, Rocky Linux, AlmaLinux and Fedora run in enforcing mode out of the box. When it blocks something, the fix is almost never to turn it off: it is a label, a port definition or a boolean, and the audit log tells you which. In this lesson you read SELinux's state and labels on a Rocky Linux 10.2 server, break a small web server in the three most common ways, read each denial, apply the fix Red Hat intends, debug with a permissive domain instead of switching SELinux off, and roll every change back. Ubuntu uses AppArmor instead (the previous lesson), so everything here is RHEL-family behaviour.
To follow along you need a RHEL-family machine; a Rocky Linux or AlmaLinux 10 VM behaves like the lab's Rocky Linux 10.2. The lab installs Apache from AppStream, writes a small report page for a later section, and starts a stand-in backend on 127.0.0.1:8096 (Python's built-in web server, run as a transient systemd service):
Mode first
Mandatory access control (MAC) means the kernel checks a policy written by the system, on top of ordinary file permissions, and the owner of a file cannot opt out of it. SELinux has three modes. Enforcing applies the policy and blocks what it does not allow. Permissive evaluates the policy and logs what it would have blocked, but blocks nothing. Disabled loads no policy at all.
sestatus shows the mode now (Current mode) and the mode the next boot will use (Mode from config file, from /etc/selinux/config). When the two disagree, someone ran setenforce 0 on a live system: the host is unprotected until the next reboot, and that is a finding worth raising in any review. Loaded policy name: targeted is the RHEL default policy, which confines the services that face the network and leaves interactive administrator sessions largely unconfined.
Since RHEL 9, "disabled" in the config file no longer means what it says. The comment in Rocky's /etc/selinux/config explains that SELINUX=disabled boots a kernel with SELinux running and no policy loaded; switching it off completely takes selinux=0 on the kernel command line (grubby --update-kernel ALL --args selinux=0). Red Hat's documentation says not to disable SELinux except in narrow cases and to use permissive mode for debugging instead. Disabling has a second cost: files created while it is off get no labels, so turning it back on needs a full relabel at boot.
Labels on users, files and processes
Everything SELinux decides is based on labels, called contexts. A context has four fields separated by colons (user, role, type and level), and the targeted policy is written almost entirely in terms of the third one, the type. A rule says, in effect, "a process of type httpd_t may read files of type httpd_sys_content_t". Anything no rule allows is denied. -Z shows contexts on most tools.
Your own shell runs as unconfined_t: the targeted policy does not confine an administrator's session, which is why sudo works as you expect. The web server is different. Started by systemd, it runs in the httpd_t domain (a domain is the type of a process), and the policy for httpd_t does not include shadow_t or user_home_dir_t. A compromised httpd therefore cannot read /etc/shadow or wander through home directories, and this holds even for a confined process running as root: the domain, not the user ID, is what the policy checks.
Reading a denial
The most common SELinux problem is a file with the wrong label. mv keeps a file's context, so a page written in a home directory and moved into the web root still carries user_home_t. (cp creates a new file, which takes the label of the directory it lands in.) The file's permissions allow Apache to read it; SELinux does not.
Each blocked access is an AVC record (access vector cache, the kernel's cache of policy decisions) in /var/log/audit/audit.log. ausearch -m AVC -ts recent finds the ones from the last ten minutes, -c httpd narrows them to one command, and --input-logs makes ausearch read the log files even when it is not run from a terminal (at an interactive prompt it does that anyway).
Read the AVC line left to right: denied { read } is the permission refused, comm="httpd" and name="hard-selinux.html" are who and what, scontext is the process's context (httpd_t), tcontext is the file's (user_home_t), tclass=file is the kind of object, and permissive=0 means the access really was blocked. The event also carries a SYSCALL record whose arch=c00000b7 is this lab's aarch64 CPU; an x86_64 server shows c000003e. audit2why translates the record, and here it teaches the most important lesson about these tools.
audit2why suggests the boolean httpd_read_user_content, and audit2allow offers a rule letting httpd_t read every file labelled user_home_t. Both would make the page load, and both are wrong: they widen what an internet-facing daemon may read on the whole host to fix one mislabelled file. The tools answer "what would allow this access", not "why does this file have that label". The correct fix restores the label the policy expects for that path.
New paths and new ports
restorecon resets a file to the label the policy's file-context rules give its path. When content lives somewhere the policy knows nothing about, such as /srv/hard-selinux (which inherits the generic var_t), you first add a rule for the path with semanage fcontext and then apply it with restorecon. The Apache configuration here serves that directory at /reports/ and also forwards /app/ to a backend used in the next section.
# Serve /srv/hard-selinux at /reports/, and pass /app/ to a local backend.Alias /reports/ /srv/hard-selinux/<Directory /srv/hard-selinux>Require all granted</Directory>ProxyPass /app/ http://127.0.0.1:8096/
The rule is stored in the local policy (semanage fcontext -l -C lists local customisations only), so it survives a full relabel, whereas chcon changes a label directly and the next restorecon or relabel quietly undoes it. Use chcon only for a quick test. Ports work the same way: every port has a type, and httpd_t may bind only ports of type http_port_t. Add Listen 8095 to Apache and it refuses to start.
The name_bind denial names the port (src=8095) and its type, unreserved_port_t, the type of unlisted ports from 1024 to 32767. Adding 8095 to http_port_t is the fix: the policy still limits Apache to web ports, and 8095 is now one of them. The same applies to SSH on another port (ssh_port_t), which is why RHEL's sshd_config mentions semanage port.
Booleans
Some legitimate behaviour is off by default and switched on with a boolean, a named on/off setting in the policy. Apache as a reverse proxy is the classic case: by default httpd_t may not open connections to arbitrary ports, so a compromised web server cannot scan the network or reach internal services. With a backend listening on 127.0.0.1:8096, the proxy returns 503.
This time the boolean is the right answer, but read the list with care. nis_enabled would also allow the connection and has nothing to do with web servers. httpd_can_network_connect allows connections to any port; httpd_can_network_relay is narrower and allows only web and proxy ports, so labelling a backend's port http_port_t and using the relay boolean is the tighter option when you control the backend. -P writes the change into the policy on disk so it survives a reboot; without it the setting lasts until the next boot.
Debugging without switching SELinux off
When a service fails and you need to know every access it would need, the blunt tool is setenforce 0, which stops enforcement for every domain on the host. The precise tool is a permissive domain: semanage permissive -a httpd_t makes only Apache's domain permissive, so it logs what it would have been denied and carries on, while everything else stays enforced.
The proxy works, getenforce still says Enforcing, and the AVC record ends in permissive=1: logged, not blocked. Collect the records you need, fix the causes with the tools above, and remove the permissive domain; the message from libsemanage confirms it is gone, and the proxy is back to 503 because the boolean is off again. Every change in this lesson has a matching undo: semanage port -d, semanage fcontext -d (then restorecon to reapply the default labels), setsebool -P ... off and semanage permissive -d. Custom modules come out with semodule -r NAME. The lab also stops the backend (sudo systemctl stop hard-selinux-backend) and removes httpd.
Each of these commands changes one host's local policy store. On a fleet, deliver them like any other configuration: semanage export -f FILE writes a host's local customisations as a list of semanage commands, and semanage import -f FILE applies them to another host in one transaction (it first removes that host's own customisations, per semanage-export(8)). Configuration-management modules for SELinux do the same setting by setting, and a custom policy module belongs in a package. Budget time for relabelling too: a full relabel after SELinux was disabled visits every file, so on a large filesystem the host spends a long time in early boot.
fcontext rule, new ports need semanage port, and a few features need a boolean. Each is one command, verified by retrying the action and checking that ausearch -m AVC -ts recent stays quiet.Try this
On a RHEL-family lab machine with httpd installed, copy (cp, not mv) a file from your home directory into /var/www/html and check its label with ls -Z; then move one with mv and compare. Fetch both with curl and explain the difference. Next, add Listen 8088 to a file in /etc/httpd/conf.d/, restart httpd and look up why it fails with sudo ausearch -m AVC -ts recent. Check sudo semanage port -l | grep -w 8088 before you add anything: when a port already has another type (8081, for example, is transproxy_port_t in the RHEL 10 policy), semanage port -a refuses and you would have to decide whether to change it with -m. 8088 is unlisted, so semanage port -a -t http_port_t -p tcp 8088 is the fix. Finish by deleting the port rule and the Listen line, restarting httpd, and confirming getenforce still prints Enforcing.
Takeaway
Read the AVC record before changing anything: a wrong tcontext means restorecon or semanage fcontext, an unlisted port means semanage port, a switched-off feature means setsebool -P, and debugging means a permissive domain, never setenforce 0. Treat the suggestions of audit2why and audit2allow as a list of possibilities, not a fix.