click the screen · press Enter
← back to blog
Kubernetes Journey · Topic 26

Seccomp Can't Say Which Files a Container Touches. AppArmor Can.

AppArmor and Linux capabilities confining a container beyond seccomp - Kubernetes Journey

Last topic I got quite happy about seccomp. One line, a curated syscall filter, a smaller attack surface. But there's a gap in it I glossed over, and it's the reason this post exists. Seccomp can block the mkdir syscall so a container can't create directories at all. What it can't say is "you may write to /opt/app, but nowhere else". Seccomp works at the level of "which syscalls", not "which files".

So a container that legitimately needs to write somewhere is, as far as seccomp cares, allowed to write everywhere that syscall reaches. Closing that gap needs a different kind of control. Two of them, actually: AppArmor, which decides which files, directories and resources a process may touch, and Linux capabilities, which slice the root user's power into small pieces you can hand out one at a time. This is the rest of the container-hardening picture.

Seccomp isn't the whole lock: it filters syscalls but can't say which files a process touches or scope root; AppArmor restricts paths and capabilities drop root into a small set, so a container runs as root but keeps only about 14 Linux capabilities
Three locks, not one: which syscalls (seccomp), which files (AppArmor), which root powers (capabilities).

Three locks, each on a different door

It helped me to stop thinking of these as competing tools and start seeing them as three locks on three different doors. Each answers a question the others can't.

Seccomp

Which syscalls? Filters the calls a process can make into the kernel. Block mount, ptrace, keyctl

AppArmor

Which resources? Confines which files, directories, network and capabilities a process may touch. Path-aware.

Capabilities

Which root powers? Splits root into ~40 units. Drop them all, add back only what the app needs.

Defence in depth: one control's blind spot is another's whole job.

Seccomp we covered. AppArmor and capabilities are the other two, and they're what turn "reasonably locked down" into "genuinely hard to do anything unexpected from inside this container".

AppArmor: which files can this thing touch?

AppArmor is a Linux Security Module that confines a program to a declared set of resources: specific file paths (and whether it can read, write or execute them), network access, and Linux capabilities. It's path-based and it's shipped enabled by default on Ubuntu and most Debian-based distros, which is handy because a lot of Kubernetes nodes run exactly that. (Red Hat's world uses SELinux for the same job, a different design, same goal.)

First thing on any node: check it's actually on. Three quick checks, because a profile that was never going to load is a miserable thing to debug.

systemctl status apparmor            # is the service active?
cat /sys/module/apparmor/parameters/enabled   # kernel module: expect "Y"
cat /sys/kernel/security/apparmor/profiles    # what's already loaded

That last file lists the profiles the kernel already knows about, and you'll almost always see docker-default in there, the profile your container runtime applies to every container unless you say otherwise. It's the AppArmor equivalent of seccomp's default profile: sensible, broad, and worth tightening for anything that matters.

The three modes (this is the clever bit)

An AppArmor profile runs in one of three modes, and the middle one is genuinely useful rather than just a setting:

AppArmor profile modes
01
Enforce

Rules are applied. Anything not allowed is blocked. Production.

02
Complain

Nothing is blocked, but every violation is logged. This is how you learn what the app needs.

03
Unconfined

No restrictions, no logging. The default for anything without a profile.

Complain mode is the trick: run the app, watch what it actually does, then enforce exactly that.

What a profile looks like

A profile is just a text file. The simplest useful one denies all writes across the whole filesystem, everything else is allowed, but nothing gets written:

profile apparmor-deny-write flags=(attach_disconnected) {
    file,
    # Deny all file writes.
    deny /** w,
}

file, permits filesystem access in general; deny /** w, then carves out write access on every path. You build real profiles by inverting this: deny by default, allow the exact paths the app needs. Sound familiar? It's the same default-deny habit as the firewall and the seccomp whitelist, just applied to files.

Let AppArmor write the profile for you

Hand-writing profiles is fiddly and error-prone, so I don't. AppArmor ships a tool, aa-genprof, that watches an app run and builds the profile from what it actually does, which is exactly the complain-mode idea made practical. Say I've got a script add_data.sh that makes a directory under /opt/app/data and writes a log there. The workflow:

sudo apt-get install -y apparmor-utils    # the aa-* tools
sudo aa-genprof /root/add_data.sh         # start profiling (sets complain mode)

It drops the script into complain mode and waits. In another terminal you run the app and put it through its normal paces, then come back and press S to scan the logs. Now it walks you through every access it saw, one prompt at a time:

aa-genprof /root/add_data.sh
Profile: /root/add_data.sh Execute: /usr/bin/mkdir (I)nherit / (C)hild / (P)rofile / (N)amed / (U)nconfined / (D)eny… → i (let it run mkdir, under the same profile) Path: /proc/filesystems New Mode: owner r → d (the script doesn't need this, so deny it)
For each access: allow what the app needs (i/a), deny what it doesn't (d). Least privilege, one decision at a time.

Press S to save and F to finish, and it flips the profile to enforce mode. The generated profile lands in /etc/apparmor.d/ and reads like a plain list of what's allowed: /usr/bin/mkdir mrix,, owner /opt/app/data/ w,, and a deny owner /proc/filesystems r, for the thing you refused. Check the state any time with aa-status:

sudo aa-status
# apparmor module is loaded.
# 13 profiles are loaded.
# 13 profiles are in enforce mode.
#     /root/add_data.sh
#     docker-default
#     ...
# 0 profiles are in complain mode.

The test that it worked: point the script at /opt instead of /opt/app and run it. The profile only allowed writes under /opt/app, so:

tee: /opt/create.log: Permission denied

The process can still print to the terminal, but writing outside its allowed path is refused. That's the control seccomp couldn't give you: not "can it write?" but "where can it write?".

AppArmor on a Kubernetes pod

To use a profile in a cluster, three things have to be true on every node the pod might land on: the AppArmor kernel module is enabled, the profile is already loaded there (AppArmor doesn't ship profiles into the cluster for you), and the container runtime supports it (containerd, CRI-O and Docker all do). That "on every node" catch trips people up, if the profile is only on node A and the pod schedules onto node B, it won't start.

Assuming the apparmor-deny-write profile is loaded on your nodes, here's an Ubuntu sleeper pod pinned to it. There are two ways to attach it, and it's worth knowing both because you'll meet the old one in existing manifests:

ubuntu-sleeper.yaml
spec: securityContext: appArmorProfile: type: Localhost localhostProfile: apparmor-deny-write containers: - name: ubuntu-sleeper image: ubuntu
ubuntu-sleeper.yaml (deprecated)
metadata: annotations: container.apparmor.security.beta.kubernetes.io/ubuntu-sleeper: localhost/apparmor-deny-write # beta annotation, superseded by the field above
Prefer the appArmorProfile field (stable in recent Kubernetes). The beta annotation still turns up in older YAML, so recognise it.

Create it, then prove the profile is doing its job. The container's own command (sleep) works fine, but try to write a file and AppArmor stops you:

kubectl create -f ubuntu-sleeper.yaml
kubectl logs ubuntu-sleeper
# Sleeping for an hour!

kubectl exec -ti ubuntu-sleeper -- touch /tmp/test
# touch: cannot touch '/tmp/test': Permission denied
# command terminated with exit code 1
That "Permission denied" is the win The pod runs, does its job, and cannot write to disk. If an attacker lands in that container, they can't drop a tool, write a cron job, or tamper with a file, because the kernel refuses the write before it happens.

Capabilities: slicing up root

Now the third lock, and it answers a question that surprised me the first time I hit it: why does a container running as root still fail to change the system clock? Back in the seccomp post, even with seccomp unconfined, this failed:

# date -s '19 APR 2012 22:00:00'
date: cannot set date: Operation not permitted

The answer is Linux capabilities. Before kernel 2.2, a process was either root (bypasses nearly every check) or not. Since 2.2, that all-or-nothing root power is split into about 40 separate units, each guarding one privileged operation. A few you'll meet:

A few Linux capabilities
CapabilityWhat it grants
CAP_NET_BIND_SERVICEBind to a port below 1024
CAP_NET_RAWUse raw sockets (this is why ping works)
CAP_CHOWNChange file ownership
CAP_SYS_TIMESet the system clock
CAP_SYS_ADMINA huge grab-bag of admin ops, nearly root
Root isn't one thing any more, it's a bag of ~40 powers you can hand out individually.

So the clock puzzle solves itself: setting the time needs CAP_SYS_TIME, and a container doesn't get it. Your runtime starts every container with a deliberately small default set, around 14 capabilities (things like CAP_CHOWN, CAP_NET_RAW, CAP_KILL, CAP_SETUID), and nothing else. Root inside the container is a shadow of root on the host.

You can inspect what a binary or process holds. getcap reads a file's capabilities, getpcaps reads a running process's:

getcap /usr/bin/ping
# /usr/bin/ping = cap_net_raw+ep      <- ping only needs raw sockets, not full root

getpcaps 779                          # by PID (e.g. sshd)
Try itCapability explorer: keep it, or drop it?
NET_BIND_SERVICE NET_RAW SYS_TIME SYS_ADMIN DAC_OVERRIDE
Tells you what a capability grants, whether it's in the default container set, and whether you should keep it. Runs in your browser.

Adding and dropping capabilities in Kubernetes

In a pod you tune this under the container's securityContext.capabilities. The least-privilege pattern is drop everything, then add back only what the app needs:

containers:
  - name: web
    image: nginx
    securityContext:
      capabilities:
        drop: ["ALL"]                 # start from zero
        add:  ["NET_BIND_SERVICE"]    # only what's needed (bind port 80)
The gotcha that gets everyone In a Kubernetes manifest you write capability names without the CAP_ prefix, so NET_BIND_SERVICE and SYS_TIME, not CAP_NET_BIND_SERVICE. Use the full CAP_ name and it silently doesn't do what you meant. I lost a good ten minutes to this.

Adding SYS_TIME would let that clock command finally work, which is a neat demo and almost never something a real workload should be allowed to do. The far more common and useful move is the drop: an nginx that only serves pages has no business holding CAP_CHOWN or CAP_SETUID, so take them away. If a compromise lands in a container that dropped ALL, the attacker inherits none of root's privileged operations, even as UID 0.

How the three fit together

Put the trio side by side and the defence-in-depth clicks. Seccomp decides which syscalls the process can make at all. AppArmor decides which files and resources those syscalls may touch. Capabilities decide which privileged operations root is even allowed to attempt. An attacker who gets code execution in a container hardened with all three finds a very small room: a short list of syscalls, a handful of writable paths, and a root account stripped of its teeth.

None of them is hard on its own. What's hard, honestly, is the discipline to apply them per workload instead of shipping everything wide open because it "just works". That's the same tension I keep hitting in this series, the secure default takes five more minutes now and saves you the incident later.

The short version Seccomp = which syscalls → AppArmor = which files/resources (deny /** w, allow the exact paths) → capabilities = which root powers (drop ALL, add what's needed) → a container runs as root but keeps only ~14 of ~40 capabilities → use all three, per workload.

Where this leaves things

So the container-hardening picture is finally whole: seccomp for syscalls, AppArmor (or SELinux) for resources, capabilities for root's powers. AppArmor's aa-genprof learns a profile from a running app so you're not guessing; Kubernetes attaches it with securityContext.appArmorProfile; and capabilities get the drop: ["ALL"] then add treatment, minding the missing CAP_ prefix.

The honest bit: AppArmor profiles are the part I trust myself with least at scale. aa-genprof is lovely for one script on one box, but keeping per-workload profiles loaded across a fleet of nodes, and updated when the app changes, is real operational work I haven't fully solved. My current line is: RuntimeDefault seccomp and drop: ["ALL"] capabilities on everything (cheap, huge return), and bespoke AppArmor only where a workload truly earns it. If you're running custom AppArmor at scale with a sane way to manage the profiles, that's the bit I'd most like to be told how to do.

FAQ

What is the difference between seccomp and AppArmor?

Seccomp filters which system calls a process may make. AppArmor controls which resources it may touch: specific files and directories, network access and Linux capabilities. Seccomp can block the mkdir syscall entirely; AppArmor can say a process may write to /opt/app but nowhere else. They solve different problems and are best used together.

What are the AppArmor profile modes?

Three. Enforce mode applies the profile's rules and blocks anything not allowed. Complain mode allows everything but logs violations, which is how you learn what an app really needs. Unconfined means no restrictions and no logging. You typically profile in complain mode, then switch to enforce.

How do I apply an AppArmor profile to a Kubernetes pod?

On modern Kubernetes, set securityContext.appArmorProfile with type: Localhost and localhostProfile: <profile-name> at the pod or container level. The profile must already be loaded on every node the pod might run on. The old container.apparmor.security.beta.kubernetes.io annotation is deprecated in favour of this field.

What are Linux capabilities?

Since kernel 2.2, the all-powerful root user is split into around 40 individual units called capabilities, such as CAP_NET_RAW or CAP_SYS_TIME. A process can be granted only the specific powers it needs. This is why a container running as root still can't change the system clock: it lacks CAP_SYS_TIME.

How do I drop Linux capabilities in a Kubernetes container?

Use securityContext.capabilities in the container spec: drop: ["ALL"] to remove every capability, then add back only the ones the app genuinely needs, for example add: ["NET_BIND_SERVICE"]. Note that Kubernetes uses the short names without the CAP_ prefix. Drop-all-then-add is the least-privilege pattern.