Introduction
SELinux provides powerful tools like booleans and port labels/types to adjust security policy without deep policy-writing knowledge, but those tools only cover what the system already knows about. When you deploy your own custom daemons or services, SELinux has no built-in context for them. You need to write a policy from scratch.
In this lab, I work through the full lifecycle of creating a custom SELinux policy for a custom binary called apachelogger. The process involves three phases:
- Generating a skeleton policy using
sepolicy - Diagnosing what the policy is missing by reading SELinux audit logs with
ausearch - Refining the policy by adding the correct interface calls and reapplying it
The goal is to move from a running-but-unconstrained daemon to one that is properly confined by SELinux with only the permissions it actually needs.
Environment
OS: RHEL 9 (AWS cloud instance)
Tools used: sepolicy, ausearch, audit2allow, vim, systemctl, ps
Step 1 - Inspect the Custom Daemon
Before writing any policy, I need to understand what the daemon is and where it lives. I start by checking its status and reading its unit file.
mkdir policy
cd policy
sudo systemctl status apacheloggerExample output:
● apachelogger.service - Simple testing daemon
Loaded: loaded (/usr/lib/systemd/system/apachelogger.service; disabled; preset: disabled)
Active: active (running) since Wed 2026-05-20 13:42:08 UTC; 3min 42s ago
Main PID: 2596 (apachelogger)
Tasks: 1 (limit: 11750)
Memory: 196.0K (peak: 452.0K)
CPU: 7ms
CGroup: /system.slice/apachelogger.service
└─2596 /usr/local/bin/apachelogger
The daemon is active. I note its binary path: /usr/local/bin/apachelogger. I confirm this by reading the unit file directly:
cat /usr/lib/systemd/system/apachelogger.serviceExample output:
[Unit]
Description=Simple testing daemon
[Service]
Type=simple
ExecStart=/usr/local/bin/apachelogger
[Install]
WantedBy=multi-user.targetThe ExecStart line confirms the binary path, this is what I’ll pass to sepolicy generate.
Step 2 - Generate the Policy Skeleton
sepolicy generate builds a skeleton policy from the binary. The --init flag tells it the binary is a system daemon (started by init/systemd), which shapes the type enforcement rules it creates.
sudo sepolicy generate --init /usr/local/bin/apacheloggerTroubleshooting: RHUI Repository Error
Running this command on the lab server initially fails with a DNS resolution error:
Errors during downloading metadata for repository 'rhel-9-appstream-rhui-rpms':
- Curl error (6): Couldn't resolve host name for
https://rhui.REGION.aws.ce.redhat.com/...
dnf.exceptions.RepoError: Failed to download metadata for repo
'rhel-9-appstream-rhui-rpms': Cannot prepare internal mirrorlist
Why this happens: Under the hood, sepolicy generate calls dnf to query system repositories for RPM metadata. This lab environment has no outbound internet access, so DNS resolution for AWS RHUI servers fails entirely.
Fix: Temporarily disable the RHUI repos so the command can run in an offline context:
sudo dnf config-manager --disable "*rhui*"
sudo sepolicy generate --init /usr/local/bin/apacheloggerExample output after fix:
Created the following files:
/home/cloud_user/policy/apachelogger.te # Type Enforcement file
/home/cloud_user/policy/apachelogger.if # Interface file
/home/cloud_user/policy/apachelogger.fc # File Contexts file
/home/cloud_user/policy/apachelogger_selinux.spec # Spec file
/home/cloud_user/policy/apachelogger.sh # Setup Script
Verifying the files are present:
lsapachelogger.fc apachelogger.if apachelogger_selinux.spec apachelogger.sh apachelogger.te
Each file plays a specific role:
| File | Purpose |
|---|---|
apachelogger.te | Type Enforcement - defines the actual allow rules |
apachelogger.if | Interface - exposes macros for other policies to use |
apachelogger.fc | File Contexts - maps file paths to SELinux labels |
apachelogger.sh | Build script - compiles and loads the policy module |
apachelogger_selinux.spec | RPM spec - packages the policy for distribution |
Step 3 - Apply the Initial Policy
I run the generated build script to compile and load the policy into the kernel:
sudo ./apachelogger.shapachelogger.sh
[cloud_user@web policy]$ sudo cat ./apachelogger.sh [sudo] password for cloud_user: #!/bin/sh -e DIRNAME=`dirname $0` cd $DIRNAME USAGE="$0 [ --update ]" if [ `id -u` != 0 ]; then echo 'You must be root to run this script' exit 1 fi if [ $# -eq 1 ]; then if [ "$1" = "--update" ] ; then time=`ls -l --time-style="+%x %X" apachelogger.te | awk '{ printf "%s %s", $6, $7 }'` rules=`ausearch --start $time -m avc --raw -se apachelogger` if [ x"$rules" != "x" ] ; then echo "Found avc's to update policy with" echo -e "$rules" | audit2allow -R echo "Do you want these changes added to policy [y/n]?" read ANS if [ "$ANS" = "y" -o "$ANS" = "Y" ] ; then echo "Updating policy" echo -e "$rules" | audit2allow -R >> apachelogger.te # Fall though and rebuild policy else exit 0 fi else echo "No new avcs found" exit 0 fi else echo -e $USAGE exit 1 fi elif [ $# -ge 2 ] ; then echo -e $USAGE exit 1 fi echo "Building and Loading Policy" set -x make -f /usr/share/selinux/devel/Makefile apachelogger.pp || exit /usr/sbin/semodule -i apachelogger.pp # Generate a man page of the installed module sepolicy manpage -p . -d apachelogger_t # Fixing the file context on /usr/local/bin/apachelogger /sbin/restorecon -F -R -v /usr/local/bin/apachelogger # Generate a rpm package for the newly generated policy pwd=$(pwd) rpmbuild --define "_sourcedir ${pwd}" --define "_specdir ${pwd}" --define "_builddir ${pwd}" --define "_srcrpmdir ${pwd}" --define "_rpmdir ${pwd}" --define "_buildrootdir ${pwd}/.build" -ba apachelogger_selinux.spec [cloud_user@web policy]$
The script does several things automatically: it compiles the .te file into a binary policy package (.pp), loads it with semodule, restores file contexts with restorecon, and generates a man page for the new domain. A key excerpt from the script shows the build steps:
make -f /usr/share/selinux/devel/Makefile apachelogger.pp || exit
/usr/sbin/semodule -i apachelogger.pp
sepolicy manpage -p . -d apachelogger_t
/sbin/restorecon -F -R -v /usr/local/bin/apacheloggerAfter the policy is loaded, I restart the daemon and confirm it now runs under the correct SELinux type:
sudo systemctl restart apachelogger
ps -efZ | grep apacheloggerExample output:
system_u:system_r:apachelogger_t:s0 root 2965 1 0 14:39 ? 00:00:00 /usr/local/bin/apachelogger
The apachelogger_t type is now present in the process label, meaning the daemon is running under its own SELinux domain. However, the policy is set to permissive mode for apachelogger_t, meaning denials are logged but not enforced yet. This is intentional, as I want to capture what the daemon actually tries to do before enforcing.
Step 4 - Diagnose Access Denials
With the daemon running in permissive mode, I query the audit log to see what it was denied:
sudo ausearch -m AVC -ts recent | grep apacheloggerExample output (relevant AVC lines):
type=AVC msg=audit(...): avc: denied { open } for pid=2965 comm="apachelogger"
path="/var/log/httpd/access_log"
scontext=system_u:system_r:apachelogger_t:s0
tcontext=system_u:object_r:httpd_log_t:s0 tclass=file permissive=1
type=AVC msg=audit(...): avc: denied { write } for pid=2965 comm="apachelogger"
name="access_log"
scontext=system_u:system_r:apachelogger_t:s0
tcontext=system_u:object_r:httpd_log_t:s0 tclass=file permissive=1
type=AVC msg=audit(...): avc: denied { search } for pid=2965 comm="apachelogger"
name="httpd"
scontext=system_u:system_r:apachelogger_t:s0
tcontext=system_u:object_r:httpd_log_t:s0 tclass=dir permissive=1
Reading these AVC messages tells the story clearly:
- Source context (
scontext):apachelogger_t- the daemon’s domain - Target context (
tcontext):httpd_log_t- Apache log files type - Denied actions:
open,writeon the file;searchon the directory
The daemon is trying to open and write to /var/log/httpd/access_log, but its policy gives it no permission to touch anything labeled httpd_log_t.
👀
permissive=1means SELinux logged the denial but did not enforce it, the action went through anyway.
Step 5 - Find the Right Policy Interfaces
Rather than writing raw allow rules, SELinux policy uses reusable interfaces, named macros that group related permissions together. I use audit2allow to suggest which interfaces apply here:
sudo ausearch -m AVC -ts recent | audit2allow -RThe output suggests two interfaces:
apache_manage_log(apachelogger_t)- grants full management of Apache log filesapache_read_log(apachelogger_t)- grants read-only access to Apache log files
To understand what each one actually does before adding them, I look them up in the SELinux interface library:
grep -r "apache_manage_log" /usr/share/selinux/devel/include/ | grep .if
grep -r "apache_read_log" /usr/share/selinux/devel/include/ | grep .ifBoth point to /usr/share/selinux/devel/include/contrib/apache.if. Inspecting that file shows their definitions:
apache_manage_log - allows the domain to fully manage Apache log directories and files:
interface(`apache_manage_log',`
gen_require(`
type httpd_log_t;
')
logging_search_logs($1)
manage_dirs_pattern($1, httpd_log_t, httpd_log_t)
manage_files_pattern($1, httpd_log_t, httpd_log_t)
read_lnk_files_pattern($1, httpd_log_t, httpd_log_t)
')
apache_read_log - allows the domain to read Apache log files (list directory, read files and symlinks):
interface(`apache_read_log',`
gen_require(`
type httpd_log_t;
')
logging_search_logs($1)
allow $1 httpd_log_t:dir list_dir_perms;
read_files_pattern($1, httpd_log_t, httpd_log_t)
read_lnk_files_pattern($1, httpd_log_t, httpd_log_t)
')
Since apachelogger writes to the log file, it needs apache_manage_log. I add both to be thorough, covering read and write paths.
Step 6 - Update the Type Enforcement File
I edit apachelogger.te to add the two interface calls:
sudo vim apachelogger.teI scroll to the bottom of the file and append the two lines:
apache_manage_log(apachelogger_t)
apache_read_log(apachelogger_t)
After saving, the complete .te file looks like this:
policy_module(apachelogger, 1.0.0)
########################################
#
# Declarations
#
type apachelogger_t;
type apachelogger_exec_t;
init_daemon_domain(apachelogger_t, apachelogger_exec_t)
permissive apachelogger_t;
########################################
#
# apachelogger local policy
#
allow apachelogger_t self:fifo_file rw_fifo_file_perms;
allow apachelogger_t self:unix_stream_socket create_stream_socket_perms;
domain_use_interactive_fds(apachelogger_t)
files_read_etc_files(apachelogger_t)
miscfiles_read_localization(apachelogger_t)
apache_manage_log(apachelogger_t)
apache_read_log(apachelogger_t)
Step 7 - Reapply and Verify
With the updated policy in place, I rebuild and reload it:
sudo ./apachelogger.sh
sudo systemctl restart apacheloggerFinally, I check the audit log one more time to confirm there are no new AVC denials:
sudo ausearch -m AVC -ts recentExample output:
<no matches>
No denials. The daemon is now properly confined, running under its own apachelogger_t domain, with exactly the permissions it needs to access Apache log files and generating no AVC errors.
Summary
| Phase | Tool | What it does |
|---|---|---|
| Inspect | systemctl, cat | Locate the binary and understand the service |
| Generate | sepolicy generate | Create a skeleton policy from the binary |
| Apply | ./apachelogger.sh | Compile and load the policy module |
| Diagnose | ausearch, audit2allow | Find what the daemon was denied |
| Research | grep, less on .if files | Understand what each interface permits |
| Refine | vim apachelogger.te | Add the correct interface calls |
| Verify | ausearch | Confirm no remaining denials |
The key insight in this workflow is the loop between applying, checking logs, and refining, as the permissive mode flag on apachelogger_t is what makes this iterative approach safe. The daemon runs, its access patterns get recorded, and you can tune the policy without ever breaking the service mid-diagnosis.