AppArmor Profiles and Path-Based Confinement

An AppArmor profile is a text policy that whitelists, for one program, exactly the files, capabilities, network operations, mounts, and signals it may use — written in a small, readable domain-specific language and compiled into a path-matching automaton by apparmor_parser. This note dissects that language: the file rules with their permission letters (r w a m k l x), the capability and network rules, the abstractions/ and tunables/ include mechanism that keeps profiles DRY, and the exec-transition modes (Px/Cx/Ix/Ux and their lowercase/fallback variants) that decide what profile a child process inherits across execve. It then confronts AppArmor’s defining trade-off head-on: because every rule names a path rather than an inode, hard links, bind mounts, mount --bind, rename games, and mount namespaces can be used to reach the same bytes under a name the profile never anticipated — the central, well-documented weakness that SELinux’s inode-following labels do not share.

This is the syntax-and-semantics companion to AppArmor (which covers the module, modes, attachment-at-exec, and the securityfs interface). Read that first for the big picture; this note is the policy-author’s reference and the honest accounting of what path-based mediation can and cannot guarantee. Pinned to Linux 6.12 LTS kernel mediation and the AppArmor 4.x userspace grammar documented in apparmor.d(5).

Mental Model

A profile is a scoped allowlist plus an exec-transition table. The allowlist part says “this program may touch these paths with these permissions, hold these capabilities, open these sockets.” The transition table is subtler and where most of the conceptual weight lives: it answers, for every program this one might exec, “what profile should the child run under?” The answer is encoded as an x permission decorated with a mode letter (p, c, i, u) and case (lowercase = keep the environment; uppercase = scrub it like a setuid binary).

flowchart TB
  subgraph profile["Profile for /usr/bin/parent"]
    FILE["file rules<br/>/etc/app/** r<br/>/var/lib/app/** rw"]
    CAP["capability rules<br/>capability net_admin"]
    NET["network rules<br/>network inet stream"]
    XR["exec rules decide child's profile"]
  end
  XR --> PX["Px /usr/bin/child<br/>→ child's OWN profile<br/>(scrub env)"]
  XR --> CX["Cx<br/>→ a child profile<br/>declared INSIDE this one"]
  XR --> IX["ix<br/>→ INHERIT this profile<br/>(child stays confined as parent)"]
  XR --> UX["Ux<br/>→ UNCONFINED<br/>(no AppArmor at all)"]
  PX -.->|"missing target?"| FALL["fallback variants:<br/>Pix → fall back to ix<br/>Pux → fall back to ux"]

The exec-transition fork at the heart of a profile. What it shows: when a confined program execs another, its own profile’s x rules choose the child’s confinement — its own profile (Px), a nested child profile (Cx), inheritance (ix), or none (Ux) — with uppercase variants scrubbing the environment and *ix/*ux suffixes providing fallbacks. The insight: confinement composes along the process tree through these rules; a single careless ux rule punches a hole straight through the sandbox, which is why ux/Ux are the dangerous modes.

File Rules and the Permission Letters

A file rule is [qualifier] <path-or-glob> <perms> [exec-transition] , — note the mandatory trailing comma. The permission letters, from apparmor.d(5) and corroborated against the kernel’s bit definitions in security/apparmor/include/perms.h:

  • rread a file or list a directory. (AA_MAY_READ, alias of the VFS MAY_READ.)
  • wwrite. Also required to unlink a file. (AA_MAY_WRITE.)
  • aappend only: write access restricted to O_APPEND opens. a and w are mutually exclusive in one rule (AA_MAY_APPEND).
  • mmemory-map executable: permit mmap(2) with PROT_EXEC. This is why an interpreter needs m on libraries it dlopens and on its own binary (AA_EXEC_MMAP, 0x00010000).
  • klock: advisory and mandatory file locking (flock/fcntl locks) (AA_MAY_LOCK, 0x8000).
  • llink: create a hard link (AA_MAY_LINK, 0x00040000). See the link-rule subtleties below — this is security-load-bearing.
  • xexecute: never used bare; always carries a transition mode (px, cx, ix, ux, …). A bare x is a syntax error.

The kernel even keeps a compact char-to-bit table for audit output: lib.c defines aa_file_perm_chrs[] = "xwracd km l ", the canonical ordering used when AppArmor renders a denied-permission mask back into letters in a log line (lib.c). Under the hood there are many more permission bits than the short letters expose — AA_MAY_CREATE, AA_MAY_DELETE, AA_MAY_RENAME, AA_MAY_SETATTR/GETATTR, AA_MAY_CHMOD/CHOWN — but the profile language collapses most of these into r/w/a for ergonomics (perms.h).

Path globbing

Paths are matched with a glob syntax that compiles into the matching DFA (Core Policy Reference):

  • * — matches any run of characters except / (stays within one directory level).
  • ** — matches across directory levels, including / (recurses into subdirectories).
  • ? — exactly one non-/ character.
  • {a,b,c} — alternation; {foo,bar} matches either; empty alternates allowed.
  • [abc], [a-z], [^abc] — character classes and negation (PCRE-style).

So /var/log/*.log matches files directly in /var/log, while /var/log/** matches everything beneath it recursively. The distinction is constantly load-bearing: an attacker who can place content one directory deeper than a * rule reaches escapes it, while ** is the broad brush.

The owner conditional and variables

owner /home/*/.app/ rw, restricts the rule to files whose owner UID equals the task’s effective/filesystem UID — the standard way to let a daemon touch each user’s own dotfiles without touching everyone’s. Profiles also use variables (@{NAME}), expanded by the parser before compilation: @{HOME} (home directories), @{PROC} (/proc), @{pid}, and @{profile_name} (auto-set to the current profile). A rule like owner @{HOME}/.config/app/** rw, expands @{HOME} to all configured home-directory roots.

Capability, Network, and Mount Rules

Beyond files, a profile mediates other operation classes:

Capability rules name a POSIX capability without the CAP_ prefix, lowercase: capability net_bind_service, lets the program bind a privileged port; capability sys_admin, (the dangerous one) lets it do much of what root can. Absent such a rule, a confined program is denied the capability even if DAC/credentials would grant it — MAC can only subtract.

Network rules scope socket operations by domain, type, and protocol: network inet stream, (IPv4 TCP), network inet6 dgram, (IPv6 UDP), network unix, (UNIX-domain sockets). Newer AppArmor adds fine-grained conditionals — network ip=127.0.0.1 port=8080, and network peer=(ip=10.0.0.1 port=9000), — letting policy pin addresses and ports (apparmor.d.5). The kernel advertises this surface as features/network_v8/ in securityfs.

Mount rules mediate mount, umount, and pivot_root — the kernel’s mount.c implements aa_bind_mount, aa_move_mount, and pivot_root mediation, and securityfs advertises features/mount/mask = "mount umount pivot_root" (mount.c). A rule like mount options=(rw,bind) /src/ -> /dst/, controls a specific bind mount. Mount mediation matters enormously here because — as the weakness section explains — bind mounts are one of the principal ways path-based confinement is confused, so controlling who may mount is part of the defence.

Abstractions and Tunables — the Include System

Writing every profile from scratch would be miserable; nearly every program needs the C library, /dev/null, /dev/urandom, locale files, and so on. AppArmor factors this shared boilerplate into abstractions under /etc/apparmor.d/abstractions/ and tunables (variable definitions) under /etc/apparmor.d/tunables/, pulled in with #include directives (apparmor.d.5):

  • #include <abstractions/base> — the universal baseline (libc, common /dev nodes, /proc/self…). Almost every profile starts here.
  • #include <abstractions/nameservice> — DNS/NSS access (/etc/resolv.conf, nsswitch, the resolver libraries).
  • #include <abstractions/python>, <abstractions/perl>, <abstractions/openssl> — runtime- and library-specific bundles.
  • #include <tunables/global> — defines @{HOME}, @{HOMEDIRS}, @{multiarch}, etc., so profiles can reference them.

The include forms: #include <magic/path> resolves relative to /etc/apparmor.d/ (the common case); #include "/abs/path" and #include "rel/path" use literal paths; and #include if exists <path> includes only if the file is present (used for optional local overrides, e.g. usr.bin.foo.d/ drop-in directories). Abstractions are how a one-line #include <abstractions/base> saves dozens of file rules — and they are also a policy supply-chain surface: a permissive abstraction silently widens every profile that includes it, so distro abstractions are themselves audited.

Exec Transitions — Px, Cx, Ix, Ux

When a confined program execs another binary, its profile’s x rule for that binary’s path decides the child’s confinement. This is the most conceptually demanding part of AppArmor, and the kernel encodes the modes as the AA_X_* flags in security/apparmor/include/file.h (AA_X_NAME, AA_X_TABLE, AA_X_CHILD, AA_X_INHERIT, AA_X_UNCONFINED, AA_X_UNSAFE), evaluated in domain.c’s x_to_label() (file.h; domain.c).

The four base modes (per apparmor.d(5)):

  • px / Pxdiscrete profile execute: the child transitions to its own profile, found by matching the child binary’s path against loaded profiles (the same find_attach search AppArmor describes). If no such profile exists, the exec is denied (unless a fallback variant is used).
  • cx / Cxchild/local profile execute: the child transitions to a child profile declared inside this profile (a nested sub-profile, see below). Confinement stays “local” to this profile rather than jumping to a global one.
  • ixinherit execute: the child runs under the same profile as the parent. No transition; the parent’s rules continue to confine the child. Used for helper binaries you want governed by the same policy.
  • ux / Uxunconfined execute: the child runs with no AppArmor confinement at all. This is the escape hatch and the dangerous one — a ux rule is a hole in the sandbox, since whatever the child does is unmediated.

The case carries a second, independent meaning: uppercase scrubs the environment the way the kernel scrubs it for a setuid binary (stripping LD_PRELOAD, LD_LIBRARY_PATH, and other influence vectors), while lowercase preserves it. So Px is “transition to the child’s profile and scrub the environment”; px is “transition but trust the inherited environment.” Uppercase is the safe default for crossing a trust boundary, because an unscrubbed environment lets the parent inject behaviour into the more-trusted child via LD_PRELOAD. This corresponds to the AA_X_UNSAFE flag in the kernel: in domain.c, when a transition is not marked unsafe, the bprm is flagged so the kernel performs secure-exec environment scrubbing.

Fallback variants combine modes: pix/Pix means “try the named profile (px), but if it doesn’t exist, fall back to inherit (ix)”; pux/Pux falls back to unconfined; cix/Cix and cux/Cux are the child-profile analogues. These avoid the hard denial of a bare px when a target profile may or may not be present.

Named transitions let you specify the target explicitly: /usr/bin/helper px -> helper_profile, sends the child to helper_profile regardless of what find_attach would have picked.

Child profiles, local profiles, and hats

A cx transition targets a child profile (also called a local or sub-profile) declared inside the parent profile:

/usr/bin/parent {
  #include <abstractions/base>
  /usr/bin/worker cx -> worker,        # exec worker → the child profile below

  profile worker {                     # a child profile, scoped to this parent
    #include <abstractions/base>
    /var/spool/** rw,
  }
}

A hat is a special child profile (syntax ^hatname { … }) entered without an exec, via the aa_change_hat() API — used by long-running servers (classically Apache with mod_apparmor) to drop into a tighter sub-policy while handling a specific request, then “change back” to the parent profile. Hats let one process move between confinement levels without forking; the change is guarded by a secret token so a compromised hat cannot trivially escape to the parent.

The Central Weakness — Path, Not Inode

Here is AppArmor’s defining honest limitation, and it follows directly from everything above: every rule names a path string, and a path is not the object. The kernel object an open ultimately reaches is an inode; AppArmor decides access by the name used to reach it. When more than one name reaches the same inode, the profile may permit through one name what it forbids through another.

This is not a hypothetical — it is exactly the critique that kept AppArmor out of the mainline kernel for years. Stephen Smalley (a principal SELinux author) argued on LKML that “path-based access control makes the user think that he is protecting a given object, when in fact the object may be accessible by another means,” and, more sharply, “Access control of any form requires unambiguous identification of subjects and objects in the system. Paths don’t achieve such identification” (LKML, 2006). The concrete evasion vectors:

Hard links. A hard link is a second name for the same inode. If a confined program may create a link (l permission) and may write /tmp/scratch but not /etc/cron.d/evil, but the inode behind /etc/cron.d/evil can be hardlinked to a path the profile allows writing, the protection on the original path is moot — the bytes are the same. AppArmor mitigates this with the link subset test: the kernel’s aa_path_link() in file.c checks that the program had AA_MAY_LINK permission on the source name and, when AA_LINK_SUBSET is set, that the new link name’s permissions are a subset of the target’s, via xindex_is_subset() (file.c). In Smalley’s words, AppArmor must “check that the program had link permission to the old link name and that both the old link name and new link name have consistent permissions in the profile.” This is a genuine mitigation — but it depends on the profile author having written link rules carefully, and it cannot help when a different, unconfined process creates the inconvenient link.

Bind mounts and mount --bind. A bind mount makes the same inode reachable under a new path. If /sensitive is protected but an operator (or a confined process with mount permission) bind-mounts it onto /innocent, a rule allowing /innocent/** now grants access to the sensitive bytes. The same content under a different path “may not be what the operator first expected.” This is why mount-rule mediation exists at all: controlling who may bind-mount is part of keeping path-based rules meaningful.

Mount namespaces. Inside a mount namespace, the very meaning of a path can differ from the host’s view — a path that resolves to one inode on the host resolves to another inside the namespace, or vice versa. AppArmor resolves names relative to the task’s namespace (with attach_disconnected / chroot-relative flags controlling edge cases of disconnected paths), but the fundamental point stands: a profile written against the host’s namespace’s path layout makes assumptions that a different mount namespace can violate. Containers, which are separate mount namespaces, are exactly where this bites — and why container AppArmor profiles must be written with the container’s path view in mind.

Rename/link races and TOCTOU. Because mediation keys on the name resolved at the time of the operation, an attacker who can rename or relink a path between the policy check and the use can, in principle, confuse mediation — the classic time-of-check-to-time-of-use problem, sharper for name-based than inode-based systems.

The contrast with SELinux is the whole point: SELinux labels the inode with a security context, and that label travels with the inode no matter how many names point at it. A hardlink or bind mount gives a new name but the inode keeps its label, so the access decision is identical through every path. This is precisely the class of evasion that label-following closes and path-matching cannot, fully, on its own. The honest summary: AppArmor’s path-based model is a real, useful MAC that is strictly weaker against an adversary who controls the namespace, the mount table, or link creation — and that weakness is structural, not a bug to be fixed. It is mitigated (link-subset checks, mount mediation, careful profiles, no_new_privs) but not eliminated. See SELinux vs AppArmor for the full balance sheet, since path-based confinement also buys the readability and namespace-relativity that make AppArmor pleasant to use.

Failure Modes and Common Misunderstandings

Forgetting m on libraries. A program that dlopens a plugin needs m (PROT_EXEC mmap) on the library path, not just r. The symptom is a cryptic load failure; the fix is adding m.

* vs ** confusion. Granting /var/log/* w and being surprised a subdirectory file is denied — * does not cross /. Conversely, ** over a broad root quietly grants far more than intended.

A stray ux/Ux rule that defeats the sandbox. Because ux runs the child unconfined, one such rule on a frequently-exec’d helper makes the parent’s careful confinement nearly pointless for anything that helper touches. Prefer Px/Cx/ix; reserve Ux for genuine trust boundaries and audit every occurrence.

Lowercase exec mode across a privilege boundary. Using px instead of Px when transitioning into a more-trusted profile leaves LD_PRELOAD and friends intact, allowing environment-based injection into the child. Uppercase (scrubbing) is the safe choice across trust boundaries.

Assuming link rules close the hardlink hole completely. The link-subset test only constrains links the confined program itself creates; it cannot police links made by other processes, and it relies on the author writing coherent permissions for both the source and link names.

Alternatives and When to Choose This Model

The path-based model’s sibling and rival is SELinux’s inode-label type enforcement — see SELinux Type Enforcement and Labels and the head-to-head in SELinux vs AppArmor. TOMOYO is the other mainline pathname-based MAC, even more learning-oriented than AppArmor. Smack is label-based but far simpler than SELinux. Choose the AppArmor path-based model when readable, version-controllable, namespace-relative profiles and a gentle learning curve matter more than airtight inode-following guarantees, and when your threat model does not include an adversary who controls the mount table or can freely create hard links to protected inodes. Where it does, layer SELinux, drop the CAP_SYS_ADMIN/mount capabilities that enable the evasions, and lean on no_new_privs to keep transitions honest.

Production Notes

Real profiles lean heavily on abstractions — a typical distro profile is mostly #include lines plus a handful of program-specific file rules — and the exec-transition modes are where production profiles get hard. The Docker docker-default profile and Kubernetes’ per-pod profiles (declared via SecurityContext) are written against the container’s mount-namespace path view, which is exactly the namespace caveat above made concrete: a profile correct on the host can mis-mediate inside the container’s namespace, and vice versa. The most frequent operational footgun is the silent over-grant: a ** glob or an included abstraction that turns out to permit more than intended, or a Ux rule added “just to get it working” that never gets tightened. The discipline that pays off is keeping profiles minimal, preferring Px/Cx transitions with explicit named targets, auditing every ux/Ux, and treating bind-mount and link permissions as security-critical because they are the levers of the path-vs-inode weakness.

See Also