AppArmor and SELinux

Docker's AppArmor profile, a custom one, and how to tell a security module refused you.

Advanced14 min · lesson 13 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (1 files, 1 KB): lsm.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/lsm.tar.gz && tar -xzf lsm.tar.gz, which creates ~/lab/lsm/. SHA-256: 8be695f0097279880f57bf23f38b21406c5b7e4fd96837359e909d49673ef15a
Watch out
Run this only in the SecOpsLog disposable lab VM, secopslog-docker-sec. The lab loads an AppArmor profile into the host kernel and writes /etc/apparmor.d. If the VM doesn't exist yet, create it on your workstation from the lab kit folder with ./setup/create-lab.sh --profile sec. Then open a shell with multipass shell secopslog-docker-sec (limactl shell secopslog-docker-sec on Lima). Reset it afterwards with ./setup/create-lab.sh --profile sec --recreate. On the sec VM the ubuntu user isn't in the docker group, so every Docker command below uses sudo.

mount: mounting none on /mnt/x failed: Permission denied, from a container that was started with --cap-add SYS_ADMIN for exactly that mount. The capability is there, and Docker's seccomp profile allows mount once CAP_SYS_ADMIN is added ("seccomp: filter system calls"). The kernel log has nothing to say about it. The refusal comes from a third layer, a Linux Security Module, and this one doesn't log. Docker works with two of these modules, AppArmor and SELinux. You'll see what Docker's AppArmor profile blocks and how to prove it was the cause. Then you'll write, load and debug a profile of your own. Last comes what an SELinux host needs before its labels apply to containers.

Mandatory access control in one paragraph

Ordinary file permissions are discretionary. The owner of a file decides, and root passes every check. A Linux Security Module (LSM) adds hooks in the kernel that run after those checks. They can refuse an operation even for root with every capability. That's mandatory access control: a policy the host admin loads, which processes can't change. AppArmor writes its policy as per-program profiles of paths and permissions. It ships enabled on Ubuntu, Debian and their derivatives. SELinux labels every process and file with a type and allows access by type. It ships enforcing on Fedora, RHEL and its rebuilds. A host runs one of the two as its main MAC module.

docker-default

On an AppArmor host, Docker generates a profile called docker-default and loads it into the kernel when the daemon starts. It applies that profile to every container that doesn't ask for something else:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo docker info --format '{{json .SecurityOptions}}'
["name=apparmor,profile=default","name=seccomp,profile=builtin","name=cgroupns"]
$ sudo docker run --rm alpine:3.24 cat /proc/self/attr/apparmor/current
docker-default (enforce)
$ sudo docker run -d --name lab-lsm alpine:3.24 sleep 300 >/dev/null sudo docker inspect -f 'profile={{.AppArmorProfile}} opts={{.HostConfig.SecurityOpt}}' lab-lsm sudo aa-status | grep docker-default
profile=docker-default opts=[] docker-default /usr/bin/busybox (2330) docker-default

docker info lists apparmor with profile=default. Inside the container, /proc/self/attr/apparmor/current reads docker-default (enforce). On kernels with several stacked LSMs that path is the reliable one. The older /proc/self/attr/current still works on this host. docker inspect shows the profile in AppArmorProfile while SecurityOpt stays empty. On the host, aa-status lists the profile and every confined process, here the container's busybox running sleep.

The profile is generated from a template in the moby/profiles repository. It allows ordinary file and network access and leaves capability decisions to Docker's capability set. What it adds is a set of denials for things containers should never do, even when someone hands them a capability. It denies mounting filesystems and writing to most of /proc (sysrq-trigger, kcore, sys/kernel and others) and to /sys. It also denies tracing processes outside the profile. --privileged replaces the profile with unconfined.

A refusal that leaves no log line

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo docker run --rm --cap-add SYS_ADMIN alpine:3.24 sh -c 'mkdir /mnt/x && mount -t tmpfs none /mnt/x && echo mounted'
mount: mounting none on /mnt/x failed: Permission denied
$ sudo journalctl -k --since -1min --no-pager -o cat | grep 'apparmor="DENIED"' | grep -c 'profile="docker-default"'
0
$ sudo docker run --rm --cap-add SYS_ADMIN --security-opt apparmor=unconfined alpine:3.24 sh -c 'mkdir /mnt/x && mount -t tmpfs none /mnt/x && echo mounted'
mounted

The mount fails, and you won't find an AppArmor denial for docker-default in the last minute of the kernel log. That's not a logging problem. docker-default refuses mounts with an explicit deny mount rule. AppArmor doesn't log explicit deny rules unless the rule is written audit deny. The third command gives you the diagnosis. The same container with --security-opt apparmor=unconfined mounts the tmpfs. If removing the profile changes the result and nothing else changed, the profile was the cause.

Run that A/B test only on a disposable host or a throwaway container, and treat it as a measurement, never as a fix. With the capability still added, the unconfined container could mount anything its other settings allow. That's the escape path "Capability and kernel escapes" describes. The same reasoning sorts out any Permission denied or Operation not permitted you get from a container:

A container gets EPERM or EACCES. Which layer said no?
Change one control, retry the same command
everything else stays as it was
file permissions
Compare UID/GID with the file owner and mode
ls -ln and id; an added capability does not help a non-root process
capabilities
Read CapEff from the host
getpcaps or /proc/<pid>/status; add only the capability the error names
seccomp
Retry with seccomp=unconfined
then find the call with SECCOMP_FILTER_FLAG_LOG
AppArmor
Retry with apparmor=unconfined
docker-default denials are silent; custom profiles log apparmor="DENIED"
SELinux
Search the audit log for AVC records
ausearch -m avc; label=disable only as a test
Each test removes one layer on a throwaway container. The fix is a narrow change to that layer, never leaving it switched off.

A profile of your own

docker-default is broad so that it fits every image. In a profile for one workload, you say what the process may do and refuse the rest. Those refusals are logged. This one lets a container read anything, run busybox, and write only under /data:

lab-app
abi <abi/4.0>,
include <tunables/global>
# lab-app: read the image, run busybox, write only under /data.
# Anything not allowed here is denied, and AppArmor logs the denial.
profile lab-app flags=(attach_disconnected,mediate_deleted) {
include <abstractions/base>
/** r,
/bin/busybox mrix,
/data/** w,
signal (receive) peer=unconfined,
}

abi <abi/4.0> pins the policy language version and tunables/global defines variables such as @{PROC}. attach_disconnected makes paths resolve inside the container's own mount namespace, which every container profile needs. abstractions/base covers what almost every program touches, such as shared libraries, /dev/null and locale files. /** r allows reading everything. /bin/busybox mrix allows mapping and executing busybox, and ix keeps the new program in the same profile. /data/** w is the only write rule. signal (receive) peer=unconfined lets the container runtime deliver docker stop. Everything else is refused. That includes every capability, because the profile has no capability rule, and all networking, because it has no network rule.

Docker doesn't load custom profiles. You load them with apparmor_parser. Keep the file in /etc/apparmor.d and the AppArmor service loads it again at boot:

ubuntu@secopslog-docker-sec:~/lab/lsm · Docker 29.9.2
$ sudo install -m 644 lab-app /etc/apparmor.d/lab-app sudo apparmor_parser -r /etc/apparmor.d/lab-app sudo aa-status | grep lab-app
lab-app
ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo docker run --rm --security-opt apparmor=lab-app -v lab-lsm-data:/data alpine:3.24 sh -c ' cat /proc/self/attr/apparmor/current echo ok > /data/out && cat /data/out apk --version echo x > /etc/lab-x'
lab-app (enforce) ok sh: apk: Permission denied sh: can't create /etc/lab-x: Permission denied
$ sudo journalctl -k --since -1min --no-pager -o cat --grep 'apparmor="DENIED"' | grep -o 'operation=.*profile="lab-app" name="[^"]*"'
operation="exec" class="file" profile="lab-app" name="/sbin/apk" operation="mknod" class="file" profile="lab-app" name="/etc/lab-x"
$ sudo docker run --rm -v lab-lsm-data:/data alpine:3.24 sh -c 'apk --version; echo x > /etc/lab-x && echo wrote /etc/lab-x'
apk-tools 3.0.8-r0, compiled for aarch64. wrote /etc/lab-x

The container runs under lab-app (enforce). Writing /data/out works. Running apk, a separate binary, fails, and so does creating /etc/lab-x. Unlike docker-default's explicit denies, these refusals come from rules the profile doesn't have. AppArmor logs each one with the operation (exec, mknod), the profile and the path. That's what you need to decide whether a rule is missing. Without auditd the kernel rate-limits these messages, so a large burst of denials can lose lines. Hosts running auditd keep them in /var/log/audit/audit.log. The same command under docker-default runs apk and writes /etc/lab-x, which confirms the profile made the difference.

Writing a profile is an iterative job, and complain mode is the tool for it. A profile in complain mode logs what it would refuse, and allows it. apparmor_parser -C loads a profile in complain mode:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo apparmor_parser -r -C /etc/apparmor.d/lab-app sudo docker run --rm --security-opt apparmor=lab-app alpine:3.24 sh -c 'echo x > /etc/lab-x && echo wrote /etc/lab-x' sudo journalctl -k --since -1min --no-pager -o cat --grep 'apparmor="ALLOWED"' | grep -o 'operation=.*profile="lab-app" name="[^"]*"' sudo apparmor_parser -r /etc/apparmor.d/lab-app
wrote /etc/lab-x operation="mknod" class="file" profile="lab-app" name="/etc/lab-x" operation="open" class="file" profile="lab-app" name="/etc/lab-x" operation="file_perm" class="file" profile="lab-app" name="/etc/lab-x"

In complain mode the write succeeds, and the log shows apparmor="ALLOWED" for each step of it (mknod, open, file_perm). So the method goes like this. Load a new profile in complain mode and run the app through its real workload. Turn the logged accesses that are legitimate into rules (aa-logprof from apparmor-utils can propose them). Then reload it in enforce mode, which the last command of that step did. In Compose the setting is security_opt: [apparmor=lab-app]. The failure you'll meet first with custom profiles is a name the kernel doesn't know. That happens, for example, after a host was rebuilt and the file was never copied to /etc/apparmor.d:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo docker run --rm --security-opt apparmor=lab-missing alpine:3.24 true
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: unable to apply apparmor profile: apparmor failed to apply profile: write fsmount:fscontext:proc/thread-self/attr/apparmor/exec: no such file or directory Run 'docker run --help' for more information

runc can't switch the container to a profile that isn't loaded, so the container isn't created. The useful part of the message is unable to apply apparmor profile. Check aa-status on that host. Clean up, which unloads the profile with apparmor_parser -R and removes its file:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo docker rm -f lab-lsm sudo docker volume rm lab-lsm-data sudo apparmor_parser -R /etc/apparmor.d/lab-app sudo rm /etc/apparmor.d/lab-app rm -rf ~/lab/lsm
lab-lsm lab-lsm-data

SELinux hosts

The lab VMs run Ubuntu, so SELinux is described here from the Docker and Fedora docs, not demonstrated. With SELinux, container processes run as the type container_t, and files meant for containers carry container_file_t. The policy allows container_t to touch little else. Each container also gets its own pair of MCS categories, such as s0:c12,c34. So two containers with the same UID still can't read each other's files. ps -eZ shows the process labels and ls -Z the file labels.

Docker Engine from Docker's own packages doesn't enable SELinux support by default. Until the daemon runs with it, containers aren't given container_t labels, and the :z and :Z volume options relabel nothing:

/etc/docker/daemon.json
{
"selinux-enabled": true
}

Distro packages such as Fedora's moby-engine enable it already, so check docker info. An SELinux-enabled daemon lists selinux under Security Options. You then need to label a bind mount before a container can use it. :z gives the content a shared container label and :Z a label private to one container. "Volumes, bind mounts and tmpfs in practice" in Docker in depth covers which to use, and the host paths never to relabel. A refusal shows up as an AVC record, which you can find with ausearch -m avc -ts recent on hosts running auditd. For a single container, --security-opt label=disable is the equivalent of apparmor=unconfined. It belongs in the same A/B test, never in production. setenforce 0 turns SELinux off for the whole host and every workload on it. It's not a fix for one container.

Hands-on

On secopslog-docker-sec, before you run the clean-up step, repeat the custom command with echo x > /tmp/lab-x in place of the /etc/lab-x write. Read the profile first and predict the result from its single write rule, /data/** w. Wait a few seconds, as the lab does, then rerun the custom-log command and check whether a new mknod line names /tmp/lab-x. Clean up afterwards so the profile doesn't stay loaded in the VM.

Takeaway

An empty kernel log doesn't clear AppArmor. docker-default refused your tmpfs mount with a count of 0 DENIED lines, and only the apparmor=unconfined A/B run named the layer. Where this goes wrong is the test flag staying in a run command or Compose file as the "fix". The other common slip is a custom profile that never reached /etc/apparmor.d on a rebuilt host, so the container fails with unable to apply apparmor profile.

Quick check
01A container started with --cap-add SYS_ADMIN cannot mount a tmpfs. The host's kernel log shows no AppArmor denial. With --security-opt apparmor=unconfined added, the mount works. What is going on?
Correct — The lab shows the refusal, zero docker-default DENIED lines, and the mount working unconfined.
Incorrect — AppArmor never grants capabilities; it can only refuse. The capability was present in both runs.
Incorrect — The two are independent; apparmor=unconfined leaves the seccomp filter loaded, and seccomp allows mount once CAP_SYS_ADMIN is added.
Incorrect — Without auditd the kernel log still receives AppArmor records; the lab's custom profile denials appear there.
02After a host was rebuilt, containers that use --security-opt apparmor=web-strict fail to start with "unable to apply apparmor profile". Other containers run fine. What is the likely cause?
Incorrect — Then all AppArmor containers, including those using docker-default, would be affected, and the daemon would not list apparmor.
Incorrect — Profiles do not conflict that way; each container gets one profile.
Correct — Docker loads only docker-default; custom profiles must be in /etc/apparmor.d or loaded with apparmor_parser. The lab shows the same error for an unknown name.
Incorrect — runc applies complain-mode profiles; the lab runs a container under lab-app in complain mode.
03A RHEL host runs SELinux in enforcing mode and Docker CE from Docker's repository with an empty daemon.json. Bind mounts with :Z seem to make no difference, and ps -eZ does not show container processes as container_t. What is missing?
Incorrect — container-selinux provides the container types for Docker as well; the daemon has to be told to use them.
Correct — Docker CE does not enable SELinux support by default; without it containers are not labelled and :z/:Z do nothing.
Incorrect — It is the other way round: --mount rejects Z, and -v or Compose carry the relabel option.
Incorrect — Permissive mode stops enforcement for every workload on the host and does not label anything.

Related