AppArmor: Path-Based Mandatory Access Control
- Explain what AppArmor adds on top of file permissions, and how its path-based model differs from SELinux labels
- Find a program's profile in /etc/apparmor.d and read its header, abstractions and file rules
- Switch a profile between enforce, complain and disabled, with the aa-* tools or by editing files
- Read an apparmor="DENIED" log line, add the one rule it asks for, then check and reload the profile
Why AppArmor exists
Understand what MAC adds on top of ordinary permissions, and how AppArmor's model differs from SELinux.
Ordinary Linux permissions are discretionary (DAC): a file's owner decides who may read or write it, and a process gets whatever its user is allowed. A hijacked
tcpdumprunning as root can therefore read every file root can read, which is all of them.AppArmor adds mandatory access control (MAC): for each confined program, the administrator writes a profile, a list of exactly what that program may touch. The kernel checks the profile after the normal permission check passes, and the program cannot opt out. Think of the profile as a guest list for one program. DAC asks whether the user may open the door; AppArmor asks whether this program is on the list for this path. A compromised
tcpdumpcan still write.pcapfiles, but it can't read/etc/shadow, because no rule in its profile grants that path.Ubuntu and Debian turn AppArmor on by default; RHEL and Fedora use SELinux. Both are MAC, but they decide differently:
- AppArmor is path-based. Rules name files by path:
/var/lib/notesd/state.db rw,. Only the confined programs have profiles, and everything else runs unconfined. - SELinux is label-based. Every file and process carries a label (a context such as
httpd_sys_content_t), and the policy matches labels, not paths. If you move a file, it keeps its label.
The practical consequence: in AppArmor, where a file is decides access. There's no
restoreconand no relabelling, because there are no labels. A file copied into a path the profile allows becomes readable at once, and a file moved out of it becomes unreadable.Check yourself
AppArmor matches its rules against a file's path. What does SELinux match against instead?
- AppArmor is path-based. Rules name files by path:
Profiles live in /etc/apparmor.d
Find a program's profile, tell the profile's name from the program it attaches to, and know what each subdirectory is for.
Every profile is a text file in
/etc/apparmor.d/. By convention the file is named after the program's path with the slashes turned into dots: the profile for/usr/sbin/mysqldlives in/etc/apparmor.d/usr.sbin.mysqld. The parser doesn't enforce the convention, but every tool and every admin expects it.The directory also holds the pieces profiles are built from and the switches that change their mode:
abstractions/holds reusable rule sets (base,nameservice,user-tmp) that profiles pull in with#include.tunables/holds variables such as@{HOME}and@{PROC}.local/holds site additions to shipped profiles (more below).disable/holds symlinks to profiles that must not load.force-complain/holds symlinks to profiles that must load in complain mode.
grep -nE '^profile|^ *#include' /etc/apparmor.d/usr.bin.tcpdump2:#include <tunables/global> 4:profile tcpdump /usr/bin/tcpdump { 5: #include <abstractions/base> 6: #include <abstractions/nameservice> 7: #include <abstractions/user-tmp> 74: #include <local/usr.bin.tcpdump>- -n
- Prefix each match with its line number.
- -E
- Extended regex, so | means "or".
This is the profile Ubuntu 22.04's tcpdumppackage ships. Line 4 is the header.profile tcpdumpgives the profile its name, and/usr/bin/tcpdumpis the attachment, the binary it confines. A header that is just a path, like/usr/bin/man {, uses the path as the name too. The rules sit between the braces. The includes pull in abstractions, and the last one pulls in the site'slocal/file.sudo apparmor_parser -N /etc/apparmor.d/usr.bin.manCache read/write disabled: interface file missing. (Kernel needs AppArmor 2.4 compatibility patch.) /usr/bin/man man_filter man_groff
- -N / --names
- Parse the files and print the name of every profile they define, without loading anything.
One file can define several profiles: usr.bin.mandefines/usr/bin/manplus the helper profilesman_filterandman_groff. The same command onusr.bin.tcpdumpprints justtcpdump. These names are whataa-statuslists and what log lines print asprofile="...", so-Nis how you map a log line back to a file. The first line comes from the lab box, whose kernel has no AppArmor interface, so the parser can't use its compiled-policy cache. It only reads the file, so it works anyway. On a host running AppArmor you get just the names.Check yourself
In the header
profile tcpdump /usr/bin/tcpdump {, which part is the profile's name?Read the rules: paths, permissions, includes, deny
Read a profile's file rules and know what each permission letter and exec mode grants.
A file rule is a path, a space, the permissions, then a comma:
/etc/notesd/notesd.conf r,. The permission letters are:rread,wwrite,aappend only (it can't be combined withw);mmap the file executable (needed for the program's own binary and its libraries);klock,llink;xexecute, which always needs a qualifier saying which profile the child runs under:ixinherits this profile,pxswitches to the child's own profile,cxswitches to a child profile, anduxruns it unconfined. An uppercasePx/Uxalso scrubs dangerous environment variables likeLD_PRELOAD.
Paths can glob:
*matches within one directory level,**matches across levels, and{a,b}matches either word. Two qualifiers change a rule's meaning.denyforbids the access even if another rule allows it, and doesn't log it.ownerapplies the rule only to files the process's user owns.You'll build a small profile for a made-up service,
notesd, in a practice directory,~/aa-lab. It's the same shape as the profiles in the labs.mkdir -p ~/aa-lab/disable ~/aa-lab/force-complain ~/aa-lab/local cat > ~/aa-lab/usr.local.bin.notesd <<'EOF' #include <tunables/global> /usr/local/bin/notesd { #include <abstractions/base> /usr/local/bin/notesd mr, /etc/notesd/notesd.conf r, /var/log/notesd/*.log w, /usr/bin/gzip ix, deny /etc/shadow r, #include if exists <local/usr.local.bin.notesd> } EOF- mkdir -p
- Create the directories and any missing parents; no error if they already exist.
- <<'EOF'
- Here-document: everything up to the EOF line is the file's content. The quotes stop the shell expanding anything inside it.
Silent on success. This writes a complete profile into the practice directory and makes the disable/,force-complain/andlocal/directories next to it, mirroring/etc/apparmor.d. The file name follows the convention:/usr/local/bin/notesdbecomesusr.local.bin.notesd.grep -nE '^ +(deny +)?/' ~/aa-lab/usr.local.bin.notesd6: /usr/local/bin/notesd mr, 7: /etc/notesd/notesd.conf r, 8: /var/log/notesd/*.log w, 9: /usr/bin/gzip ix, 10: deny /etc/shadow r,
- -nE
- Line numbers, extended regex. The pattern matches indented lines that start with a path, with or without deny in front.
The file rules, one per line. notesd may map and read its own binary ( mr), read its config, and write any.logdirectly in/var/log/notesd/(*doesn't descend into subdirectories). It may rungzipinside its own profile (ix), and it may never read/etc/shadow, even if an abstraction allowed it. Nothing grants/var/lib/notesd/, so if notesd tries to open its database there, it will be denied.sudo cp ~/aa-lab/usr.local.bin.notesd /etc/apparmor.d/ sudo apparmor_parser -p /etc/apparmor.d/usr.local.bin.notesd | grep '^##included' | grep -v tunablesCache read/write disabled: interface file missing. (Kernel needs AppArmor 2.4 compatibility patch.) ##included <abstractions/base> ##included <abstractions/crypto> ##included <abstractions/crypto.d> ##included <abstractions/base.d> ##included <local/usr.local.bin.notesd>
- -p / --preprocess
- Print the profile with every include expanded in place, each marked with a ##included line. Nothing is loaded.
The first line copies the practice profile into /etc/apparmor.d, where the parser and theaa-*tools look for profiles and their includes. One#include <abstractions/base>line brings in several hundred lines of rules, including a nestedabstractions/cryptoand the.ddirectories that sites can extend.-pstill marks thelocal/include although that file doesn't exist yet:include if existsjust adds nothing for it. When you can't tell where an access is allowed from,-pshows every rule the profile really contains. TheCache read/write disabledline is the same lab-box notice as in-Nabove.Check yourself
Which exec permission runs a child program under the same profile as its parent?
Modes: enforce, complain, disabled
Read a profile's mode and switch it, with the aa-* tools or by editing the files they edit.
A loaded profile runs in one of two modes, and a third state keeps it from loading at all:
- Enforce (the default): accesses the profile doesn't grant are blocked and logged as
apparmor="DENIED". - Complain: nothing is blocked, but every access the profile would refuse is logged as
apparmor="ALLOWED". This is the tuning mode, the counterpart of SELinux's permissive mode, except it applies to one profile instead of the whole system. - Disabled: the profile isn't loaded, so the program runs unconfined.
Example — not runnable on your lab box
sudo aa-statusapparmor module is loaded. 9 profiles are loaded. 8 profiles are in enforce mode. /usr/bin/man lsb_release man_filter man_groff nvidia_modprobe nvidia_modprobe//kmod rsyslogd tcpdump 1 profiles are in complain mode. /usr/local/bin/notesd 0 profiles are in kill mode. 0 profiles are in unconfined mode. 1 processes have profiles defined. 1 processes are in enforce mode. /usr/sbin/rsyslogd (612) rsyslogd 0 processes are in complain mode. 0 processes are unconfined but have a profile defined. 0 processes are in mixed mode. 0 processes are in kill mode.
Representative output from an Ubuntu 22.04 host; the profile list depends on what's installed. aa-status(also calledapparmor_status) reads the kernel's list of loaded profiles, grouped by mode, then the running processes each one confines. It reports the kernel's state, not the files: a profile you edited but didn't reload still shows its old mode. The lab box hasaa-status, but AppArmor can't be used from inside the lab (itssecurityfsinterface isn't mounted), so there it printsapparmor module is loaded.thenapparmor filesystem is not mounted.and exits 3.sudo aa-complain --no-reload /etc/apparmor.d/usr.local.bin.notesd grep -n '{$' /etc/apparmor.d/usr.local.bin.notesd sudo aa-enforce --no-reload /etc/apparmor.d/usr.local.bin.notesd grep -n '{$' /etc/apparmor.d/usr.local.bin.notesdSetting /etc/apparmor.d/usr.local.bin.notesd to complain mode. 3:/usr/local/bin/notesd flags=(complain) { Setting /etc/apparmor.d/usr.local.bin.notesd to enforce mode. 3:/usr/local/bin/notesd {- --no-reload
- Change the profile file only; don't load the result into the kernel.
aa-complain,aa-enforceandaa-disablecome in theapparmor-utilspackage. Each takes a profile file in/etc/apparmor.dor the program's path (aa-enforce /usr/local/bin/notesdworks too), changes the file, then reloads the profile.aa-complainaddsflags=(complain)to the header line, as the firstgrepshows.aa-enforceremoves that flag and anydisable/link.aa-disablecreates thedisable/link and unloads the profile. The lab box's kernel has no AppArmor to reload into, so--no-reloadmakes the tools edit the file and stop there. Without it the edit still happens, but the reload fails withunable to find a suitable fs in /proc/mountsand the tool exits 1. On a real host, leave it off.Those tools only edit files you can edit yourself, then reload. You can make the same change by hand, which is also how you do it on a host without
apparmor-utils. Complain mode is a flag on the header line. Try it on the practice copy in~/aa-lab.sed -i 's#^/usr/local/bin/notesd {#/usr/local/bin/notesd flags=(complain) {#' ~/aa-lab/usr.local.bin.notesd grep -n 'flags=' ~/aa-lab/usr.local.bin.notesd3:/usr/local/bin/notesd flags=(complain) {- -i
- Edit the file in place.
- s#old#new#
- Substitute; # is the delimiter so the slashes in the path need no escaping.
This is the edit aa-complainmakes. The flag goes between the attachment and the opening brace. In a named profile, it goes after the path too:profile tcpdump /usr/bin/tcpdump flags=(complain) {. Deletingflags=(complain)is whataa-enforcedoes.sed -i 's# flags=(complain) {# {#' ~/aa-lab/usr.local.bin.notesd ln -sf ../usr.local.bin.notesd ~/aa-lab/disable/usr.local.bin.notesd readlink ~/aa-lab/disable/usr.local.bin.notesd../usr.local.bin.notesd
- ln -sf
- Make a symbolic link, replacing one that's already there.
The first line puts the profile back in enforce mode. The second disables it: AppArmor's loader skips any profile with a link in disable/(Skipping profile in /etc/apparmor.d/disable: usr.local.bin.notesd). A link inforce-complain/works the same way and loads the profile in complain mode without editing it. Both survive reboots because they're files. A disable link doesn't unload a profile that's already running, though. On a live host you also runsudo apparmor_parser -R /etc/apparmor.d/usr.local.bin.notesd.Check yourself
What do you add to a profile's header line to put that one profile into complain mode?
- Enforce (the default): accesses the profile doesn't grant are blocked and logged as
Read a denial
Find AppArmor's log lines and pull out the profile, the path and the access it was refused.
Every AppArmor decision worth logging becomes a kernel audit message with
type=1400. Where it lands depends on the host:- with
auditdrunning, in/var/log/audit/audit.logastype=AVC msg=audit(...): apparmor="DENIED" ...; - without it, in the kernel log:
dmesg,journalctl -k, and on Ubuntu also/var/log/kern.logand/var/log/syslog.
The line is a list of
key="value"pairs, so you don't need a special tool to read it, justgrep. The practice file below is a kernel log from a host where notesd is denied, plus two lines that aren't denials.cat > ~/aa-lab/kern.log <<'EOF' Oct 2 14:02:55 web01 kernel: [ 8796.120331] audit: type=1400 audit(1759413775.100:208): apparmor="STATUS" operation="profile_replace" profile="unconfined" name="/usr/local/bin/notesd" pid=4180 comm="apparmor_parser" Oct 2 14:03:11 web01 kernel: [ 8812.441093] audit: type=1400 audit(1759413791.412:212): apparmor="DENIED" operation="open" profile="/usr/local/bin/notesd" name="/var/lib/notesd/state.db" pid=4213 comm="notesd" requested_mask="wr" denied_mask="wr" fsuid=0 ouid=0 Oct 2 14:03:12 web01 kernel: [ 8813.002417] audit: type=1400 audit(1759413791.973:213): apparmor="ALLOWED" operation="open" profile="/usr/sbin/nginx" name="/srv/www/index.html" pid=3377 comm="nginx" requested_mask="r" denied_mask="r" fsuid=33 ouid=0 Oct 2 14:03:41 web01 kernel: [ 8842.517760] audit: type=1400 audit(1759413821.488:214): apparmor="DENIED" operation="open" profile="/usr/local/bin/notesd" name="/var/lib/notesd/state.db" pid=4213 comm="notesd" requested_mask="wr" denied_mask="wr" fsuid=0 ouid=0 EOFSilent on success. The four lines are an apparmor_parserreload (STATUS), two identical denials for notesd, and oneALLOWEDline from an nginx profile in complain mode.grep -oE 'apparmor="[A-Z]+"' ~/aa-lab/kern.log | sort | uniq -c1 apparmor="ALLOWED" 2 apparmor="DENIED" 1 apparmor="STATUS"- -o
- Print only the matched apparmor="..." field, not the whole line.
- uniq -c
- Collapse identical adjacent lines and count them (hence the sort first).
First, sort the lines by kind. DENIEDmeans an enforcing profile blocked something.ALLOWEDmeans a complain-mode profile would have blocked it.STATUSrecords a profile being loaded or replaced. Only the first two describe missing rules.grep 'apparmor="DENIED"' ~/aa-lab/kern.log | grep -oE '(profile|name|requested_mask)="[^"]*"' | sort | uniq -c2 name="/var/lib/notesd/state.db" 2 profile="/usr/local/bin/notesd" 2 requested_mask="wr"- -o
- Print only the matched part of each line, one match per line.
- -E
- Extended regex, so (a|b|c) alternation works unescaped.
Three fields answer the whole question. profilesays which profile refused, so which file to edit.nameis the path it refused.requested_maskis the access the program asked for, using the rule letters. The kernel prints them in its own order, sowrmeans write and read.denied_maskis the part of the request that was refused. Theuniq -ccount of 2 shows the same denial repeated, which is common when a service retries.Check yourself
A denial reads requested_mask="wr". Which permissions must the new rule grant?
- with
Fix it: one rule, a syntax check, a reload
Add the rule the denial asks for, check the profile without loading it, then load it on a live host.
The right fix is the narrowest rule that grants the denied access: the exact path from
name=with the letters fromrequested_mask. Don't switch the profile to complain, and don't grant/var/lib/** rw,. Both make the error go away by confining less. The rule goes inside the braces, and order doesn't matter: AppArmor combines every rule in the profile, then subtracts thedenyrules.sed -i '/^}/i\ /var/lib/notesd/state.db rw,' ~/aa-lab/usr.local.bin.notesd grep -n -A1 'state.db' ~/aa-lab/usr.local.bin.notesd13: /var/lib/notesd/state.db rw, 14-}
- /^}/i\ text
- Insert
textas a new line before the line that is just the closing brace.
The new rule sits just above the closing brace, inside the profile block. In a real terminal you would more often open the file in vimornanoand type the line. Thesedform just makes the edit repeatable. The result is the same: one path, the two letters the log asked for, and a trailing comma.sudo cp ~/aa-lab/usr.local.bin.notesd /etc/apparmor.d/ sudo apparmor_parser -Q -K /etc/apparmor.d/usr.local.bin.notesd && echo 'profile compiles'Cache read/write disabled: interface file missing. (Kernel needs AppArmor 2.4 compatibility patch.) profile compiles
- -Q / --skip-kernel-load
- Parse and compile the profile, but don't load it into the kernel.
- -K / --skip-cache
- Don't read or write the compiled-policy cache.
The first line puts the edited practice copy where the parser looks. Then a syntax check: silence from the parser and exit status 0 mean the profile compiles, and && echoprints a line only in that case. A mistake is reported with the file and line, such asAppArmor parser error for /etc/apparmor.d/usr.local.bin.notesd in profile /etc/apparmor.d/usr.local.bin.notesd at line 13: Conflict 'a' and 'w' perms are mutually exclusive.for awarule. Because nothing is loaded, this works even where AppArmor isn't running, as on the lab box. There the parser first prints theCache read/write disablednotice and then checks the profile normally.Example — not runnable on your lab box
sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.notesd- -r / --replace
- Load the profile, replacing the copy in the kernel if one is loaded.
- -R / --remove
- Unload the profile (what aa-disable does after creating the disable/ link).
Silent on success, and it takes effect at once. Running processes pick up the new rules without a restart. sudo systemctl reload apparmorreloads every profile instead. Neither works inside a lab container: with no securityfs mounted, the parser stops withunable to find a suitable fs in /proc/mounts, is it mounted?. That's why the labs grade the profile files, which are the part you edit.Now do it — read the profiles in the labs
Use the reads that start every AppArmor task on a box seeded with real profiles and a real denial
The two AppArmor labs plant profiles in
/etc/apparmor.dand give you an assignment or a denial. Inside one of them, run these reads before you change anything. Say what each one tells you: which profile, which header line, which path and which access. The fix is then a single edit.Runnable in the apparmor-profile-modes room — that room’s setup plants the files it reads
cat /root/case/assignment.env; ls /etc/apparmor.d/COMPLAIN_PROFILE=usr.sbin.mysqld COMPLAIN_BIN=/usr/sbin/mysqld DISABLE_PROFILE=usr.bin.tcpdump DISABLE_BIN=/usr/bin/tcpdump abi abstractions disable force-complain local lsb_release nvidia_modprobe tunables usr.bin.man usr.bin.tcpdump usr.sbin.mysqld usr.sbin.rsyslogd
In the modes lab, the assignment names one profile to put into complain mode and one to disable. Your names differ per session. The listing shows the profile files beside the abstractions/,tunables/,disable/andforce-complain/directories, and both changes are made in that directory.Runnable in the apparmor-profile-modes room — that room’s setup plants the files it reads
. /root/case/assignment.env; grep -n '{$' "/etc/apparmor.d/$COMPLAIN_PROFILE"2:/usr/sbin/mysqld {Sourcing the assignment file sets $COMPLAIN_PROFILEin your shell. The grep finds the header, the only line that ends in an opening brace. That's the line the complain flag goes on.Runnable in the apparmor-profile-rule room — that room’s setup plants the files it reads
grep -oE '(profile|name|requested_mask)="[^"]*"' /root/case/denial.txtprofile="/usr/local/bin/appd60e0" name="/var/lib/appd60e0/state.db" requested_mask="wr"
In the rule lab, the same three fields as in the practice log tell you the profile ( /etc/apparmor.d/usr.local.bin.<app>), the path to grant, and the letters to grant on it. The app's name differs per session.
- AppArmor is MAC layered on top of DAC: a per-program profile lists the paths that program may touch. It's path-based, while SELinux is label-based.
- Profiles live in /etc/apparmor.d, named after the binary's path with dots for slashes. The header holds the profile's name and attachment, #include pulls in abstractions, and site additions to shipped profiles go in local/.
- File rules are
path perms,: r w a m k l, and x always with a qualifier (ix, px, cx, ux). deny forbids silently. Globs: within a level, * across levels. - Enforce blocks and logs DENIED; complain allows and logs ALLOWED; disabled doesn't load. Switch modes with aa-complain/aa-enforce/aa-disable, or by hand with flags=(complain), a force-complain/ link or a disable/ link.
- A DENIED line's profile=, name= and requested_mask= give you the file, the path and the letters for one narrow rule. Check it with apparmor_parser -Q -K and load it with apparmor_parser -r.
| Task | Tool | Command |
|---|---|---|
| What's loaded, in which mode? | aa-status | sudo aa-status |
| Which profiles does a file define? | apparmor_parser | apparmor_parser -N /etc/apparmor.d/FILE |
| Tune a profile without blocking | complain mode | aa-complain FILE, or flags=(complain) in the header |
| Stop a profile loading | disable | aa-disable FILE, or ln -s ../FILE /etc/apparmor.d/disable/ + apparmor_parser -R FILE |
| What was blocked? | kernel / audit log | journalctl -k | grep 'apparmor="DENIED"' |
| Check a profile without loading it | apparmor_parser | apparmor_parser -Q -K FILE |
| Load the edited profile | apparmor_parser | apparmor_parser -r FILE |
