AppArmor
AppArmor (“Application Armor”) is a path-based mandatory access control (MAC) module built on the Linux Security Module (LSM) framework. It confines individual programs to a per-program profile — a policy that whitelists the exact files, capabilities, and network operations the program may use — and it attaches that profile to a process at
execve(2)time by matching the executable’s pathname. Its defining philosophy is the inverse of SELinux: a program with no profile runs unconfined (subject only to ordinary discretionary access control (DAC)), so confinement is opt-in per binary rather than blanket-on for the whole system. AppArmor ships and is enabled by default on Ubuntu, Debian (since “Buster”, Debian 10), and SUSE/openSUSE (Ubuntu Server docs; Debian wiki). The kernel side issecurity/apparmor/in the tree (this note is pinned to Linux 6.12 LTS, released 2024-11-17); the userspace side is theapparmor/apparmor-utilspackages that compile and load profiles.
The single most important idea: AppArmor mediates by path, not by label. SELinux stamps a persistent security label on every inode and reasons about labels; AppArmor names a file by where it sits in the filesystem tree (/etc/shadow, /var/log/**) and matches that string. This makes AppArmor profiles dramatically easier to read and write, and it is why it became the distro default on Ubuntu/SUSE — but it is also the root of its central, honest weakness, covered in depth in the sibling note AppArmor Profiles and Path-Based Confinement.
Mental Model
Think of AppArmor as a per-executable allowlist that clicks into place the instant a program is exec’d. Before exec, the parent is whatever it was. At exec, the kernel looks at the path of the new program, asks “is there a profile attached to this path?”, and if so the new task runs confined by that profile from its first instruction. Every file open, capability use, socket call, mount, or signal the program then attempts is checked against the profile’s rules; anything not explicitly allowed is denied (in enforce mode) or merely logged (in complain mode). If no profile matches the path, the task runs unconfined — AppArmor adds nothing, and only DAC applies.
flowchart TB subgraph build["Build / load time (userspace)"] SRC["Profile text<br/>/etc/apparmor.d/usr.bin.foo"] PARSER["apparmor_parser<br/>(compiles to a DFA)"] SRC --> PARSER end PARSER -->|"write compiled policy"| LOAD["securityfs:<br/>/sys/kernel/security/apparmor/.load"] LOAD --> KPOL["In-kernel policy tree<br/>(profiles + match DFA)"] subgraph run["Run time (kernel)"] EXEC["execve(/usr/bin/foo)"] --> ATTACH{"path matches an<br/>attached profile?"} ATTACH -->|"yes"| CONF["task runs CONFINED<br/>by usr.bin.foo profile"] ATTACH -->|"no"| UNCONF["task runs UNCONFINED<br/>(DAC only)"] end KPOL -.->|"consulted at exec"| ATTACH CONF --> HOOK["every file/cap/net/mount/signal op<br/>checked at LSM hooks"] HOOK -->|"enforce: deny → EACCES/EPERM<br/>complain: allow + log"| RESULT["allowed or denied"]
The two-phase life of an AppArmor policy. What it shows: policy is authored as text, compiled by apparmor_parser into a deterministic-finite-automaton (DFA) match engine, and pushed into the kernel through a securityfs file; at execve the kernel matches the new program’s path against attached profiles, and a match makes the task confined for the rest of its life. The insight: the path-match at exec is the whole gate — match and you are confined, miss and you run unconfined. There is no system-wide default-deny the way SELinux has; confinement is per-binary and opt-in.
Mechanical Walk-through
How a profile reaches the kernel
A profile is plain text living in /etc/apparmor.d/, conventionally named after the binary’s full path with / replaced by . — /usr/bin/foo becomes /etc/apparmor.d/usr.bin.foo (apparmor.7; Ubuntu docs). That naming is a convention for humans and tooling, not a kernel requirement — what actually binds a profile to a binary is the profile’s attachment specification (the path in its header), not the filename.
The apparmor_parser(8) tool reads the text, expands its includes and variables, and compiles the path-matching rules into a DFA (a deterministic finite automaton — a state machine that, fed a path string character by character, lands in an accepting state carrying the permission bits for that path). It then writes the compiled binary policy into the kernel through securityfs. Per the parser man page, the default interface directory is /sys/kernel/security/apparmor, and the three management operations map to three magic files (apparmor_parser.8):
--add/-awrites to.load— insert a profile not already present (the default action).--replace/-rwrites to.replace— atomically swap a new version of an already-loaded profile.--remove/-Rwrites to.remove— unload a profile.
In the kernel, apparmorfs.c registers these as the file-operation hooks policy_load, policy_replace, and policy_remove, all funnelling into policy_update(), which checks the caller may manage policy (aa_may_manage_policy) and then calls aa_replace_profiles() / aa_remove_profiles() to splice the compiled policy into the in-kernel profile tree (apparmorfs.c). Because the parser does the expensive DFA compilation in userspace and the kernel just loads the finished automaton, AppArmor caches compiled profiles under /var/cache/apparmor to skip recompilation on reboot (-W/--write-cache, -L/--cache-loc).
How a profile attaches at exec
This is the heart of AppArmor and the place it most differs from SELinux. When a confined-or-unconfined task calls execve, the LSM hook apparmor_bprm_creds_for_exec runs (declared in domain.h, implemented in domain.c). For a task that is currently unconfined, the kernel walks the loaded profiles and calls find_attach() to pick the best-matching profile for the new program’s path (domain.c).
find_attach() does not do a simple filename equality test. Each profile carries an xmatch DFA built from its attachment path expression, and the kernel runs the new binary’s pathname through every profile’s xmatch automaton. The matching rule, quoted from the source comment, is explicit: “A more specific path match will be preferred over a less specific one… If the best match has both the same level of path specificity and the same number of matching extended attributes as another profile, signal a conflict and refuse to match.” In other words, longest/most-specific match wins, exact non-regex names short-circuit the search, and a genuine tie produces info = "conflicting profile attachments" and no attachment rather than an arbitrary pick. AppArmor can additionally condition attachment on the binary’s extended attributes (xattrs), so two binaries at paths a glob would both match can be told apart by an xattr.
If a profile is found, the new task’s credentials are updated so it runs confined by that profile. If the task was already confined, its current profile’s exec rules decide what happens next (the Px/Cx/Ix/Ux transition modes — see AppArmor Profiles and Path-Based Confinement). If nothing matches and the task was unconfined, it stays unconfined. Critically, a process that is already running unconfined cannot be retroactively confined (apparmor.7): “once a profile is loaded for a program, that program will be confined on the next exec(3).” You must (re)start the program after loading its profile.
How a confined operation is checked
Once a task is confined, AppArmor’s LSM hooks fire on every security-relevant operation. For a file open, the kernel resolves the file’s pathname, runs it through the profile’s file-permission DFA, and reads off the allowed permission mask (the r/w/m/k/l/x bits — defined in security/apparmor/include/file.h and perms.h). It then compares the requested access against the allowed mask. In enforce mode, a missing permission yields EACCES/EPERM; in complain mode the access is permitted and an audit record is emitted marked ALLOWED. Beyond files, profiles mediate POSIX capabilities, network socket operations, mount/pivot_root, ptrace, signals, and io_uring — the full feature surface is enumerated in securityfs under apparmor/features/ (file, network_v8, mount, caps, ptrace, signal, io_uring, namespaces, rlimit, …), straight from the aa_sfs_entry_features[] table in apparmorfs.c.
The Two Modes — Enforce and Complain
AppArmor profiles run in one of two modes, and understanding the difference is essential to actually using it.
Enforce mode is the real thing: the profile is law. Any operation not explicitly allowed is denied and logged. This is the production posture.
Complain mode (also called learning mode) is the inverse: every operation is allowed regardless of the profile, but each operation that the profile would not have permitted is logged as an audit event. The apparmor.7 man page is blunt that this provides no protection: “complain mode does not provide any security, only auditing,” and must not be relied on in a hostile environment. Its purpose is profile development: you put a profile in complain mode, exercise the application through all its normal code paths, and the logs accumulate a record of everything the program actually does. Then aa-logprof scans those audit messages and proposes rules to add to the profile, converging on a least-privilege policy you can then switch to enforce. This learning workflow is AppArmor’s signature ergonomic advantage over SELinux’s audit2allow loop, and it is why AppArmor is reputed to be far easier to adopt.
The userspace tools that drive modes (from the apparmor-utils package, Ubuntu docs):
aa-enforce <profile>— set a profile to enforce mode and reload it.aa-complain <profile>— set a profile to complain/learning mode and reload it.aa-genprof <program>— generate a new profile by running the program and watching its behaviour.aa-logprof— “scan log files for AppArmor audit messages, review them and update the profiles,” the engine of the learning loop.aa-disable <profile>— disable a profile (and prevent it loading at boot).aa-status(a.k.a.apparmor_status) — report which profiles are loaded and in which mode, and which processes are confined.aa-unconfined— list listening network daemons that have no profile — directly surfacing AppArmor’s opt-in gap.
The securityfs Interface
AppArmor exposes its state and control surface through the securityfs mount, conventionally at /sys/kernel/security/, under the apparmor/ subdirectory. The structure comes directly from the aa_sfs_entry_apparmor[] table in apparmorfs.c:
/sys/kernel/security/apparmor/
├── .load # write compiled policy here to add a profile (mode 0640)
├── .replace # write here to replace an existing profile
├── .remove # write a profile name here to unload it
├── .access # transaction file for policy queries (mode 0666)
├── .ns_name # the current AppArmor policy namespace name
├── .ns_level # nesting level of the current policy namespace
├── .ns_stacked # whether namespaces are stacked
├── .stacked # whether the current label is a stack of profiles
├── profiles # human-readable list of every loaded profile + its mode
└── features/ # capability advertisement: what this kernel's AppArmor supports
├── policy/ # versions (v5..v9), permstable, set_load
├── domain/ # exec-transition feature flags
├── file/ # file-mediation feature flags
├── network_v8/ # socket mediation
├── mount/ # "mount umount pivot_root"
├── caps/ # capability mediation
├── ptrace/, signal/, io_uring/, namespaces/, rlimit/
└── capability # mask of mediatable POSIX capabilitiesThe profiles file is the one humans read most: aa-status parses it, and a quick grep ' (enforce)$' /sys/kernel/security/apparmor/profiles | sort lists every enforced profile (Atlassian KB). The features/ tree is how the userspace parser negotiates with the running kernel: the parser reads the advertised feature set so it can refuse to emit policy a given kernel cannot enforce, and so newer policy abstractions degrade gracefully on older kernels. The version string under features/policy/ was "1.2" and the advertised binary-policy ABI versions v5–v9 in the 6.12 source.
Configuration Example — A Minimal Profile
A profile’s header is where attachment happens. Consider a stripped-down profile for a hypothetical /usr/bin/foo:
# /etc/apparmor.d/usr.bin.foo
#include <tunables/global> # pull in @{HOME}, @{PROC}, etc. (path variables)
/usr/bin/foo { # attachment spec: this profile binds to /usr/bin/foo
#include <abstractions/base> # the common allowlist every program needs (libc, /dev/null, ...)
capability net_bind_service, # may bind ports < 1024; capabilities named WITHOUT the CAP_ prefix
/usr/bin/foo mr, # may map (m) and read (r) its own binary
/etc/foo/** r, # may read anything under /etc/foo recursively (** crosses dirs)
/var/log/foo.log w, # may write its log
owner @{HOME}/.foo/ rw, # may read/write its per-user dotdir; owner = file uid == task euid
network inet stream, # may use IPv4 TCP sockets
}
Line by line: the #include <tunables/global> and #include <abstractions/base> lines pull in shared policy fragments (the tunables/ define variables like @{HOME}; the abstractions/ bundle the boilerplate access every Linux program needs — see AppArmor Profiles and Path-Based Confinement for both mechanisms in depth). The line /usr/bin/foo { is the attachment specification — the string the kernel’s find_attach() DFA matches the executed path against; everything in braces is this profile’s rule set. capability net_bind_service, whitelists exactly one capability (note: no CAP_ prefix, lowercase). The file rules grant precise permission letters per path; ** matches across directory levels while a single * would not. owner narrows a rule to files whose owner UID equals the running task’s effective UID. network inet stream, permits IPv4 stream (TCP) sockets. Everything not listed is denied in enforce mode. The trailing comma on every rule and the semicolon-free grammar are AppArmor’s, not C’s — a missing comma is the single most common syntax error.
To load and enforce it:
sudo apparmor_parser -r /etc/apparmor.d/usr.bin.foo # compile + replace into kernel
sudo aa-enforce /usr/bin/foo # ensure enforce mode
sudo aa-status # confirm it is loaded & enforcedFailure Modes and Common Misunderstandings
“I loaded the profile but the program isn’t confined.” Almost always because the program was already running. Confinement attaches only at the next exec; restart the program (apparmor.7).
“My profile name doesn’t match my binary’s path, so nothing happens.” The filename in /etc/apparmor.d/ is convention; the attachment is the path in the profile header. If they disagree, the header wins. A profile whose header path matches no executable simply never attaches.
Silent denials look like inexplicable application bugs. An enforce-mode denial returns a generic EACCES/EPERM with no hint that AppArmor is the cause. The fix is to check dmesg/journalctl or the audit log for apparmor="DENIED" records, then either add the rule or, during development, flip to complain mode and re-run aa-logprof. The aa-unconfined tool is the complement: it reveals network daemons that should have a profile but don’t.
Unconfined-by-default is a coverage gap, not a bug — but you must remember it. Because an unprofiled program runs with no MAC, AppArmor protects only the programs you have profiled. This is the philosophical inverse of SELinux’s “everything is labeled and confined.” It is the gentlest possible adoption path (you confine high-risk daemons and leave the rest alone) and simultaneously the easiest way to think you are protected when you are not. This is the operational face of the deeper path-vs-label critique covered next.
Complain mode is not security. Operators sometimes leave a profile in complain mode “to be safe” — it provides zero protection, only logging. In production, profiles must be in enforce.
Alternatives and When to Choose AppArmor
The dominant alternative is SELinux, the label-based type-enforcement MAC that is the default on the Red Hat / Fedora family. The honest head-to-head lives in SELinux vs AppArmor, but the short version: AppArmor trades strength for usability. Its path-based model is far easier to read, write, and audit, and its complain-mode learning loop makes per-application confinement approachable. SELinux’s label-based model is harder to learn but stronger — labels follow the inode no matter how it is reached, closing the hardlink/bind-mount evasion class that path-based mediation is exposed to (the central weakness analysed in AppArmor Profiles and Path-Based Confinement, and the substance of Stephen Smalley’s foundational critique: “path-based access control makes the user think that he is protecting a given object, when in fact the object may be accessible by another means” (LKML, 2006)).
Other LSMs in the same family: Smack (a simpler label-based MAC for embedded use) and TOMOYO (another pathname-based MAC, learning-oriented like AppArmor). Choose AppArmor when you are on Ubuntu/Debian/SUSE (it is already there), want to confine a handful of high-risk daemons quickly, and value readable, version-controllable profiles over the strongest possible guarantee. Choose SELinux when you need system-wide, label-following confinement and can absorb the steeper operational cost. Because both ride the LSM framework and the framework now supports stacking, a kernel can technically have only one of the exclusive label-based MACs active at a time, but AppArmor can coexist with minor LSMs like Yama.
Production Notes
AppArmor is the workhorse confinement layer behind container runtimes on Debian-family hosts: Docker and Podman ship a default AppArmor profile (docker-default) applied to every container unless overridden, and Kubernetes lets a pod request an AppArmor profile through its securityContext (SecurityContext). Ubuntu confines many of its bundled daemons (CUPS, MySQL, tcpdump, man, browsers’ sandbox helpers) with shipped profiles. The standard operational loop in the field is exactly the learning loop above: deploy a new daemon, run it in complain mode through a representative workload, harvest the logs with aa-logprof, switch to enforce, and keep the profile in version control alongside the service. The most common production incident is a legitimate feature breaking after a software update because the new version touches a path the old profile didn’t allow — diagnosed by the apparmor="DENIED" audit line and fixed by one new rule.
See Also
- AppArmor Profiles and Path-Based Confinement — the sibling deep-dive: profile syntax (permission letters, capability/network rules, abstractions/tunables), the
Px/Cx/Ix/Uxexec transitions, and the path-vs-inode evasion weakness in full - SELinux — the label-based type-enforcement MAC; the other dominant production MAC
- SELinux vs AppArmor — honest head-to-head: path-based vs label-based, usability vs strength
- The Linux Security Module Framework — the hook infrastructure AppArmor rides
- Mandatory vs Discretionary Access Control — why MAC can confine a process DAC would allow
- POSIX Capabilities — the
capabilityrules in a profile name these bits - Smack, TOMOYO — sibling LSMs
- Linux Security MOC — parent map (section D, the MAC modules)