How AppArmor stacking and policy namespaces work

Try running cat /proc/<pid>/attr/current for a few processes on a plain Ubuntu box. You’ll see something boring, like unconfined or snap.firefox.firefox (enforce). Now look at a process inside an LXD container, from the host, and you get something that looks like your cat walked across the keyboard:

lxd-c1_</var/snap/lxd/common/lxd>//&:lxd-c1_<var-snap-lxd-common-lxd>:unconfined

That string is an AppArmor label. A label is a stack of profiles, each profile lives in a policy namespace, and namespaces nest. A single string encodes all of that, and the same two characters, //, mean three different things depending on where they appear. Somebody looked at C++ comments, Perl regular expressions and URLs, and decided that what the world needed was all three at once.

Let me walk you through what the syntax means and what the kernel does with it.

Profiles and hats

The simplest label names a single profile, for example /usr/sbin/cupsd or snap.firefox.firefox. Profiles can have child profiles, and hats are the best-known kind. A child profile is written as parent//child, so /usr/sbin/apache2//php-site is the php-site profile nested inside apache2.

So the first meaning of // is a path through the profile tree. The kernel resolves it in __lookupn_profile, which splits the name on // and descends one level into each profile’s children for every part. A hat is still one profile. It is not a stack and not a namespace.

Policy namespaces

A policy namespace (struct aa_ns) is a separate set of profiles with its own name, its own parent and its own sub_ns list of children. The kernel boots with a root namespace. Others are created when policy is loaded with a namespace prefix (see aa_prepare_ns, called from aa_replace_profiles), or by mkdir under /sys/kernel/security/apparmor/policy/namespaces/ (ns_mkdir_op → __aa_find_or_create_ns). LXD uses this to give each container its own namespace, nested inside the host’s.

In a name, the namespace sits between colons, before the profile: :ns1:/usr/sbin/nginx.

Namespaces can also nest. A nested namespace is written as a path between the colons, and the parts of that path are separated with //. So in :ns1//cns:G, the namespace is cns, a child of ns1, and the profile is G. That is the second meaning of //. aa_splitn_fqname separates the namespace from the profile name, and __aa_lookupn_ns walks the namespace path one //-separated part at a time.

Every namespace has its own unconfined profile. It is not on the regular profile list. Each struct aa_ns holds a separate unconfined pointer, created by alloc_unconfined. A namespace name with no profile name after it means that namespace’s unconfined. aa_fqlookupn_profile falls back to ns->unconfined when the name part is empty, so :ns1: and :ns1:unconfined are the same profile.

Stacking

A label (struct aa_label) is not a single profile. It holds a vector, vec[], of size profiles. A label with more than one entry is a stack, and its members are joined with //&. That is the third meaning of //, and the reason you must look for //& before you look for a plain //.

What a stack means is simple: every member must allow an operation. Take file access as an example. aa_path_perm runs profile_path_perm once for each confined member, using fn_for_each_confined, and keeps an error if any of them fails. One denial is enough to deny the operation. Access is the intersection of what the members allow.

Members that are an unconfined profile are skipped by that loop, which is what _confined means. So why keep them in the label at all? A label’s namespace is the namespace of its last member (labels_ns → vec_ns). Members are kept sorted by namespace depth first, then by name (profile_cmp, ns_cmp, used through aa_vec_unique). So the deepest namespace always comes last. A :c1:unconfined member restricts nothing, but it makes the task belong to c1. Exec attachment searches c1’s profiles, and root inside the container counts as policy admin for c1.

Because the kernel sorts the members, the order in a label string tells you nothing about the order in which profiles were stacked. Compare labels as sets. The string looks like a sequence, reads like a path and behaves like a set. Three for the price of one. This matters enough that there’s a whole section on comparing labels near the end, once we’ve seen how labels are printed.

Anatomy of a label

Here is an example that uses every part of the syntax, as the root namespace sees it:

/snap/example//hat//&:ns1:unconfined//&:ns1//cns:G (enforce)
&…
Leading &: a request to add to the current stack. You write it; the kernel never prints it.
//&
Separates stack members.
:a//b:
Namespace path at the start of a member. Here // nests namespaces.
/snap/…
Profile name.
//hat
Child profile in the same namespace. Here // walks the profile tree.
(mode)
Mode suffix, only in output such as attr/current.

Split on //& and you get three members:

# Raw member Namespace Profile
0 /snap/example//hat the viewer’s own /snap/example → hat
1 :ns1:unconfined ns1 unconfined
2 :ns1//cns:G ns1 → cns G

The kernel parses labels in aa_label_strn_parse. To read one yourself, work from the outside in:

  1. If the string came from /proc, strip the trailing " (mode)" first. It is added when the label is printed and is not part of the grammar the kernel parses.
  2. A leading & means “keep what I have and add these” (if (*str == '&' || force_stack) in aa_label_strn_parse).
  3. Split on //& (aa_label_strn_split; the split is done by a small DFA, stacksplitdfa).
  4. In each member, a leading :…: is the namespace path. Split it on //.
  5. The rest is the profile path. Split it on // for hats.

There is one subtlety in step 4. A member that does not name a namespace is looked up in the namespace of the last member that did, not in the viewer’s. The comment above fqlookupn_profile in label.c says so: in :ns:A//&B, both A and B are in ns. Printing mirrors this: aa_profile_snxprint only writes a :ns: prefix when the namespace changes from the previous member. A label with two profiles in c1 therefore prints as :c1:A//&B, not :c1:A//&:c1:B.

Asking for a stack

There are two ways to move a task to profile B. Changing the profile replaces your own profile; members from outer namespaces stay. Stacking adds B to the label:

Request Before After
change to B A B
stack &B A A//&B

From user space, you write stack B to /proc/self/attr/current (or to attr/exec, which applies it at the next exec). In the kernel, do_setattr maps the stack command to aa_change_profile with AA_CHANGE_STACK. A leading & on the profile name has the same effect. With stacking, aa_change_profile merges your current label with the new profile using aa_label_merge. Everything you had stays, so stacking can add restrictions but never lift any. libapparmor wraps this as aa_stack_profile(), and profiles can ask for it on exec with a target such as px -> &B. The leading & is handled in x_to_label.

The leading & is part of a request, never part of a state. attr/current always shows the resulting stack. The ampersand is like a receipt the kernel throws away once it has done what you asked.

One host, four namespaces, six tasks

Here is one host with LXD and two containers. Container c1 also has a nested namespace n1, and tasks in n1 also carry a c1 profile called n1-guard. Container c2 runs Apache with a hat. The letters mark which profiles each task’s label points at. Names are shortened; see the end of the post for what LXD really calls them.

n1 is there to show the deeper case, but note that LXD will not create it for you. LXD running inside c1 reads /sys/kernel/security/apparmor/.ns_stacked, which says yes when the reader’s label has members from more than one namespace (seq_ns_nsstacked_show). In that case LXD skips creating a namespace for its own containers. A second level is something you would have to set up by hand. The kernel is fine with it, and LXD doesn’t try.

root namespace · host policy

  • unconfined
  • /usr/sbin/cupsd A
  • snap.firefox.firefox B
  • lxd-c1 CDE
  • lxd-c2 F

:c1: · container c1

  • unconfined C
  • /usr/sbin/nginx D
  • /usr/sbin/mysqld
  • n1-guard E

:c1//n1: · nested namespace inside c1

  • unconfined
  • /usr/sbin/nginx E

:c2: · container c2

  • unconfined
  • /usr/sbin/apache2
  • /usr/sbin/apache2//php-site F

The same six tasks, with their labels as the root namespace sees them (mode suffixes left out):

Task Where Label
A host /usr/sbin/cupsd
B host snap snap.firefox.firefox
C init in c1 lxd-c1//&:c1:unconfined
D nginx in c1 lxd-c1//&:c1:/usr/sbin/nginx
E nginx in n1, in c1 lxd-c1//&:c1:n1-guard//&:c1//n1:/usr/sbin/nginx
F Apache worker in c2 lxd-c2//&:c2:/usr/sbin/apache2//php-site

LXD follows a pattern: one member per namespace level, from the root down to the task’s innermost namespace. That is a convention of how LXD sets up containers. The kernel does not require it; you can stack several profiles from the same namespace if you want to.

Note also that D and E both run under a profile named /usr/sbin/nginx. They are different profiles, in different namespaces, so always compare the namespace and the profile name together.

Exec works one member at a time

How did task D end up with :c1:/usr/sbin/nginx? It started as lxd-c1//&:c1:unconfined and executed /usr/sbin/nginx. On exec, apparmor_bprm_creds_for_exec builds the new label with fn_label_build. That calls profile_transition for each member separately and assembles the results into a new stack:

The result is lxd-c1//&:c1:/usr/sbin/nginx. For task E, the same exec of the same binary started from :c1//n1:unconfined, so find_attach searched n1 and found n1’s nginx profile. c1’s nginx profile was never considered.

Labels depend on who is looking

The label string is not a fixed property of a task. The kernel prints it relative to the namespace of the task reading it. aa_getprocattr gets the reader’s namespace with aa_get_current_ns() and prints with aa_label_snxprint, using FLAG_SHOW_MODE | FLAG_VIEW_SUBNS | FLAG_HIDDEN_UNCONFINED:

The example label from earlier, read from three places:

Reader Sees
root /snap/example//hat//&:ns1:unconfined//&:ns1//cns:G (enforce)
ns1 unconfined//&:cns:G (enforce)
ns1//cns G (enforce)

And task E from the LXD example:

Reader Sees
root lxd-c1//&:c1:n1-guard//&:c1//n1:/usr/sbin/nginx
c1 n1-guard//&:n1:/usr/sbin/nginx
c1//n1 /usr/sbin/nginx

(Mode suffixes left out here too.)

From inside n1, nginx looks like an ordinary task with one profile. It is still confined by lxd-c1 and n1-guard, which n1 can neither see nor unload. So never compare label strings that were read from different namespaces.

The mode suffix belongs to the whole label, not to each member. label_modename looks at the visible members, ignores any that are their namespace’s unconfined, and prints mixed if the rest are in different modes. That is why, if profile_foo is in enforce mode, profile_foo//&:ns1:unconfined reads as (enforce), not (mixed).

Comparing labels

If you write tools that look at AppArmor labels, sooner or later you’ll want to check whether two of them are the same. Comparing the strings is the obvious way, and it’s wrong in more ways than you’d think. All of the examples below come from a real system, read from inside container c1. alpha, beta and zeta are profiles in c1’s namespace, and aaa is a profile in a nested namespace, c1//test.

The order you stack in doesn’t matter. Both of these end up with the same label:

Profile changes Resulting label
switch to alpha, then stack &beta alpha//&beta (enforce)
switch to beta, then stack &alpha alpha//&beta (enforce)
switch to beta//&alpha directly alpha//&beta (enforce)

If your tool stacked beta on alpha and expects to see beta//&alpha, it will never find it.

Stacking a profile twice does nothing. The kernel removes duplicates, so alpha with &alpha stacked on top is just alpha (enforce), not alpha//&alpha.

The mode can change under you. Take a task running as alpha//&beta, and switch beta to complain mode by reloading it. The task’s stack hasn’t changed, but its label now reads alpha//&beta (mixed). If you only care which profiles confine a task, strip the mode before you compare. If you care about the mode, compare it separately.

You can’t reorder the members yourself. Remember that a member without a :ns: prefix belongs to the namespace of the member before it. That makes the order of the members part of their meaning:

Request Result
zeta//&:test:aaa zeta in c1, aaa in c1//test
:test:aaa//&zeta error: no profile zeta in c1//test

So you can’t normalise a label by sorting its member strings either. A plain byte sort puts :test:aaa before zeta, because : sorts before z. That gives you the second string, which means something else. The kernel sorts by namespace depth first and by name second, and it works on the profiles, not on the text.

The same task has a different label for every reader. Here is the task running as zeta with :test:aaa stacked on top:

Reader Sees
the host lxd-c1_</var/snap/lxd/common/lxd>//&:lxd-c1_<var-snap-lxd-common-lxd>:zeta//&:lxd-c1_<var-snap-lxd-common-lxd>//test:aaa (enforce)
a task in c1 zeta//&:test:aaa (enforce)
the task itself aaa (enforce)

The last row surprised me the first time. The task now belongs to c1//test, the namespace of its deepest member. From there, zeta is in a parent namespace, so the task can’t see half of its own stack.

So what should you do instead? Turn each label into a set of (namespace, profile) pairs:

  1. Strip the mode suffix, and keep it aside if you care about it.
  2. Split the members on //&.
  3. Give each member a namespace: its own :ns: prefix if it has one, otherwise the namespace of the member before it, otherwise the reader’s namespace.
  4. Turn each namespace into a full path by putting the reader’s namespace in front of it.

Then compare the sets. Even then, only compare labels that the same reader read, or that you’ve turned into full paths this way. A reader can’t see namespaces above its own, so what it sees is never the whole label.

Takeaways

Real LXD names are longer than the ones I used above. On a snap-installed LXD, a container’s host-side profile is called something like lxd-c1_</var/snap/lxd/common/lxd>, and its namespace is called lxd-c1_<var-snap-lxd-common-lxd>.

The < and > have no special meaning, either to the kernel or to the AppArmor policy language. They are just characters in a name, and LXD picked them as a convention. LXD’s InstanceProfileName builds the name as <instance>_< + LXD’s data directory + >, presumably so that two LXD installations on one host, say a deb and a snap, can’t collide. The namespace name, from InstanceNamespaceName in the same file, uses the same pattern, with two differences. The leading / is trimmed and the rest are turned into -, because LXD creates the namespace with mkdir under /sys/kernel/security/apparmor/policy/namespaces/ and a directory name can’t contain /. Both functions prefix lxd-, and replace the whole name with a SHA-256 hash once it gets near AppArmor’s 253 character limit. So a container with a long enough name ends up with a profile called lxd- followed by 64 hex digits, and good luck guessing which container that is.

If you want to look inside compiled profiles and run queries against them, AppArmor Insight is a tool I wrote for exactly that.

← All posts