Skip to content

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): tcpdump refusing to write a capture file to /srv/captures/.

Before you begin

  • You have a shell with sudo on 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

  1. Find out which security module is active:

    $ cat /sys/kernel/security/lsm
    lockdown,capability,yama,selinux,bpf,landlock,ima,evm
    

    The list names every active Linux Security Module. Look for selinux or apparmor.

    AppArmor is enabled by default. Check that it is, and which profiles are enforced (output from Debian 13 with tcpdump installed):

    $ 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 targeted policy:

    $ getenforce
    Enforcing
    

    If it prints Permissive, SELinux logs denials but doesn't refuse anything, so it isn't the cause of your problem; if it prints Disabled, 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 like ProtectHome= or ReadOnlyPaths=, or Landlock.

    If you have enabled AppArmor, follow the Debian/Ubuntu tab: the commands are the same, though the profiles shipped by Arch's apparmor package 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, restorecon and semanage work 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.

  2. 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 →

    $ sudo tcpdump -i lo -c 1 -w /srv/captures/trace
    tcpdump: /srv/captures/trace: Permission denied
    

    On Ubuntu the message is tcpdump: Couldn't change ownership of savefile instead; 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 →

    $ curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.html
    403
    

    Apache's error log, /var/log/httpd/error_log, says only (13)Permission denied: … file permissions deny server access, although the file mode is 0644.

  3. Read the denial.

    AppArmor logs denials as kernel audit messages. Without auditd installed (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=0
    

    Denials from other profiles may appear in the list too; look for the profile and path of the program that failed. If auditd is installed, the same messages are in /var/log/audit/audit.log instead, as type=AVC records; find them with sudo ausearch --input-logs -m AVC -ts recent.

    The fields that matter:

    • profile="tcpdump": the profile that refused the access.
    • operation="mknod" and name="/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. r is read, w write, c create, a append, x execute, k lock, l link, d delete.

    Look at the profile to see what it does allow:

    sudo grep -E ' rw,$' /etc/apparmor.d/usr.bin.tcpdump
    

    The tcpdump profile lets it write files whose names end in .pcap, .pcapng or .cap anywhere, 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 recent limits the search to the last ten minutes. --input-logs makes ausearch read 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 AVC record says that a process running as type httpd_t (scontext, the source) was refused read on a file labelled user_home_t (tcontext, the target). permissive=0 means the access was actually refused.

    Install the analysis tools, and ask audit2why to 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 1
    

    audit2why reports 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 on httpd_read_user_content would 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 the user_home_t label of the home directory it was created in.

  4. Fix the cause.

    Packaged profiles include a file in /etc/apparmor.d/local/ with the same name, for local additions that survive package upgrades. Allow tcpdump to write in /srv/captures/ there:

    echo '/srv/captures/** rw,' | sudo tee -a /etc/apparmor.d/local/usr.bin.tcpdump
    

    Then reload the profile, so that the kernel uses the new rule:

    sudo apparmor_parser -r /etc/apparmor.d/usr.bin.tcpdump
    

    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-logprof from the apparmor-utils package 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 with sudo aa-enforce <PROFILE> when you're done.

    Ask restorecon what 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:s0
    

    The file kept its label when it was moved, because mv keeps a file's label; files created in or copied into /var/www/html/ get httpd_sys_content_t. Reset it to the expected label:

    sudo restorecon -v /var/www/html/index.html
    

    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 with restorecon: sudo semanage fcontext -a -t httpd_sys_content_t '/srv/web(/.*)?' and sudo 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: audit2why names the boolean. List them with getsebool -a and set one permanently with sudo 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 with sudo 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 with sudo semanage permissive -d httpd_t when you're done. The rest of the system stays enforcing.

  5. 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 captured
    

    The command waits for one packet on the loopback interface; any local network activity (or ping -c 1 127.0.0.1 in another terminal) provides it.

    $ curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.html
    200
    

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) or sudo ausearch --input-logs -m AVC -ts recent (SELinux).
  • Enforcement is still on: aa-status still lists the profile in enforce mode, or getenforce still prints Enforcing.

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.