AppArmor is the mandatory access control framework that Ubuntu, Debian, and openSUSE ship enabled by default โ and that most administrators never look at twice. A correctly configured AppArmor profile contains the damage of a compromised process: a hijacked nginx cannot read /etc/shadow, a backdoored cron job cannot dial out on arbitrary ports. This guide covers how to inventory profiles, switch between modes, troubleshoot denials, and write a custom profile that survives package upgrades.
Confirm AppArmor is actually enforcing
sudo aa-status
sudo aa-status --enabled && echo enabled
cat /sys/kernel/security/apparmor/profiles | head
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx # set one profile to enforce
Healthy aa-status output: "X profiles are loaded", "Y profiles are in enforce mode", "Z processes have profiles defined". A server with all profiles in complain mode is in audit-only โ denials are logged but not blocked.
The four operational modes
- enforce โ denials are blocked and logged. The default for most shipped profiles.
- complain โ denials are logged but allowed. Useful when developing a profile.
- audit โ every access is logged (noisy; for forensics only).
- disable โ profile is loaded but inactive. Equivalent to no profile.
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx
sudo aa-complain /etc/apparmor.d/usr.sbin.nginx
sudo aa-disable /etc/apparmor.d/usr.sbin.nginx
sudo aa-status | grep nginx
Inventory: which processes are confined
sudo aa-status --enforced
sudo aa-status --complaining
ps -eZ | head # process security context
cat /proc/$(pgrep -f nginx | head -1)/attr/current
The last command shows the AppArmor context of a running process. An entry like /usr/sbin/nginx (enforce) means active enforcement; an empty result or unconfined means the binary runs without restriction.
Troubleshooting denials
When a confined service starts misbehaving after an update, check the kernel log:
sudo dmesg | grep -i 'apparmor.*DENIED'
sudo journalctl -k --since today | grep apparmor
sudo aa-notify -s 1 -v # last day of denials with summary
Each denial shows: operation (e.g. open, exec, capable), profile, name (the path or capability), and requested_mask. Read the line, decide whether to allow or fix the application, then update the profile.
Adding allowed paths to a profile
Profiles live in /etc/apparmor.d/. Convention: filename is the binary path with / replaced by . โ /usr/sbin/nginx becomes /etc/apparmor.d/usr.sbin.nginx. To allow nginx to write a new log directory:
sudo $EDITOR /etc/apparmor.d/local/usr.sbin.nginx
# add: /var/log/myapp/** rw,
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
Always edit the local/ include, not the main file โ distribution upgrades overwrite the main profile, and your edits survive in local/.
Generating a profile from scratch
sudo aa-genprof /usr/local/bin/myapp
# in another terminal: exercise the application
# back in the wizard: choose 'S' to scan denials, 'A' to allow, 'F' to finish
aa-genprof watches kernel denials in real time and proposes allow rules. After saving, switch the profile to enforce and re-test. Iterate with aa-logprof when the application gains new behaviour.
Profile syntax cheatsheet
/usr/local/bin/myapp {
# capabilities
capability net_bind_service,
capability dac_override,
# files
/etc/myapp/** r,
/var/log/myapp/** rw,
/var/lib/myapp/** rwk, # k = file locking
/usr/lib/x86_64-linux-gnu/** mr, # m = mmap, r = read
# network
network inet stream,
network inet dgram,
# signals
signal (send) peer=unconfined,
# subprocess (run /bin/sh under its own profile or unconfined)
/bin/sh ix, # ix = inherit profile
}
Container considerations
Docker on Ubuntu/Debian applies the docker-default profile to every container by default. Override per-container with --security-opt apparmor=my-profile. To audit:
docker info | grep -i apparmor
sudo aa-status | grep docker
docker run --security-opt apparmor=docker-default nginx
The audit script
#!/bin/bash
echo "== AppArmor mode =="
aa-status --enabled && echo enabled
echo "== Profile counts =="
aa-status | grep -E 'profiles are|processes are'
echo "== Profiles in complain mode =="
aa-status --complaining
echo "== Running unconfined processes (interesting ones) =="
aa-status --process-mixed | grep -E 'sshd|nginx|apache|postgres|mysql' || echo "all confined"
echo "== Recent denials =="
journalctl -k --since '24 hours ago' | grep -c 'apparmor=.DENIED.'
Common pitfalls
- Disabling AppArmor entirely as a "fix" for one denial โ you lose the protection for every other binary.
- Editing the upstream profile file; distribution upgrades silently revert your changes.
- Leaving a profile in
complainafter debugging; the host appears protected but is not. - Forgetting to
apparmor_parser -rafter editing โ the kernel keeps the old profile loaded.
AppArmor is high-leverage security: ten minutes per service to write or extend a profile blocks an entire class of post-exploitation movement. Audit your profiles monthly, ship denial counts to your monitoring, and treat every unconfined process on a hardened server as a follow-up ticket.