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:
- 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. - A leading
&means “keep what I have and add these” (if (*str == '&' || force_stack)inaa_label_strn_parse). - Split on
//&(aa_label_strn_split; the split is done by a small DFA,stacksplitdfa). - In each member, a leading
:…:is the namespace path. Split it on//. - 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:
lxd-c1checks its own exec rules, in the root namespace. LXD writes that profile so that programs run inside the container stay inlxd-c1.:c1:unconfinedhas no rules. For an unconfined profile,profile_transitioncallsfind_attachwithprofile->ns. That looks for a profile whose attachment matches the executed file, but only in that member’s own namespace,c1. It finds:c1:/usr/sbin/nginx.
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:
- Members in namespaces the reader cannot see are left out.
aa_ns_visiblelets the reader see its own namespace and everything nested inside it, but not the namespaces it is nested in. - Visible namespace names are written relative to the reader.
aa_ns_namestrips the reader’s own path prefix, and a member in the reader’s own namespace gets no prefix at all. - If the label’s own namespace (the deepest one) is not visible to the reader,
aa_getprocattrreturns-EACCESand prints nothing.
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:
- Strip the mode suffix, and keep it aside if you care about it.
- Split the members on
//&. - 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. - 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
- Split on
//&before//. The same two characters mean a hat, a nested namespace or a stack separator, depending on where they appear. Your regular expression will be wrong the first time. Mine was. - A member without
:ns:belongs to the namespace of the previous member that had one. Only if no member before it had one does it belong to the reader’s namespace. - The kernel sorts stack members. Compare them as sets.
- The mode suffix covers the whole label, and
unconfinedmembers don’t count towardsmixed. :ns1:is:ns1:unconfined.- The same name does not mean the same profile. Always compare the namespace and the profile name together.
- Profiles from ancestor namespaces always apply, even when you can’t see them.
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.