SELinux on RHEL

Labels, denials and the right fix.

Intermediate18 min · lesson 22 of 24

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):

deploy@rocky10 · Rocky Linux 10.2
$ sudo dnf install -y -q httpd rpm -q httpd
… Installed: … httpd-2.4.63-13.el10_2.6.aarch64 httpd-core-2.4.63-13.el10_2.6.aarch64 …
$ sudo mkdir -p /srv/hard-selinux echo 'weekly report' | sudo tee /srv/hard-selinux/index.html
weekly report
$ sudo systemd-run --unit hard-selinux-backend --quiet python3 -m http.server 8096 --bind 127.0.0.1 --directory /srv/hard-selinux

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.

deploy@rocky10 · Rocky Linux 10.2
$ getenforce
Enforcing
$ 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
$ grep -v '^#' /etc/selinux/config
… SELINUX=enforcing SELINUXTYPE=targeted

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.

deploy@rocky10 · Rocky Linux 10.2
$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
$ ls -dZ /etc/shadow /home/deploy /var/www/html
system_u:object_r:shadow_t:s0 /etc/shadow unconfined_u:object_r:user_home_dir_t:s0 /home/deploy system_u:object_r:httpd_sys_content_t:s0 /var/www/html
$ ps -eZ | grep httpd
system_u:system_r:httpd_t:s0 42898 ? 00:00:00 httpd system_u:system_r:httpd_t:s0 42903 ? 00:00:00 httpd system_u:system_r:httpd_t:s0 42904 ? 00:00:00 httpd system_u:system_r:httpd_t:s0 42905 ? 00:00:00 httpd system_u:system_r:httpd_t:s0 42906 ? 00:00:00 httpd

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.

deploy@rocky10 · Rocky Linux 10.2
$ echo 'hello from rocky10' > ~/hard-selinux.html sudo mv ~/hard-selinux.html /var/www/html/ ls -Z /var/www/html/hard-selinux.html
unconfined_u:object_r:user_home_t:s0 /var/www/html/hard-selinux.html
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/hard-selinux.html
403

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).

deploy@rocky10 · Rocky Linux 10.2
$ sudo ausearch --input-logs -m AVC -c httpd -ts recent
… time->Sun Sep 27 09:23:43 2026 … type=SYSCALL msg=audit(1790501023.971:4204): arch=c00000b7 syscall=56 success=no exit=-13 a0=ffffffffffffff9c a1=ffff60004978 a2=80000 a3=0 items=0 ppid=42898 pid=42904 auid=4294967295 uid=48 gid=48 euid=48 suid=48 fsuid=48 egid=48 sgid=48 fsgid=48 tty=(none) ses=4294967295 comm="httpd" exe="/usr/sbin/httpd" subj=system_u:system_r:httpd_t:s0 key=(null) type=AVC msg=audit(1790501023.971:4204): avc: denied { read } for pid=42904 comm="httpd" name="hard-selinux.html" dev="vda3" ino=58757085 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

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.

deploy@rocky10 · Rocky Linux 10.2
$ sudo ausearch --input-logs -m AVC -c httpd -ts recent | audit2why
type=AVC msg=audit(1790501023.971:4204): avc: denied { read } for pid=42904 comm="httpd" name="hard-selinux.html" dev="vda3" ino=58757085 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0 Was caused by: The boolean httpd_read_user_content was set incorrectly. Description: Allow httpd to read user content Allow access by executing: # setsebool -P httpd_read_user_content 1
$ sudo ausearch --input-logs -m AVC -c httpd -ts recent | audit2allow
… #============= httpd_t ============== #!!!! This avc can be allowed using the boolean 'httpd_read_user_content' allow httpd_t user_home_t:file read;

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.

deploy@rocky10 · Rocky Linux 10.2
$ sudo restorecon -v /var/www/html/hard-selinux.html
Relabeled /var/www/html/hard-selinux.html from unconfined_u:object_r:user_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
$ curl -s http://127.0.0.1/hard-selinux.html
hello from rocky10
An AVC denial: which fix?
SELinux denied an access
read the tcontext and the path first
Wrong label on a file
restorecon
semanage fcontext for a new path
Service on a new port
semanage port
add the port to the service type
Supported feature switched off
setsebool -P
a boolean Red Hat ships and tests
Nothing else fits
custom module
read the .te rule before loading it
Try them in this order. A custom policy module is the last resort, never the first.

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.

/etc/httpd/conf.d/hard-selinux.conf
# 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/
deploy@rocky10 · Rocky Linux 10.2
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/reports/ ls -Z /srv/hard-selinux/index.html
403 unconfined_u:object_r:var_t:s0 /srv/hard-selinux/index.html
$ sudo semanage fcontext -a -t httpd_sys_content_t '/srv/hard-selinux(/.*)?' sudo restorecon -Rv /srv/hard-selinux
Relabeled /srv/hard-selinux from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0 Relabeled /srv/hard-selinux/index.html from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
$ curl -s http://127.0.0.1/reports/ sudo semanage fcontext -l -C
weekly report SELinux fcontext type Context /srv/hard-selinux(/.*)? all files system_u:object_r:httpd_sys_content_t:s0

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.

deploy@rocky10 · Rocky Linux 10.2
$ sudo systemctl restart httpd
Job for httpd.service failed because the control process exited with error code. See "systemctl status httpd.service" and "journalctl -xeu httpd.service" for details.
$ sudo ausearch --input-logs -m AVC -c httpd -ts recent | grep name_bind
type=AVC msg=audit(1790501027.091:4297): avc: denied { name_bind } for pid=43726 comm="httpd" src=8095 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=0 …
$ sudo semanage port -l | grep -w http_port_t
http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000 …
$ sudo semanage port -a -t http_port_t -p tcp 8095 sudo systemctl restart httpd
$ curl -s http://127.0.0.1:8095/hard-selinux.html
hello from rocky10

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.

deploy@rocky10 · Rocky Linux 10.2
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/app/
503
$ sudo ausearch --input-logs -m AVC -c httpd -ts recent | grep name_connect | audit2why
type=AVC msg=audit(1790501028.071:4352): avc: denied { name_connect } for pid=43847 comm="httpd" dest=8096 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=0 Was caused by: One of the following booleans was set incorrectly. Description: Allow httpd to can network connect Allow access by executing: # setsebool -P httpd_can_network_connect 1 Description: Allow nis to enabled Allow access by executing: # setsebool -P nis_enabled 1
$ 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_redis --> off httpd_can_network_relay --> off

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.

deploy@rocky10 · Rocky Linux 10.2
$ sudo setsebool -P httpd_can_network_connect on getsebool httpd_can_network_connect
httpd_can_network_connect --> on
$ curl -s http://127.0.0.1/app/
weekly report
$ sudo setsebool -P httpd_can_network_connect off

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.

deploy@rocky10 · Rocky Linux 10.2
$ sudo semanage permissive -a httpd_t curl -s http://127.0.0.1/app/ getenforce
weekly report Enforcing
$ sudo ausearch --input-logs -m AVC -c httpd -ts recent | grep name_connect | tail -n 1
type=AVC msg=audit(1790501034.401:4408): avc: denied { name_connect } for pid=43849 comm="httpd" dest=8096 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=1
$ sudo semanage permissive -d httpd_t curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/app/
libsemanage.semanage_direct_remove_key: Removing last permissive_httpd_t module (no other permissive_httpd_t module exists at another priority). 503

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.

deploy@rocky10 · Rocky Linux 10.2
$ sudo semanage port -d -t http_port_t -p tcp 8095 sudo semanage fcontext -d '/srv/hard-selinux(/.*)?' sudo semanage port -l -C sudo semanage fcontext -l -C getenforce
Enforcing

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.

Why "disable SELinux" is always the wrong runbook step
Enforcing SELinux is Red Hat's default. The CIS Red Hat Enterprise Linux 10 benchmark, as encoded in the SCAP Security Guide 0.1.82 content on the lab, requires SELinux not to be disabled at Level 1 and requires enforcing mode at Level 2. Disabling it removes containment for every confined service to fix one denial, and the host then stops maintaining labels. Operational impact of keeping it on is real but bounded: new paths need an 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.

Quick check
01A developer builds a site in ~/site on the server and moves it into /var/www/html with sudo mv. Pages return 403 and the AVC shows tcontext=unconfined_u:object_r:user_home_t:s0. audit2why suggests setsebool -P httpd_read_user_content 1. What should you do?
Incorrect — audit2why lists what would allow the access, not why the file is mislabelled; this boolean lets Apache read every user's home content.
Incorrect — The generated rule allows httpd_t to read all user_home_t files, which is just as broad as the boolean and harder to review later.
Correct — The files kept a label from elsewhere; restoring the path's default label fixes the cause without widening the policy.
Incorrect — chcon works until the next restorecon or relabel resets the files, so the 403 returns later without warning.
02A service on a RHEL 10 host misbehaves and you suspect SELinux. A colleague proposes setenforce 0 for an hour while you watch the logs. Why is semanage permissive -a on the service's domain the better choice?
Correct — A permissive domain gives you the same diagnostic records for the one service and keeps containment for everything else.
Incorrect — setenforce 0 does not change the boot mode at all; it only lasts until reboot. The benefit of the domain approach is its narrow scope.
Incorrect — The opposite: a permissive domain still logs every would-be denial, with permissive=1, which is exactly what you need to see.
Incorrect — Permissive mode keeps the policy loaded and labels maintained; relabelling is the cost of disabling SELinux, not of setenforce 0.
03You move a web service to TCP 8090 and it fails to start with a name_bind AVC showing tcontext=system_u:object_r:unreserved_port_t:s0. Which change fixes it the way the policy intends?
Incorrect — That boolean covers outgoing connections (name_connect), not binding a listening port (name_bind), so the service would still fail.
Incorrect — The domain is correct; the port has the generic type. Relabelling files does not change what a port is labelled.
Incorrect — That removes all confinement from the service for one port, and permissive domains are for debugging, not a fix.
Correct — Ports have types like files do; adding 8090 to the service's port type lets it bind there while the policy still limits it to its own ports.

Related