CoursesLinux hardeningAppArmor profiles

AppArmor profiles

Path-based confinement, complain to enforce.

Advanced12 min · lesson 13 of 16

Hire a plumber and you might hand them a key that opens the bathroom and the utility closet, and nothing else. Not the safe, not the bedrooms. AppArmor (Application Armor) hands out keys exactly like that, one per program. It is the Mandatory Access Control system that ships turned on by default on Debian and Ubuntu. Mandatory Access Control (MAC) means the rules are written by the system and enforced by the kernel, the core of the operating system that sits between programs and the hardware, no matter which user runs the program, root included. Ordinary Linux permissions work the opposite way. They are discretionary (Discretionary Access Control, or DAC): the owner of a file decides who may read it, and a running program inherits all the power of the user behind it. AppArmor adds a second layer the user cannot wave away. For each confined program it keeps a profile: a written list of the exact files that program may touch, the capabilities it may pick up (small, named slices of root's power), and the network operations it may make. A profile for the nginx web server says it may read the web root, bind its ports, and little else. So when an attacker takes over nginx, they inherit only what the profile allows, not the run of the whole machine.

Your first question on any host is what is confined, and how tightly. aa-status answers it.

~/secopslog — bash
$ sudo aa-status
apparmor module is loaded. 34 profiles are loaded. 32 profiles are in enforce mode. /usr/bin/man /usr/lib/NetworkManager/nm-dhcp-client.action /usr/sbin/cups-browsed /usr/sbin/cupsd /usr/sbin/tcpdump ... (27 more) 2 profiles are in complain mode. /usr/local/bin/myapp /usr/sbin/mysqld 0 profiles are in kill mode. 0 profiles are in unconfined mode. 3 processes have profiles defined. 2 processes are in enforce mode. /usr/sbin/cupsd (812) cupsd /usr/sbin/tcpdump (1190) tcpdump 1 processes are in complain mode. /usr/local/bin/myapp (4821) myapp 0 processes are unconfined but have a profile defined.

Read that top to bottom. Profiles in enforce mode are live keys: anything the profile does not permit, the kernel denies. Profiles in complain mode are watched but not blocked (more on that shortly). Kill mode goes one step further and kills a program the instant it does something off-list. Unconfined mode is the strange one: the profile is loaded but set to allow everything, so it is attached to the program and yet holds it to nothing at all. For your purposes that reads as no protection, and you almost never want to see it. The profiles themselves live as plain text in /etc/apparmor.d/, one file per program, named after the binary with the slashes turned into dots, so /usr/local/bin/myapp becomes usr.local.bin.myapp. The bottom half of the report pairs profiles with the running processes, which is how you confirm a service you care about is genuinely confined right now, not merely defined on disk.

What a profile actually says

A profile is an allowlist, the guest list on a club door. If an action is on the list it goes through; if it is not, it is turned away. The profile spells out what the program may do and denies everything else by default, so you never have to predict every bad action, only name the handful of good ones. Each rule is a path plus a set of access modes: r to read, w to write, a to append only, m to memory-map a file as executable (which a program needs in order to load its own code and its shared libraries), k to lock a file, and l to create links. There are also execute-transition modes that decide what happens when the confined program launches another binary, but the file rules are where you spend most of your time.

/etc/apparmor.d/usr.local.bin.myapp
#include <tunables/global>
profile myapp /usr/local/bin/myapp {
#include <abstractions/base>
#include <abstractions/nameservice>
capability net_bind_service, # may bind a port below 1024
network inet stream, # may open IPv4 TCP sockets
network inet6 stream, # ... and IPv6
/usr/local/bin/myapp mr, # map + read its own binary
/etc/myapp/** r, # read its config tree
/var/lib/myapp/** rw, # read/write its data dir
/var/log/myapp/*.log w, # write its log files
deny /etc/shadow r, # never read the password hashes
deny /home/** r, # never read user data
}

Two lines here do a lot of quiet work. #include <abstractions/base> pulls in the baseline accesses almost every program needs (shared libraries, /dev/null, locale data), so you are not re-listing them in every profile. capability net_bind_service grants one specific power: the ability to bind a port below 1024. Think of the root account's authority as a single master key; Linux capabilities are that key sawn into a few dozen small ones, and AppArmor hands this program exactly one of them. The two network lines are narrow in the same way. They let myapp open TCP (Transmission Control Protocol) connections over IPv4 and IPv6, the old and new versions of the internet's addressing scheme, and nothing else, so it cannot open the kind of raw socket you would use to quietly sniff traffic. The two deny lines are belt-and-suspenders. Reads are already denied by default, but writing deny /etc/shadow r and deny /home/** r states the intent out loud and blocks those reads even if a later rule or an included abstraction would otherwise have permitted them.

Path, not label

AppArmor matches on the path, the file's address on disk, not on a tag stuck to the file itself. That is what makes a profile easy to read: every line names a place you can walk over to and look at. It is the big practical difference from SELinux (Security-Enhanced Linux, the other main Mandatory Access Control system on Linux), where the rules are written against labels attached to every file and process, so to see why something was denied you first have to go read the labels. The path model also sets the boundary you have to think about. A rule grants access to a path, so if the same bytes are reachable by another path (a symlink, a bind mount, a second hard link), the rule that names one path does not automatically cover the other. AppArmor resolves symlinks down to the real path and matches on that, which is usually what you want, but it means you write rules against where files actually live. Keep confined binaries where their profile expects them, and be careful about granting write access to a directory an attacker could fill with symlinks.

The life of an AppArmor profile
1Scaffold
aa-genprof drops a skeleton into /etc/apparmor.d
2Complain mode
logs every access, blocks nothing
3Exercise the app
run its real workload so every path shows up
4aa-logprof
review each logged access, fold it into the rules
5Enforce mode
aa-enforce: now anything off-list is denied
6Reload & verify
apparmor_parser -r, confirm the live mode
You only leave complain once the app has run its real workload without new denials.

Complain first, then enforce

You would not fit a new lock to your front door and hand back the only key without checking it turns. AppArmor builds the same caution in, and calls it complain mode. In complain mode the profile logs every access the program makes and blocks none of them, so you can watch a real workload, confirm the profile covers everything legitimate, and only then switch to blocking. This is the same tune-then-enforce discipline you find in SELinux and in seccomp (secure computing mode, the kernel feature that filters which system calls a program is allowed to make). You never flip an unproven profile straight to enforce on a production service, because the first legitimate access it forgot to allow becomes an outage.

~/secopslog — bash
$ sudo aa-complain /usr/local/bin/myapp
Setting /usr/local/bin/myapp to complain mode.

Now run the program through its real work: hit every endpoint, run every job, touch every config it reads on a normal day. Each access lands in the log. aa-logprof then reads that log and walks you through the new accesses one at a time, proposing a rule for each and letting you accept, generalize, or reject it.

~/secopslog — bash
$ sudo aa-logprof
Reading log entries from /var/log/syslog. Updating AppArmor profiles in /etc/apparmor.d. Profile: /usr/local/bin/myapp Path: /var/lib/myapp/cache/session.db New Mode: owner rw Severity: unknown [1 - owner /var/lib/myapp/cache/session.db rw,] (A)llow / [(D)eny] / (I)gnore / (G)lob / Glob with (E)xtension / (N)ew / Audi(t) / (O)wner permissions / Abo(r)t / (F)inish

Press A to allow the rule as shown, or G to turn a specific filename into a wildcard so the rule covers a whole directory instead of one file, which is how you avoid a profile that breaks the moment a temp file gets a new name. Deny records that the access should never be permitted. When you finish, aa-logprof writes the updated rules back into the profile. With the profile now covering the real workload, switch it to blocking.

~/secopslog — bash
$ sudo aa-enforce /usr/local/bin/myapp
Setting /usr/local/bin/myapp to enforce mode.

Watch the denials

Enforcement is only half the value. The other half is that every block is written down, so a denial is both a stopped action and a lead worth reading. On a modern systemd host (systemd is the init system that starts and supervises services on most Linux distributions today) the AppArmor kernel messages land in the journal, systemd's central log store, as audit records. Pull them with journalctl.

~/secopslog — bash
$ sudo journalctl -k --grep='apparmor="DENIED"' | tail -n 2
Jun 10 14:07:19 web01 kernel: audit: type=1400 audit(1718028439.712:142): apparmor="DENIED" operation="open" class="file" profile="myapp" name="/etc/shadow" pid=4821 comm="myapp" requested_mask="r" denied_mask="r" fsuid=0 ouid=0 Jun 10 14:07:19 web01 kernel: audit: type=1400 audit(1718028439.712:143): apparmor="DENIED" operation="open" class="file" profile="myapp" name="/home/deploy/.ssh/id_ed25519" pid=4821 comm="myapp" requested_mask="r" denied_mask="r" fsuid=0 ouid=1000

Read one line like a sentence. profile="myapp" is which confined program tripped, operation="open" and name="/etc/shadow" are what it tried to touch, requested_mask="r" is that it wanted to read, and apparmor="DENIED" is the kernel refusing. Those two lines tell a story: something running as myapp reached for the password hashes and for a user's SSH private key, and the profile stopped both cold. That is the exact shape of a compromised process probing for secrets, and the profile turned it into an alert instead of a breach. One caution for the tuning phase: in complain mode these same lines read apparmor="ALLOWED", because complain audits without blocking. Seeing ALLOWED is your reminder that the profile is not protecting anything yet.

Reload and verify

When you edit a profile by hand, the change on disk does nothing until you load it into the kernel. apparmor_parser -r compiles and reloads a single profile in place. At boot the apparmor systemd service loads everything in /etc/apparmor.d/ for you, so your edits stick across reboots. To prove the live state rather than trust it, read the kernel's own list of profiles straight from securityfs, a small in-memory filesystem the kernel uses to expose the state of its security modules. It prints each profile with its current mode in parentheses.

~/secopslog — bash
$ sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.myapp # reload the edited profile cat /sys/kernel/security/apparmor/profiles | grep myapp # confirm the live mode
/usr/local/bin/myapp (enforce)
Complain does not protect, and disabling throws it away
Two failure modes bite here. First, a profile left in complain mode logs violations but blocks nothing, so a host that looks confined in your notes is wide open in reality. Complain is a tuning step, not a resting state, and aa-status is where you catch a profile that never got promoted to enforce. Second, when an app breaks under a denial, the tempting fix is aa-disable or deleting the profile, which throws away the confinement for good. The real fix is to read the denied access, and if it is legitimate, add that one path or capability with aa-logprof, then re-enforce. Keep profiles enforcing on anything internet-facing (web servers, mail, browsers), because those are the programs whose compromise the profile exists to contain.
Quick check
01A profile is in complain mode when a compromised process tries to read /etc/shadow. What happens?
Incorrect — That is enforce-mode behavior; complain never blocks.
Correct — complain audits without enforcing, which is why it is for tuning, not protection.
Incorrect — kill mode can terminate a program, but complain does not.
Incorrect — Complain's whole purpose is to log every access it sees.
02AppArmor writes its rules against a file's path rather than a label attached to the file. What practical consequence does that have?
Correct — the lesson warns that a second route to the same data is not automatically covered, which is why write access to a directory an attacker can fill with symlinks is risky.
Incorrect — because matching is by path, moving a file to a path the profile does not name changes what is allowed.
Incorrect — that is the SELinux label model; AppArmor's appeal is that each rule names a path you can walk over and look at.
Incorrect — rules follow the path, not the inode, so a binary moved off its expected path falls outside its profile.
03In enforce mode, the journal shows profile myapp was DENIED read on /etc/shadow and on /home/deploy/.ssh/id_ed25519. The app has never legitimately needed either file. What is the right reading?
Incorrect — those are the password hashes and a user's private key; granting them would hand an attacker exactly what the profile just blocked.
Correct — the lesson describes this as the shape of a process probing for secrets, contained into an alert instead of a breach.
Incorrect — complain would stop blocking these reads and remove the protection at the worst moment; the denials are the system working.
Incorrect — abstractions/nameservice covers DNS and lookups, not reads of /etc/shadow or SSH keys; this is a real probe.

Wire aa-status into whatever watches your fleet, and alert when an internet-facing profile drops out of enforce, whether someone set it to complain to debug a problem and forgot, or a deploy shipped a new build the old profile no longer matches. A web server, a mail daemon, or a browser running unconfined is exactly the gap these profiles exist to close, so the profile you most need in enforce is the one on whatever is listening to the internet.

Try this

Work through “Reload and verify” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: complain does not protect, and disabling throws it away. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related