Inside AppArmor: how profile inheritance and Px transitions

They are not parallel; they are different kernel operations.
Readers expect Px, Cx, and ix to be parallel choices on a syntax menu, but they are not parallel; they are different kernel operations. That matters because the wrong choice can change what fails, what still runs, and what to verify before trusting apparmor profile transitions px.

Most teams expect AppArmor Px transitions, Cx, ix, and px to be interchangeable execute-mode letters, but the hidden risk is that each mode hits a different kernel path. Px execs into a globally loaded profile and scrubs loader-sensitive environment variables; ix inherits the parent profile; Cx targets a local child profile inside the parent; lowercase px keeps the global transition but disables the scrub. That means a wrong letter can silently preserve too much authority, fail with a missing profile error, or keep unsafe environment state across an exec boundary.

A Px transition exec’s a binary into a different AppArmor profile and scrubs the environment of variables like LD_PRELOAD. ix keeps the binary under the parent’s profile with the environment intact. Cx looks like Px but only resolves to a local child profile defined inside the current one.

The lowercase variants — px, cx, ux — skip environment scrubbing. Everything else in apparmor.d(5)’s execute-mode list, including the fallback combos like Pix and CUx, is a recombination of those three axes.

  • Px = transition to a global profile, environment scrubbed; px = same target, environment preserved (unsafe).
  • Cx targets must be a LOCAL child profile declared inside the current profile; Cx -> globalname fails at apparmor_parser load time, not at exec time.
  • ix performs no profile transition — the executed binary keeps running under the parent profile, so any new rules you add to the parent immediately apply to it.
  • A missing Px target produces an audit record indicating a denied exec operation; the exec(2) call returns EACCES to the caller.
  • Hats (entered via aa_change_hat(2)) are not exec-time targets — you cannot use a hat name as a Px target, and a hat cannot itself contain child profiles.
Opening visual showing the hidden path in Inside AppArmor: how profile inheritance and Px transitions actually work
The mechanism readers need to notice first.

AppArmor’s exec mediation is two operations, not six letters

When a confined process calls execve(2), the kernel’s AppArmor LSM hooks make one binary decision before anything else: does the new image keep running under the calling profile, or does the kernel attach a different profile to the new task_struct? Those are two physically different code paths in the kernel, with different audit signatures and different security guarantees. Calling them both “execute modes” obscures the split.

Inheritance keeps the parent profile attached to the new image. The mediation policy doesn’t change, the environment passes through unchanged, and from /proc/self/attr/current‘s perspective nothing has shifted.

A related write-up: system call internals.

Transition swaps the attached profile for a different one. On the way through, the kernel scrubs LD_PRELOAD, LD_LIBRARY_PATH, and other sensitive environment variables.

The capital/lowercase split (Px vs px, Cx vs cx, Ux vs ux) is independent of inherit-vs-transition: it toggles whether AppArmor asks the kernel for that scrub. The apparmor.d(5) man page documents both halves of this — the table of available transitions and the separate treatment of the lowercase modes as the variants that leave the environment intact. The two halves aren’t a single concept.

The 2×2×2 decomposition that derives every mode letter

Three independent yes/no choices generate the entire mode-letter zoo:

  1. Inherit (i) or transition (p / c)? Inherit means the child runs under the parent profile. Transition means the child runs under a new profile.
  2. If transition, global (p) or local (c)? A global target is a profile loaded by its full name into the kernel’s profile namespace. A local target is a child profile syntactically nested inside the current profile and only resolvable from within it.
  3. Safe (uppercase) or unsafe (lowercase)? Safe sends the exec through the kernel’s environment-scrubbing path. Unsafe doesn’t.

Cross-multiply those and you get the canonical six: Px, px, Cx, cx, ix (only one form — there’s nothing to scrub if no transition happens), and ux/Ux for the “transition to unconfined” case. The AppArmor Core Policy Reference on the upstream wiki spells out the same axes. The SUSE and openSUSE docs present them as a flat table, which is exactly why the relationship gets lost.

The fallback variants — Pix, pix, Cix, cix, PUx, pux, CUx, cux — add a fourth axis: “if the transition target doesn’t resolve, fall back to inherit (i) or unconfined (u) rather than failing the exec.”

Every letter you encounter in a profile is some combination of those four dials. There is no “Px is its own thing” carve-out.

Collapsing those four axes into a single reference matrix makes it easier to read a profile and predict what the kernel will do:

Mode Kernel operation Target namespace Env scrub Behavior on miss How to verify it fired
Px Domain transition Global Yes EACCES + audit record /proc/self/attr/current shows new profile; LD_PRELOAD stripped
px Domain transition Global No EACCES + audit line New profile in attr; LD_PRELOAD survives
Cx Domain transition Local (declared inside parent) Yes Parser rejects at load — profile never enters kernel attr shows parent//child form; env scrubbed
cx Domain transition Local No Parser load failure parent//child in attr; env preserved
ix Inherit (no transition) n/a No (no transition to gate on) No transition-target lookup; normal exec permission checks (DAC, file rules, capabilities) still apply attr unchanged from parent; env unchanged
Ux Transition to unconfined n/a Yes n/a attr reads unconfined; env scrubbed
ux Transition to unconfined n/a No n/a attr unconfined; env preserved (dangerous)
Pix Px → fallback to ix on miss Global, then parent’s profile Yes on hit; n/a on inherit fallback Silently inherits parent profile attr shows target on hit, parent name on fallback
Cix Cx → fallback to ix Local, then parent’s profile Yes on hit Parser load failure if local target missing attr shows child or parent depending on resolution
PUx Px → fallback to Ux on miss Global, then unconfined Yes (always) Drops to unconfined — accepts security cost attr shows target or unconfined
CUx Cx → fallback to Ux on miss Local, then unconfined Yes Parser load failure for missing local; otherwise drops unconfined attr shows child or unconfined

Two rows in that table do most of the day-to-day work for service profiles — Px and Pix. The rest exist to handle corner cases: workers spawned by a daemon, intentional unconfinement, and tracing wrappers.

Read top to bottom, every mode is determined by which combination of the four axes it expresses.

Terminal output for Inside AppArmor: how profile inheritance and Px transitions actually work
Captured output from running it locally.

What the kernel actually does on a Px exec

An exec under a Px rule walks roughly this sequence:

  1. The LSM exec hook fires, AppArmor looks up the calling profile’s exec rule for the target path, and matches mode Px.
  2. It resolves the target profile name — by default the basename of the executable, unless Px -> explicit_target is given.
  3. It performs a profile lookup against the global profile namespace.
  4. On hit, it marks the exec as a domain transition.
  5. The kernel scrubs LD_PRELOAD, LD_LIBRARY_PATH, and the rest of the dynamic-loader-sensitive variables.
  6. The new task’s security context points at the new profile.

On miss — the profile name doesn’t resolve — AppArmor short-circuits the exec. The syscall returns EACCES to userspace and an audit record gets emitted indicating the denied exec.

There is a longer treatment in how the kernel handles exec.

Why “Cx -> globalname” and “Px -> missing” fail

These look like the same class of error and aren’t. A Cx -> specialprofile rule where specialprofile isn’t declared as a child profile inside the current profile gets rejected at policy load time, not exec time.

apparmor_parser resolves Cx against the current profile’s local namespace only. If there’s no profile specialprofile { ... } block syntactically nested inside the parent, the load fails with a parser error referencing the missing local hat/child, and the profile never reaches the kernel.

A Px -> missing rule, by contrast, is syntactically legal. The parser doesn’t verify that the global target exists — global profiles can be loaded in any order, so a forward reference is normal.

The lookup happens at exec time, against whatever’s in the kernel’s namespace at that moment. It produces a transition denial in the audit log.

Two different failure stages, two different remedies:

  • Cx miss → fix the profile’s syntax (the local target isn’t declared inside the parent).
  • Px miss → load the target profile, or switch to Pix if you genuinely want inherit-on-miss.

The QuickProfileLanguage wiki page hints at this asymmetry by listing local profiles in a different syntactic position from global ones, but doesn’t connect that to the error you actually see.

The connection is mechanical: local resolution is a compile-time lookup; global resolution is a runtime lookup. The mode letter you choose tells the parser which lookup to perform.

Topic diagram for Inside AppArmor: how profile inheritance and Px transitions actually work
Purpose-built diagram for this article — Inside AppArmor: how profile inheritance and Px transitions actually work.

Hats are a third thing entirely

Hats look like child profiles but they aren’t reachable through exec. A hat is entered with aa_change_hat(2) — a userspace library call from libapparmor that flips the process’s current attr without performing an exec.

The kernel treats hats as a sub-namespace of the parent profile, not as exec-time transition targets. Two consequences fall out of that:

  • You cannot use a hat name as a Px target. The parser will accept Px -> foo syntactically, but if foo is only declared as a hat (using the ^foo syntax) and not as a top-level profile, the runtime lookup fails the same way it would for any other missing global. The hat is in the parent’s sub-namespace, not in the global one.
  • Hats cannot themselves contain nested child profiles.apparmor_parser rejects this at load time. Hats are designed for stack-like permission narrowing within one process (think the Apache mod_apparmor model where each PHP request lives in a hat), not for spawning new confinement domains.

Letting a hat declare children would create a profile graph the kernel can’t represent in its current attr layout.

The practical confusion this causes: someone writes a profile for php-fpm, declares ^worker as a hat, then writes /usr/bin/php Px -> worker, and watches every request log a transition denial.

The hat exists in the parent’s sub-namespace, but Px is looking in the global namespace, and never finds it.

The fix is to either change ^worker to a child profile (profile worker { ... } declared without the caret) and use Cx -> worker, or to keep the hat and switch to aa_change_hat() from inside the PHP handler.

PyPI download statistics for inside
Live data: PyPI download counts for inside.

If you’re working from a profile that was auto-generated by aa-genprof, the mode letters it picked are the ones the tool inferred from your test run — not always the ones you want. The interactive utilities shipped in the apparmor-utils package (aa-genprof, aa-logprof, aa-complain) make a best-effort guess from the audit records they replay, so it pays to walk the 2×2×2 decomposition over each transition rule before accepting its suggestions.

Verifying a transition actually fired

Three commands tell you whether the transition you wrote in the profile is the transition the kernel performed. None of them require special tooling beyond what ships with the apparmor package on Debian, Ubuntu, Fedora, and openSUSE.

  • aa-status shows the loaded profile inventory and the processes currently confined by each one. If your Px target appears under “profiles are in enforce mode” but no processes show under it after the parent execs the child, the transition didn’t fire — most likely a parser-level mode mismatch (you wrote ix when you meant Px).
  • cat /proc/self/attr/current inside the child — e.g. by exec’ing a wrapper that prints it before exec‘ing the real binary — shows the exact profile name and mode the kernel attached. The output looks like child_profile (enforce) for a successful transition, parent_profile (enforce) if you actually got ix instead, or unconfined if a Pux rule fell back.
  • Diffing env output between a Px-entered process and a px-entered one shows the environment scrub: LD_PRELOAD set in the parent shell survives into px but not Px. That diff is the single most direct confirmation that the safe/unsafe axis worked.

A short Python helper makes this loop reproducible across rule changes:

For more on this, see production shell habits.

import os, subprocess, sys

def show_confinement(label):
    with open("/proc/self/attr/current") as f:
        attr = f.read().strip()
    ld = os.environ.get("LD_PRELOAD", "<unset>")
    print(f"{label}: profile={attr!r}  LD_PRELOAD={ld!r}")

if __name__ == "__main__":
    show_confinement(sys.argv[1] if len(sys.argv) > 1 else "child")

Drop that as a small wrapper on PATH, exec it from the parent under each candidate mode (Px, px, Cx, ix), and the printed line tells you exactly what landed. Pair it with journalctl -k -g apparmor to catch the corresponding audit records.

Architecture diagram for Inside AppArmor: how profile inheritance and Px transitions actually work
System flow for this topic.

A four-question rubric for picking the right mode letter

Walk these questions in order. The answers map to exactly one mode letter (plus an optional fallback suffix).

  1. Does the child need its own confinement policy, or should it run under the parent’s? Under the parent’s → ix. Stop. Its own → continue.
  2. Is the target profile a global profile loaded separately, or a child profile declared inside this profile? Global → Px family. Local → Cx family.
  3. Does the child run with privileges (or handle untrusted data) such that LD_PRELOAD from the caller must not survive? Yes (almost always) → capital letter. No (you have a specific reason to preserve environment, e.g. a tracing wrapper) → lowercase.
  4. If the target profile is missing at exec time, what should happen? Fail with EACCES → bare Px/Cx. Fall back to inheriting the parent → Pix/Cix. Fall back to unconfined → PUx/CUx (and accept the security cost).

When to pick each mode

Phrased as if/then choices, the rubric collapses into six common cases:

deployment command toolkit goes into the specifics of this.

  • Pick Px if the child binary has its own globally loaded profile, you control the deploy order so the target is already in the kernel before the parent runs, and you want the exec to fail loudly if it isn’t. This is the safe default for a service binary that exec‘s a helper with its own loaded profile.
  • Choose Pix if the deploy order isn’t guaranteed (boot-time races, lazy profile loading) and the parent’s policy is acceptable as a fallback. You accept that a missing target silently runs under the parent rather than EACCES’ing.
  • Pick Cx if the child profile only makes sense in the context of this one parent — a daemon spawning narrowed worker processes that nothing else on the system will ever exec. The local-namespace constraint is a feature: nothing outside the parent can accidentally enter that profile.
  • Pick ix if the child is logically part of the same trust domain — wrappers, helper binaries, loader processes, shell pipelines where the parent’s policy already mediates what the child can touch.
  • Choose PUx/CUx only if you genuinely want the missing-target case to drop to unconfined (typical: a setup or admin helper that hands off to a user-controlled binary), and you’ve documented the security trade-off in the profile.
  • Avoid lowercase modes (px, cx, ux) unless you have a specific reason the child must inherit LD_PRELOAD — e.g. a tracing harness, a profiler that injects via the loader, or a debug build that requires LD_LIBRARY_PATH. The default answer to “should I use lowercase?” is “no, use the uppercase and write a wrapper if you need environment plumbing.”

If you find yourself reaching for ux or Ux outside of those documented cases, stop and check whether you meant Pix instead. “Transition out of confinement entirely” is rarely what you actually want, while “transition to a real profile, fall back to the parent if it’s missing” usually is.

The SUSE SLES 15 SP6 AppArmor profile-syntax chapter covers the same execute modes in its distro-side reference. Framing the lowercase-vs-uppercase choice as the last branch of the rubric makes the trade-off load-bearing rather than ornamental.

Dashboard: AppArmor Profile Mechanics
Radar of AppArmor Profile Mechanics.

What this means for the next profile you write

The mode letter zoo is not a memorization problem. It’s a three-axis decomposition (inherit-vs-transition, global-vs-local, safe-vs-unsafe) plus a fallback suffix, and every error message the parser or kernel emits about transitions points at exactly which axis you got wrong.

Write the rule from the rubric, verify with /proc/self/attr/current and an env diff, and the next time you see a transition denial in the audit log you’ll know whether to load a missing profile, fix a local-vs-global confusion, or stop trying to Px into a hat.

There is a longer treatment in kernel tuning patterns.

You might also find filesystem path resolution useful.

Continue with distro-level security primitives.

Further reading

Can Not Find Kubeconfig File