How to troubleshoot SELinux or AppArmor denials¶
A program fails with Permission denied (or the equivalent EACCES or 403 Forbidden), even though the file permissions and ownership allow the access.
On a system with mandatory access control, the refusal may come from SELinux or AppArmor instead.
This guide shows how to confirm that, find the denial in the logs, and fix its cause without turning enforcement off.
The examples use a common case for each family:
- RHEL, AlmaLinux and Rocky Linux (SELinux): a web page moved into
/var/www/html/from a home directory, which Apache refuses to serve. - Debian and Ubuntu (AppArmor):
tcpdumprefusing to write a capture file to/srv/captures/.
Before you begin¶
- You have a shell with
sudoon the machine. - You know which program fails and roughly when, so that you can reproduce the failure.
Don't switch enforcement off to fix a denial
setenforce 0, SELINUX=disabled, aa-disable or aa-teardown make the error go away by removing the protection for the whole system (or the whole program), and they hide the actual cause.
Fix the label, the boolean or the profile rule instead, as below.
Take care with broad relabelling too: changing the file contexts of home directories or /etc/ssh with semanage fcontext can stop sshd reading authorized_keys and lock you out of SSH. Restrict each change to the paths that need it, and keep a second session open until you've checked that you can still log in.
Steps¶
-
Find out which security module is active:
The list names every active Linux Security Module. Look for
selinuxorapparmor.AppArmor is enabled by default. Check that it is, and which profiles are enforced (output from Debian 13 with
tcpdumpinstalled):$ sudo aa-status apparmor module is loaded. 106 profiles are loaded. 7 profiles are in enforce mode. /usr/bin/man ... tcpdump 0 profiles are in complain mode. ...A profile in enforce mode refuses what it doesn't allow; one in complain mode only logs it. Programs without a loaded profile aren't restricted by AppArmor at all.
SELinux is enabled and enforcing by default, with the
targetedpolicy:If it prints
Permissive, SELinux logs denials but doesn't refuse anything, so it isn't the cause of your problem; if it printsDisabled, SELinux isn't involved at all.Neither SELinux nor AppArmor is active on a default Arch installation: the kernel has both built in, but neither is in the active list unless you add it with the
lsm=kernel parameter. If the list above has neither, the refusal comes from somewhere else, such as file permissions, ACLs, a systemd sandboxing directive likeProtectHome=orReadOnlyPaths=, or Landlock.If you have enabled AppArmor, follow the Debian/Ubuntu tab: the commands are the same, though the profiles shipped by Arch's
apparmorpackage differ.Neither SELinux nor AppArmor is active in a default Gentoo installation; both need a kernel configured for them and, for SELinux, an SELinux profile. If the list above has neither, the refusal comes from somewhere else, such as file permissions or ACLs.
If you run an SELinux profile, follow the RHEL/Alma/Rocky tab:
ausearch,audit2why,restoreconandsemanagework the same way, though the policy (Gentoo's own, not Red Hat's) differs in its types and booleans. With AppArmor, follow the Debian/Ubuntu tab.The remaining steps have tabs for SELinux on the RHEL family and AppArmor on Debian and Ubuntu. On Arch and Gentoo they apply only if you have enabled one of the two; your distribution's tab above says which tab to follow.
-
Reproduce the failure, so that a fresh denial is logged:
<!-- verify-setup
The reader's situation: tcpdump installed, and a capture directory under /srv.¶
export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq tcpdump >/dev/null mkdir -p /srv/captures →
On Ubuntu the message is
tcpdump: Couldn't change ownership of savefileinstead; the cause is the same. Root is refused here although it can normally write anywhere, which is a strong sign of a mandatory access control denial.<!-- verify-setup
The reader's situation: Apache serving /var/www/html, and a page that was¶
written in a home directory and moved into place.¶
dnf install -y -q httpd >/dev/null systemctl enable --now httpd echo '
Hello
' > /home/labuser/index.html chmod 0644 /home/labuser/index.html mv /home/labuser/index.html /var/www/html/index.html →Apache's error log,
/var/log/httpd/error_log, says only(13)Permission denied: … file permissions deny server access, although the file mode is0644. -
Read the denial.
AppArmor logs denials as kernel audit messages. Without
auditdinstalled (the default), they go to the kernel log:$ sudo journalctl -k -g 'apparmor="DENIED"' Nov 07 13:48:34 server kernel: audit: type=1400 audit(1794059314.608:108): apparmor="DENIED" operation="mknod" class="file" profile="tcpdump" name="/srv/captures/trace" pid=898 comm="tcpdump" requested_mask="c" denied_mask="c" fsuid=0 ouid=0Denials from other profiles may appear in the list too; look for the profile and path of the program that failed. If
auditdis installed, the same messages are in/var/log/audit/audit.loginstead, astype=AVCrecords; find them withsudo ausearch --input-logs -m AVC -ts recent.The fields that matter:
profile="tcpdump": the profile that refused the access.operation="mknod"andname="/srv/captures/trace": what the program tried to do, and to which path (here, create the file).denied_mask="c": the permission that was missing.ris read,wwrite,ccreate,aappend,xexecute,klock,llink,ddelete.
Look at the profile to see what it does allow:
The
tcpdumpprofile lets it write files whose names end in.pcap,.pcapngor.capanywhere, and any file in the user's home directory, but nothing else.SELinux denials are AVC (access vector cache) records in the audit log. Show the recent ones:
$ sudo ausearch --input-logs -m AVC,USER_AVC -ts recent ---- time->Sat Nov 7 14:01:30 2026 type=PROCTITLE msg=audit(1794060090.714:314): proctitle=2F7573722F7362696E2F6874747064002D44464F524547524F554E44 type=SYSCALL msg=audit(1794060090.714:314): arch=c000003e syscall=257 success=no exit=-13 ... uid=48 ... comm="httpd" exe="/usr/sbin/httpd" subj=system_u:system_r:httpd_t:s0 key=(null) type=AVC msg=audit(1794060090.714:314): avc: denied { read } for pid=5155 comm="httpd" name="index.html" dev="vda4" ino=41943172 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0-ts recentlimits the search to the last ten minutes.--input-logsmakesausearchread the log files even when its standard input isn't a terminal, as in a script; in an interactive shell it makes no difference.The
AVCrecord says that a process running as typehttpd_t(scontext, the source) was refusedreadon afilelabelleduser_home_t(tcontext, the target).permissive=0means the access was actually refused.Install the analysis tools, and ask
audit2whyto explain the denial:$ sudo dnf install -y policycoreutils-python-utils $ sudo ausearch --input-logs -m AVC -ts recent | audit2why type=AVC msg=audit(1794060090.714:314): avc: denied { read } for pid=5155 comm="httpd" name="index.html" ... 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 1audit2whyreports either a boolean that would allow the access, as here, or that the policy has no allow rule for it at all. Read its suggestion as a description of what the policy would have to allow, not as the fix. Here, turning onhttpd_read_user_contentwould let the web server read files in every home directory, when the real problem is one file with the wrong label: the page still carries theuser_home_tlabel of the home directory it was created in. -
Fix the cause.
Packaged profiles include a file in
/etc/apparmor.d/local/with the same name, for local additions that survive package upgrades. Allowtcpdumpto write in/srv/captures/there:Then reload the profile, so that the kernel uses the new rule:
Grant only what the denial asks for, on the narrowest path that works. Here the alternative needs no change at all: name the capture file
trace.pcap, which the profile already allows.For a program that needs many new permissions,
aa-logproffrom theapparmor-utilspackage reads the logged denials and proposes rules one by one for you to accept or refuse. Putting a profile in complain mode (sudo aa-complain <PROFILE>) to collect denials is a diagnostic step only: put it back withsudo aa-enforce <PROFILE>when you're done.Ask
restoreconwhat label the policy expects for the file, without changing anything:$ sudo restorecon -n -v /var/www/html/index.html Would relabel /var/www/html/index.html from unconfined_u:object_r:user_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0The file kept its label when it was moved, because
mvkeeps a file's label; files created in or copied into/var/www/html/gethttpd_sys_content_t. Reset it to the expected label:Use
restorecon -R -v <DIRECTORY>for a whole directory tree, where<DIRECTORY>is its path.Other common causes, and their fixes:
- Content in a non-standard place, such as
/srv/web: add a file context rule, then apply it withrestorecon:sudo semanage fcontext -a -t httpd_sys_content_t '/srv/web(/.*)?'andsudo restorecon -R -v /srv/web. - A non-standard port: label it, for example
sudo semanage port -a -t http_port_t -p tcp 8081. - A behaviour the policy anticipates but doesn't allow by default, such as a web application making outgoing connections:
audit2whynames the boolean. List them withgetsebool -aand set one permanently withsudo setsebool -P <BOOLEAN> on. - None of these applies: only then write a local policy module from the denials with
audit2allow -M <NAME>, read the type enforcement source file it creates to check that it allows only what's needed, and load it withsudo semodule -i <NAME>.pp.
To collect all denials of one service at once, you can make just its domain permissive with
sudo semanage permissive -a httpd_t, reproduce the problem, and remove the exception withsudo semanage permissive -d httpd_twhen you're done. The rest of the system stays enforcing. - Content in a non-standard place, such as
-
Run the program again:
<!-- verify-setup
Give tcpdump packets to capture on the loopback interface.¶
nohup sh -c 'sleep 2; ping -c 20 127.0.0.1' >/dev/null 2>&1
$ sudo timeout 30 tcpdump -i lo -c 1 -w /srv/captures/trace tcpdump: listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes 1 packet capturedThe command waits for one packet on the loopback interface; any local network activity (or
ping -c 1 127.0.0.1in another terminal) provides it.
Verify¶
- The program works, and the step that failed before doesn't log a new denial: check with
sudo journalctl -k -g 'apparmor="DENIED"' --since "5 minutes ago"(AppArmor) orsudo ausearch --input-logs -m AVC -ts recent(SELinux). - Enforcement is still on:
aa-statusstill lists the profile in enforce mode, orgetenforcestill printsEnforcing.
Troubleshooting¶
There's no denial in the logs, but the access is still refused.
SELinux policy contains dontaudit rules that silence denials which are normally harmless noise; very rarely, they hide the one you're looking for.
Run sudo semodule -DB to rebuild the policy without them, reproduce the problem, then restore them with sudo semodule -B.
If there's still nothing, the refusal isn't from MAC: check permissions on every directory in the path (namei -l <PATH>), ACLs, and the service's sandboxing settings.
restorecon changes the label back after you set it with chcon.
chcon changes the label of a file but not the policy's file context rules, so the next relabel (by restorecon or a full relabel at boot) undoes it. Use semanage fcontext for anything permanent.
An AppArmor rule in local/ has no effect.
The profile wasn't reloaded, or the rule has a syntax error. sudo apparmor_parser -r reports errors with the file and line; every rule must end with a comma.
Related¶
- Explanation: SELinux and AppArmor
- Reference: Audit rules
- How-to guide: How to audit access to a file