Skip to main content
RunBook Academy

← All break/fix scenarios in Linux

advancedSecurity~40 min

Break/Fix: 403 on a file with correct ownership, and ausearch finds nothing

Reported symptoms

  • The web server returns 403 for every file under one directory and serves the rest of the site normally
  • `ls -l` shows the files owned by the web server user with mode 0644
  • The same files served correctly from the same path last week
  • `sudo -u www-data cat` on the file succeeds, so it is not a filesystem permission problem
  • `sudo ausearch -m avc -ts recent` reports `<no matches>`, which the team read as "SELinux is not involved"

Evidence

  • · `ls -l /var/www/html/reports/q3.pdf` shows `-rw-r--r--. 1 apache apache` — note the trailing dot
  • · `ls -lZ /var/www/html/reports/` shows type `user_home_t` while a working sibling directory shows `httpd_sys_content_t`
  • · `getenforce` reports `Enforcing`
  • · `sudo ausearch -m avc -ts recent` reports `<no matches>`
  • · `systemctl is-active auditd` reports `inactive`
  • · `sudo journalctl -k --since "10 min ago" | grep -i avc` shows `avc: denied { read } ... scontext=system_u:system_r:httpd_t ... tcontext=unconfined_u:object_r:user_home_t`
  • · `matchpathcon /var/www/html/reports/q3.pdf` reports the expected context differs from the actual one
  • · The deploy script was changed nine days ago from `cp -r` to `mv`
Diagnosis and resolutionclick to reveal

Root cause

SELinux is denying the read because the files carry the wrong type. The deploy script was changed from `cp` to `mv`: `cp` without a context-preserving flag lets the kernel apply the destination directory's default type, while `mv` is a rename that preserves the source file's existing label. The staging directory sits under a home directory, so every file moved into the document root arrived labelled `user_home_t`, which `httpd_t` has no rule to read. Discretionary access control is entirely satisfied — owner, group, mode and ACLs are all correct — so every tool an operator reaches for first says the permissions are fine. The denial was invisible because `auditd` is not running on this host: kernel AVC messages then go only to the kernel ring buffer and the journal, and `ausearch` reads the audit log file, so it truthfully reports no matches for a denial that is happening constantly.

Remediation

Relabel the affected path with `sudo restorecon -Rv /var/www/html/reports` and confirm the web server can read the files again. If the path is outside the default policy's file-context rules, register the mapping first with `sudo semanage fcontext -a -t httpd_sys_content_t "/srv/reports(/.*)?"` and then relabel, so the label survives a full filesystem relabel instead of being reverted by it. Fix the deploy script so the labels are correct on arrival — either return to `cp` (or `install`), or keep `mv` and add an explicit `restorecon` step afterwards. Start and enable `auditd` so the next denial is visible where operators look for it. Do not leave `setenforce 0` in place as the fix; it is a diagnostic, and a permissive host is a host whose next denial will also go unnoticed.

Verification

`ls -lZ /var/www/html/reports/` must show `httpd_sys_content_t` on every file, and `sudo matchpathcon -V /var/www/html/reports/q3.pdf` must report the context as verified rather than as a mismatch. With `getenforce` reporting `Enforcing` — check this explicitly, because the diagnostic step may have left it permissive — request the file over HTTP and confirm a 200. Confirm no new denials arrive: `sudo ausearch -m avc -ts recent` now works because auditd is running, and must stay empty while the page is loaded repeatedly. Finally run the deploy script again and re-check the labels; a relabel that the next deploy undoes is not a fix.

Prevention

Keep `auditd` running on every enforcing host, and treat an `ausearch` that returns nothing on a host where auditd is stopped as no evidence rather than as evidence of absence — cross-check `journalctl -k` or `dmesg` for AVC lines. Register file-context rules with `semanage fcontext` for any path the default policy does not know about, so `restorecon` and a full relabel both produce the right answer. Make deployment tooling label-aware: `cp` and `install` take the destination default, `mv` and `tar` without `--selinux` preserve or drop labels, and `rsync` needs `-X` to carry them. Add a label assertion to post-deploy validation.

Reported symptoms

  • Every URL under /reports/ returns 403. The rest of the site is fine.
  • ls -l shows the files owned by apache:apache, mode 0644, in a directory that is 0755 apache:apache.
  • sudo -u apache cat /var/www/html/reports/q3.pdf prints the file, so the web server user can read it.
  • The application log shows the web server’s own 403, not an application error.
  • Someone checked for SELinux denials with ausearch -m avc -ts recent, got no matches, and moved the investigation to the application team.

Evidence provided

$ ls -l /var/www/html/reports/q3.pdf
-rw-r--r--. 1 apache apache 184320 Aug  2 04:12 /var/www/html/reports/q3.pdf

$ sudo -u apache cat /var/www/html/reports/q3.pdf > /dev/null; echo "exit=$?"
exit=0

$ getfacl /var/www/html/reports/q3.pdf 2>/dev/null | tail -4
user::rw-
group::r--
other::r--

$ ls -lZ /var/www/html/
drwxr-xr-x. 2 apache apache system_u:object_r:httpd_sys_content_t:s0   4096 Jul 14 10:02 assets
drwxr-xr-x. 2 apache apache unconfined_u:object_r:user_home_t:s0       4096 Aug  2 04:12 reports

$ getenforce
Enforcing

$ sudo ausearch -m avc -ts recent
<no matches>

$ systemctl is-active auditd
inactive

$ sudo journalctl -k --since '10 min ago' | grep -i avc | tail -2
Aug 11 09:22:07 web01 kernel: audit: type=1400 audit(1786000927.114:812): avc:  denied  { read } for  pid=1422 comm="httpd" name="q3.pdf" dev="dm-0" ino=918273 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

$ matchpathcon /var/www/html/reports/q3.pdf
/var/www/html/reports/q3.pdf	system_u:object_r:httpd_sys_content_t:s0

Work the evidence before reading on

Four things in that transcript are worth pausing on:

  1. sudo -u apache cat succeeds. The web server user can read the file. So whatever is blocking the web server is not the web server’s user identity.
  2. The ls -l output ends each mode string with a dot: -rw-r--r--. That dot is not a typo and it is not part of the mode.
  3. ausearch says <no matches>. journalctl -k says the opposite, loudly. Both are telling the truth.
  4. matchpathcon prints a context. Compare it to the one in ls -lZ.

Answer this before continuing: why does sudo -u apache cat work when httpd cannot read the same file as the same user?

Root cause

1. The dot in ls -l is the clue everyone scrolls past

A trailing . on the mode string means the file has an SELinux security context. A trailing + means it has a POSIX ACL. Both are printed by default, both are one character wide, and both are routinely read as punctuation.

Once you see the dot, ls -lZ is the obvious next command — and it shows the whole answer in one line:

drwxr-xr-x. apache apache system_u:object_r:httpd_sys_content_t:s0   assets   <- works
drwxr-xr-x. apache apache unconfined_u:object_r:user_home_t:s0       reports  <- 403

2. DAC and MAC are two independent checks

Linux runs discretionary access control (owner, group, mode, ACLs) and mandatory access control (SELinux) as separate gates. A read has to pass both. Everything the team checked belongs to the first gate, and the first gate is wide open.

sudo -u apache cat succeeds because that shell runs in the unconfined_t domain, which policy allows to read user_home_t. The web server runs in the httpd_t domain, which does not. Same user, same file, different domain, different answer — and that is the single most useful thing to internalise about MAC: the process’s domain matters as much as its uid.

Confirm which domain the service is actually running in — it is not the one your shell is in:

ps -eZ | grep httpd | head -2

Because you cannot easily borrow another domain from a shell, the only faithful reproduction is to make the real service do the read: request the URL and watch the kernel log.

3. mv preserves labels; cp does not

The deploy script changed nine days ago:

# Before - correct labels, slower
cp -r /home/deploy/staging/reports/. /var/www/html/reports/

# After - fast, and wrong
mv /home/deploy/staging/reports/* /var/www/html/reports/

cp creates a new inode at the destination, and the kernel applies the type transition rule for the destination directory — so the copy comes out httpd_sys_content_t. mv within the same filesystem is a rename: no new inode, no transition, the file keeps the user_home_t label it was born with under /home.

The same trap exists in several familiar tools:

ToolLabel behaviour
cpDestination default (correct by accident)
cp -a, cp --preserve=contextSource label preserved
mv (same filesystem)Source label preserved
mv (across filesystems)Behaves as a copy — destination default
tar -xDestination default unless --selinux
rsyncDestination default unless -X
installDestination default

4. ausearch was telling the truth

ausearch reads /var/log/audit/audit.log, which is written by auditd. With auditd stopped, the kernel has nowhere to send audit records except the kernel ring buffer, so they surface in dmesg and the journal as type=1400 ... avc: denied and never reach the file ausearch reads.

<no matches> from ausearch on a host where auditd is not running is no evidence at all. It is the most expensive false negative in SELinux troubleshooting, because it reads exactly like a clean result.

Resolution

  1. Confirm the hypothesis where the denial actually happens. Watch sudo journalctl -kf in one terminal while you request the URL in another; a fresh avc: denied line arriving with each request is the confirmation. Reading the file from your own shell will not reproduce it, because your shell is not in the confined domain
  2. Start auditd first, so every subsequent step is visible in the place you will look next time:
  3. `` sudo systemctl enable --now auditd ``
  4. Ask policy what the label should be. matchpathcon /var/www/html/reports/q3.pdf prints the expected context; sudo matchpathcon -V on a path reports actual against expected and is the form worth learning
  5. If the path is one the default policy already knows — anything under /var/www on a stock policy — relabel it directly:
  6. `` sudo restorecon -Rv /var/www/html/reports ``
  7. If the path is outside the default policy, register the rule before relabelling, or the next full relabel will undo your work:
  8. `` sudo semanage fcontext -a -t httpd_sys_content_t '/srv/reports(/.*)?' sudo restorecon -Rv /srv/reports ``
  9. Fix the deploy script, which is the actual defect. Either go back to cp, or keep mv and relabel afterwards:
  10. `` mv /home/deploy/staging/reports/* /var/www/html/reports/ restorecon -Rv /var/www/html/reports ``
  11. Confirm enforcing mode is still on. If the diagnostic step used setenforce 0, put it back and verify with getenforce
  12. **Do not reach for audit2allow here.** It generates a policy module permitting httpd_t to read user_home_t — which would let the web server serve every user home directory on the box. The label is wrong; the policy is right

Verification

  1. Labels are correct and policy agrees. ls -lZ /var/www/html/reports/ shows httpd_sys_content_t throughout, and sudo matchpathcon -V /var/www/html/reports/q3.pdf reports verified rather than a mismatch
  2. Enforcing is on. getenforce returns Enforcing. A 200 in permissive mode proves nothing
  3. The request actually succeeds. curl -sS -o /dev/null -w '%{http_code}\n' https://web01.example.com/reports/q3.pdf returns 200
  4. No new denials. With auditd running, sudo ausearch -m avc -ts recent stays empty while the page is loaded repeatedly. This is the check that could not work before and can now
  5. The audit path itself works. Deliberately provoke a denial on a scratch file, confirm ausearch reports it, and clean up. An audit pipeline nobody has tested is the reason this incident lasted a day
  6. The next deploy does not undo it. Run the deploy script and re-check ls -lZ. If the labels regress, the script fix did not land
  7. It survives a relabel. sudo restorecon -Rvn /var/www/html/reports in dry-run mode should propose no changes; if it proposes reverting your labels, the semanage fcontext rule is missing

Prevention

  • Run auditd on every enforcing host and monitor that it is running. An SELinux host without auditd enforces silently, which is the worst of both worlds.
  • Treat ausearch returning nothing as inconclusive until you have confirmed auditd is active. Cross-check journalctl -k | grep avc, which works whether or not auditd is running.
  • Register file contexts with semanage fcontext for every non-default path, then restorecon. A label set by chcon alone is temporary: it is correct until the next restorecon -R or automatic relabel silently reverts it.
  • Make deployment tooling label-aware and assert on it afterwards. A one-line restorecon -Rv at the end of a deploy costs nothing and removes the entire class.
  • Never ship SELINUX=permissive as a remediation. If a policy genuinely needs extending, extend it with a reviewed module — and read what audit2allow proposes before applying it, because it will happily generate a rule far broader than the problem.