AppArmor & SELinux

Mandatory access control on files and capabilities.

Advanced14 min · lesson 14 of 25

Seccomp (secure computing mode) decides which system calls a program is allowed to make. Capabilities chop root's power into roughly forty separate keys, so you can hand over one without handing over the whole ring. Both are worth having. Neither one has any opinion about which files a program may open. A web server an attacker has taken over can still read /etc/shadow, the file holding every password hash on the box, as long as the ordinary file permissions allow it. Inside a container running as root, they usually do. Closing that gap is the job of a Linux Security Module.

A Linux Security Module (LSM, a second checkpoint built into the kernel) is a hook the kernel calls before it grants access to a file, a capability, or an operation. Ordinary Unix permissions work like an office building: whoever owns a room controls its lock, and the caretaker carries a master key that opens everything. That is discretionary access control (DAC), where the owner decides who gets in and root overrides everyone. Mandatory access control (MAC) posts a guard at the door with a fixed list of who may enter which room. The master key does not get you past the guard. Two modules dominate. AppArmor ships on Debian and Ubuntu. SELinux (Security-Enhanced Linux) ships on Red Hat Enterprise Linux and Fedora.

You are almost certainly running one already and have not noticed. On a host with AppArmor loaded, Docker writes a profile called docker-default and straps it onto every container you start, unless you go out of your way to tell it not to. You can read the name of that profile from inside the container.

terminal
# which profile is confining this container?
$ docker run --rm alpine cat /proc/self/attr/current
docker-default (enforce)
# --privileged (or apparmor=unconfined) throws the guard out entirely:
$ docker run --rm --privileged alpine cat /proc/self/attr/current
unconfined

What docker-default actually stops

The profile is a hand-picked deny list of things a container almost never needs and an attacker very much wants. Loading kernel modules. Writing to the corners of /proc and /sys that talk straight to the kernel. Mounting filesystems. That last one deserves its own sentence, because mount is a classic escape move. With it you can remount a host path as writable, or abuse an old cgroup (control group, the kernel feature that caps how much CPU and memory a container may use) release_agent trick to run your code on the node itself. Watch docker-default refuse a mount even after you hand the container the exact capability that is supposed to authorize it.

terminal
# give the container CAP_SYS_ADMIN, the capability that mount(2) requires...
$ docker run --rm --cap-add SYS_ADMIN alpine \
sh -c 'mkdir /mnt/x && mount -t tmpfs none /mnt/x'
mount: mounting none on /mnt/x failed: Permission denied
# the capability check passed. AppArmor is what said no. Read it on the HOST:
$ sudo dmesg | tail -1
audit: type=1400 audit(1752690411.882:73): apparmor="DENIED"
operation="mount" class="mount" profile="docker-default"
name="/mnt/x/" pid=4127 comm="mount" srcname="none" flags="rw"

That is what mandatory access control buys you. The capability check said yes, the profile still said no, because the guard's list beats the master key. docker-default is deliberately broad, wide enough to fit any workload without breaking it. Pinning one app down to its own files takes a profile you write yourself.

A profile is an allowlist of paths and caps

A custom profile flips the question around. Rather than listing what to forbid, you write down what the process may do, and everything you left out falls through to deny. A tight profile for a web server reads its content directory, writes to one cache path, opens a TCP socket, and touches nothing else. Someone who lands code execution inside it is boxed into the behavior you declared. Load a new profile in complain mode first, which logs what it would have blocked instead of blocking it, watch which paths the app honestly needs, then switch it to enforce.

/etc/apparmor.d/docker-myapp
#include <tunables/global>
profile docker-myapp flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet tcp, # bind/connect TCP, nothing else
/app/** r, # read app files, read-only
/var/cache/app/** rw, # the one path it may write
deny /etc/shadow rwklx, # never touch credentials
deny /bin/** wl, # no writing into binary dirs
deny mount, # no mounting, no escape primitive
}
terminal
# compile the profile into the kernel, then run the app under it:
$ sudo apparmor_parser -r -W /etc/apparmor.d/docker-myapp
$ docker run --rm --security-opt apparmor=docker-myapp myapp:1.0 \
sh -c 'echo pwned > /app/index.html'
sh: can't create /app/index.html: Permission denied
# /app is r, not rw, so the write is denied and logged with the access mask:
$ sudo dmesg | tail -1
audit: type=1400 audit(1752690533.204:81): apparmor="DENIED"
operation="open" profile="docker-myapp" name="/app/index.html"
pid=5210 comm="sh" requested_mask="wc" denied_mask="wc" fsuid=0 ouid=0

SELinux does the same job with labels

Red Hat, Fedora, and everything downstream of them run SELinux instead, and it reaches the same place by a different route. Every process and every file wears a label, and part of that label is a type. Container processes run as the type container_t. Content meant for containers is labeled container_file_t. The kernel's rule is blunt: container_t may touch container_file_t and very little else. So a container cannot read a host file carrying some other type, whatever the Unix permissions on that file happen to say. AppArmor decides by path. SELinux decides by label. Either way it is the kernel holding a line underneath your app.

This bites the first time you bind-mount a host directory into a container on an SELinux box. The directory keeps the label it had on the host, container_t cannot touch it, and you get a Permission denied that looks exactly like a bug in your own code. The fix is a suffix on the volume. Lowercase :z relabels the directory to a shared container type that several containers can use. Uppercase :Z relabels it private to one container, with a unique category of its own. Docker runs the relabel for you the moment you add the suffix.

terminal
# Fedora host, SELinux enforcing. Bind a host dir in with no label suffix:
$ docker run --rm -v /srv/appdata:/data fedora cat /data/config.yml
cat: /data/config.yml: Permission denied
# the kernel logged an AVC (access vector cache) denial, not a plain Unix error:
$ sudo ausearch -m avc -ts recent | tail -4
type=AVC msg=audit(1752690644.1:92): avc: denied { read } for
pid=8842 comm="cat" name="config.yml"
scontext=system_u:system_r:container_t:s0:c12,c34
tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0
# add :Z and Docker relabels the volume to a private container type:
$ docker run --rm -v /srv/appdata:/data:Z fedora cat /data/config.yml
db_host: 10.0.0.5
$ ls -Z /srv/appdata/config.yml
system_u:object_r:container_file_t:s0:c12,c34 /srv/appdata/config.yml
A container operation got denied. Which layer said no?
Permission denied inside a container
work out who refused before you weaken anything
DAC
File perms / UID
ls -l, id show wrong owner or mode. Fix ownership, do not chmod 777.
capability
Dropped Linux cap
getpcaps $$ or capsh --print. Add the one cap you need, never --privileged.
seccomp
Blocked syscall
strace shows EPERM on the call and no LSM log. Allow that one syscall only.
AppArmor
Profile denial
dmesg shows apparmor="DENIED". Add the path or cap to the profile, keep it enforcing.
SELinux
Label mismatch
ausearch -m avc shows denied. Relabel the volume with :Z, do not setenforce 0.
The audit line names the enforcer every single time. Read it, allow the one thing the app genuinely needs, and leave the layer switched on. Turning AppArmor or SELinux off to make one app start strips protection from every workload on the host, including all the ones that were behaving.
:Z will relabel whatever you point it at
The :Z suffix walks the whole host path and rewrites the SELinux type of every file underneath it. Aim it at a scratch data directory and nothing bad happens. Aim it at a shared or system path like /home or /var, or at a directory two containers both mount, and you have a real problem: the relabel wipes the original types and can lock the host, or the other container, out of files it owns. Bind-mount narrow, dedicated directories, and reach for lowercase :z when more than one container legitimately shares the same data.

Both modules land you in the same place. Even root inside the container answers to a policy the host wrote, and that policy has the last word on which files, capabilities, and network moves are allowed, on top of whatever the ordinary DAC permissions would have waved through.

Which one you get depends on the distribution underneath you. Ubuntu and Debian hosts pick up the AppArmor profiles that ship with Docker. On the Red Hat family, SELinux labels are what matter, and the :z and :Z suffixes on volumes are how you set them. Switching the module off to "make the volume work" turns up in incident write-ups often enough to count as a pattern. Fix the labels instead.

Custom profiles are strong and easy to get wrong. Start from the vendor default, run your version in complain mode, read the denials it collects, and tighten one rule at a time.

Run the same check after every change window. Confirm the control is still on, paste the command and its output into the ticket, and refuse to close the change if the reading drifted from what you expected. Pick the tightest scope the workload can still live with. That habit compounds across every host and every pipeline you own.

Try this

Find out whether your host is enforcing AppArmor or SELinux, then look at the security options and labels on a container that is actually running.

terminal
$ docker info --format 'SecurityOptions={{.SecurityOptions}}'
SecurityOptions=[name=apparmor name=seccomp,profile=builtin name=cgroupns]
$ docker run -d --name lsmtest alpine sleep 120 >/dev/null
$ docker inspect -f 'AppArmorProfile={{.AppArmorProfile}} SecurityOpt={{.HostConfig.SecurityOpt}}' lsmtest
AppArmorProfile=docker-default SecurityOpt=[]
$ docker rm -f lsmtest >/dev/null
$ # On SELinux hosts also check: getenforce; ls -Z on volume mounts

Takeaway

Leave the module enforcing. When a container hits a wall, fix the volume label or the profile rather than switching AppArmor or SELinux off, and count docker-default and confined types as part of your baseline rather than optional decoration.

Quick check
01You start a container with --cap-add SYS_ADMIN so it can mount a tmpfs. The mount fails anyway, and dmesg on the host shows apparmor="DENIED" operation="mount" profile="docker-default". What happened?
Incorrect — It was added. Running getpcaps against the container process would show CAP_SYS_ADMIN sitting right there, and the capability check passed cleanly.
Correct — Mandatory access control sits above both file permissions and capabilities, so the profile denies mount whatever caps the process is holding. The guard's list beats the master key.
Incorrect — Not what is happening here. The capability is present, and the block comes from the AppArmor profile, which the dmesg line names outright.
Incorrect — A seccomp block shows up as EPERM from the syscall with no AppArmor log line at all. This record says apparmor="DENIED", so AppArmor is the one refusing.
02AppArmor and SELinux are both Linux Security Modules that enforce mandatory access control, and they reach their decisions differently. What is the core difference in how each one allows or denies access to a file?
Incorrect — Neither one is a syscall filter or a capability filter. Both are mandatory access control modules that gate access to files and operations.
Correct — An AppArmor profile lists paths like /app/**. SELinux tags every process and file with a type such as container_t or container_file_t, and the rules allow type-to-type access.
Incorrect — Both are mandatory access control. Discretionary access control is the ordinary Unix owner-and-permission model that MAC sits on top of.
Incorrect — That is not the split. Both are enforced by the kernel for every user, and the real difference is path-based versus label-based decisions.
03On an SELinux-enforcing host, two containers both need read and write access to the same host directory /srv/shared. Which bind-mount suffix do you use, and why not the other one?
Correct — :z applies a shared container_file_t label several containers can reach, while :Z stamps on a unique category only one container carries, so shared data needs :z.
Incorrect — No. :Z relabels the path private with a unique category, so the second container gets denied. Shared data needs the lowercase :z.
Incorrect — Without a suffix the host directory keeps its original label, container_t cannot touch it, and you get a Permission denied.
Incorrect — Turning SELinux off strips protection from every workload on the host. The fix is the right relabel suffix, not switching enforcement off.

Related