leftmatch_fb() ignores diff-encoded DFA states, so profile attachments silently fail (e.g. Ubuntu's firefox profile)

Summary

aa_dfa_leftmatch() → leftmatch_fb() (security/apparmor/match.c), which find_attach() uses to match a profile's attachment (xmatch), takes the default transition as state = def[state] and moves on to the next character. It does not handle MATCH_FLAG_DIFF_ENCODE. match_char(), used by aa_dfa_match() and friends, does handle it: on a miss in a diff-encoded state it follows def[state] and retries the same character.

When apparmor_parser emits a differentially encoded xmatch DFA (the default whenever the kernel advertises policy/diff_encode), the attachment can therefore end in a non-accepting state for a path that matches the pattern. No conflict is involved, so nothing is audited. The exec simply stays unconfined.

File rules are not affected because they are matched with match_char(). Only profile attachment is affected.

Real-world impact

Ubuntu's /etc/apparmor.d/firefox:

profile firefox /{usr/lib/firefox{,-esr,-beta,-devedition,-nightly},opt/firefox}/firefox{,-esr,-bin} flags=(unconfined) {
  userns,
  ...
}

This profile exists so that Firefox installed as a .deb (for example from packages.mozilla.org) may create user namespaces. Because the attachment never matches, /usr/lib/firefox/firefox-bin runs unconfined. With kernel.apparmor_restrict_unprivileged_userns=1 it is then moved to unprivileged_userns and denied sys_admin:

apparmor="AUDIT" operation="userns_create" info="Userns create - transitioning profile" profile="unconfined" comm="firefox-bin" target="unprivileged_userns" execpath="/usr/lib/firefox/firefox-bin"
apparmor="DENIED" operation="capable" class="cap" profile="unprivileged_userns" comm="firefox-bin" capability=21 capname="sys_admin"

As a result, Firefox's content sandbox runs without user-namespace isolation. Any other profile whose attachment compiles to a diff-encoded DFA is affected in the same way.

Reproducer (run as root)

#!/bin/sh
set -e
D=${1:-/tmp/aa-repro}
PAT='{usr/lib/firefox{,-esr,-beta,-devedition,-nightly},opt/firefox}/firefox{,-esr,-bin}'
for v in enc noenc; do
    mkdir -p "$D/$v/usr/lib/firefox"
    cp /usr/bin/dash "$D/$v/usr/lib/firefox/firefox-bin"
    printf 'abi <abi/5.0>,\ninclude <tunables/global>\nprofile repro_%s %s/%s/%s flags=(unconfined) { }\n' \
        "$v" "$D" "$v" "$PAT" > "$D/$v.profile"
done
apparmor_parser -r -T                    "$D/enc.profile"
apparmor_parser -r -T -O no-diff-encode  "$D/noenc.profile"
for v in enc noenc; do
    printf '%-6s -> ' "$v"
    "$D/$v/usr/lib/firefox/firefox-bin" -c 'cat /proc/$$/attr/current; :' | tr -d '\0'; echo
done
apparmor_parser -R "$D/enc.profile" "$D/noenc.profile"
rm -rf "$D"

Expected output:

enc    -> repro_enc (unconfined)
noenc  -> repro_noenc (unconfined)

Actual output:

enc    -> unconfined
noenc  -> repro_noenc (unconfined)

The two profiles differ only in -O no-diff-encode.

Further observations

  • Simpler patterns attach: /plain/path, /a/{b,c}/x, /a/x{,-y,-z} and /a/* all attach normally. Larger or nested alternations fail once the DFA ends up diff-encoded. Examples: /{X/usr/lib/fire{fox,dog},opt/firefox}/firefox-bin and X/{usr/lib/firefox{,-esr},opt/firefox}/firefox-bin. Each of those attaches when compiled with -O no-diff-encode.
  • Not a conflict: no "conflicting profile attachments" audit is ever emitted.
  • Simpler sequential alternations attach: for example unix-chkpwd (/{,usr/}{,s}bin/unix_chkpwd) attaches as expected.
  • File rules unaffected (from the code): they go through aa_dfa_match() / match_char(), which handle diff encoding.

Analysis

leftmatch_fb(), equivalence-class branch (the plain branch is the same):

wb->history[wb->pos] = state;
pos = base_idx(base[state]) + equiv[(u8) *str++];
if (check[pos] == state)
        state = next[pos];
else
        state = def[state];        /* <- diff-encoded states need a retry */

versus match_char():

if ((check)[pos] != (state)) {
        (state) = (def)[(state)];
        if (b & MATCH_FLAG_DIFF_ENCODE)
                continue;          /* retry same char in the default state */
        break;
}

Suggested fix (untested sketch)

--- a/security/apparmor/match.c
+++ b/security/apparmor/match.c
@@ static aa_state_t leftmatch_fb(struct aa_dfa *dfa, aa_state_t start,
-	aa_state_t state = start, pos;
+	aa_state_t state = start;
@@
 		while (*str) {
 			unsigned int adjust;
+			u8 c = equiv[(u8) *str++];
 
 			wb->history[wb->pos] = state;
-			pos = base_idx(base[state]) + equiv[(u8) *str++];
-			if (check[pos] == state)
-				state = next[pos];
-			else
-				state = def[state];
+			match_char(state, def, base, next, check, c);
 			if (is_loop(wb, state, &adjust)) {
@@
 		while (*str) {
 			unsigned int adjust;
+			u8 c = (u8) *str++;
 
 			wb->history[wb->pos] = state;
-			pos = base_idx(base[state]) + (u8) *str++;
-			if (check[pos] == state)
-				state = next[pos];
-			else
-				state = def[state];
+			match_char(state, def, base, next, check, c);
 			if (is_loop(wb, state, &adjust)) {

The is_loop() history and the longest-left count logic should also be reviewed for diff-encoded default chains.

Workarounds

  • Compile policy with Optimize=no-diff-encode in /etc/apparmor/parser.conf, then reload all profiles.
  • Alternatively, give the affected profile a simpler attachment, for example /usr/lib/firefox/firefox{,-bin}, which attaches here.

Environment

  • Ubuntu 26.04.1 LTS (resolute)
  • Kernel: Ubuntu linux source 7.0.0-38.38 (upstream 7.0.14). /proc/version_signature: Ubuntu 7.0.0-38.38-generic 7.0.14. The build is local, with CONFIG_SECURITY_APPARMOR* identical to the 7.0.0-38-generic config, and security/apparmor/ is unmodified.
  • apparmor / libapparmor1 5.0.2-0ubuntu1~26.04.1, AppArmor parser 5.0.2
  • Kernel features: policy/diff_encode = yes, domain/computed_longest_left, domain/attach_conditions
  • firefox 158.0.1~build1 (packages.mozilla.org .deb)