Skip to content
Categoria: Hardening8 min read

SELinux Without Fear: Custom Policies for Critical Services

Por Lucas Andrade ·

From audit2allow forensics to versioned policy modules running in production, without falling into permanent permissive mode.

SELinux Without Fear: Custom Policies for Critical Services

Every time a service breaks on RHEL or Rocky, the on-call reflex is the same: setenforce 0, problem solved, ticket closed. Six months later the entire cluster runs in permissive, nobody remembers why, and the compliance report becomes science fiction. The Basilisk OffSec team spent two years taking over environments like that in authorized red teams, and the conclusion is blunt: a disabled SELinux is one of the most reliable paths from a single RCE to full compromise. This post shows how to write custom policies for critical services without breaking production, and why setenforce 0 is not a fix but a deferred bill. We walk the path from diagnosing a denial, through capturing the AVCs cleanly, to a reviewed, signed policy deployed via Ansible, always with the goal of keeping the host in Enforcing.

Enforcing over permissive: why it matters

SELinux is Mandatory Access Control: even when a process runs as root, the policy limits which types it can read, write, and execute. That is exactly what breaks an exploit chain, because a compromised httpd_t simply cannot read shadow_t or write into bin_t. Permissive only logs, it blocks nothing, so a cluster in permissive is functionally unprotected. The target state is always Enforcing, verifiable with getenforce and sestatus. A well-maintained enforcing host is not an obstacle to operations, it is the last perimeter defense when an application vulnerability is exploited; it complements the baseline work in Linux Server Hardening: Applying CIS Benchmark Without Breaking Production.

Read the existing policy before writing

Before writing any policy you have to read what already exists. The seinfo -t command lists roughly 5,000 types on stock RHEL 9, and sesearch --allow -s httpd_t shows exactly what Apache can touch. seinfo -ahttpd_t -x resolves a domain's attributes, and sesearch --allow -s httpd_t -t etc_t -c file answers precisely whether an access is already allowed. We start every investigation with these queries, because in 80% of cases a suitable type or boolean already exists, and you do not need to write a new policy at all, only to fix the label or flip the switch.

Capture AVCs cleanly

When something is genuinely missing, you capture the denials instead of guessing. Run the service with ausearch -m AVC -ts recent in parallel and put the affected domain into TEMPORARY permissive mode, never the whole system: semanage permissive -a httpd_t. That way only this one service runs unhindered and logs every violation, while the rest of the system stays enforcing. Reproduce the complete use case (start, reload, every code path), collect the AVCs, and remove the exception immediately afterward with semanage permissive -d httpd_t. A forgotten permissive entry is as dangerous as a globally disabled SELinux.

audit2allow is double-edged

audit2allow is a double-edged knife. Running ausearch -m AVC | audit2allow -M mymodule generates a .te that compiles and works, but frequently grants absurd permissions like allow httpd_t shadow_t:file read. Our internal checklist requires every .te to go through manual review before semodule -i. Hunt for rules touching shadow_t, etc_t, kernel_t, or self:capability sys_admin, those are red flags. Allowing a denial because the service will not start otherwise is convenient and often the very origin of the gap an attacker needs later. For every rule ask: why does the process want this, and is the access truly necessary?

Policy from scratch with refpolicy

For new services we prefer writing policy from scratch using the refpolicy macro language. A typical module has three files: myservice.te with the rules, myservice.fc with file contexts, and myservice.if with interfaces for other domains. make -f /usr/share/selinux/devel/Makefile generates the .pp that you install with semodule -i. We version those three files in git alongside Ansible, and every PR goes through the same review as application code. That keeps the policy traceable, reproducible, and auditable, instead of rotting as undocumented handwork on a single host.

Ports and file contexts: 80% of cases

Services opening sockets on non-standard ports are the most common case of silent breakage. Postgres on 5433 for example needs semanage port -a -t postgresql_port_t -p tcp 5433, not a new policy. Nginx serving files outside /var/www wants semanage fcontext -a -t httpd_sys_content_t "/srv/app(/.*)?" followed by restorecon -Rv /srv/app. Eighty percent of the cases we see are label and port problems, not missing allow rules. So always check first with ls -Z and semanage port -l whether a wrong label or an unregistered port is the cause, before you even think about a .te.

Booleans instead of custom policy

Many apparently complex requirements are already covered by a boolean. getsebool -a | grep httpd shows dozens of switches; httpd_can_network_connect allows outbound connections, httpd_can_network_connect_db only to the database. Set them persistently with setsebool -P httpd_can_network_connect_db on. A boolean is always preferable to a custom module, because it is maintained by the distribution packagers, documented, and carried across updates. Custom policy is the last resort, not the first reach; those who look for booleans first write far fewer of their own .te files in practice. Document every boolean you set in your configuration management, because a manually flipped, unversioned switch is the next source of policy drift and silently disappears when the host is rebuilt.

Confined vs. unconfined: the common fallacy

A widespread mistake is believing a service is protected just because SELinux is enforcing. Many self-started processes run as unconfined_service_t or init_t and are effectively unrestrained. Check with ps -eZ | grep myservice which domain the service actually runs in. A binary under /usr/local/bin often carries bin_t instead of its own domain, so no transition happens. All the effort of a custom policy is worthless if the process never transitions into the confined domain; that is exactly what the .fc file plus a type_transition from the launching init_t exist for. Always verify the transition rather than assume it.

Testing and rollback

A policy is code and gets tested like code. Install first in staging with semodule -i myservice.pp, exercise the complete use case, and check ausearch -m AVC -ts recent for zero new denials. List loaded modules with semodule -l, and remove a faulty one immediately with semodule -r myservice. Keep the priority mechanism in mind: semodule -X 400 -i loads at higher priority and overrides the distribution version in a controlled way. For fast iteration, briefly set the target domain to permissive, collect the remaining AVCs in one pass, and add them with justification, instead of falling into ten rounds of deploy-and-pray.

Observability and policy drift

Production maintenance demands observability. We configure setroubleshoot-server in silent mode forwarding AVCs to the SIEM via journald, with Sigma rules tuned for unexpected denials. When a deploy breaks, the alert arrives before the user complains. We also run sealert -a /var/log/audit/audit.log weekly in staging to catch policy drift before it reaches production. For the forensic side, when a denial hints at a real attack, DFIR on Linux: Live Triage with UAC and Velociraptor picks up, and the signed policy fits the chain from Supply Chain Security: Sigstore Signing and Real SBOMs in CI/CD.

Pitfalls

The most common pitfalls: setenforce 0 as a permanent state instead of a 15-minute diagnosis; using chcon instead of semanage fcontext plus restorecon, which loses the label at the next restorecon or relabel; applying audit2allow output blindly; and forgetting dontaudit rules that hide relevant denials (temporarily disabled with semodule -DB). Another classic is a label lost during a tar restore; use tar --selinux or relabel explicitly afterward. The attacker-perspective recon baseline is in Advanced Nmap: NSE Scripts for Internal Recon in a Simulated Corporate Lab.

Checklist

Before a custom policy goes to production: (1) searched first for a suitable type and boolean; (2) AVCs captured only in per-domain permissive, system stayed enforcing; (3) every .te reviewed manually, no shadow_t/sys_admin rule without justification; (4) file contexts via semanage fcontext plus restorecon, never chcon; (5) ports registered; (6) three files versioned in git; (7) .pp signed and deployed via Ansible; (8) SIEM alert on unexpected denials active; (9) enforcing confirmed via getenforce; (10) no permissive entry left behind.

FAQ

Why not replace SELinux with AppArmor? On the RHEL family SELinux is the supported, integrated path; switching throws away the distribution policies. Does enforcing cost performance? The overhead is in the low single-digit percent and negligible in practice against the security gain. What about a service that just will not start? Set per-domain permissive, reproduce the full flow, collect AVCs, write the policy, review it, switch back to enforcing, verify. Never leave the whole system permissive.

Practical takeaway: never run setenforce 0 in production for more than 15 minutes. Use semanage permissive -a domain_t to isolate the problem, capture AVCs with ausearch, review the audit2allow output manually, commit the .te in git, sign the .pp, and deploy through Ansible. If you cannot justify every allow rule in code review, the policy is not ready. SELinux is not an obstacle, it is the last perimeter defense standing between a single vulnerability and total loss of the host.

Related posts

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly