Signal9Signal9 — home
online
all lessons

AppArmor: Path-Based Mandatory Access Control

Intermediate40 min7 steps
What you'll learn
  • Explain what AppArmor adds on top of file permissions, and how its path-based model differs from SELinux labels
  • Find a program's profile in /etc/apparmor.d and read its header, abstractions and file rules
  • Switch a profile between enforce, complain and disabled, with the aa-* tools or by editing files
  • Read an apparmor="DENIED" log line, add the one rule it asks for, then check and reload the profile
  1. Why AppArmor exists

    Understand what MAC adds on top of ordinary permissions, and how AppArmor's model differs from SELinux.

    Ordinary Linux permissions are discretionary (DAC): a file's owner decides who may read or write it, and a process gets whatever its user is allowed. A hijacked tcpdump running as root can therefore read every file root can read, which is all of them.

    AppArmor adds mandatory access control (MAC): for each confined program, the administrator writes a profile, a list of exactly what that program may touch. The kernel checks the profile after the normal permission check passes, and the program cannot opt out. Think of the profile as a guest list for one program. DAC asks whether the user may open the door; AppArmor asks whether this program is on the list for this path. A compromised tcpdump can still write .pcap files, but it can't read /etc/shadow, because no rule in its profile grants that path.

    Ubuntu and Debian turn AppArmor on by default; RHEL and Fedora use SELinux. Both are MAC, but they decide differently:

    • AppArmor is path-based. Rules name files by path: /var/lib/notesd/state.db rw,. Only the confined programs have profiles, and everything else runs unconfined.
    • SELinux is label-based. Every file and process carries a label (a context such as httpd_sys_content_t), and the policy matches labels, not paths. If you move a file, it keeps its label.

    The practical consequence: in AppArmor, where a file is decides access. There's no restorecon and no relabelling, because there are no labels. A file copied into a path the profile allows becomes readable at once, and a file moved out of it becomes unreadable.

    Check yourself

    AppArmor matches its rules against a file's path. What does SELinux match against instead?

  2. Profiles live in /etc/apparmor.d

    Find a program's profile, tell the profile's name from the program it attaches to, and know what each subdirectory is for.

    Every profile is a text file in /etc/apparmor.d/. By convention the file is named after the program's path with the slashes turned into dots: the profile for /usr/sbin/mysqld lives in /etc/apparmor.d/usr.sbin.mysqld. The parser doesn't enforce the convention, but every tool and every admin expects it.

    The directory also holds the pieces profiles are built from and the switches that change their mode:

    • abstractions/ holds reusable rule sets (base, nameservice, user-tmp) that profiles pull in with #include.
    • tunables/ holds variables such as @{HOME} and @{PROC}.
    • local/ holds site additions to shipped profiles (more below).
    • disable/ holds symlinks to profiles that must not load.
    • force-complain/ holds symlinks to profiles that must load in complain mode.
    grep -nE '^profile|^ *#include' /etc/apparmor.d/usr.bin.tcpdump
    2:#include <tunables/global>
    4:profile tcpdump /usr/bin/tcpdump {
    5:  #include <abstractions/base>
    6:  #include <abstractions/nameservice>
    7:  #include <abstractions/user-tmp>
    74:  #include <local/usr.bin.tcpdump>
    -n
    Prefix each match with its line number.
    -E
    Extended regex, so | means "or".
    This is the profile Ubuntu 22.04's tcpdump package ships. Line 4 is the header. profile tcpdump gives the profile its name, and /usr/bin/tcpdump is the attachment, the binary it confines. A header that is just a path, like /usr/bin/man {, uses the path as the name too. The rules sit between the braces. The includes pull in abstractions, and the last one pulls in the site's local/ file.
    sudo apparmor_parser -N /etc/apparmor.d/usr.bin.man
    Cache read/write disabled: interface file missing. (Kernel needs AppArmor 2.4 compatibility patch.)
    /usr/bin/man
    man_filter
    man_groff
    -N / --names
    Parse the files and print the name of every profile they define, without loading anything.
    One file can define several profiles: usr.bin.man defines /usr/bin/man plus the helper profiles man_filter and man_groff. The same command on usr.bin.tcpdump prints just tcpdump. These names are what aa-status lists and what log lines print as profile="...", so -N is how you map a log line back to a file. The first line comes from the lab box, whose kernel has no AppArmor interface, so the parser can't use its compiled-policy cache. It only reads the file, so it works anyway. On a host running AppArmor you get just the names.

    Check yourself

    In the header profile tcpdump /usr/bin/tcpdump {, which part is the profile's name?

  3. Read the rules: paths, permissions, includes, deny

    Read a profile's file rules and know what each permission letter and exec mode grants.

    A file rule is a path, a space, the permissions, then a comma: /etc/notesd/notesd.conf r,. The permission letters are:

    • r read, w write, a append only (it can't be combined with w);
    • m map the file executable (needed for the program's own binary and its libraries);
    • k lock, l link;
    • x execute, which always needs a qualifier saying which profile the child runs under: ix inherits this profile, px switches to the child's own profile, cx switches to a child profile, and ux runs it unconfined. An uppercase Px/Ux also scrubs dangerous environment variables like LD_PRELOAD.

    Paths can glob: * matches within one directory level, ** matches across levels, and {a,b} matches either word. Two qualifiers change a rule's meaning. deny forbids the access even if another rule allows it, and doesn't log it. owner applies the rule only to files the process's user owns.

    You'll build a small profile for a made-up service, notesd, in a practice directory, ~/aa-lab. It's the same shape as the profiles in the labs.

    mkdir -p ~/aa-lab/disable ~/aa-lab/force-complain ~/aa-lab/local
    cat > ~/aa-lab/usr.local.bin.notesd <<'EOF'
    #include <tunables/global>
    
    /usr/local/bin/notesd {
      #include <abstractions/base>
    
      /usr/local/bin/notesd mr,
      /etc/notesd/notesd.conf r,
      /var/log/notesd/*.log w,
      /usr/bin/gzip ix,
      deny /etc/shadow r,
    
      #include if exists <local/usr.local.bin.notesd>
    }
    EOF
    mkdir -p
    Create the directories and any missing parents; no error if they already exist.
    <<'EOF'
    Here-document: everything up to the EOF line is the file's content. The quotes stop the shell expanding anything inside it.
    Silent on success. This writes a complete profile into the practice directory and makes the disable/, force-complain/ and local/ directories next to it, mirroring /etc/apparmor.d. The file name follows the convention: /usr/local/bin/notesd becomes usr.local.bin.notesd.
    grep -nE '^ +(deny +)?/' ~/aa-lab/usr.local.bin.notesd
    6:  /usr/local/bin/notesd mr,
    7:  /etc/notesd/notesd.conf r,
    8:  /var/log/notesd/*.log w,
    9:  /usr/bin/gzip ix,
    10:  deny /etc/shadow r,
    -nE
    Line numbers, extended regex. The pattern matches indented lines that start with a path, with or without deny in front.
    The file rules, one per line. notesd may map and read its own binary (mr), read its config, and write any .log directly in /var/log/notesd/ (* doesn't descend into subdirectories). It may run gzip inside its own profile (ix), and it may never read /etc/shadow, even if an abstraction allowed it. Nothing grants /var/lib/notesd/, so if notesd tries to open its database there, it will be denied.
    sudo cp ~/aa-lab/usr.local.bin.notesd /etc/apparmor.d/
    sudo apparmor_parser -p /etc/apparmor.d/usr.local.bin.notesd | grep '^##included' | grep -v tunables
    Cache read/write disabled: interface file missing. (Kernel needs AppArmor 2.4 compatibility patch.)
    ##included <abstractions/base>
    ##included <abstractions/crypto>
    ##included <abstractions/crypto.d>
    ##included <abstractions/base.d>
    ##included <local/usr.local.bin.notesd>
    -p / --preprocess
    Print the profile with every include expanded in place, each marked with a ##included line. Nothing is loaded.
    The first line copies the practice profile into /etc/apparmor.d, where the parser and the aa-* tools look for profiles and their includes. One #include <abstractions/base> line brings in several hundred lines of rules, including a nested abstractions/crypto and the .d directories that sites can extend. -p still marks the local/ include although that file doesn't exist yet: include if exists just adds nothing for it. When you can't tell where an access is allowed from, -p shows every rule the profile really contains. The Cache read/write disabled line is the same lab-box notice as in -N above.

    Check yourself

    Which exec permission runs a child program under the same profile as its parent?

  4. Modes: enforce, complain, disabled

    Read a profile's mode and switch it, with the aa-* tools or by editing the files they edit.

    A loaded profile runs in one of two modes, and a third state keeps it from loading at all:

    • Enforce (the default): accesses the profile doesn't grant are blocked and logged as apparmor="DENIED".
    • Complain: nothing is blocked, but every access the profile would refuse is logged as apparmor="ALLOWED". This is the tuning mode, the counterpart of SELinux's permissive mode, except it applies to one profile instead of the whole system.
    • Disabled: the profile isn't loaded, so the program runs unconfined.

    Example — not runnable on your lab box

    sudo aa-status
    apparmor module is loaded.
    9 profiles are loaded.
    8 profiles are in enforce mode.
       /usr/bin/man
       lsb_release
       man_filter
       man_groff
       nvidia_modprobe
       nvidia_modprobe//kmod
       rsyslogd
       tcpdump
    1 profiles are in complain mode.
       /usr/local/bin/notesd
    0 profiles are in kill mode.
    0 profiles are in unconfined mode.
    1 processes have profiles defined.
    1 processes are in enforce mode.
       /usr/sbin/rsyslogd (612) rsyslogd
    0 processes are in complain mode.
    0 processes are unconfined but have a profile defined.
    0 processes are in mixed mode.
    0 processes are in kill mode.
    Representative output from an Ubuntu 22.04 host; the profile list depends on what's installed. aa-status (also called apparmor_status) reads the kernel's list of loaded profiles, grouped by mode, then the running processes each one confines. It reports the kernel's state, not the files: a profile you edited but didn't reload still shows its old mode. The lab box has aa-status, but AppArmor can't be used from inside the lab (its securityfs interface isn't mounted), so there it prints apparmor module is loaded. then apparmor filesystem is not mounted. and exits 3.
    sudo aa-complain --no-reload /etc/apparmor.d/usr.local.bin.notesd
    grep -n '{$' /etc/apparmor.d/usr.local.bin.notesd
    sudo aa-enforce --no-reload /etc/apparmor.d/usr.local.bin.notesd
    grep -n '{$' /etc/apparmor.d/usr.local.bin.notesd
    Setting /etc/apparmor.d/usr.local.bin.notesd to complain mode.
    3:/usr/local/bin/notesd flags=(complain) {
    Setting /etc/apparmor.d/usr.local.bin.notesd to enforce mode.
    3:/usr/local/bin/notesd {
    --no-reload
    Change the profile file only; don't load the result into the kernel.
    aa-complain, aa-enforce and aa-disable come in the apparmor-utils package. Each takes a profile file in /etc/apparmor.d or the program's path (aa-enforce /usr/local/bin/notesd works too), changes the file, then reloads the profile. aa-complain adds flags=(complain) to the header line, as the first grep shows. aa-enforce removes that flag and any disable/ link. aa-disable creates the disable/ link and unloads the profile. The lab box's kernel has no AppArmor to reload into, so --no-reload makes the tools edit the file and stop there. Without it the edit still happens, but the reload fails with unable to find a suitable fs in /proc/mounts and the tool exits 1. On a real host, leave it off.

    Those tools only edit files you can edit yourself, then reload. You can make the same change by hand, which is also how you do it on a host without apparmor-utils. Complain mode is a flag on the header line. Try it on the practice copy in ~/aa-lab.

    sed -i 's#^/usr/local/bin/notesd {#/usr/local/bin/notesd flags=(complain) {#' ~/aa-lab/usr.local.bin.notesd
    grep -n 'flags=' ~/aa-lab/usr.local.bin.notesd
    3:/usr/local/bin/notesd flags=(complain) {
    -i
    Edit the file in place.
    s#old#new#
    Substitute; # is the delimiter so the slashes in the path need no escaping.
    This is the edit aa-complain makes. The flag goes between the attachment and the opening brace. In a named profile, it goes after the path too: profile tcpdump /usr/bin/tcpdump flags=(complain) {. Deleting flags=(complain) is what aa-enforce does.
    sed -i 's# flags=(complain) {# {#' ~/aa-lab/usr.local.bin.notesd
    ln -sf ../usr.local.bin.notesd ~/aa-lab/disable/usr.local.bin.notesd
    readlink ~/aa-lab/disable/usr.local.bin.notesd
    ../usr.local.bin.notesd
    ln -sf
    Make a symbolic link, replacing one that's already there.
    The first line puts the profile back in enforce mode. The second disables it: AppArmor's loader skips any profile with a link in disable/ (Skipping profile in /etc/apparmor.d/disable: usr.local.bin.notesd). A link in force-complain/ works the same way and loads the profile in complain mode without editing it. Both survive reboots because they're files. A disable link doesn't unload a profile that's already running, though. On a live host you also run sudo apparmor_parser -R /etc/apparmor.d/usr.local.bin.notesd.

    Check yourself

    What do you add to a profile's header line to put that one profile into complain mode?

  5. Read a denial

    Find AppArmor's log lines and pull out the profile, the path and the access it was refused.

    Every AppArmor decision worth logging becomes a kernel audit message with type=1400. Where it lands depends on the host:

    • with auditd running, in /var/log/audit/audit.log as type=AVC msg=audit(...): apparmor="DENIED" ...;
    • without it, in the kernel log: dmesg, journalctl -k, and on Ubuntu also /var/log/kern.log and /var/log/syslog.

    The line is a list of key="value" pairs, so you don't need a special tool to read it, just grep. The practice file below is a kernel log from a host where notesd is denied, plus two lines that aren't denials.

    cat > ~/aa-lab/kern.log <<'EOF'
    Oct  2 14:02:55 web01 kernel: [ 8796.120331] audit: type=1400 audit(1759413775.100:208): apparmor="STATUS" operation="profile_replace" profile="unconfined" name="/usr/local/bin/notesd" pid=4180 comm="apparmor_parser"
    Oct  2 14:03:11 web01 kernel: [ 8812.441093] audit: type=1400 audit(1759413791.412:212): apparmor="DENIED" operation="open" profile="/usr/local/bin/notesd" name="/var/lib/notesd/state.db" pid=4213 comm="notesd" requested_mask="wr" denied_mask="wr" fsuid=0 ouid=0
    Oct  2 14:03:12 web01 kernel: [ 8813.002417] audit: type=1400 audit(1759413791.973:213): apparmor="ALLOWED" operation="open" profile="/usr/sbin/nginx" name="/srv/www/index.html" pid=3377 comm="nginx" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
    Oct  2 14:03:41 web01 kernel: [ 8842.517760] audit: type=1400 audit(1759413821.488:214): apparmor="DENIED" operation="open" profile="/usr/local/bin/notesd" name="/var/lib/notesd/state.db" pid=4213 comm="notesd" requested_mask="wr" denied_mask="wr" fsuid=0 ouid=0
    EOF
    Silent on success. The four lines are an apparmor_parser reload (STATUS), two identical denials for notesd, and one ALLOWED line from an nginx profile in complain mode.
    grep -oE 'apparmor="[A-Z]+"' ~/aa-lab/kern.log | sort | uniq -c
          1 apparmor="ALLOWED"
          2 apparmor="DENIED"
          1 apparmor="STATUS"
    -o
    Print only the matched apparmor="..." field, not the whole line.
    uniq -c
    Collapse identical adjacent lines and count them (hence the sort first).
    First, sort the lines by kind. DENIED means an enforcing profile blocked something. ALLOWED means a complain-mode profile would have blocked it. STATUS records a profile being loaded or replaced. Only the first two describe missing rules.
    grep 'apparmor="DENIED"' ~/aa-lab/kern.log | grep -oE '(profile|name|requested_mask)="[^"]*"' | sort | uniq -c
          2 name="/var/lib/notesd/state.db"
          2 profile="/usr/local/bin/notesd"
          2 requested_mask="wr"
    -o
    Print only the matched part of each line, one match per line.
    -E
    Extended regex, so (a|b|c) alternation works unescaped.
    Three fields answer the whole question. profile says which profile refused, so which file to edit. name is the path it refused. requested_mask is the access the program asked for, using the rule letters. The kernel prints them in its own order, so wr means write and read. denied_mask is the part of the request that was refused. The uniq -c count of 2 shows the same denial repeated, which is common when a service retries.

    Check yourself

    A denial reads requested_mask="wr". Which permissions must the new rule grant?

  6. Fix it: one rule, a syntax check, a reload

    Add the rule the denial asks for, check the profile without loading it, then load it on a live host.

    The right fix is the narrowest rule that grants the denied access: the exact path from name= with the letters from requested_mask. Don't switch the profile to complain, and don't grant /var/lib/** rw,. Both make the error go away by confining less. The rule goes inside the braces, and order doesn't matter: AppArmor combines every rule in the profile, then subtracts the deny rules.

    sed -i '/^}/i\  /var/lib/notesd/state.db rw,' ~/aa-lab/usr.local.bin.notesd
    grep -n -A1 'state.db' ~/aa-lab/usr.local.bin.notesd
    13:  /var/lib/notesd/state.db rw,
    14-}
    /^}/i\ text
    Insert text as a new line before the line that is just the closing brace.
    The new rule sits just above the closing brace, inside the profile block. In a real terminal you would more often open the file in vim or nano and type the line. The sed form just makes the edit repeatable. The result is the same: one path, the two letters the log asked for, and a trailing comma.
    sudo cp ~/aa-lab/usr.local.bin.notesd /etc/apparmor.d/
    sudo apparmor_parser -Q -K /etc/apparmor.d/usr.local.bin.notesd && echo 'profile compiles'
    Cache read/write disabled: interface file missing. (Kernel needs AppArmor 2.4 compatibility patch.)
    profile compiles
    -Q / --skip-kernel-load
    Parse and compile the profile, but don't load it into the kernel.
    -K / --skip-cache
    Don't read or write the compiled-policy cache.
    The first line puts the edited practice copy where the parser looks. Then a syntax check: silence from the parser and exit status 0 mean the profile compiles, and && echo prints a line only in that case. A mistake is reported with the file and line, such as AppArmor parser error for /etc/apparmor.d/usr.local.bin.notesd in profile /etc/apparmor.d/usr.local.bin.notesd at line 13: Conflict 'a' and 'w' perms are mutually exclusive. for a wa rule. Because nothing is loaded, this works even where AppArmor isn't running, as on the lab box. There the parser first prints the Cache read/write disabled notice and then checks the profile normally.

    Example — not runnable on your lab box

    sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.notesd
    -r / --replace
    Load the profile, replacing the copy in the kernel if one is loaded.
    -R / --remove
    Unload the profile (what aa-disable does after creating the disable/ link).
    Silent on success, and it takes effect at once. Running processes pick up the new rules without a restart. sudo systemctl reload apparmor reloads every profile instead. Neither works inside a lab container: with no securityfs mounted, the parser stops with unable to find a suitable fs in /proc/mounts, is it mounted?. That's why the labs grade the profile files, which are the part you edit.
  7. Now do it — read the profiles in the labs

    Use the reads that start every AppArmor task on a box seeded with real profiles and a real denial

    The two AppArmor labs plant profiles in /etc/apparmor.d and give you an assignment or a denial. Inside one of them, run these reads before you change anything. Say what each one tells you: which profile, which header line, which path and which access. The fix is then a single edit.

    Runnable in the apparmor-profile-modes room — that room’s setup plants the files it reads

    cat /root/case/assignment.env; ls /etc/apparmor.d/
    COMPLAIN_PROFILE=usr.sbin.mysqld
    COMPLAIN_BIN=/usr/sbin/mysqld
    DISABLE_PROFILE=usr.bin.tcpdump
    DISABLE_BIN=/usr/bin/tcpdump
    abi
    abstractions
    disable
    force-complain
    local
    lsb_release
    nvidia_modprobe
    tunables
    usr.bin.man
    usr.bin.tcpdump
    usr.sbin.mysqld
    usr.sbin.rsyslogd
    In the modes lab, the assignment names one profile to put into complain mode and one to disable. Your names differ per session. The listing shows the profile files beside the abstractions/, tunables/, disable/ and force-complain/ directories, and both changes are made in that directory.

    Runnable in the apparmor-profile-modes room — that room’s setup plants the files it reads

    . /root/case/assignment.env; grep -n '{$' "/etc/apparmor.d/$COMPLAIN_PROFILE"
    2:/usr/sbin/mysqld {
    Sourcing the assignment file sets $COMPLAIN_PROFILE in your shell. The grep finds the header, the only line that ends in an opening brace. That's the line the complain flag goes on.

    Runnable in the apparmor-profile-rule room — that room’s setup plants the files it reads

    grep -oE '(profile|name|requested_mask)="[^"]*"' /root/case/denial.txt
    profile="/usr/local/bin/appd60e0"
    name="/var/lib/appd60e0/state.db"
    requested_mask="wr"
    In the rule lab, the same three fields as in the practice log tell you the profile (/etc/apparmor.d/usr.local.bin.<app>), the path to grant, and the letters to grant on it. The app's name differs per session.
Recap
  • AppArmor is MAC layered on top of DAC: a per-program profile lists the paths that program may touch. It's path-based, while SELinux is label-based.
  • Profiles live in /etc/apparmor.d, named after the binary's path with dots for slashes. The header holds the profile's name and attachment, #include pulls in abstractions, and site additions to shipped profiles go in local/.
  • File rules are path perms,: r w a m k l, and x always with a qualifier (ix, px, cx, ux). deny forbids silently. Globs: within a level, * across levels.
  • Enforce blocks and logs DENIED; complain allows and logs ALLOWED; disabled doesn't load. Switch modes with aa-complain/aa-enforce/aa-disable, or by hand with flags=(complain), a force-complain/ link or a disable/ link.
  • A DENIED line's profile=, name= and requested_mask= give you the file, the path and the letters for one narrow rule. Check it with apparmor_parser -Q -K and load it with apparmor_parser -r.
Task → command
TaskToolCommand
What's loaded, in which mode?aa-statussudo aa-status
Which profiles does a file define?apparmor_parserapparmor_parser -N /etc/apparmor.d/FILE
Tune a profile without blockingcomplain modeaa-complain FILE, or flags=(complain) in the header
Stop a profile loadingdisableaa-disable FILE, or ln -s ../FILE /etc/apparmor.d/disable/ + apparmor_parser -R FILE
What was blocked?kernel / audit logjournalctl -k | grep 'apparmor="DENIED"'
Check a profile without loading itapparmor_parserapparmor_parser -Q -K FILE
Load the edited profileapparmor_parserapparmor_parser -r FILE