Skip to content

Latest commit

 

History

History
356 lines (270 loc) · 15.5 KB

File metadata and controls

356 lines (270 loc) · 15.5 KB

AppArmor: A Principal Engineer's Reference

AppArmor ("Application Armor") is a Linux kernel Security Module (LSM) that enforces Mandatory Access Control (MAC) by confining programs to a defined set of resources. It is active by default on Ubuntu (the OS used inside Lima guests on macOS in this repo, and inside WSL 2 distros on Windows) and is the primary host-side security layer that Docker, containerd, and Kubernetes rely on to harden containers beyond what namespaces and cgroups alone provide.

Table of contents


0. Where AppArmor fits in the stack

flowchart TB
  subgraph guest["Lima guest (macOS) · WSL 2 distro (Windows) — Ubuntu, where AppArmor lives"]
    subgraph kernel["Linux kernel — LSM hook intercepts every syscall touching a labeled resource (file, socket, capability, …) → allows / denies / logs the access"]
      subgraph runtime["Container runtime (runc / crun) — applies seccomp filter + AppArmor profile at exec()"]
        pod["Container / Pod — process (nginx, sidecar, …)"]
      end
    end
  end
Loading

AppArmor operates inside the Linux guest — the Lima VM on macOS, or the WSL 2 distro on Windows — not on the macOS or Windows host itself. On macOS the host has no AppArmor (it uses its own TCC / SIP / mandatory sandbox frameworks); on Windows the host uses its own security model. Everything below assumes you are working inside a Lima guest, a WSL 2 distro, or a Linux host directly.


1. Core concepts

Profiles

An AppArmor profile is a text file that describes what a single program binary is allowed to do. Profiles are stored in /etc/apparmor.d/ and identified by the absolute path of the confined executable (e.g. /usr/sbin/nginx).

## on LiMa guest
prayagupa@lima-lima-qemu-dockerd:/Users/prayagupa$ ls -l /etc/apparmor.d/
total 472
-rw-r--r-- 1 root root   354 Aug 15  2025 1password
-rw-r--r-- 1 root root   352 Aug 15  2025 Discord
-rw-r--r-- 1 root root   386 Aug 15  2025 MongoDB_Compass
-rw-r--r-- 1 root root   404 Aug 15  2025 QtWebEngineProcess
drwxr-xr-x 2 root root  4096 Mar 21 09:14 abi
drwxr-xr-x 4 root root  4096 Mar 21 09:14 abstractions
-rw-r--r-- 1 root root   374 Aug 15  2025 balena-etcher
-rw-r--r-- 1 root root   348 Aug 15  2025 brave
-rw-r--r-- 1 root root   342 Aug 15  2025 buildah
-rw-r--r-- 1 root root   342 Aug 15  2025 busybox
.
.
.

A profile specifies:

Resource type Example rule
File read/write/exec /var/log/nginx/** rw,
Networking network inet stream,
Linux capabilities capability net_bind_service,
Signal sending signal send set=(term) peer=/usr/bin/nginx,
Mount operations deny mount,
Unix sockets unix (create connect) type=stream,

Enforcement modes

Mode Kernel behavior Use when
enforce Denies and logs any access not explicitly allowed Production — default for Docker's built-in profile
complain Logs violations but allows them Auditing a new application before writing a tight profile
disabled Profile loaded but not active Emergency bypass; avoid in production
unconfined No profile loaded at all Never intentional — indicates the profile was never applied

Check the mode of any profile:

aa-status          # summary of all loaded profiles and their modes
# apparmor module is loaded.
# apparmor filesystem is not mounted.
cat /sys/kernel/security/apparmor/profiles   # kernel-level view

Labels vs. paths

Unlike SELinux (which labels every file inode), AppArmor is path-based: the kernel matches the pathname of the file being accessed against the rules in the profile. This makes profiles easier to write but means that hard links, bind mounts, and /proc/*/fd/ tricks can sometimes route around a rule — a relevant consideration when reviewing container escape CVEs.


2. AppArmor and Docker

Docker ships a built-in profile named docker-default. It is loaded automatically into the kernel when the Docker daemon starts and is applied to every container that does not specify a custom profile.

What docker-default does

  • Denies raw network access (CAP_NET_RAW blocked for most containers).
  • Blocks access to /proc/sysrq-trigger, /proc/kcore, /proc/kmem, and similar sensitive kernel interfaces.
  • Denies mount syscalls inside the container.
  • Restricts writes to /sys/** (read-only by default).
  • Allows all file access inside the container's overlayfs layer (the profile is deliberately coarse at the file level — seccomp handles syscall filtering).

View the profile:

# inside the Lima guest
cat /etc/apparmor.d/docker   # or the generated path under /var/lib/docker/

Overriding the profile per-container

# Run with no AppArmor confinement (privileged debugging only)
docker run --security-opt apparmor=unconfined …

# Run with a custom profile already loaded into the kernel
docker run --security-opt apparmor=my-custom-profile …

Design rule: Never ship apparmor=unconfined in a production manifest. If a container legitimately needs extra capabilities (e.g. NET_ADMIN for a CNI plugin), write a narrow custom profile or use a targeted securityContext.capabilities.add and keep the AppArmor profile in enforce mode.


3. AppArmor and Kubernetes

Kubernetes applies AppArmor profiles at the Pod / container level via the securityContext.appArmorProfile field, GA since Kubernetes 1.30. (The older container.apparmor.security.beta.kubernetes.io/* annotation API is deprecated — use the field on 1.30+.)

The appArmorProfile field (GA 1.30+)

securityContext:
  appArmorProfile:
    type: RuntimeDefault     # use the container runtime's default (= docker-default or equivalent)
    # type: Localhost        # load a profile that already exists on the node
    # localhostProfile: my-nginx-profile
    # type: Unconfined       # no confinement — avoid in production

Node prerequisite

The profile must be loaded on every k8s node that can schedule the Pod. Use a DaemonSet or a node provisioning step (cloud-init, Ansible, etc.) to push and load custom profiles:

# Load a profile from a file
apparmor_parser -r -W /etc/apparmor.d/my-nginx-profile

# Confirm it is loaded
aa-status | grep my-nginx-profile

If the profile is absent on a node and type: Localhost is set, the kubelet will refuse to start the container — the Pod stays in Pending with an event like apparmor profile not found: my-nginx-profile.


4. Writing a custom profile

Workflow

  1. Run the application in complain mode to capture every access it actually makes.
  2. Generate a profile stub from the audit log.
  3. Tighten the profile, remove overly broad rules.
  4. Switch to enforce mode and regression-test.
# Install tooling (Ubuntu)
apt-get install apparmor-utils auditd

# Create a stub profile for a binary
aa-genprof /usr/sbin/nginx
# → interactively walks you through an exercise run, outputs a profile

# Or load the binary in complain mode manually, exercise it, then scan logs
aa-logprof   # reads /var/log/audit/audit.log or /var/log/syslog and proposes rules

Minimal nginx example

#include <tunables/global>

/usr/sbin/nginx {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  capability net_bind_service,
  capability setuid,
  capability setgid,

  /etc/nginx/**           r,
  /var/log/nginx/**       rw,
  /var/www/html/**        r,
  /run/nginx.pid          rw,

  network inet stream,
  network inet6 stream,

  deny /proc/sys/**       w,
  deny /sys/**            w,
}

Load and enforce:

apparmor_parser -r -W /etc/apparmor.d/usr.sbin.nginx
aa-enforce /etc/apparmor.d/usr.sbin.nginx

5. Operational commands

# Full status — loaded profiles, modes, and confined processes
aa-status

# Put a profile into complain mode (non-destructive audit)
aa-complain /etc/apparmor.d/usr.sbin.nginx

# Put a profile into enforce mode
aa-enforce /etc/apparmor.d/usr.sbin.nginx

# Reload a profile after editing (without restarting the daemon)
apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx

# Disable a profile (unload from kernel)
aa-disable /etc/apparmor.d/usr.sbin.nginx

# View live denials (requires auditd or readable syslog)
grep "apparmor=\"DENIED\"" /var/log/syslog
journalctl -k | grep apparmor

6. AppArmor vs. seccomp — complementary, not redundant

A common interview question: "If you have seccomp, why do you need AppArmor?"

seccomp AppArmor
Filters Syscall numbers + arguments File paths, network families, capabilities, mounts
Layer Kernel syscall table LSM hook (after syscall dispatch)
Granularity Per-syscall, per-arg Per-binary, per-resource path
Docker default default.json seccomp profile docker-default AppArmor profile
Bypassed by Using allowed syscalls creatively Hard links, /proc/*/fd/ path aliasing

They defend against different attack surfaces. seccomp reduces the kernel attack surface (fewer syscalls reachable from a container). AppArmor constrains what resources an already-running process can touch. Both should be active; neither replaces the other.


7. Interaction with Lima guests

Lima's Ubuntu guests ship with AppArmor enabled (/sys/module/apparmor/parameters/enabled = Y).

cat /sys/module/apparmor/parameters/enabled 
Y

The Docker provisioner installed by lima-qemu-dockerd.yaml inherits this — so docker-default is loaded and applied to every container run inside the VM.

Key operational notes:

  • If you see container startup failures with permission denied errors that disappear with --privileged, check AppArmor before assuming a filesystem permission issue. AppArmor denials are silent to the container process (it sees EACCES or EPERM) but are logged in the guest's journalctl -k.

  • Custom profiles you load inside the Lima guest are ephemeral unless you add a provisioning step (Lima provision: scripts in lima-qemu-dockerd.yaml) to reload them on VM restart. /etc/apparmor.d/ contents persist on the disk image, but the kernel cache is rebuilt at boot — apparmor_parser must run again.

  • The Lima vz backend (macOS Virtualization.framework) and the qemu backend both run the same Ubuntu guest kernel, so AppArmor behavior is identical regardless of which vmType you choose.

  • Windows / WSL 2 parallel. The same model applies to Docker under WSL 2 on Windows: AppArmor lives inside the WSL 2 Linux distro (Ubuntu, or Docker Desktop's managed docker-desktop distro), never on the Windows host. Profiles are loaded with apparmor_parser inside the distro and — exactly like Lima — the kernel cache is rebuilt at boot, so a wsl --shutdown/restart drops any profile that a startup step does not re-apply. See docker-windows.md.


8. Threats and limitations of relying on AppArmor

AppArmor is defense-in-depth, not a hard security boundary. It raises the cost of an attack and shrinks what a compromised process can touch, but several failure modes silently erode or eliminate that protection. Know them before you count on it.

Threat / limitation Why it matters Mitigation
Path-based, not label-based Rules match pathnames, so hard links, bind mounts, symlinks, and /proc/<pid>/fd/ aliasing can route around a rule; moving or renaming a confined binary can drop its profile entirely. Deny rename/link vectors in the profile, keep the rootfs read-only (container-security.md); use SELinux (inode labels) where that bypass class is unacceptable.
Only what the profile names is constrained Uncovered paths default to allow. Coarse profiles like docker-default are deliberately loose at the file level and give a false sense of coverage. Write tight per-workload profiles (§4); treat docker-default as a floor, not a ceiling; pair with seccomp (§6).
Mode is silently defeatable complain logs but allows; unconfined, --privileged, or a profile that never loaded means zero enforcement — with no error surfaced to the workload. Verify aa-status and docker inspect --format '{{.AppArmorProfile}}' in CI; forbid apparmor=unconfined and --privileged in review.
Ephemeral profiles drift to unconfined The kernel cache is rebuilt at boot; after a reboot / wsl --shutdown a custom profile that isn't re-applied leaves the workload unconfined. Load profiles from a provisioning step (cloud-init, DaemonSet, Lima provision:); alert on unexpected unconfined.
Not a kernel-exploit boundary AppArmor is an in-kernel LSM; a kernel bug — or a syscall the profile cannot express — bypasses it. It cannot contain a kernel-level compromise. Layer with seccomp, user namespaces, and a VM/microVM boundary for untrusted code; keep the guest kernel patched.
Availability risk → the "unconfined" escape hatch An over-tight profile breaks the app with silent EACCES/EPERM; the tempting fix is to disable confinement globally, losing all protection. Diagnose the specific denial in journalctl -k and allow it narrowly; never blanket-unconfined (§2, §5).
Privileged actors can unload it Replacing or unloading a profile needs CAP_MAC_ADMIN in the initial namespace; root on the host/guest can aa-disable or swap a profile. Protect the host/guest boundary; restrict who can run apparmor_parser/aa-*; audit profile changes.
Distro / portability gap AppArmor is Ubuntu/Debian/SUSE only; RHEL, Amazon Linux, and Azure Linux use SELinux — the same profile-based control simply is not present. Do not assume AppArmor exists across a multi-cloud fleet; map controls per distro and bake them into the image.

Design rule: Treat AppArmor as one layer in a stack (seccomp + dropped caps + read-only rootfs + user namespaces + VM isolation), never as the sole control. The most common real-world failure is not a clever bypass — it is a profile weakened to unconfined to make an app work, or one that silently stopped loading. Verify enforcement; do not assume it.


See also