AppArmor on Ubuntu
Profiles, complain mode and denials.
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.
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.
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:
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/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 -eread -r host < /etc/hostnameread -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:
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).
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.
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.
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:
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.
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,}
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.
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:
# Never read home directories, whatever an include might allow.deny /home/** r,
The read is refused and ausearch finds nothing. Even in complain mode the explicit deny still blocks. Put the profile back in enforce mode:
Then add audit in front of the rule and reload, and the rule logs again:
audit deny /home/** r,
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.
Verify, roll back, remove
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:
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.
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.