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 with a per-program profile — a policy that allowlists the exact files, capabilities, sockets, mounts and signals 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 Debian 10 “Buster”) and SUSE/openSUSE (Ubuntu Server docs; Debian wiki). The kernel half issecurity/apparmor/in the tree; the userspace half 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 against a compiled automaton. That choice makes profiles dramatically easier to read and write — and it is the reason mainline kernel developers fought AppArmor’s inclusion for four years, because a path is a name for an object rather than the object itself, and objects can have more than one name.
Version pin. All kernel-source claims below are verified against Linux 6.12, a long-term-support (LTS) branch; 6.12.107 was the current 6.12 stable release on 2026-08-28 per kernel.org’s release index, which also lists mainline at 7.2 and stable at 7.2.2 on that date. 6.12 is the vault’s pin because it is a maintained LTS that distributions actually ship. Userspace claims are verified against the AppArmor project’s master branch on GitLab and against the Ubuntu 24.04 “noble” manual pages, as of 2026-08-29. Where a fact is Ubuntu-specific rather than upstream, this note says so explicitly — that distinction turns out to matter a great deal in the user-namespace section.
This note covers AppArmor end to end: why it looks the way it does (the four-year mainline fight), how policy is compiled and loaded, exactly what happens at execve, the permission letters and exec-transition modes decoded against kernel and parser source, the profile modes and the learning workflow, a real shipped profile walked line by line, the honest case for and against path-based mediation, and AppArmor’s recent role in restricting user namespaces. Its sibling AppArmor Profiles and Path-Based Confinement goes further into profile authoring — abstractions, tunables, globbing grammar, child profiles and hats. For the hook plumbing all of this rides on, see The Linux Security Module Framework; for the head-to-head with the other production MAC, SELinux vs AppArmor.
Mental Model — An Allowlist That Clicks On At exec
Think of AppArmor as a per-executable allowlist that snaps into place the instant a program is exec’d. Before exec, a task is whatever it was. At exec, the kernel takes the pathname 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 subsequent file open, capability use, socket call, mount, ptrace or signal 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 task runs unconfined and AppArmor adds nothing.
Two properties fall straight out of this and explain most of AppArmor’s behaviour. First, confinement is a property of the task’s credentials, not of the file — so a running process cannot be retroactively confined by loading a profile; you must restart it. Second, the policy is compiled in userspace and loaded as a finished state machine — the kernel never parses profile text, which is why apparmor_parser is a large C++ program and the kernel side is comparatively small.
flowchart TB subgraph build["Build / load time — userspace"] SRC["Profile text<br/>/etc/apparmor.d/usr.sbin.dnsmasq"] PARSER["apparmor_parser<br/>expands includes, compiles<br/>path rules into a DFA"] SRC --> PARSER end PARSER -->|"write compiled binary policy"| LOAD["securityfs<br/>/sys/kernel/security/apparmor/.load"] LOAD --> KPOL["In-kernel policy namespace:<br/>profile tree + xmatch DFA<br/>+ per-class permission DFAs"] subgraph run["Run time — kernel"] EXEC["execve /usr/sbin/dnsmasq"] --> ATTACH{"does the path match<br/>an attachment expression?"} ATTACH -->|"yes"| CONF["new task runs CONFINED<br/>by the dnsmasq profile"] ATTACH -->|"no"| UNCONF["new task runs UNCONFINED<br/>DAC only"] end KPOL -.->|"consulted by find_attach at exec"| ATTACH CONF --> HOOK["every file / capability / socket /<br/>mount / signal / ptrace operation<br/>checked at its LSM hook"] HOOK -->|"enforce: deny returns EACCES or EPERM<br/>complain: allow and 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 — a state machine that, fed a path string one character at a time, lands in an accepting state carrying that path’s permission bits), 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 entire 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, which is simultaneously AppArmor’s gentlest adoption property and its largest coverage gap.
Why AppArmor Looks Like This — Four Years of Argument
AppArmor’s design is not an abstract engineering preference; it is the residue of a long, public, and occasionally bad-tempered argument on the Linux kernel mailing list. Understanding that argument is the fastest route to understanding the module, because every feature that looks odd — the environment-scrubbing exec modes, the attach_disconnected flag, the insistence that the parser negotiate features with the kernel — traces to a concession made during it.
AppArmor began life as SubDomain, a product of Immunix (later acquired by Novell, whose SUSE distribution shipped it, and later still maintained by Canonical for Ubuntu). It was one of several security projects competing for mainline inclusion when the LSM framework itself was created in 2001–2003; see The Linux Security Module Framework for that founding story. SubDomain/AppArmor was first posted to LKML as a patch series in April 2006, and the reception was hostile in a very specific way: reviewers did not object to the code so much as to the model.
The canonical statement of the objection came from Stephen Smalley of the NSA — one of SELinux’s principal authors — replying to Tony Jones’s cover letter on 2006-04-20. Jones had written that “AppArmor deliberately uses this simple access control model to make it as easy as possible for the administrator to manage the policy, because the worst security of all is that which is never deployed because it was too hard.” Smalley’s answer is the sentence the entire dispute is remembered by:
“The worst security is that which doesn’t do what it claims to do, giving a false sense of security. Which is precisely the problem with path-based access control; it makes the user think that he is protecting a given object, when in fact the object may be accessible by another means.” — Stephen Smalley, LKML, 2006-04-20
The same message raises three further objections that remain the honest technical case against the model, and all three are worth stating plainly because they are still true:
- Unbounded policy per object. “So you have an unbounded number of policies that can govern access to any given file object and the enforcement of the system policy is entirely dependent on the file tree structure and any process that can manipulate that structure.” A file with three hard links can be reached by three names, and each name may match a different rule.
- Shared directories defeat naming. “What about runtime files generated in shared directories like
/tmp,/var/run?” A path is a poor discriminator where many programs write files with generated names into one directory. - Partial coverage invites the attacker to the gap. Against Jones’s explicit statement that “AppArmor is not intended to protect every aspect of the system from every other aspect of the system,” Smalley wrote: “So the attacker knows precisely how to bypass it. The attacker (beyond the script kiddies) isn’t going to attack you at the point of strength; he will attack you where you are known to be weak.”
There was also a purely mechanical complaint, and it is the one that shaped AppArmor’s kernel code most directly. The 2006 posting admitted that “AppArmor needs to re-construct the full path name of files to perform initial validation. Some of the LSM hooks that we mediate do not have vfsmount/nameidata passed. Our temporary workaround is to export the namespace_sem semaphore so we can safely walk the process’s namespace.” Exporting a core VFS lock to an out-of-tree security module was never going to be accepted. The resolution, years later, was to add proper path-based LSM hooks to the VFS — which is why security/apparmor/Kconfig in 6.12 still carries select SECURITY_PATH alongside SECURITY_NETWORK and SECURITYFS. That one Kconfig line is the fossil of a five-line workaround that cost AppArmor several years.
The stalemate ran until 2007, when LWN’s Jonathan Corbet summarised the position: “AppArmor still uses a pathname-based mechanism for its policy enforcement. This approach sits poorly with developers — especially those in the SELinux camp — who think that pathnames are an inherently insecure method.” Corbet also recorded that the dispute had apparently been settled at the 2006 Kernel Summit, “where it was determined that the use of pathnames was not enough to keep AppArmor out of the kernel,” and quotes Andrew Morton laying out the two remaining options with visible exasperation — either “grudgingly merge this stuff as a service to Suse and to their users… and leave it all as an object lesson in how-not-to-develop-kernel-features,” or “leave it out and require that Suse wear the permanent cost and quality impact of maintaining it out-of-tree.” Morton’s closing line — “Please don’t put us in this position again. Get stuff upstream before shipping it to customers, OK? It ain’t rocket science.” — is the most-quoted sentence of the whole affair (LWN, 2007-06-27).
The merge finally happened three years after that. James Morris’s security-subsystem summary for the 2.6.36 merge window, posted 2010-07-30, listed John Johansen’s fourteen AppArmor commits — “LSM interface, and security module initialization”, “policy routines for loading and unpacking policy”, and the rest — under the heading that AppArmor was going in (LWN, 2010-07-30). In the comments on that article TOMOYO’s Tetsuo Handa put the timeline bluntly: “It took two years for TOMOYO and four years for AppArmor since their first postings, which means they have been rejected for those periods.”
The merge is verifiable directly from the source tree, and this note verified it rather than trusting the secondary account: security/apparmor/lsm.c returns HTTP 404 at tag v2.6.35 and HTTP 200 at tag v2.6.36 on raw.githubusercontent.com, and linux-2.6.36.tar.xz is dated 20-Oct-2010 in kernel.org’s v2.6 archive index. So: first posted April 2006, merged in the 2.6.36 window July 2010, shipped in a released kernel October 2010.
timeline title AppArmor from SubDomain to a default-on distro MAC (verified dates) 2006-04 : First LKML posting as an LSM patch series; Smalley's "false sense of security" critique the same week 2006 : Kernel Summit — pathname-based policy declared not a blocker in principle 2007-06 : LWN "Linux security non-modules and AppArmor"; Morton's grudging-merge-or-not ultimatum 2010-07 : Merged in the 2.6.36 merge window, 14 commits from John Johansen, pushed by Canonical 2010-10 : Linux 2.6.36 released — AppArmor ships in a mainline kernel for the first time 2022-12 : Linux 6.1 adds the userns_create LSM hook, which AppArmor implements 2023-10 : Ubuntu 23.10 ships AppArmor-based unprivileged user-namespace restriction 2024-11 : Linux 6.12 LTS — the tree this note is pinned to 2025-03 : Qualys discloses three bypasses of Ubuntu's userns restriction; Ubuntu classifies them as hardening limits, not vulnerabilities
AppArmor’s timeline. What it shows: a four-year gap between first posting and merge, then a fourteen-year gap before the module took on a genuinely new job (mediating user-namespace creation). The insight: the mainline resistance was never about code quality — it was a disagreement about whether a name is an acceptable proxy for an object. That question was never resolved; it was set aside. Everything in the “Path Versus Label” section below is that unresolved question resurfacing.
Mechanical Walk-through, Part 1 — How Policy Reaches the Kernel
A profile is plain text living in /etc/apparmor.d/. Historically it was named after the binary’s full path with / replaced by . — /usr/sbin/dnsmasq becoming /etc/apparmor.d/usr.sbin.dnsmasq — but that is a convention for humans and packaging tools, not a kernel requirement, and the upstream project has been migrating to short names (busybox, nautilus, firefox) since profiles gained explicit names. What actually binds a profile to a binary is the attachment specification in the profile header, never the filename.
apparmor_parser(8) reads the text, expands its include directives and variables, and compiles the rules into DFAs — one xmatch DFA per profile for attachment, plus per-class DFAs for file, network, mount and the rest. It then writes the compiled binary policy into the kernel through securityfs. Per the parser manual page, the interface directory is /sys/kernel/security/apparmor, and three operations map to three magic files (apparmor_parser.8):
| Parser flag | securityfs file | Kernel file_operations | Effect |
|---|---|---|---|
--add / -a (default) | .load | profile_load | Insert a profile that is not already loaded |
--replace / -r | .replace | profile_replace | Atomically swap a new version of a loaded profile |
--remove / -R | .remove | profile_remove | Unload a profile by name |
The three-file policy-management interface. What it shows: every policy change is a write(2) to one of three files, dispatched to one of three file_operations write handlers in security/apparmor/apparmorfs.c. The insight: --replace is not “remove then add” — it is a single atomic operation in the kernel, which is why reloading a profile never leaves a window in which a confined daemon is briefly unconfined.
Inside the kernel, profile_load() and profile_replace() both funnel into policy_update(), differing only in the permission mask they request: AA_MAY_LOAD_POLICY for a load, AA_MAY_LOAD_POLICY | AA_MAY_REPLACE_POLICY for a replace. policy_update() calls aa_may_manage_policy() to check that the writing task is allowed to manage policy in this namespace at all, then hands the buffer to aa_replace_profiles(), which unpacks and splices it into the in-kernel profile tree. profile_remove() takes the same permission check and calls aa_remove_profiles() (apparmorfs.c, v6.12).
Because the parser does the expensive DFA construction in userspace and the kernel loads a finished automaton, compiled profiles are cached under /var/cache/apparmor to skip recompilation on boot (-W/--write-cache, -L/--cache-loc). The kernel does still verify what it is handed: CONFIG_SECURITY_APPARMOR_PARANOID_LOAD, default y in 6.12, “allows controlling whether apparmor does a full verification of loaded policy” and the Kconfig help text warns it “should not be disabled except for embedded systems where the image is read only, includes policy, and has some form of integrity check” (Kconfig, v6.12).
sequenceDiagram autonumber participant Admin as "admin / systemd unit" participant Parser as "apparmor_parser" participant Cache as "/var/cache/apparmor" participant SFS as "securityfs .replace" participant K as "apparmorfs.c" participant Tree as "in-kernel profile tree" Admin->>Parser: "apparmor_parser -r /etc/apparmor.d/usr.sbin.dnsmasq" Parser->>Parser: "expand include directives and tunables" Parser->>Parser: "compile xmatch + per-class rules into DFAs" Parser->>Cache: "write compiled blob keyed by kernel feature set" Parser->>SFS: "write(2) binary policy" SFS->>K: "profile_replace() → policy_update(LOAD|REPLACE)" K->>K: "aa_may_manage_policy(cred, label, ns, mask)" alt "caller may not manage policy" K-->>Parser: "-EACCES" else "permitted" K->>K: "unpack + verify (PARANOID_LOAD)" K->>Tree: "aa_replace_profiles() — atomic swap" Tree-->>Parser: "0" end Note over Tree: "already-running dnsmasq is NOT re-confined;<br/>the new profile applies at the next execve"
A profile load, end to end. What it shows: the compile/verify/splice pipeline and the two places it can refuse — the policy-management permission check and the paranoid unpack verification. The insight to take: the final note is the one that bites operators. aa_replace_profiles() swaps the policy, not the credentials of running tasks. A task confined by version 1 of a profile keeps a reference to the newest version of that same profile, but a task that was unconfined when it exec’d stays unconfined forever, no matter what you load afterwards.
The securityfs surface
The aa_sfs_entry_apparmor[] table in apparmorfs.c defines the always-present files; .load, .replace, .remove and revision are created separately in aa_create_aafs() because they exist once per policy namespace.
/sys/kernel/security/apparmor/
├── .load # write compiled policy here to add a profile (mode 0666 at the root ns)
├── .replace # write here to atomically replace a profile (0666)
├── .remove # write a profile name here to unload it (0666)
├── .access # transaction file for policy queries (0666)
├── .ns_name # name of the current policy namespace (0444)
├── .ns_level # nesting level of the current policy namespace (0444)
├── .ns_stacked # whether policy namespaces are stacked (0444)
├── .stacked # whether the current label is a stack of profiles (0444)
├── profiles # human-readable list of loaded profiles + mode (0444)
├── revision # monotonically increasing policy revision counter (0444)
├── raw_data_compression_level_min / _max (0444)
├── policy/ # per-namespace virtualized view (profiles/, raw_data/, namespaces/)
└── features/ # what this kernel's AppArmor can actually mediate
├── policy/ # binary policy ABI versions the kernel accepts
├── 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/, ...Modes above are read from the securityfs_create_file() calls in aa_create_aafs() and the AA_SFS_FILE_FOPS() entries in the aa_sfs_entry_apparmor[] table. Note the asymmetry: the root-namespace .load/.replace/.remove are created 0666 — permissive file modes, because the real gate is aa_may_manage_policy() inside the write handler, not the DAC bits — whereas the per-sub-namespace copies created by __aafs_ns_mkdir() are 0640.
The features/ tree is how the userspace parser negotiates with the running kernel. The parser reads the advertised feature set and compiles policy against an ABI the kernel actually supports, so a profile using a rule class this kernel cannot mediate is rejected at compile time rather than silently ignored at run time. This mechanism is not academic: Ubuntu’s user-namespace work (below) required shipping a new parser ABI precisely because, with the old default ABI, “profiles which do not contain this userns, permission will also silently be granted this permission as well” (Ubuntu spec, 2023). Feature negotiation done wrong is a silent policy hole.
Mechanical Walk-through, Part 2 — What Happens at execve
This is the heart of AppArmor and the place it differs most from SELinux. AppArmor registers apparmor_bprm_creds_for_exec on the LSM bprm_creds_for_exec hook, plus bprm_committing_creds and bprm_committed_creds for the two commit phases (lsm.c, v6.12). The bprm_creds_for_exec hook runs after the kernel has opened and validated the new executable but before the new credentials are installed, which is exactly the window in which a domain transition must be decided.
Inside, security/apparmor/domain.c splits into two very different code paths depending on whether the calling task was confined.
Path A — the task is currently unconfined. profile_transition() resolves the new binary’s pathname with aa_path_name() and calls find_attach() over the namespace’s profile list. If a profile attaches, the new task is confined by it. If none attaches, the task stays unconfined. That is the whole of the unconfined case: no rules are consulted, because an unconfined task has none.
Path B — the task is already confined. The current profile’s own file rules decide. aa_str_perms() runs the binary’s pathname through the profile’s file DFA and produces a struct aa_perms. If perms.allow & MAY_EXEC is set, the accompanying xindex — a 32-bit field packed into the same permission structure — says how to transition, and x_to_label() decodes it. If MAY_EXEC is not set, the exec is denied with -EACCES outright (unless the profile is in complain mode, in which case AppArmor manufactures a “learning profile” on the fly, records what would have been denied, and lets the exec proceed).
The asymmetry between A and B is the single most consequential fact about AppArmor’s confinement model, and it is worth stating as a rule: an unconfined task can only ever gain confinement, and a confined task can only transition where its own profile says it may. There is no path by which a confined task escapes to unconfined without an explicit ux-family rule someone wrote.
sequenceDiagram autonumber participant U as "userspace task" participant EX as "kernel: bprm_execve()" participant LSM as "security_bprm_creds_for_exec()" participant AA as "apparmor_bprm_creds_for_exec()" participant DOM as "domain.c" participant POL as "profile tree + DFAs" U->>EX: "execve("/usr/sbin/dnsmasq", argv, envp)" EX->>EX: "open file, read header, pick binfmt" EX->>LSM: "hook: decide creds for the new program" LSM->>AA: "dispatch to AppArmor" AA->>DOM: "handle_onexec / profile_transition" DOM->>DOM: "aa_path_name() → resolved pathname string" alt "caller unconfined" DOM->>POL: "find_attach(ns profile list, name)" POL-->>DOM: "best-matching profile label, or NULL" else "caller confined" DOM->>POL: "aa_str_perms(file DFA, name) → perms{allow, xindex}" POL-->>DOM: "MAY_EXEC? plus xindex encoding the transition mode" DOM->>DOM: "x_to_label(): px/cx/ix/ux decode, then find_attach or table lookup" end DOM->>DOM: "if the mode is NOT marked AA_X_UNSAFE → secure_exec = true" DOM-->>AA: "new label (or ERR_PTR(-EACCES))" AA-->>EX: "bprm->secureexec set if scrubbing required" EX->>EX: "if secureexec: strip LD_PRELOAD, LD_LIBRARY_PATH, ... like setuid" EX->>U: "new task runs under the new label"
One execve through AppArmor. What it shows: where in the exec path the decision is made, the two disjoint code paths for confined and unconfined callers, and where environment scrubbing is decided. The insight: the secure_exec decision is made in the same function as the transition decision, from the same xindex bits — which is why the exec-mode letter case (px versus Px) controls both the domain change and whether LD_PRELOAD survives it. They are not two separate settings; they are one field.
How find_attach() actually picks a profile
find_attach() does not do a filename equality test. Each profile carries an xmatch DFA compiled from its attachment expression, and the function does a linear walk of the profile list, running the candidate pathname through every profile’s xmatch automaton via aa_dfa_leftmatch(), which returns both a final state and a count of how many characters of the name it consumed. The source comment states the ranking rule in full:
“Find the ‘best’ matching profile. Profiles must match the path and extended attributes (if any) associated with the file. A more specific path match will be preferred over a less specific one, and a match with more matching extended attributes will be preferred over one with fewer. 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.” —
domain.c, v6.12
Three things in that are worth pulling out, because they are the source of real-world surprises:
- “More specific” means a longer matched prefix, measured as
candidate_len = max(count, attach->xmatch_len). It is not a lexical notion of specificity, it is a character count. A profile attaching/usr/bin/*and one attaching/usr/bin/fo*will rank by how far the automaton got. - An exact non-regex name short-circuits the search entirely. The
else if (!strcmp(profile->base.name, name))branch takes the candidate and jumps straight toout:, skipping the rest of the list. - A genuine tie produces no attachment at all.
conflict = truecausesfind_attach()to returnNULLwith*info = "conflicting profile attachments". It does not pick arbitrarily. Two profiles whose globs match a binary equally well leave that binary unconfined — a fail-open outcome that surprises people who expect a conflict to fail closed.
AppArmor can additionally condition attachment on the binary’s extended attributes: if attach->xattr_count is non-zero, aa_xattrs_match() runs and a mismatch disqualifies the profile. This exists so that two binaries at paths a glob would both match can be told apart by an xattr — a small, deliberate concession toward label-like discrimination inside a path-based model.
flowchart TB START["execve of /usr/sbin/dnsmasq<br/>by an unconfined task"] --> RESOLVE["aa_path_name(): resolve the file<br/>to a namespace-relative pathname string"] RESOLVE --> LOOP["find_attach(): linear walk of<br/>every profile in the namespace"] LOOP --> HASDFA{"profile has an<br/>xmatch DFA?"} HASDFA -->|"no (plain name)"| STRCMP{"strcmp(profile name,<br/>path) == 0?"} STRCMP -->|"yes"| WIN["exact match — take it,<br/>stop searching"] STRCMP -->|"no"| NEXT["next profile"] HASDFA -->|"yes"| MATCH["aa_dfa_leftmatch(): run the path<br/>through the DFA, get state + count"] MATCH --> ACCEPT{"accepting state<br/>with MAY_EXEC?"} ACCEPT -->|"no"| NEXT ACCEPT -->|"yes"| XATTR{"profile requires<br/>matching xattrs?"} XATTR -->|"yes, and they differ"| NEXT XATTR -->|"ok"| RANK{"count vs candidate_len"} RANK -->|"longer match"| BETTER["new best candidate;<br/>clear conflict flag"] RANK -->|"equal, equal xattrs"| CONFLICT["conflict = true"] RANK -->|"shorter"| NEXT BETTER --> NEXT CONFLICT --> NEXT NEXT --> DONE{"list exhausted?"} DONE -->|"no"| LOOP DONE -->|"yes"| VERDICT{"candidate found<br/>and no conflict?"} VERDICT -->|"yes"| CONF["task confined by that profile"] VERDICT -->|"no candidate"| UNCONF["task stays UNCONFINED"] VERDICT -->|"conflict"| UNCONF2["NULL + info="conflicting profile<br/>attachments" → UNCONFINED"] WIN --> CONF
Profile attachment as an algorithm. What it shows: the exact ranking and tie-breaking find_attach() performs, including the exact-name short circuit and the two distinct ways to end up unconfined. The insight: both failure outcomes — no match and an ambiguous match — land in the same place, unconfined. If you write two overlapping glob attachments for the same binary you do not get a warning at load time and you do not get the stricter of the two; you get no confinement at all. Prefer exact attachment paths, and use aa-status to confirm a process is actually confined rather than assuming it from the presence of a profile file.
The Permission Letters, Decoded Against the Source
A file rule in an AppArmor profile is a path expression followed by a string of permission letters and a comma: /var/log/dnsmasq*.log w,. The letters are the part readers cannot hold in their heads, partly because there are two overlapping alphabets — the letters you may write in a profile, and the letters AppArmor prints in an audit record. Conflating them is a common and confusing error.
The kernel’s canonical letter-to-bit mapping is a single string in security/apparmor/lib.c, indexed by bit position:
const char aa_file_perm_chrs[] = "xwracd km l ";Read it as an array: index n is the character for bit 2^n. Cross-referencing against the AA_MAY_* defines in security/apparmor/include/perms.h gives the table below — every value here was read out of the v6.12 tree, not from documentation.
| Letter | Bit | Kernel constant | What it permits |
|---|---|---|---|
r | 0x4 | MAY_READ | Read the file, or list the directory. Required for interpreted content (shell scripts) because the interpreter reads the script. |
w | 0x2 | MAY_WRITE | Write the file. Also required to unlink it. Conflicts with a; the parser rejects a rule carrying both. |
a | 0x8 | MAY_APPEND | Append only: the open is refused unless O_APPEND is passed. Conflicts with w. |
x | 0x1 | MAY_EXEC | Execute — but never on its own; x must carry a transition prefix (see the next section). |
m | 0x10000 | AA_EXEC_MMAP | mmap(2) the file with PROT_EXEC. This is what limits which files a program may load as a shared library. |
k | 0x8000 | AA_MAY_LOCK | Take a file lock (flock, fcntl locks). |
l | 0x40000 | AA_MAY_LINK | Create a hard link to this file. |
c | 0x10 | AA_MAY_CREATE | Audit output only — see below. |
d | 0x20 | AA_MAY_DELETE | Audit output only — see below. |
File permission letters. What it shows: the profile-writable letters and the exact kernel bit each sets. The insight: m is the one people forget. A profile that grants r on a library but not m will let the program open() and read() the .so and then fail at mmap(PROT_EXEC) — which surfaces as a link-loader error, not an obvious permission error. The convention @{exec_path} mr, (read plus executable-map its own binary) appears in essentially every shipped profile for exactly this reason.
The c and d rows carry the trap. AA_MAY_CREATE and AA_MAY_DELETE are real bits with real letters in aa_file_perm_chrs[], and aa_audit_perm_mask() prints them in the requested_mask= and denied_mask= fields of audit records, so you will see denied_mask="c" in the log. But c is not a file permission you may write in a profile: in profile grammar c is the transition-to-subprofile exec prefix, and creation/deletion are granted implicitly by w. The list of letters actually accepted in a rule is given by apparmor.d(5): r, w, a, m, l, k, and the x family. Seeing denied_mask="c" and “fixing” it by adding c to a rule produces a parse error at best and a transition rule you did not intend at worst; the correct fix is w.
Two further mechanical details that explain otherwise baffling behaviour, both read from security/apparmor/include/file.h:
aa_map_file_to_perms()translates open flags into permission bits before the check, and it is not a one-to-one map.O_TRUNCimpliesMAY_WRITE(“trunc implies write permission” in the source).O_APPENDcombined with write replacesMAY_WRITEwithMAY_APPENDrather than adding to it — which is why a profile granting onlyaworks for an appending logger and fails the instant something opens the same file withoutO_APPEND.O_CREATsetsAA_MAY_CREATE, which is why the audit record for a failed creation showsceven though your rule vocabulary has noc.
Beyond files, struct aa_perms (in include/perms.h) carries far more than allow. The full set of per-rule bitmasks is allow, deny, subtree, cond, kill, complain, prompt, audit, quiet, hide, plus the xindex, tag and label indices. This is how one compiled rule can simultaneously say “allow this”, “audit it”, “do not log the denial for that”, and “if this profile is in kill mode, SIGKILL on violation” — the mode-specific masks are filled in by aa_apply_modes_to_perms() at check time rather than at compile time.
Exec Transitions — px, Px, ix, cx, ux and Why Case Matters
x never stands alone in a file rule. Executing something is always accompanied by a transition mode that answers two questions at once: which profile does the child run under, and is the environment scrubbed on the way there. Getting this wrong is where real policies fail, so it is worth decoding the letters against both the parser and the kernel.
The parser’s own enumeration, from parser/parser.h in the AppArmor userspace tree, is the ground truth for what each character means:
enum class cod_t : char {
READ = 'r', WRITE = 'w', APPEND = 'a', EXEC = 'x',
LINK = 'l', LOCK = 'k', MMAP = 'm', INHERIT = 'i',
UNCONFINED = 'U', UNSAFE_UNCONFINED = 'u',
PROFILE = 'P', UNSAFE_PROFILE = 'p',
LOCAL = 'C', UNSAFE_LOCAL = 'c',
};That enum settles the case question definitively, and in the direction most people guess wrong: the lowercase letter is the unsafe one. In parser_misc.c, case cod_t::UNSAFE_PROFILE: (that is, p) sets tperms = AA_EXEC_UNSAFE and falls through to the safe case; the uppercase P skips that line. The kernel then reads the flag in profile_transition():
if (!(perms.xindex & AA_X_UNSAFE)) {
/* ... "scrubbing environment variables for %s" ... */
*secure_exec = true;
}So AA_X_UNSAFE set means do not scrub; unset means the kernel sets bprm->secureexec and the loader strips LD_PRELOAD, LD_LIBRARY_PATH and friends exactly as it does for a setuid binary. The parser is explicit about why this matters, emitting a WARN_DANGEROUS warning for ux that reads: “Unconfined exec qualifier (ux) allows some dangerous environment variables to be passed to the unconfined process.” The apparmor.d(5) page is blunter still about ux: “Any profile using this mode provides negligible security. Use at your own risk.”
| Mode | Child runs under | Environment | Failure behaviour | Notes |
|---|---|---|---|---|
ix | the current profile (inherit) | not scrubbed | n/a | No uppercase variant exists — “ix executions don’t change privileges”, so there is nothing to scrub against. |
px | a discrete (top-level) profile matched by name | not scrubbed | exec denied if no profile matches | Calling domain can influence the callee via LD_PRELOAD. |
Px | a discrete profile | scrubbed | exec denied if no profile matches | The correct default for confining a helper. |
cx | a child/local profile defined inside this profile | not scrubbed | exec denied if no local profile matches | Searches profile->base.profiles, not the namespace. |
Cx | a child/local profile | scrubbed | exec denied if no local profile matches | |
ux | nothing — fully unconfined | not scrubbed | n/a | Parser emits a “dangerous” warning. |
Ux | nothing — fully unconfined | scrubbed | n/a | Still discards all MAC on the child. |
pix / Pix | discrete profile, else inherit | per case | falls back to ix | Fail-soft: never breaks the exec. |
cix / Cix | child profile, else inherit | per case | falls back to ix | |
pux / PUx | discrete profile, else unconfined | per case | falls back to ux / Ux | Fail-open. Use with care. |
cux / CUx | child profile, else unconfined | per case | falls back to ux / Ux | Fail-open. |
deny x | — | — | denies execution | Only bare x is allowed with deny; all transition modes conflict with the deny qualifier. |
The exec transition modes. What it shows: every mode as a triple of destination, environment handling, and what happens when the destination does not exist. The insight: two independent axes are encoded in one token. The letter picks the destination (p discrete, c local, u none, i stay put); the case picks whether the environment is scrubbed; and a trailing i or u adds a fallback. Px and pux differ in both safety axes at once — one scrubs and fails closed, the other does not scrub and fails open — which is why “just make it work” edits to a profile so often silently remove protection.
On the kernel side these letters are compiled down to bit flags packed into xindex, defined in security/apparmor/include/file.h:
| Flag | Value | Meaning |
|---|---|---|
AA_X_NAME | 0x04000000 | Use the executable’s own name to find the target profile (px-style) |
AA_X_TABLE | 0x08000000 | Use an entry from the profile’s transition-name table (a -> target directed transition) |
AA_X_UNSAFE | 0x10000000 | Do not scrub the environment (lowercase mode) |
AA_X_CHILD | 0x20000000 | Search the profile’s own child profiles rather than the namespace (cx-style) |
AA_X_INHERIT | 0x40000000 | Fallback: keep the current profile (ix and the *ix modes) |
AA_X_UNCONFINED | 0x80000000 | Fallback: go unconfined (ux and the *Ux modes) |
The xindex bit layout. What it shows: the transition mode is not an enum but a set of orthogonal flags plus a 24-bit index (AA_X_INDEX_MASK = 0x00ffffff) into a name table. The insight: this is why the fallback modes compose so cleanly — pix is simply AA_X_NAME | AA_X_INHERIT | AA_X_UNSAFE, and x_to_label() reads the fallback bits only after the primary lookup returns NULL. Nothing special-cases twelve mode names; there are six bits and a lookup.
flowchart TB E["confined task calls execve(target)"] --> P["aa_str_perms(): run target's path<br/>through the profile's file DFA"] P --> ME{"perms.allow &<br/>MAY_EXEC ?"} ME -->|"no, enforce mode"| DENY["-EACCES · audit apparmor="DENIED""] ME -->|"no, complain mode"| LEARN["aa_new_learning_profile():<br/>synthesize a null profile,<br/>log what would have been denied,<br/>allow the exec"] ME -->|"yes"| XT{"xindex type bits"} XT -->|"AA_X_TABLE, entry starts with '&'"| STACK["stack: transition, then<br/>aa_label_parse() the '&name'<br/>onto the result"] XT -->|"AA_X_TABLE, plain name"| TBL["x_table_lookup(): resolve the<br/>-> target name from the table"] XT -->|"AA_X_NAME + AA_X_CHILD"| CHILD["find_attach() over<br/>profile->base.profiles (cx)"] XT -->|"AA_X_NAME"| DISC["find_attach() over<br/>ns->base.profiles (px)"] XT -->|"AA_X_NONE"| NONE["no target"] TBL --> FOUND{"target found?"} CHILD --> FOUND DISC --> FOUND NONE --> FOUND STACK --> SAFE FOUND -->|"yes"| SAFE{"xindex & AA_X_UNSAFE ?"} FOUND -->|"no, AA_X_INHERIT set"| IX["ix fallback:<br/>keep the current label"] FOUND -->|"no, AA_X_UNCONFINED set"| UX["ux fallback:<br/>ns_unconfined()"] FOUND -->|"no, neither"| FAIL["-EACCES<br/>info="profile transition not found""] IX --> SAFE UX --> SAFE SAFE -->|"set (lowercase mode)"| KEEPENV["environment preserved;<br/>caller can set LD_PRELOAD on the callee"] SAFE -->|"clear (uppercase mode)"| SCRUB["bprm->secureexec = 1;<br/>loader scrubs LD_* like setuid"] KEEPENV --> RUN["child runs under the new label"] SCRUB --> RUN
The transition decision, as x_to_label() and profile_transition() actually implement it. What it shows: the order of operations — permission first, destination second, fallback third, scrubbing last — and the two distinct “no target” outcomes. The insight: the fallback bits are consulted only when the primary lookup fails, and the environment decision is made after the fallback. A pux rule that falls back to unconfined also declines to scrub, so an attacker who can prevent the target profile from being loaded gets both an unconfined child and a controlled environment for it. Prefer Px (fail closed) and reserve PUx for cases where you have consciously decided that breakage is worse than exposure.
The -> syntax overrides destination selection: /bin/** px -> profile, sends every match to the named profile rather than to one derived from the executable’s name; internally this sets AA_X_TABLE and the 24-bit index into the profile’s transition-name table. A target beginning with & means stack rather than replace — the kernel transitions first and then aa_label_parse()s the &-named profile onto the result, producing a label that is the conjunction of both profiles’ rules. This is the mechanism behind the shipped unprivileged_userns profile discussed below.
Profile Modes and the Learning Workflow
The kernel recognises five profile modes, not the two that most documentation mentions. From security/apparmor/include/policy.h and the name table in policy.c:
enum profile_mode {
APPARMOR_ENFORCE, /* enforce access rules */
APPARMOR_COMPLAIN, /* allow and log access violations */
APPARMOR_KILL, /* kill task on access violation */
APPARMOR_UNCONFINED, /* profile set to unconfined */
APPARMOR_USER, /* modified complain mode to userspace */
};
const char *const aa_profile_mode_names[] = {
"enforce", "complain", "kill", "unconfined", "user",
};Those strings are what /sys/kernel/security/apparmor/profiles prints beside each profile name and what aa-status parses.
- enforce — the production posture. Anything not allowed is denied and audited. This is the module-wide default:
enum profile_mode aa_g_profile_mode = APPARMOR_ENFORCE;inlsm.c. - complain (learning mode) — the operation is allowed regardless, and anything the profile would not have permitted is logged. The manual page is unambiguous that this is not a security posture: “complain mode does not provide any security, only auditing, while it is enabled. It should not be used in a hostile environment or bad behaviors may be logged and added to the profile as if they are resource accesses that should be used by the application” (apparmor.7). A subtlety the same page adds and which trips people up: complain mode does not neutralise explicit rules — “allow rules will still quiet or force audit messages, and deny rules will still result in denials.” An explicit
denystill denies in complain mode. - kill — a violation
SIGKILLs the task instead of returning an error.aa_apply_modes_to_perms()implements this by settingperms->kill = ALL_PERMS_MASK. Useful when a failed access must not be silently retried. - unconfined — a profile that exists, has a name, and enforces nothing. This sounds pointless and is in fact load-bearing: it lets a process show up in
aa-statusunder a meaningful label rather than the anonymousunconfined, and it lets the profile carry a small number of targeted permissions (this is exactly how Ubuntu’s user-namespace allowlisting works — see below). - user (also called prompt mode) — a variant of complain in which the decision is escalated to a userspace agent through the
promptpermission mask. Present in the 6.12 enum and wired intoaa_apply_modes_to_perms(); the interactive userspace side is newer than the kernel hook and is not something to assume is deployed.
stateDiagram-v2 [*] --> Unconfined: "task exec'd with no matching profile" Unconfined --> Enforce: "exec a binary whose profile attaches" Unconfined --> Complain: "exec a binary whose profile attaches<br/>and is loaded in complain mode" Unconfined --> NamedUnconfined: "exec a binary with a flags=(unconfined) profile" state "unconfined (anonymous)" as Unconfined state "unconfined (named profile,<br/>flags=(unconfined))" as NamedUnconfined state "enforce" as Enforce state "complain / learning" as Complain state "kill" as Kill state "user / prompt" as Prompt Complain --> Enforce: "aa-enforce, or edit flags + apparmor_parser -r" Enforce --> Complain: "aa-complain" Enforce --> Kill: "flags=(kill) + reload" Kill --> Enforce: "flags=(enforce) + reload" Enforce --> Prompt: "flags=(prompt) + reload" Prompt --> Enforce: "flags=(enforce) + reload" Enforce --> Enforce: "violation → EACCES/EPERM<br/>audit apparmor="DENIED"" Complain --> Complain: "violation → ALLOWED<br/>audit apparmor="ALLOWED"" Kill --> [*]: "violation → SIGKILL" note right of Unconfined A running unconfined task can never be confined by loading a profile. Only the next execve can change its label. end note
The five profile modes and the transitions between them. What it shows: mode is a property of the loaded profile, changed by reloading policy, while the task’s confinement is a property of its credentials, changed only by exec. The insight: the two arrows that matter operationally are the missing ones. There is no arrow from unconfined back into a confined state without an execve, and there is no arrow that makes complain mode safe. Complain is a development posture; a profile left in complain in production is an audit log with no enforcement behind it.
The workflow those modes exist to serve is AppArmor’s signature ergonomic advantage:
aa-genprof <program>— generate a skeleton profile and run the program while watching what it touches.aa-complain /etc/apparmor.d/<profile>— put the profile (and its children and hats) into complain mode. Equivalently, addflags=(complain)to the profile header and reload withapparmor_parser -r. Note the manual’s warning that thecomplainflag “must also be added manually to any hats or children profiles of the profile or they will continue to use the previous mode” — theaa-complaintool handles this for you; hand-editing does not.- Exercise the application through every code path you care about — startup, reload, log rotation, the rarely-used admin subcommand. Anything not exercised will not appear in the log and will be denied later.
aa-logprof— scan the audit log forapparmor="ALLOWED"records, present each as a proposed rule, and write the accepted ones back into the profile.aa-enforce— switch to enforce and reload.aa-status(a.k.a.apparmor_status) — confirm what is loaded, in which mode, and which running processes are actually confined.aa-status --filter.mode=unconfinedlists profiles that exist but enforce nothing.aa-unconfined— list listening network daemons that have no profile at all. This is the tool that directly measures AppArmor’s opt-in coverage gap, and it is the one most worth running on a system you have inherited.
For debugging rather than development, the module-wide mode can be forced: echo -n complain > /sys/module/apparmor/parameters/mode, or apparmor.mode=complain on the kernel command line. The manual’s warning applies in full — “Setting complain mode globally disables all apparmor security protections.”
Reading a denial
A denial is a generic EACCES/EPERM to the application, with nothing that says AppArmor caused it; the evidence is in the audit trail. The manual page’s own examples are the shape to recognise:
audit(1386511672.612:238): apparmor="DENIED" operation="exec"
parent=7589 profile="/tmp/sh" name="/bin/uname" pid=7605
comm="sh" requested_mask="x" denied_mask="x" fsuid=0 ouid=0
audit(1386511772.804:246): apparmor="DENIED" operation="capable"
parent=7246 profile="/tmp/sh" pid=7589 comm="sh"
capability=2 capname="dac_override"Read it right to left: denied_mask names the permission letters that were missing (x here), requested_mask what was asked for, name the object, profile the confining profile — with any active hat appended after a // separator. The second record is the capability form: no mask, but a capname, which maps to the capability dac_override, rule you would need to add. Complain-mode records are identical except that apparmor="DENIED" becomes apparmor="ALLOWED", which is precisely what makes aa-logprof able to consume them.
Uncertain
Verify: apparmor.7 states that a confined process “cannot call the following system calls:
create_module(2) delete_module(2) init_module(2) ioperm(2) iopl(2) ptrace(2) reboot(2) setdomainname(2) sethostname(2) swapoff(2) swapon(2) sysctl(2)”. Reason: this list is visibly stale —create_module(2)andsysctl(2)no longer exist in modern Linux, andptracehas had its own dedicated AppArmor rule class (features/ptrace) for years rather than being unconditionally blocked. The list reads as a description of a much older AppArmor whose blanket restriction has since been replaced by per-class mediation of the underlying capabilities. To resolve: enumerate the capability and rule classes actually advertised under/sys/kernel/security/apparmor/features/on a 6.12 kernel and compare against this list, rather than trusting the manual page.#uncertain
A Real Profile, Line by Line
Toy profiles teach syntax; shipped profiles teach judgement. What follows is the upstream AppArmor project’s profile for dnsmasq, abridged to the structurally interesting lines and annotated. The full text is at profiles/apparmor.d/usr.sbin.dnsmasq in the AppArmor tree.
abi <abi/5.0>, # 1
@{TFTP_DIR}=/var/tftp /srv/tftp /srv/tftpboot # 2
include <tunables/global> # 3
profile dnsmasq /usr/{bin,sbin}/dnsmasq flags=(attach_disconnected) { # 4
include <abstractions/base> # 5
include <abstractions/dbus>
include <abstractions/nameservice>
capability chown, # 6
capability net_bind_service,
capability setgid,
capability setuid,
capability dac_override,
capability net_admin, # for DHCP server
capability net_raw, # for DHCP server ping checks
network inet raw, # 7
network inet6 raw,
signal (receive) peer=libvirtd, # 8
ptrace (readby) peer=libvirtd,
owner /dev/tty rw, # 9
@{PROC}/@{pid}/fd/ r, # 10
/etc/dnsmasq.conf r, # 11
/etc/dnsmasq.d/* r,
@{exec_path} mr, # 12
/var/log/dnsmasq*.log w, # 13
@{run}/*dnsmasq*.pid w,
/var/lib/misc/dnsmasq.leases rw, # 14
/{,usr/}bin/{ba,da,}sh ix, # 15
/usr/lib{,64}/libvirt/libvirt_leaseshelper Cx -> libvirt_leaseshelper, # 16
@{TFTP_DIR}/ r, # 17
@{TFTP_DIR}/** r,
profile libvirt_leaseshelper { # 18
include <abstractions/base>
/usr/lib{,64}/libvirt/libvirt_leaseshelper mr,
/var/lib/libvirt/dnsmasq/*.leases rw,
@{run}/leaseshelper.pid rwk, # 19
}
include if exists <local/usr.sbin.dnsmasq> # 20
}
abi <abi/5.0>,pins the policy ABI this profile is written against. This is the userspace half of the feature negotiation described earlier: the parser compiles against the named ABI so that a rule class this profile does not mention is not silently granted by a newer kernel — the failure mode that bit Ubuntu’s first attempt atusernsmediation.@{TFTP_DIR}=...defines a tunable: a policy variable expanding to an alternation. Wherever@{TFTP_DIR}appears, the parser substitutes all three paths.include <tunables/global>pulls in the standard variables —@{HOME},@{PROC},@{run},@{sys},@{pid},@{exec_path}.- The profile header.
dnsmasqis the profile’s name;/usr/{bin,sbin}/dnsmasqis the attachment expression compiled into the xmatch DFA thatfind_attach()runs. Note the brace alternation — one profile attaching at two paths, which is how distributions handle the/usr-merge transition.flags=(attach_disconnected)tells AppArmor how to handle files whose path cannot be resolved relative to the namespace root (a file on an unmounted or detached mount): rather than failing the check outright, attach the disconnected path. Without it, a daemon that holds an fd across a mount change starts seeing inexplicable denials. include <abstractions/...>pulls in shared rule fragments.baseis the boilerplate every program needs (the C library,/dev/null,/dev/urandom, locale files);nameserviceis the/etc/nsswitch.conf,/etc/hosts, DNS-socket bundle;dbusthe D-Bus session plumbing. Almost every real profile is mostly abstractions.capabilityrules name POSIX capabilities without theCAP_prefix, lowercase. AppArmor’s check is an intersection, not a grant:apparmor_capable()is called from thecapableLSM hook, which only runs where the kernel was already about to allow the capability. A profile listingcapability net_admin,does not give an unprivileged processCAP_NET_ADMIN; it declines to take it away.network inet raw,permitssocket(AF_INET, SOCK_RAW, ...). Rules are per address-family and per socket type. Note the comment discipline in the real profile: each capability that is not obviously required carries a one-line justification. This is the difference between a profile that survives a security review and one that accretes.signal (receive) peer=libvirtd,andptrace (readby) peer=libvirtd,are peer rules: they name the label on the other side of the interaction. Both sides must permit it — libvirtd’s own profile needs the matchingsignal (send)/ptrace (read). This is one place AppArmor genuinely reasons about labels rather than paths.owner /dev/tty rw,— theownerconditional narrows the rule to files whose owner UID equals the task’s filesystem UID. It is the cheap way to write “its own files” without enumerating them.@{PROC}/@{pid}/fd/ r,— the trailing/matters: this grants listing the directory, not reading files inside it. A path with a trailing slash is a directory rule./etc/dnsmasq.d/* r,— a single*matches within one directory level and does not cross/.**crosses directory boundaries. Getting this wrong in the permissive direction is the most common way a profile becomes decorative.@{exec_path} mr,— read and executable-map its own binary.mis required or the dynamic loader fails; see the permission-letter table above./var/log/dnsmasq*.log w,— write only, no read. A log writer that cannot read its own log is a small but real containment win.rwon the DHCP lease file, because leases are read back at startup./{,usr/}bin/{ba,da,}sh ix,— the--dhcp-scriptfeature execs a shell.ixmeans the shell inherits the dnsmasq profile rather than running unconfined or under the shell’s own profile. This is the right call: the script is dnsmasq’s own extension point, so it should get dnsmasq’s privileges and no more. Had this beenuxthe entire profile would be bypassable by anyone who can set--dhcp-script.Cx -> libvirt_leaseshelper,— a directed transition to a child profile, with environment scrubbing (uppercaseC). The helper binary gets its own, much smaller rule set defined inside this profile, andLD_PRELOADfrom the dnsmasq process is stripped on the way in.@{TFTP_DIR}/ r,then@{TFTP_DIR}/** r,— the pair grants directory listing and recursive read. You almost always need both; the first alone lists but cannot open, the second alone opens but cannot list.- The child profile referenced by line 16. It exists only inside
dnsmasqand is reachable only via acx/Cxrule — it will never be picked byfind_attach()at top level, becausex_to_label()searchesprofile->base.profilesforAA_X_CHILDrather than the namespace list. rwk— read, write, and lock. Withoutk,flock()on the pidfile fails with a permission error whileopen()succeeds, which produces a spectacularly confusing bug report.include if exists <local/...>— the site-override hook. Local additions go in/etc/apparmor.d/local/usr.sbin.dnsmasqso that a package upgrade replacing the main profile does not clobber them.
For contrast, here is a complete, tiny profile — bin.ping from the same tree — which is worth reading precisely because it is short enough to hold in one view:
include <tunables/global>
profile ping /{usr/,}bin/{,iputils-}ping {
include <abstractions/base>
include <abstractions/consoles>
include <abstractions/nameservice>
capability net_raw,
capability setuid,
network inet raw,
network inet6 raw,
@{exec_path} mixr,
/etc/modules.conf r,
@{PROC}/sys/net/ipv6/conf/all/disable_ipv6 r,
include if exists <local/bin.ping>
}
@{exec_path} mixr, is the line to study: four letters, m (executable-map), i + x (execute with inherit transition), r (read). ping re-execs itself in some configurations, and ix keeps the re-exec inside the same profile. The whole of ping’s privilege — raw sockets and the setuid transition it needs to acquire them — is those two capability lines. Everything else the binary might try is denied.
Path Versus Label — The Argument, Honestly, In Both Directions
Everything distinctive about AppArmor follows from one decision: a rule names an object by its path. The critique from 2006 has not been refuted, and it is not a matter of taste, so it is worth stating precisely rather than gesturing at.
A path is not an object. It is a name for an object, resolved by walking the mount and directory structure. In a POSIX filesystem an inode may have many names — hard links give one inode several directory entries, bind mounts give one subtree several mount points, symbolic links give one path several spellings, and per-task mount namespaces can give two processes different names for the same file and the same name for different files. Every one of those is an aliasing channel: a rule written about /etc/shadow says nothing about /tmp/evil when /tmp/evil is a hard link to the same inode. SELinux’s label is on the inode, so it follows every name.
flowchart LR subgraph aa["Path-based mediation (AppArmor)"] R1["Rule:<br/>deny /etc/shadow rw"] -.->|"matches the string"| N1["name: /etc/shadow"] N1 --> I1(("inode 4711")) N2["name: /tmp/alias<br/>(hard link)"] --> I1 N3["name: /mnt/root/etc/shadow<br/>(bind mount)"] --> I1 R1 -.->|"NO rule matches"| N2 R1 -.->|"NO rule matches"| N3 end subgraph se["Label-based mediation (SELinux)"] R2["Rule:<br/>deny type shadow_t"] --> I2(("inode 4711<br/>xattr: shadow_t")) M1["name: /etc/shadow"] --> I2 M2["name: /tmp/alias"] --> I2 M3["name: /mnt/root/etc/shadow"] --> I2 end
The aliasing problem in one picture. What it shows: in the left model the rule attaches to the name and the object is reachable by names the rule never mentions; in the right model the rule attaches to the object and every name inherits the verdict. The insight: this is not a bug in AppArmor’s implementation — it is the definitional consequence of naming objects indirectly, and it is precisely what Smalley meant by “the object may be accessible by another means.” Any assessment of an AppArmor profile has to ask not only “what paths did I allow?” but “what other paths reach the same inodes, and who can create more?”
AppArmor is not naive about this, and the mitigations are real and in the source:
- Creating a hard link is itself a mediated operation.
l(AA_MAY_LINK) is required on the new name, so a confined process cannot manufacture an alias unless its own profile permits it. - The link-subset test. When the
AA_LINK_SUBSETflag is set on a link rule,profile_path_link()insecurity/apparmor/file.cre-evaluates the target’s permissions and enforces, in the source’s own words, “requiring allowed permission on link are a subset of the allowed permissions on target.” If they are not, the link is refused withinfo = "link not subset of target". There is even an exec-specific arm:xindex_is_subset()prevents creating a link whose exec transition would be more permissive than the target’s. A confined process therefore cannot uselink()to launder a file into a more permissive name. - Mounts are mediated too.
mount,umountandpivot_rootare their own rule class (advertised underfeatures/mount), so a confined process cannot bind-mount its way to a friendlier path unless the profile allows it. - Attachment can be conditioned on
xattrs.aa_xattrs_match()lets a profile refuse to attach unless the binary carries specific extended attributes — a deliberate borrowing of label-like discrimination.
What remains, and what no amount of engineering inside AppArmor can close, is the third-party alias: a process that is unconfined, or confined by a different profile that legitimately holds l or mount permissions, can create a name that AppArmor’s rules were never written about. Because AppArmor’s default is unconfined, on a typical system there are many such processes. That is Smalley’s third objection — the attacker attacks where you are weak — restated in modern terms, and it is the honest answer to “is AppArmor as strong as SELinux?” It is not, and the gap is structural.
The case for the model is equally real and is why two major distribution families chose it anyway:
| Dimension | AppArmor (path) | SELinux (label) |
|---|---|---|
| What a rule names | a pathname pattern | a security type on the inode |
| Aliasing (hard link, bind mount) | rule can be sidestepped by another name | label follows the inode |
| Default for an unprofiled program | unconfined — no MAC at all | confined by a system-wide policy; nothing is unlabeled |
| Reading a policy cold | rules mention real paths; readable by anyone who knows the filesystem | requires knowing the type/attribute vocabulary first |
| Filesystem requirement | none — works on any filesystem, including ones without xattr support | needs a labeled filesystem; unlabeled/network mounts need explicit context mounts |
| Failure after a software update | new path not in the profile → one denial, one new rule | new file mislabeled → relabel, or a policy module change |
| Learning loop | complain mode + aa-logprof proposes rules from real behaviour | permissive mode + audit2allow proposes types from AVC denials |
| Blast radius of a mistake | a too-broad glob silently widens one profile | a too-broad type applies system-wide |
| Where it ships by default | Ubuntu, Debian ≥ 10, SUSE/openSUSE | Red Hat / Fedora / CentOS Stream, Android |
The trade-off grid. What it shows: the comparison across the dimensions that actually decide adoption, not just the security-strength axis. The insight: the two rows that decide most real deployments are the last three. AppArmor’s per-program, opt-in, path-named model means you can confine one risky daemon this afternoon and leave the other 400 binaries alone; SELinux’s model means you cannot, but also means an attacker cannot find an unlabeled corner. The choice is usually made for you by your distribution, and the correct response is to use whichever one your distribution enables well rather than to disable it in favour of the theoretically stronger one you will not maintain.
The kernel community’s resolution of the argument was, in the end, not a technical verdict but a scope judgement: the 2006 Kernel Summit “determined that the use of pathnames was not enough to keep AppArmor out of the kernel” (LWN, 2007), and Linus Torvalds took the position that pathname-based policy was reasonable to him. AppArmor’s own documentation and its authors have never claimed the label model’s guarantees; the 2006 cover letter said so in as many words — “AppArmor is not intended to protect every aspect of the system from every other aspect of the system.” Holding it to a promise it declines to make is as unfair as deploying it as though it made one. For the detailed head-to-head, see SELinux vs AppArmor; for the aliasing question in profile-authoring terms, AppArmor Profiles and Path-Based Confinement.
Confining User Namespaces — AppArmor’s Newest Job
For fourteen years after the merge, AppArmor’s job description did not change: confine programs to files, capabilities, sockets and signals. Then Ubuntu gave it a genuinely new one, and it is the most consequential thing to happen to the module since 2010.
The problem is unprivileged user namespaces. A user namespace lets an ordinary user call unshare(CLONE_NEWUSER) and become UID 0 inside the new namespace, holding a full set of capabilities with respect to objects that namespace owns. That is a genuine sandboxing primitive — Flatpak, bwrap, LXD, Chrome’s renderer sandbox and every rootless container runtime depend on it — but it also hands unprivileged code a capability-shaped key to kernel interfaces that were written on the assumption that only real root would ever reach them. Qualys opens its 2025 advisory by quoting grsecurity’s retrospective on exactly this: unprivileged user namespaces “greatly increased kernel attack surface, exposed many interfaces that previously saw little security scrutiny” (Qualys, 2025-03-27).
Distributions responded with a sledgehammer. Ubuntu’s own write-up describes it plainly: “Ubuntu, Debian and other distros have carried a big hammer patch to be able to globally disable unprivileged user namespaces. This patch, however, can only ever be enabled at the system level as an emergency mitigation because it breaks too many applications (such as desktop environments, Flatpak, LXD, container runtimes, etc.)” (Canonical, 2025-03-27). A single global switch that breaks the desktop is not a security control anybody leaves on. What was wanted was a per-program switch — which is precisely the shape of an AppArmor profile.
The kernel half, and exactly how far upstream goes
The enabling change was a new LSM hook. userns_create does not exist in include/linux/lsm_hook_defs.h at tag v6.0 or v5.19 and does appear at v6.1 and every tag since — an existence check across tags, which dates the hook to the Linux 6.1 release without relying on anyone’s changelog. AppArmor implements it in security/apparmor/lsm.c:
static int apparmor_userns_create(const struct cred *cred)
{
struct aa_label *label;
struct aa_profile *profile;
int error = 0;
DEFINE_AUDIT_DATA(ad, LSM_AUDIT_DATA_TASK, AA_CLASS_NS,
OP_USERNS_CREATE);
ad.subj_cred = current_cred();
label = begin_current_label_crit_section();
if (!unconfined(label)) {
error = fn_for_each(label, profile,
aa_profile_ns_perm(profile, &ad,
AA_USERNS_CREATE));
}
end_current_label_crit_section(label);
return error;
}Read the guard, because it is the whole story: if (!unconfined(label)). Upstream AppArmor mediates user-namespace creation only for tasks that already have a profile. An unconfined task — which, given AppArmor’s opt-in model, is most tasks on a normal system — sails straight through with error = 0. AA_CLASS_NS is mediation class 21 in include/apparmor.h, and aa_profile_ns_perm() is the class’s permission check; the audit operation string is OP_USERNS_CREATE, which is what you grep the log for.
That code is byte-for-byte identical at v6.12, v6.18, v7.0 and v7.2, so the upstream position has not moved in four years of releases. The complete set of AppArmor sysctls in the upstream apparmor_sysctl_table[] is likewise unchanged across those four tags — unprivileged_userns_apparmor_policy, apparmor_display_secid_mode, and apparmor_restrict_unprivileged_unconfined, all mode 0600 and all gated by aa_current_policy_admin_capable() in apparmor_dointvec(). kernel.apparmor_restrict_unprivileged_userns — the knob every Ubuntu article names — is not among them. It is a Canonical out-of-tree (“SAUCE”) patch, exactly as the 2023 specification said it would be: “Support for LSM mediation of user namespaces was merged into the upstream Linux kernel for the 6.1 release. However, the required changes to allow AppArmor to make use of this are not yet upstream. As such the linux… source packages in Ubuntu will require SAUCE patches” (Ubuntu spec, 2023). As of the v7.2 mainline tree read for this note, they still are not upstream. If you read security/apparmor/ on kernel.org and conclude that AppArmor restricts unprivileged user namespaces, you have read the wrong tree.
The policy half — userns, ABIs, and profiles that confine nothing
On the profile side the vocabulary is a new rule class. apparmor_parser gained it in apparmor-3.0.7-1ubuntu2 during the Kinetic cycle, and a profile opts in with a bare
userns,
or, in the newer explicit form, allow userns create,. The trap here is the one the earlier feature-negotiation discussion set up: rule classes a profile’s ABI does not know about are granted, not denied. The spec is explicit that “the default ABI of this version of apparmor_parser does not contain support for the userns feature, and so profiles which do not contain this userns, permission will also silently be granted this permission as well.” Shipping the restriction therefore required shipping AppArmor 4.0-alpha1 with the new abi/4.0 and changing /etc/apparmor/parser.conf to compile against it by default. A feature-negotiation mistake would have made the entire effort decorative.
The second policy-side invention is the flags=(unconfined) profile — the fourth of the five modes listed earlier, and the one that sounds pointless until now. Ubuntu needed a way to say “this program may create user namespaces, and I am not otherwise ready to write it a real profile.” A named profile that enforces nothing but carries one permission does exactly that:
abi <abi/4.0>,
/usr/bin/flatpak flags=(unconfined) {
allow userns create,
}
The spec’s own assessment is refreshingly candid — “Whilst this does not achieve any meaningful confinement of the application, it does allow such applications to continue to use unprivileged user namespaces and avoids the risk of introducing any regression in functionality,” and it expects this “will likely be the most appropriate for the majority of applications that legitimately require the use of unprivileged user namespaces.” Hold that sentence; the bypasses below are its direct consequence.
The third piece is the shipped unprivileged_userns profile, whose header comment states its job: “Special profile transitioned to by unconfined when creating an unprivileged user namespace” (upstream profile).
profile unprivileged_userns {
audit deny capability, # 1
audit deny change_profile, # 2
allow network, # 3
allow signal,
allow dbus,
allow file rwlkm /{,**},
allow unix,
allow mqueue,
allow ptrace,
allow userns, # 4
# stack children to strip capabilities
allow pix /** -> &unprivileged_userns , # 5
include if exists <local/unprivileged_userns>
}
audit deny capability,— every capability, denied, and logged. This is the whole point: the task may have a user namespace, but inside it the capabilities are worthless.audit deny change_profile,— it may not talk its way into a more generous profile.- The block of
allowrules starting here grants back essentially everything else — files read/write/lock/link/map across the whole tree, networking, signals, D-Bus,ptrace— because this profile is not trying to confine the application, only to strip capability use. allow userns,— creating further nested user namespaces is permitted.allow pix /** -> &unprivileged_userns ,— the profile’s cleverest line. Everyexectransitions with an&-prefixed target, which by the rule established earlier means stack rather than replace: the child ends up labelled with both its own attachment andunprivileged_userns, and because a stack is an intersection, the capability denial follows the process through every subsequentexec. Without it, the firstexecwould escape the restriction. Note also that the mode is lowercasepix— the unsafe, non-scrubbing variant — soLD_PRELOADsurvives the transition. The profile records no rationale for that, and this note has not found one upstream; it is worth knowing before treating this profile as a boundary rather than a capability filter.
John Johansen’s summary of the two deployment states is the clearest statement of what the profile buys (LWN comment, 2024-05-04): “If the unprivileged_userns profile is loaded user code will be allowed to create a user namespace, but that user namespace will be restricted so that it has no capabilities within the user namespace. If the unprivileged_userns profile is not loaded unknown user code will not be able to create user namespaces at all.”
The change_profile hole, and the patch for it
An obvious attack presents itself: if some profile on the system grants userns, an unconfined attacker can simply become that profile. AppArmor exposes voluntary transitions through /proc/self/attr/current and /proc/self/attr/exec; do_setattr() in lsm.c parses the commands changehat, permhat, changeprofile, permprofile and stack on current, and exec / stack on exec, mapping them onto aa_change_profile() with AA_CHANGE_ONEXEC and friends. The aa-exec tool is a thin wrapper over that interface.
The counter-measure is in security/apparmor/domain.c, guarded by the upstream sysctl apparmor_restrict_unprivileged_unconfined:
if (!stack && unconfined(label) &&
label == &labels_ns(label)->unconfined->label &&
aa_unprivileged_unconfined_restricted &&
cap_capable(current_cred(), &init_user_ns, CAP_MAC_OVERRIDE,
CAP_OPT_NOAUDIT)) {
/* regardless of the request in this case apparmor
* stacks against unconfined so admin set policy can't be
* by-passed
*/
stack = true;cap_capable() returns 0 when the task has the capability, so the condition is true precisely when the caller lacks CAP_MAC_OVERRIDE. In that case the requested transition is silently converted from a replace into a stack: the target profile is intersected with unconfined rather than substituted for it, and since the ambient restriction denies userns to unconfined tasks, the attacker gains nothing. It is the same stacking trick the unprivileged_userns profile uses, applied to a different escape route.
Uncertain
Verify: which capability Ubuntu’s shipped kernel actually tests here. Upstream
security/apparmor/domain.cat v6.12 testsCAP_MAC_OVERRIDE(quoted above), while the 2023 Ubuntu specification’s prose says “This restriction will not apply to processes with the CAP_MAC_ADMIN capability.” Reason: the two documents disagree, and Ubuntu’s kernel carries SAUCE patches in this exact area, so the shipped code may differ from mainline. To resolve: readsecurity/apparmor/domain.cfrom thelinuxsource package of a current Ubuntu release rather than fromtorvalds/linux.#uncertain
The bypasses, and why they are not bugs
In January 2025 Qualys reported three ways around the restriction to the Ubuntu Security Team; the coordinated disclosure landed on 2025-03-27 and was covered by LWN. All three share one root cause — the flags=(unconfined) profiles the spec deliberately blessed.
aa-execinto a permissive profile.aa-exec -p trinity -- unshare -U -r -m /bin/shtransitions into any shipped profile carryinguserns,and then creates a fully capable namespace. This is the holeapparmor_restrict_unprivileged_unconfinedcloses — and Qualys notes, with some justice, that the fix “was already mentioned on Ubuntu’s excellent security podcast in October 2023, but unfortunately it was never enabled by default.”busybox. Even with the sysctl on,busyboxis installed by default on both Ubuntu Server and Desktop and ships a profile grantinguserns,. Runbusybox shand you are legitimately inside a permissive profile without any transition to restrict. Ubuntu’s own mitigation guidance concedes the point: “The busybox shell is such an example and it is available by default on standard Ubuntu installations.”LD_PRELOADintonautilus. A three-line constructor library thatexecves/bin/sh, preloaded into GNOME’s file manager, yields a shell running undernautilus’s permissive profile. This one is a direct consequence of a mechanism decoded earlier in this note. Look again atprofile_transition(): when the caller is unconfined, the function callsfind_attach(), returns the new label, and returns before touching*secure_exec— whichapparmor_bprm_creds_for_exec()initialised asbool unsafe = false;. Environment scrubbing is decided only on the confined path, from thexindexbits of a transition rule. An attachment from an unconfined caller has no transition rule and therefore noAA_X_UNSAFEbit to clear, sobprm->secureexecis never set andLD_PRELOADsurvives into the newly attached profile. The advisory does not explain the mechanism; the source does.
flowchart TB U["unprivileged task calls<br/>unshare(CLONE_NEWUSER)"] --> HOOK["LSM hook userns_create<br/>(added in Linux 6.1)"] HOOK --> AA["apparmor_userns_create()"] AA --> CONF{"is the task's label<br/>unconfined?"} CONF -->|"no — confined"| RULE{"profile has<br/>userns create ?"} RULE -->|"yes"| OK["namespace created"] RULE -->|"no"| DENY["-EACCES<br/>audit operation="userns_create""] CONF -->|"yes — unconfined"| UP{"which tree?"} UP -->|"upstream 6.12 to 7.2"| OK2["allowed — hook returns 0<br/>NO mediation of unconfined tasks"] UP -->|"Ubuntu SAUCE +<br/>kernel.apparmor_restrict_<br/>unprivileged_userns = 1"| PROF{"is the unprivileged_userns<br/>profile loaded?"} PROF -->|"no"| DENY2["denied outright"] PROF -->|"yes"| STACK["transition into unprivileged_userns:<br/>namespace created, but<br/>audit deny capability strips<br/>every capability inside it"] OK2 -.->|"Qualys bypass 1"| AAEXEC["aa-exec -p trinity<br/>→ blocked by kernel.apparmor_<br/>restrict_unprivileged_unconfined"] STACK -.->|"Qualys bypass 2"| BB["exec busybox — its own profile<br/>grants userns, no transition needed"] STACK -.->|"Qualys bypass 3"| LDP["LD_PRELOAD into nautilus —<br/>unconfined attach never sets<br/>bprm->secureexec, so the<br/>environment is not scrubbed"]
User-namespace mediation, upstream versus Ubuntu, with the three published bypasses attached to the branches they exploit. What it shows: two independent decisions — whether the caller is confined at all, and, on Ubuntu, whether a permissive profile is reachable — and the fact that every bypass targets the unconfined branch rather than the mediation logic itself. The insight: the bypasses are not defects in AppArmor’s checks; they are Smalley’s 2006 third objection arriving on schedule. A control whose default state is “unconfined” can always be attacked at the default. Canonical’s answer is that this is hardening, not a boundary — the bypasses “do not enable more access than what the default Linux kernel unprivileged user namespace feature allows in most Linux distributions,” and an imperfect reduction of kernel attack surface still beats none.
The hardening steps Canonical published alongside the advisory are worth recording because they are what an operator actually does: set kernel.apparmor_restrict_unprivileged_unconfined=1 in /etc/sysctl.d/; disable the busybox and nautilus profiles by symlinking them into /etc/apparmor.d/disable and unloading with apparmor_parser -R; and, for desktops, install a purpose-built bwrap profile so Nautilus’s thumbnailing keeps working. Two caveats in the same post matter more than the steps: “if installed, LXD will completely disable the user namespace restriction feature when running, effectively making this sysctl irrelevant,” and sudo aa-status --filter.mode=unconfined is how you enumerate the permissive profiles on your own system. On a machine running LXD, this entire section describes a feature that is switched off.
Failure Modes and Gotchas
Nearly every AppArmor incident is one of a small number of recurring shapes, and most of them present as something other than a permissions problem.
A newly loaded profile does not confine anything already running. This is the single most common false conclusion. aa_replace_profiles() swaps the policy; it does not rewrite the credentials of live tasks. A daemon that started before its profile was loaded stays unconfined until it is restarted, so systemctl reload apparmor followed by “the profile is loaded, we’re protected” is wrong unless the service was also restarted. Verify with aa-status, which reports processes and their labels, not merely which profile files exist.
Two overlapping attachment globs leave the binary unconfined. find_attach() treats an exact tie in match length and xattr count as a conflict and returns NULL with info = "conflicting profile attachments". There is no load-time warning and no “stricter one wins” rule — you get no confinement at all, silently, which is the worst possible resolution of an ambiguity.
Booting with two exclusive LSMs silently disables one. AppArmor declares .flags = LSM_FLAG_LEGACY_MAJOR | LSM_FLAG_EXCLUSIVE in its DEFINE_LSM, and so does SELinux. security/security.c refuses the second exclusive module it meets in CONFIG_LSM order and reports it through init_debug(), which prints nothing unless you booted with lsm.debug. “My profiles won’t load on this kernel” is sometimes “SELinux appeared earlier in the list.” cat /sys/kernel/security/lsm settles it; see LSM Stacking and Module Ordering.
no_new_privs blocks profile transitions. When a task has no_new_privs set — which systemd’s NoNewPrivileges=yes and essentially every container runtime do — apparmor_bprm_creds_for_exec() refuses any transition whose new label is not an unconfined subset of the label held when no_new_privs was set, returning -EPERM with info = "no new privs". Transitions from unconfined and transitions that stack are exempt, because both can only reduce privilege. The symptom is a helper binary that execs fine outside the unit and fails with EPERM inside it, with an audit record naming no new privs rather than any file or capability.
Missing m and missing k produce non-obvious errors. A library granted r but not m fails at mmap(PROT_EXEC), which surfaces as a dynamic-loader error, not a permission message. A pidfile granted rw but not k opens successfully and then fails at flock(). Both look like application bugs.
Path punctuation is load-bearing. * does not cross / and ** does; a trailing / grants directory listing and nothing inside it; owner narrows a rule to the task’s own filesystem UID. Each of these silently widens or narrows a rule, and the permissive mistakes are the ones nobody notices.
Disconnected paths deny inexplicably. If a file’s path cannot be resolved relative to the namespace root — an fd held across a mount change, a file in a detached mount — the check fails unless the profile carries flags=(attach_disconnected). This is why almost every container-related profile in the wild sets that flag.
Denials are invisible if nothing is collecting them. AppArmor returns a plain EACCES/EPERM to the application. Without auditd, records go to the kernel ring buffer and can be rate-limited away under load. journalctl -k | grep 'apparmor="DENIED"' is the minimum; ausearch is better.
Complain mode is not a safety net, it is a learning mode. The manual page’s warning is worth re-reading before leaving a profile in complain “just for now”: it “should not be used in a hostile environment or bad behaviors may be logged and added to the profile as if they are resource accesses that should be used by the application.” aa-logprof will cheerfully propose a rule that an attacker’s behaviour generated.
A ux rule discards everything. apparmor.d(5) says of it: “Any profile using this mode provides negligible security. Use at your own risk.” A profile with one ux on a shell is a profile with a documented escape hatch — and the pux/cux fallback forms fail open in exactly the same way when the target profile is missing.
Cached policy can be stale after a kernel change. Compiled profiles under /var/cache/apparmor are keyed by the kernel’s advertised feature set. A kernel upgrade that changes features/ invalidates them; if a deployment pipeline ships a prebuilt cache, it must be rebuilt against the kernel it will run on or the load fails.
flowchart TB S["Symptom: the application fails,<br/>or the confinement seems absent"] --> Q1{"aa-status: is the<br/>process listed as confined?"} Q1 -->|"no"| Q2{"does a profile file exist<br/>and is it loaded?"} Q2 -->|"no"| W1["write / load the profile"] Q2 -->|"yes"| Q3{"was the process started<br/>BEFORE the profile was loaded?"} Q3 -->|"yes"| W2["restart the service —<br/>confinement is set at execve"] Q3 -->|"no"| Q4{"cat /sys/kernel/security/lsm<br/>— is apparmor present?"} Q4 -->|"no"| W3["another exclusive LSM won<br/>the slot; fix CONFIG_LSM / lsm="] Q4 -->|"yes"| W4["overlapping attachment globs →<br/>"conflicting profile attachments"<br/>= no confinement; use exact paths"] Q1 -->|"yes"| Q5{"any apparmor="DENIED"<br/>records in the audit log?"} Q5 -->|"none at all"| W5["is auditd running?<br/>are kernel messages rate-limited?<br/>is the profile in complain mode?"] Q5 -->|"denied_mask has letters"| W6["add the missing letters —<br/>but c/d are audit-only:<br/>the fix for c is w"] Q5 -->|"capname=..."| W7["add capability <name>,"] Q5 -->|"info: no new privs"| W8["NoNewPrivileges is set;<br/>the transition would not<br/>reduce privilege"] Q5 -->|"operation: userns_create"| W9["add userns create,<br/>(and check the profile abi)"] Q5 -->|"denied on a path that<br/>looks allowed"| W10["disconnected path →<br/>flags=(attach_disconnected),<br/>or * vs ** vs trailing /"]
A triage tree for AppArmor problems. What it shows: the two top-level branches — “not confined when it should be” and “confined and denying” — and the distinct evidence each one leaves. The insight: the left branch is diagnosed with aa-status and the right branch with the audit log, and confusing them wastes the most time. A missing denial record is itself a finding: if the process is confined and the log is empty, either the collector is not running or the failure is not AppArmor’s.
Alternatives and When to Choose Them
AppArmor is one guard among several posted at the system-call boundary, and the useful question is almost never “AppArmor or X” but “which of these does the job I actually have, and which of them compose.”
The one genuine either/or is SELinux. Both declare LSM_FLAG_EXCLUSIVE, so at most one of them initialises; you cannot run both, and in practice your distribution has already chosen. That comparison is drawn in full in the Path Versus Label section above and in the dedicated SELinux vs AppArmor note. Smack competes for the same slot — its DEFINE_LSM at v6.12 carries the identical LSM_FLAG_LEGACY_MAJOR | LSM_FLAG_EXCLUSIVE pair — and is a simpler label model aimed at embedded systems. TOMOYO, notably, does not: its DEFINE_LSM declares only LSM_FLAG_LEGACY_MAJOR, so it is not exclusive and can in principle initialise alongside AppArmor. It is another pathname-based MAC whose upstream history runs parallel to AppArmor’s — Tetsuo Handa’s remark quoted earlier, that TOMOYO waited two years and AppArmor four, comes from the author of the module that took the shorter road — and both Smack and TOMOYO see far less production use than the two dominant MACs.
Everything else on the list stacks with AppArmor rather than replacing it, and the reason is that they mediate at different points with different visibility.
| Mechanism | Mediates | Sees | Who installs it | Blind spot AppArmor covers | Blind spot it covers for AppArmor |
|---|---|---|---|---|---|
| seccomp-BPF | System-call entry | Syscall number and raw register arguments — no resolved objects | Unprivileged, with no_new_privs | Cannot say “not that file”; a pointer argument is just a number | Removes whole syscalls AppArmor has no rule class for |
| POSIX Capabilities | capable() call sites | Which CAP_* bit is requested | root, or file capabilities | Cannot express “which object” | Shrinks privilege before any MAC check runs |
| Landlock | LSM hooks, scoped to one task’s ruleset | Resolved objects; filesystem, plus TCP/scoping at higher ABI levels | Unprivileged self-sandboxing | Needs an admin to author policy | Lets an application confine itself with no root and no system policy |
| User Namespaces + mount/pid namespaces | What the process can name | The namespace’s own view | Unprivileged (subject to the restriction above) | Namespaces hide objects rather than denying access to them | Removes objects from the namespace entirely — nothing to write a rule about |
| BPF-LSM | The same LSM hooks | Resolved objects, plus BPF maps and helpers | CAP_BPF + CAP_MAC_ADMIN | Policy is compiled and reloaded, not programmable | Policy loadable and updatable at runtime, with arbitrary logic |
| IMA / fs-verity | File contents at open | Hashes and signatures | Admin | Says nothing about whether a file was tampered with | Answers “is this the binary I think it is”, which no path rule can |
Where each mechanism attaches, and what each cannot see. What it shows: the mechanisms differ less in strength than in vantage point — seccomp is early and object-blind, AppArmor and Landlock are late and object-aware, namespaces remove the object from view entirely, and integrity measurement asks a question about content rather than access. The insight: the two rightmost columns are why real hardening stacks them. seccomp cannot tell openat("/etc/shadow") from openat("/tmp/x") because the path has not been resolved when the filter runs; AppArmor can, but has no way to remove keyctl from the kernel’s attack surface. The combination is strictly stronger than either, and neither substitutes for the other.
The practical selection rules follow directly. If you are an administrator confining a daemon you did not write, AppArmor’s profile plus a capability drop is the highest-value pair, and the learning workflow makes it achievable in an afternoon. If you are an application author sandboxing your own process, reach for Landlock and seccomp first: they need no root, no system policy and no coordination with the distribution, and they travel with your binary rather than with the machine. If you need policy that changes at runtime — per-workload rules pushed by an agent — BPF-LSM is the only member of this list that can do it without recompiling and reloading. If your threat model includes an attacker who can create alternative names for a file, path-based mediation is structurally the wrong tool and SELinux’s labels are the right one, subject to the enormous practical caveat that a well-maintained AppArmor policy beats a disabled SELinux policy every time. And if the question is “can I stop unprivileged code reaching a risky kernel interface at all,” neither MAC is the primary answer: seccomp removes the syscall, and that is a stronger statement than any rule about the objects it would have touched.
Production Notes
Where it is on by default. AppArmor ships enabled on Ubuntu, on Debian since Debian 10 “Buster”, and on SUSE/openSUSE. Canonical’s security documentation records the userspace version per Ubuntu release — 4.0.1 on 24.04 LTS, 3.0.4 on 22.04, 2.13.3 on 20.04, 2.12 on 18.04, 2.10.95 on 16.04 and 14.04 (Ubuntu security docs, page last updated 2026-03-09) — which matters because rule classes arrive with the parser, not the kernel: userns needs a 4.0-series parser and the abi/4.0 feature set, so the same kernel behaves differently under 22.04’s 3.0.4 and 24.04’s 4.0.1.
Boot and service management. systemd loads early policy by calling apparmor_parser against the compiled cache in /etc/apparmor/earlypolicy/; anything not loaded early is loaded by apparmor.service. That unit has a deliberate quirk worth knowing before you debug it: “the stop command is intentionally a no-op because of how systemd implements the reload command (typically by a stop followed by a start). A true stop could lead to tasks operating in an unconfined state after the start. To unload profiles, use aa-teardown.” A unit can also request confinement for itself with AppArmorProfile= (systemd.exec(5), added in systemd 210): “Profiles must already be loaded in the kernel, or the unit will fail. If prefixed by -, all errors will be ignored.” The failure is specific and greppable — exit status 231, EXIT_APPARMOR_PROFILE, “Failed to prepare changing AppArmor profile.”
Containers. Docker generates a per-container profile called docker-default from a Go template. Read at moby v27.3.1 in profiles/apparmor/template.go, it is a short deny-list rather than an allowlist — network, capability, file, umount, grant everything, and the security comes from the deny rules beneath:
profile {{.Name}} flags=(attach_disconnected,mediate_deleted) {
deny @{PROC}/sysrq-trigger rwklx,
deny @{PROC}/kcore rwklx,
deny mount,
deny /sys/firmware/** rwklx,
deny /sys/kernel/security/** rwklx,
signal (receive) peer=unconfined,
signal (receive) peer=runc,
...
}
Three things are worth extracting. First, deny /sys/kernel/security/** rwklx is AppArmor denying access to its own securityfs control interface — a container that could write .replace would own the host’s policy. Second, the file’s own header says “This profile is replicated in containerd and libpod. If you make a change to this profile, please make follow-up PRs to those projects” — the same rules exist in three codebases, so a fix in one is not a fix on your machine. Third, the signal (receive) peer=runc and peer=crun rules exist purely so docker stop works: peer rules are two-sided, and forgetting them produces containers that ignore SIGTERM. A separate, much larger profile for the daemon lives in contrib/apparmor/template.go — note that it is in contrib/, an example rather than something the daemon loads for itself, and that its header is profile /usr/bin/docker (attach_disconnected, complain): it is written in complain mode, which is to say it audits and enforces nothing as shipped.
Kubernetes. AppArmor support has been stable since Kubernetes 1.31, with the feature gate removed; the pre-1.30 annotation form is gone. The modern spelling is a field on either the Pod’s or the container’s securityContext, with the container’s winning if both are set (Kubernetes docs):
| Field | Values | Meaning |
|---|---|---|
appArmorProfile.type | RuntimeDefault | Use the container runtime’s own default profile (docker-default / cri-containerd.apparmor.d) |
Localhost | Use a profile already loaded on the node, named by localhostProfile | |
Unconfined | No AppArmor enforcement | |
appArmorProfile.localhostProfile | profile name | Required if and only if type: Localhost |
flowchart LR subgraph nodeside["Every Node, independently"] LOAD["profile text delivered out-of-band<br/>(DaemonSet, config management,<br/>Security Profiles Operator)"] --> PARSE["apparmor_parser -r"] PARSE --> KP["profile loaded in the<br/>node kernel"] end subgraph cplane["Control plane"] POD["Pod spec:<br/>securityContext.appArmorProfile<br/>type + localhostProfile"] --> SCHED["scheduler<br/>(knows NOTHING about<br/>which profiles a node has)"] end SCHED --> KUBELET["kubelet on the chosen node"] KUBELET --> CRI["CRI runtime (containerd / CRI-O)<br/>sets the profile in the OCI spec"] CRI --> CHECK{"is that profile<br/>loaded on THIS node?"} KP -.->|"answers"| CHECK CHECK -->|"yes"| RUN["container starts confined;<br/>/proc/1/attr/current shows<br/>the profile name + (enforce)"] CHECK -->|"no"| FAIL["container does NOT start —<br/>kubelet event: "failed to generate<br/>apparmor spec opts:<br/>apparmor profile not found""]
How a Kubernetes Pod actually gets an AppArmor profile. What it shows: two independent pipelines that meet only at container start — the profile reaches the node by some mechanism Kubernetes does not provide, and the Pod reaches the node by a scheduler that has no idea which profiles are there. The insight: the failure is fail-closed, which is the right default but is often mistaken for an image or registry problem. A Localhost profile is a node-level dependency expressed in a Pod-level field, so either load every profile on every node or encode the dependency as a node label plus a node selector.
The operational trap is stated plainly in the same document: “Kubernetes 1.37 does not provide any built-in mechanisms for loading AppArmor profiles onto Nodes… The scheduler is not aware of which profiles are loaded onto which Node, so the full set of profiles must be loaded onto every Node.” A Pod scheduled to a node missing its profile does not run unconfined — it fails to start, with a kubelet event reading failed to generate apparmor spec opts: apparmor profile not found. The two workable patterns are to load profiles everywhere (a DaemonSet, or the Security Profiles Operator) or to label nodes per profile and use a node selector. Verify a running pod with kubectl exec <pod> -- cat /proc/1/attr/current, which should print something like cri-containerd.apparmor.d (enforce).
Snaps. On Ubuntu, AppArmor is not merely one hardening option among several — it is the enforcement mechanism behind snap confinement, described by Canonical as “a core technology for the Linux Security Module (LSM) on Ubuntu, as well as for Snaps in Ubuntu Core.” A snap’s interface connections are compiled into AppArmor rules, which is why disabling AppArmor on an Ubuntu system does considerably more than turn off a few daemon profiles.
The two commands to run on a system you inherited. aa-status tells you which profiles are loaded, in which of the five modes, and which running processes are confined — and aa-status --filter.mode=unconfined enumerates the flags=(unconfined) profiles that the user-namespace section showed to be the soft underbelly of the whole model. aa-unconfined lists listening network daemons with no profile at all. Between them they measure the exact quantity AppArmor’s design leaves unmeasured by default: how much of the system is actually confined.
See Also
- The Linux Security Module Framework — the hook rack AppArmor plugs into:
security_*()call sites, static-call dispatch, per-object security blobs, and whybprm_creds_for_execruns where it does. This note assumes that machinery rather than re-explaining it - AppArmor Profiles and Path-Based Confinement — the sibling that goes deeper on authoring: globbing grammar, abstractions and tunables, child profiles and hats
- SELinux vs AppArmor — the head-to-head, and why you cannot run both (
LSM_FLAG_EXCLUSIVE) - SELinux · SELinux Type Enforcement and Labels — the label-based model the Path Versus Label section argues against and with
- User Namespaces — the mechanism AppArmor now gates: what
unshare(CLONE_NEWUSER)grants, why it is an attack-surface multiplier, and what “root inside the namespace” really means. Ubuntu’skernel.apparmor_restrict_unprivileged_usernsis only comprehensible against that background - User Namespaces and Privilege Escalation — the exploit history that motivated the restriction
- POSIX Capabilities — what
capabilityrules name; AppArmor intersects with the capability check rather than granting it - Seccomp and seccomp-BPF — the complementary confinement mechanism: syscall-level, object-blind, unprivileged to install, and stackable with AppArmor rather than an alternative to it
- Landlock · Landlock vs seccomp vs Namespaces — unprivileged self-sandboxing, for when no administrator will write you a profile
- LSM Stacking and Module Ordering · Major vs Minor LSMs — why only one exclusive MAC initialises, and how the silent refusal is diagnosed
- Mandatory vs Discretionary Access Control · Discretionary Access Control — the layer AppArmor sits above and can only subtract from
- The Linux Audit Subsystem · Audit Rules and auditd — where
apparmor="DENIED"records land and how to search them - Container Security Confinement · Namespaces and cgroups as Container Building Blocks — how
docker-defaultcomposes with capabilities, seccomp and namespaces - Smack · TOMOYO — the other two MAC modules of this generation; Smack shares AppArmor’s
LSM_FLAG_EXCLUSIVE, TOMOYO does not - Linux Security MOC — parent map (section D, the mandatory access control modules)