AppArmor on Ubuntu

Profiles, complain mode and denials.

Intermediate16 min · lesson 21 of 24

AppArmor is the mandatory access control system Ubuntu enables by default. It confines a program to a list of files, capabilities and network operations written in a profile, whatever user runs it, so a compromised service gets what its profile allows rather than everything its user can reach. In this lesson you read what Ubuntu 26.04 already confines, write a profile for a small tool, run it in complain mode, enforce it, read a real denial, fix it with one rule, learn exactly when a denial is logged and when it is not, and remove the profile again. Everything here is Ubuntu (and Debian and SUSE) behaviour; RHEL uses SELinux instead, which is the next lesson.

What is confined on a default server

Ordinary Linux permissions are discretionary: a file's owner decides who may read it, and a program inherits every right of the user running it. Mandatory access control (MAC) adds a second check in the kernel, driven by policy that the program cannot change. AppArmor writes that policy per program, by path: a profile names the executable it attaches to and lists what it may do, and everything not listed is denied. aa-status summarises what is loaded; it ships in the apparmor package, so it is present on every Ubuntu server.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo aa-status
apparmor module is loaded. 203 profiles are loaded. 127 profiles are in enforce mode. /usr/bin/man /usr/lib/snapd/snap-confine /usr/sbin/chronyd … 2 profiles are in complain mode. Xorg Xorg_wrap 0 profiles are in prompt mode. 0 profiles are in kill mode. 74 profiles are in unconfined mode. 1password … 3 processes have profiles defined. 3 processes are in enforce mode. /usr/sbin/chronyd (368308) /usr/sbin/chronyd (368309) /usr/sbin/rsyslogd (368280) rsyslogd … 0 processes are unconfined but have a profile defined. …

A profile is in one of a few modes. Enforce blocks and logs anything the profile does not allow. Complain allows it but logs it, which is how you develop a profile. Kill is enforce plus killing the process, and prompt asks a userspace helper. The bottom half pairs profiles with running processes: on this server only chronyd and rsyslogd are confined right now, and "unconfined but have a profile defined" counts processes that started before their profile was loaded. A running process keeps the confinement it started with; restart it after loading a new profile.

The 74 profiles in unconfined mode surprise people. They are not broken. Ubuntu restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns = 1), a kernel feature many sandboxes and container tools rely on, and these profiles exist to give named programs back the userns permission and nothing else.

deploy@web01 · Ubuntu 26.04 LTS
$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1
$ cat /etc/apparmor.d/buildah
# This profile allows everything and only exists to give the # application a name instead of having the label "unconfined" abi <abi/5.0>, include <tunables/global> profile buildah /usr/bin/buildah flags=(unconfined) { userns, @{exec_path} mr, # Site-specific additions and overrides. See local/README for details. include if exists <local/buildah> }

So the number to watch is not how many profiles say "unconfined". It is whether the programs that face the network, such as a web server, a mail server or a custom API, are confined in enforce mode. Ubuntu ships enforce-mode profiles for some programs (rsyslogd, chronyd and man among them, and many that are not installed here); your own applications need profiles you write.

Tools on a default install

The editing helpers aa-complain, aa-enforce, aa-logprof and aa-genprof live in apparmor-utils, which Ubuntu Server 26.04 does not install, and a default install has no auditd either. On the untouched VM:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ sudo aa-status | head -n 3
apparmor module is loaded. 179 profiles are loaded. 103 profiles are in enforce mode.
$ sudo aa-complain /etc/apparmor.d/usr.local.bin.hard-aa-demo
sudo: 'aa-complain': command not found
$ apt-get -s install apparmor-utils | grep -E '^Inst'
Inst python3-libapparmor (5.0.2-0ubuntu1~26.04.1 Ubuntu:26.04/resolute-updates [arm64]) Inst python3-apparmor (5.0.2-0ubuntu1~26.04.1 Ubuntu:26.04/resolute-updates [all]) Inst apparmor-utils (5.0.2-0ubuntu1~26.04.1 Ubuntu:26.04/resolute-updates [all])
$ systemctl status auditd --lines 0
Unit auditd.service could not be found.

sudo apt install apparmor-utils adds the helpers and two Python libraries. You can manage profiles without them: apparmor_parser (in the base package) loads, reloads (-r) and removes (-R) profiles, and a profile's mode is the flags=(complain) part of its header, which is all aa-complain edits. The lab machine for the rest of this lesson has apparmor-utils and auditd installed; the difference that matters is where denials are logged, covered below.

A profile for a small tool

The program to confine is a short bash script in /usr/local/bin that prints the first line of a report, prefixed with the host name. Its report directory is /srv/hard-aa-demo, but it reads any file you name, which is the kind of flaw a profile exists to contain.

/usr/local/bin/hard-aa-demo
#!/usr/bin/bash
# Print the first line of a report, prefixed with this host's name.
# Usage: hard-aa-demo [REPORT] (default: /srv/hard-aa-demo/today.txt)
set -e
read -r host < /etc/hostname
read -r line < "${1:-/srv/hard-aa-demo/today.txt}"
printf '%s: %s\n' "$host" "$line"

Save it with sudo tee or an editor and make it executable with sudo chmod 755 /usr/local/bin/hard-aa-demo. It needs a report to read, and two private files in your home directory stand in for data it should never touch:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mkdir -p /srv/hard-aa-demo echo "backup finished, 0 errors" | sudo tee /srv/hard-aa-demo/today.txt echo "deploy private notes" > ~/hard-aa-notes.txt echo "deploy private plans" > ~/hard-aa-plans.txt
backup finished, 0 errors
$ hard-aa-demo
web01: backup finished, 0 errors
$ hard-aa-demo /home/deploy/hard-aa-notes.txt
web01: deploy private notes

Unconfined, it happily prints a private file from /home/deploy, because file permissions allow deploy to read its own files. Here is a first profile, saved as /etc/apparmor.d/usr.local.bin.hard-aa-demo (the convention is the program's path with dots for slashes).

/etc/apparmor.d/usr.local.bin.hard-aa-demo
abi <abi/5.0>,
include <tunables/global>
# Confine the report tool. Anything not allowed below is denied.
profile hard-aa-demo /usr/local/bin/hard-aa-demo {
include <abstractions/base>
# The script itself, and bash to run it (ix: bash stays inside this profile).
/usr/local/bin/hard-aa-demo r,
/usr/bin/bash ix,
# The reports it exists to read.
/srv/hard-aa-demo/** r,
}

abi <abi/5.0> pins the policy feature set the profile was written for, as Ubuntu 26.04's own profiles do. profile hard-aa-demo /usr/local/bin/hard-aa-demo gives the profile a short name (used in logs and aa-status) and attaches it to the program's path. abstractions/base grants what almost every program needs (shared libraries, locale data, /dev/null). Each rule is a path and permissions: r read, w write, m map as executable, ix execute a program inside this same profile. A script is run by its interpreter, so the profile has to allow bash and the script itself. AppArmor, unlike systemd, accepts a comment after a rule on the same line, but this course keeps comments on their own lines in every format.

Complain mode first, then enforce

Load the profile, then put it in complain mode so you can watch what the tool really touches before anything is blocked.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.hard-aa-demo
$ sudo aa-complain /etc/apparmor.d/usr.local.bin.hard-aa-demo
Setting /etc/apparmor.d/usr.local.bin.hard-aa-demo to complain mode.
$ grep "^profile" /etc/apparmor.d/usr.local.bin.hard-aa-demo sudo cat /sys/kernel/security/apparmor/profiles | grep hard-aa-demo
profile hard-aa-demo /usr/local/bin/hard-aa-demo flags=(complain) { hard-aa-demo (complain)
$ hard-aa-demo
web01: backup finished, 0 errors

aa-complain rewrote the header to flags=(complain) and reloaded the profile; the kernel's own list in /sys/kernel/security/apparmor/profiles (root-only) confirms the live mode. The tool worked. With auditd installed, AppArmor's records go to /var/log/audit/audit.log as type AVC (access vector cache: the record type SELinux named, which AppArmor reuses); --input-logs makes ausearch read the log files even when it is not run from a terminal.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ausearch --input-logs -m AVC -c hard-aa-demo -ts recent
---- time->Sun Sep 27 10:06:24 2026 type=PROCTITLE msg=audit(1790503584.441:45871): proctitle=2F7573722F62696E2F62617368002F7573722F6C6F63616C2F62696E2F686172642D61612D64656D6F type=PATH msg=audit(1790503584.441:45871): item=0 name="/dev/tty" inode=12 dev=00:07 mode=020666 ouid=0 ogid=5 rdev=05:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 type=CWD msg=audit(1790503584.441:45871): cwd="/home/deploy" type=SYSCALL msg=audit(1790503584.441:45871): arch=c00000b7 syscall=56 success=no exit=-6 a0=ffffffffffffff9c a1=b053194dbdc0 a2=802 a3=0 items=1 ppid=377612 pid=377613 auid=502 uid=1001 gid=1001 euid=1001 suid=1001 fsuid=1001 egid=1001 sgid=1001 fsgid=1001 tty=(none) ses=4 comm="hard-aa-demo" exe="/usr/bin/bash" subj=hard-aa-demo key=(null) type=AVC msg=audit(1790503584.441:45871): apparmor="ALLOWED" operation="open" class="file" profile="hard-aa-demo" name="/dev/tty" pid=377613 comm="hard-aa-demo" requested_mask="wr" denied_mask="wr" fsuid=1001 ouid=0 … type=AVC msg=audit(1790503584.441:45872): apparmor="ALLOWED" operation="open" class="file" profile="hard-aa-demo" name="/etc/hostname" pid=377613 comm="hard-aa-demo" requested_mask="r" denied_mask="r" fsuid=1001 ouid=0 … type=AVC msg=audit(1790503584.441:45873): apparmor="ALLOWED" operation="file_perm" class="file" profile="hard-aa-demo" name="/etc/hostname" pid=377613 comm="hard-aa-demo" requested_mask="r" denied_mask="r" fsuid=1001 ouid=0

Each event groups several records; the AVC record is AppArmor's. The SYSCALL record around it names the program (exe="/usr/bin/bash", running the script) and the CPU architecture (arch=c00000b7 is this lab's aarch64; an x86_64 server shows c000003e). Each apparmor="ALLOWED" record is an access the profile does not cover. /etc/hostname (opened, then read) is a real need that the first draft forgot. /dev/tty is bash checking for a terminal, which this tool does not need. Someone who ignores the complain log and enforces now finds out the hard way.

Enforce it anyway and see what happens:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo aa-enforce /etc/apparmor.d/usr.local.bin.hard-aa-demo
Setting /etc/apparmor.d/usr.local.bin.hard-aa-demo to enforce mode.
$ hard-aa-demo
/usr/local/bin/hard-aa-demo: line 5: /etc/hostname: Permission denied
$ sudo ausearch --input-logs -m AVC -c hard-aa-demo -ts recent | grep DENIED
type=AVC msg=audit(1790503584.812:45895): apparmor="DENIED" operation="open" class="file" profile="hard-aa-demo" name="/dev/tty" pid=377688 comm="hard-aa-demo" requested_mask="wr" denied_mask="wr" fsuid=1001 ouid=0 type=AVC msg=audit(1790503584.812:45896): apparmor="DENIED" operation="open" class="file" profile="hard-aa-demo" name="/etc/hostname" pid=377688 comm="hard-aa-demo" requested_mask="r" denied_mask="r" fsuid=1001 ouid=0

The script fails with "Permission denied" even though the file is world-readable: the kernel's AppArmor check refused it after the ordinary permission check passed. The record reads left to right: apparmor="DENIED", operation="open", profile="hard-aa-demo", name="/etc/hostname", requested_mask="r". The fix is one allow rule. For /dev/tty, which the tool never needs, a deny rule refuses it without writing a record on every run.

/etc/apparmor.d/usr.local.bin.hard-aa-demo
abi <abi/5.0>,
include <tunables/global>
# Confine the report tool. Anything not allowed below is denied.
profile hard-aa-demo /usr/local/bin/hard-aa-demo {
include <abstractions/base>
# The script itself, and bash to run it (ix: bash stays inside this profile).
/usr/local/bin/hard-aa-demo r,
/usr/bin/bash ix,
# Its own host name, for the report prefix.
/etc/hostname r,
# bash probes for a terminal; the tool does not need one. Refuse it without logging.
deny /dev/tty rw,
# The reports it exists to read.
/srv/hard-aa-demo/** r,
}
deploy@web01 · Ubuntu 26.04 LTS
$ sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.hard-aa-demo hard-aa-demo
web01: backup finished, 0 errors

apparmor_parser -r compiles the file and replaces the loaded profile in place; the apparmor.service unit loads everything in /etc/apparmor.d at boot, so the edit persists. Rules follow the real path of a file after symlinks are resolved, so a profile has to name where a file actually lives. Hard links are the gap in a path model, which is why creating links has its own l permission.

When a denial is logged, and when it is not

Now the flaw: a request for a file the tool should never read.

deploy@web01 · Ubuntu 26.04 LTS
$ hard-aa-demo /home/deploy/hard-aa-notes.txt
/usr/local/bin/hard-aa-demo: line 6: /home/deploy/hard-aa-notes.txt: Permission denied
$ sudo ausearch --input-logs -m AVC -ts recent -f /home/deploy/hard-aa-notes.txt | grep DENIED
type=AVC msg=audit(1790503584.898:45918): apparmor="DENIED" operation="open" class="file" profile="hard-aa-demo" name="/home/deploy/hard-aa-notes.txt" pid=377774 comm="hard-aa-demo" requested_mask="r" denied_mask="r" fsuid=1001 ouid=1001

Nothing in the profile mentions /home, so this is an implicit denial, and implicit denials are logged. That record is the valuable part: a program reaching for files it never needs is what a compromised process looks like. An explicit deny rule behaves differently. apparmor.d(5) says deny rejects matching requests "without logging", and that complain mode "will only convert implicit denials to ALLOWED". Add one after the reports rule and reload with sudo apparmor_parser -r:

/etc/apparmor.d/usr.local.bin.hard-aa-demo (added after the reports rule)
# Never read home directories, whatever an include might allow.
deny /home/** r,
deploy@web01 · Ubuntu 26.04 LTS
$ grep -B1 "deny /home" /etc/apparmor.d/usr.local.bin.hard-aa-demo
# Never read home directories, whatever an include might allow. deny /home/** r,
$ hard-aa-demo /home/deploy/hard-aa-plans.txt
/usr/local/bin/hard-aa-demo: line 6: /home/deploy/hard-aa-plans.txt: Permission denied
$ sudo ausearch --input-logs -m AVC -ts recent -f /home/deploy/hard-aa-plans.txt
<no matches>
$ sudo aa-complain /etc/apparmor.d/usr.local.bin.hard-aa-demo hard-aa-demo /home/deploy/hard-aa-plans.txt
… /usr/local/bin/hard-aa-demo: line 6: /home/deploy/hard-aa-plans.txt: Permission denied

The read is refused and ausearch finds nothing. Even in complain mode the explicit deny still blocks. Put the profile back in enforce mode:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo aa-enforce /etc/apparmor.d/usr.local.bin.hard-aa-demo
Setting /etc/apparmor.d/usr.local.bin.hard-aa-demo to enforce mode.

Then add audit in front of the rule and reload, and the rule logs again:

/etc/apparmor.d/usr.local.bin.hard-aa-demo (the changed rule)
audit deny /home/** r,
deploy@web01 · Ubuntu 26.04 LTS
$ grep "deny /home" /etc/apparmor.d/usr.local.bin.hard-aa-demo hard-aa-demo /home/deploy/hard-aa-plans.txt
audit deny /home/** r, /usr/local/bin/hard-aa-demo: line 6: /home/deploy/hard-aa-plans.txt: Permission denied
$ sudo ausearch --input-logs -m AVC -ts recent -f /home/deploy/hard-aa-plans.txt | grep DENIED
type=AVC msg=audit(1790503585.758:45967): apparmor="DENIED" operation="open" class="file" profile="hard-aa-demo" name="/home/deploy/hard-aa-plans.txt" pid=377970 comm="hard-aa-demo" requested_mask="r" denied_mask="r" fsuid=1001 ouid=1001

So use plain deny to silence a known, harmless request (the /dev/tty probe) or to guarantee a path stays blocked whatever an include allows, and audit deny when you want to see attempts. A profile full of plain deny rules for sensitive paths hides exactly the events you would want to alert on.

Developing a profile
1Write the profile
abi, include, rules for what the program needs
2Load in complain mode
apparmor_parser -r, then aa-complain
3Run the real workload
every job, endpoint and config path
4Read ALLOWED records
ausearch -m AVC or journalctl -k
5Add rules, then enforce
aa-enforce; watch for DENIED
6Reload after each edit
apparmor_parser -r
aa-logprof can propose rules from the log; reading the records yourself works on any install.

Verify, roll back, remove

deploy@web01 · Ubuntu 26.04 LTS
$ sudo aa-status --filter.profiles=hard-aa-demo
apparmor module is loaded. 204 profiles are loaded. 1 profiles are in enforce mode. hard-aa-demo 0 profiles are in complain mode. …
$ sudo aa-complain /etc/apparmor.d/usr.local.bin.hard-aa-demo
Setting /etc/apparmor.d/usr.local.bin.hard-aa-demo to complain mode.
$ sudo apparmor_parser -R /etc/apparmor.d/usr.local.bin.hard-aa-demo sudo rm /etc/apparmor.d/usr.local.bin.hard-aa-demo
$ sudo cat /sys/kernel/security/apparmor/profiles | grep hard-aa-demo

The filtered aa-status shows the profile in enforce mode. The first rollback is back to complain: the program works again, and you keep the log. Removing a profile is apparmor_parser -R to unload it plus deleting the file (or aa-disable, which unloads it and adds a link in /etc/apparmor.d/disable so it is not loaded at boot). A program started under the profile stays confined until it restarts. For services, systemd can also select a profile with AppArmorProfile= in the unit.

Where records go depends on auditd. On the default install, with no auditd, the same kind of denial lands in the kernel log:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ sudo journalctl -k -g 'apparmor="DENIED"' --since "-5min" --no-hostname
Sep 27 09:23:17 kernel: audit: type=1400 audit(1790500997.528:197): apparmor="DENIED" operation="open" class="file" profile="hard-aa-demo" name="/home/deploy/hard-aa-notes.txt" pid=60349 comm="hard-aa-demo" requested_mask="r" denied_mask="r" fsuid=1001 ouid=1001

Ubuntu enables AppArmor with its shipped profiles in enforce mode; that is the vendor default. Confining your own internet-facing services, and alerting when one of them drops to complain or unconfined, is SecOpsLog advice. The CIS benchmark for the older Ubuntu 24.04 release (Level 1 Server, as encoded in the SCAP Security Guide content) requires AppArmor to be installed and every loaded profile to be in enforce or complain mode; no scanning content for 26.04 was available on the lab (see the last lesson). The operational cost is a profile to maintain: a new release that reads a new path breaks under enforce until the profile is updated, so ship profile changes with application changes and test them in complain mode first.

On RHEL 10
RHEL and Rocky do not ship AppArmor; their MAC system is SELinux, enforcing by default, which labels files and processes instead of matching paths. The next lesson covers it.

Try this

On your lab machine, write the tool, its report file and the first profile above, load it with sudo apparmor_parser -r and sudo aa-complain, run the tool, and find the ALLOWED records with sudo ausearch -m AVC -c hard-aa-demo -ts recent. Enforce it and confirm the "Permission denied" and the DENIED record for /etc/hostname. Add the two rules, reload, and confirm it works. Then ask it for a file in your home directory, check that the denial is logged, add deny /home/** r,, repeat, and confirm that nothing new is logged. Finish with sudo apparmor_parser -R and delete the profile file.

Takeaway

Develop a profile in complain mode and read the ALLOWED records before you enforce. In enforce mode, treat a DENIED record for a path the program never needs as an alert, fix real needs with an allow rule and apparmor_parser -r, and remember that a plain deny rule blocks silently: use audit deny where you want to see attempts.

Quick check
01A profile in complain mode contains "deny /etc/shadow r," and the confined program, running as root, tries to read /etc/shadow. What happens?
Incorrect — Complain mode converts only implicit denials into ALLOWED; an explicit deny rule still blocks, as apparmor.d(5) states.
Correct — The explicit deny still applies in complain mode, and without the audit qualifier it logs nothing.
Incorrect — Only implicit denials and audit deny rules are logged; a plain deny rule suppresses the record.
Incorrect — AppArmor applies to root as well; being root does not bypass a profile in any mode.
02aa-status on this lesson's Ubuntu 26.04 lab server lists 74 profiles in unconfined mode. A colleague wants to switch them all to enforce. What is the better reading?
Incorrect — They belong to Ubuntu's apparmor package and serve a purpose; deleting them would break the programs they name.
Incorrect — The profiles load fine; unconfined is the mode their authors chose on purpose.
Incorrect — They are not templates; forcing them to enforce would deny those programs almost everything, since they list no file rules.
Correct — They grant the userns permission and nothing else; what matters is whether your exposed services are confined in enforce mode.
03You enforce a new profile for an internal API. It starts failing, and the log shows DENIED for /etc/ssl/openssl.cnf, a file the API reads on every request. What is the right fix?
Correct — A legitimate need the profile missed is fixed with one narrow allow rule, then a reload replaces the loaded profile in place.
Incorrect — Complain mode blocks nothing, so leaving it there removes the confinement you wrote the profile for.
Incorrect — A deny rule would still block the read and only hide the record; the API would keep failing.
Incorrect — That gives up MAC for a one-line fix; file permissions cannot stop a compromised API from reading what its user can read.

Related