Module 13 — Isolation: Capabilities & seccomp

Updated 22 August 2026

Module 13 · Isolation: capabilities, seccomp and hardening

Module 12 built a container that could see almost nothing — and was still running as root with every privilege the kernel offers. This module is the other half: how root was broken into 41 separate privileges, how seccomp removes system calls outright, and why one flag undoes all of it.

🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start, you should already know:

From Module 02fork, exec, and that exec replaces the program inside an existing process.

From Module 03 — file permissions, owners, and what a mount option is.

From Module 04 — signals, and that a signal can kill a process.

From Module 12 — namespaces, cgroups, and that "a container" is just a process with some of them applied.

Tools used here: libcap2-bin (capsh, getcap, setcap, getpcaps), util-linux (setpriv), and attr (getfattr). Install with sudo apt-get install -y libcap2-bin attr on Debian/Ubuntu, or sudo dnf install -y libcap libcap-ng-utils attr on RHEL-family.


🔑 Part A · Capabilities

A1 · What "root" actually is

For most of UNIX history, privilege was one bit. If your effective UID was 0, the kernel skipped its permission checks — all of them. If it was anything else, it did not. There was nothing in between.

That created a problem that has nothing to do with security policy and everything to do with plumbing. ping needs to open a raw network socket, which ordinary users may not do. The only way to give ping that one ability was to make it setuid root — and a setuid-root binary does not get "the ability to open raw sockets", it gets everything. One bug in ping is then a bug that can rewrite /etc/shadow.

Linux fixed this by splitting root into separate, independently grantable privileges called capabilities. There are 41 of them today, numbered 0 to 40. ping needs exactly one of them.

The counter-intuitive part, and the point of the whole section. "Root" is no longer a thing the kernel checks. When a process asks to change a file's owner, the kernel does not ask "is your UID 0?" — it asks "do you hold CAP_CHOWN?" UID 0 normally comes with all 41 capabilities, which is why the two look identical from the outside. Take one capability away and a UID-0 process fails at that one operation while remaining root for everything else. You will do exactly this in Exercise A1.2.

The count is not a number to memorise — it is a file you read:

CapabilityWhat it lets a process doWhy you meet it
CAP_CHOWNChange any file's ownerIn Docker's default set; needed by package installs
CAP_DAC_OVERRIDEIgnore file read/write/execute permission bitsThe one that makes root "able to read anything"
CAP_NET_BIND_SERVICEBind a port below 1024The reason web servers used to start as root
CAP_NET_RAWRaw and packet socketsping, tcpdump — and ARP/DNS spoofing from a compromised container
CAP_NET_ADMINInterfaces, routes, firewall rulesAnything that configures networking; CNI plugins
CAP_SYS_ADMINMount, pivot_root, and a long tail of unrelated thingsThe junk drawer — see the warning below
CAP_SYS_PTRACEAttach a debugger to another processstrace, gdb, and reading another process's memory
CAP_SYS_TIMESet the system clockNTP daemons
CAP_SYS_MODULELoad and unload kernel modulesEffectively equal to full root — a module runs in the kernel
CAP_MKNODCreate device nodesIn Docker's default set; combined with a host mount it is dangerous
CAP_SETUID / CAP_SETGIDChange UID/GID arbitrarilyHow a service drops to an unprivileged user after start
CAP_SETFCAP / CAP_SETPCAPSet capabilities on files / change your own bounding and inheritable setsSETFCAP writes file capabilities; SETPCAP is what lets a process shrink its own bounding set — it is how capsh --drop and container runtimes work
CAP_BPF, CAP_PERFMONLoad BPF programs; use perfAdded in 5.8 so observability tools stop needing CAP_SYS_ADMIN
CAP_CHECKPOINT_RESTORECheckpoint and restore a processAdded in 5.9; currently the highest-numbered capability, number 40
CAP_SYS_ADMIN is not a capability, it is a junk drawer. capabilities(7) carries an unusual note addressed to kernel developers: "Don't choose CAP_SYS_ADMIN if you can possibly avoid it!" — because, in its words, "a vast proportion of existing capability checks are associated with this capability."

How vast: Michael Kerrisk counted it in CAP_SYS_ADMIN: the new root (LWN, March 2012). In the Linux 3.2 source, 451 of 1167 capability checks were CAP_SYS_ADMIN — roughly 38%. A comment on that article puts it just over 45% by Linux 5.2. Treat those as historical measurements of a trend, not a current figure: nobody re-counts them each release.

What this means for you. --cap-add=SYS_ADMIN on a container is not "one more capability". It is mount, pivot_root, setns, quota control and a hundred unrelated operations, several of which are documented escape routes out of a container. When a vendor's install guide asks for it, that is the moment to ask which single operation they actually need.

Real-world analogy — the master key and the key ring

An old office building has one master key. It opens the front door, every office, the server room, the safe and the boiler room. There are exactly two kinds of person: someone with the master key, and someone without.

Now the boiler needs servicing. The contractor needs the boiler room — and the only key that exists is the master. So you hand over the master key for the afternoon and hope. That is setuid root.

Capabilities are the day the building manager cut the master key into 41 separate keys on a ring. The contractor gets the boiler-room key and nothing else. The window cleaner gets the roof key. Nobody carries the safe key unless they are opening the safe.

And the detail people miss: the manager's own ring is just all 41 keys. There is no longer a "manager key" that works by magic. Take the safe key off the manager's ring and the manager cannot open the safe — while still opening every other door in the building. That is UID 0 without CAP_CHOWN.

One key on that ring is labelled "misc", and over the years it quietly became the key to about a third of the doors, because reusing it was easier than cutting a new one. That is CAP_SYS_ADMIN.

Where the analogy stops working. A key works from the moment you hold it until you hand it back. A capability is checked per operation, at the moment of the operation, and is normally discarded when the process runs a different program — closer to a key that dissolves as you walk through the door.

🧪 Exercise A1.1 — Take an inventory of privilege on your own machine
bash
# 1. How many capabilities does THIS kernel know about?
#    The file holds the highest number, so the count is that + 1.
cat /proc/sys/kernel/cap_last_cap

# 2. Name every one of them
capsh --print | sed -n 's/^Bounding set =//p' | tr ',' '\n' | head -5
capsh --print | sed -n 's/^Bounding set =//p' | tr ',' '\n' | wc -l

# 3. What does your ordinary shell hold?
grep -E '^Cap(Inh|Prm|Eff|Bnd|Amb)|^NoNewPrivs|^Seccomp:' /proc/$$/status

# 4. And a root process?
sudo grep -E '^Cap(Prm|Eff|Bnd)' /proc/self/status

# 5. Hex masks are unreadable. Turn one into names.
capsh --decode=0000000000003000
capsh --decode=000001ffffffffff | tr ',' '\n' | wc -l

# 6. Which programs on this machine carry capabilities in the filesystem?
sudo getcap -r /usr/bin /usr/sbin /bin 2>/dev/null
# ...and the old way of doing the same job:
find /usr/bin /usr/sbin -perm -4000 -type f 2>/dev/null | head -6
Expected result — click to reveal
plain text
40
cap_chown
cap_dac_override
cap_dac_read_search
cap_fowner
cap_fsetid
41

CapInh:	0000000000000000
CapPrm:	0000000000000000
CapEff:	0000000000000000
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000
NoNewPrivs:	0
Seccomp:	0

CapPrm:	000001ffffffffff
CapEff:	000001ffffffffff
CapBnd:	000001ffffffffff

0x0000000000003000=cap_net_admin,cap_net_raw
41

/usr/bin/ping cap_net_raw=ep

/usr/bin/fusermount3
/usr/bin/sudo
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/mount

What to read out of this.

cap_last_cap is 40, and the name list has 41 entries, because they are numbered from zero. Read that file rather than memorising a number — it is smaller on an older kernel, and it is the only honest answer to "how many capabilities are there?"

Your ordinary shell holds nothing. CapPrm and CapEff are all zeros. Everything you can do, you can do because of file ownership and permission bits, not privilege.

CapBnd is the exception, and it is the field people misread. It is 000001ffffffffff — all 41 bits — in an unprivileged shell. The bounding set is not a grant. It is a ceiling: the set of capabilities this process, or anything it ever execs, is allowed to acquire. Holding none while being permitted all is the normal state of every login shell on the machine. Line 5 confirms the arithmetic: 000001ffffffffff decodes to 41 names.

The root process holds all three fields full, which is exactly what "root" means now — a UID that comes with the complete key ring.

0x3000 is bits 12 and 13, which is cap_net_admin,cap_net_raw. Get comfortable with capsh --decode; every capability field you will ever see in /proc, in a Kubernetes audit log or in a runtime's debug output is a 16-digit hex mask.

The last two commands are the same question asked of two eras. getcap -r finds programs carrying a specific privilege in their file metadata — on Ubuntu that is usually just ping, holding cap_net_raw and nothing more. find -perm -4000 finds the old answer: programs that become fully root. sudo, mount, passwd and su are all still setuid, and each one is an entire root shell if it has a bug. That contrast is the whole argument for capabilities, in two commands.

If getcap -r prints nothing at all, this machine simply has no file capabilities set — common in containers and minimal images. Nothing is wrong.

Now imagine this at 500 hosts. sudo getcap -r / 2>/dev/null and find / -perm -4000 -type f are two of the cheapest fleet-wide audits you can run, and both belong in your configuration baseline. A binary that gained cap_setuid=ep or a new setuid bit since the last run is either a package update or an intrusion, and you want to know which within the hour. The output is short enough to diff.
🧪 Exercise A1.2 — Prove that root is only a bag of capabilities

This is the exercise to remember. Two commands run as UID 0 and fail, because one key was taken off the ring.

bash
# Two files to experiment on
touch /tmp/ownme
echo "a secret" | sudo tee /tmp/priv >/dev/null
sudo chown "$USER" /tmp/priv
sudo chmod 600 /tmp/priv          # 0600, owned by YOU, not by root

echo "--- root with the full key ring ---"
sudo chown "$USER" /tmp/ownme && ls -l /tmp/ownme

echo "--- root with CAP_CHOWN removed ---"
sudo capsh --drop=cap_chown -- -c '
  echo "my uid is: $(id -u)"
  chown root /tmp/ownme
  echo "chown exit: $?"
  ls -l /tmp/ownme'

echo "--- root without CAP_DAC_OVERRIDE, reading a 0600 file it does not own ---"
sudo capsh --drop=cap_dac_override,cap_dac_read_search -- -c '
  cat /tmp/priv
  echo "cat exit: $?"'

sudo rm -f /tmp/ownme /tmp/priv
Expected result — click to reveal (two deliberate failures)
plain text
--- root with the full key ring ---
-rw-rw-r-- 1 zaeem zaeem 0 Aug 22 10:34 /tmp/ownme
--- root with CAP_CHOWN removed ---
my uid is: 0
chown: changing ownership of '/tmp/ownme': Operation not permitted
chown exit: 1
-rw-rw-r-- 1 zaeem zaeem 0 Aug 22 10:34 /tmp/ownme
--- root without CAP_DAC_OVERRIDE, reading a 0600 file it does not own ---
cat: /tmp/priv: Permission denied
cat exit: 1

What to read out of this.

Read the second block again: my uid is: 0 and Operation not permitted on the same screen. That combination is supposed to be impossible under the mental model most people carry, and it is the single most useful thing in this module. The process is root. id -u says so. It cannot change a file's owner, because CAP_CHOWN is not on its ring and UID 0 is not what the kernel checks.

The third block is the same lesson through a different door. Root "can read any file" only because root normally holds CAP_DAC_OVERRIDE (ignore the permission bits) and CAP_DAC_READ_SEARCH (read and traverse regardless). Remove both and root gets Permission denied from a 0600 file — exactly like any other user.

Note which error you get, because the two are diagnostic. Operation not permitted (EPERM) is a capability failure: the kernel checked a privilege and you did not have it. Permission denied (EACCES) is usually an ordinary permission-bit failure. When a container "cannot do something as root", EPERM points at a missing capability or seccomp; EACCES points at file modes, ownership or an LSM. You will use that split again in Part D.

capsh --drop removes from the bounding set, and that is a one-way door. There is no --add. A capability removed from the bounding set cannot be regained by that process or by anything it ever execs — which is precisely why container runtimes use it, and why the shrinking is safe to do early in a startup script.

A2 · The five sets, and why privilege vanishes on exec

You saw five Cap* lines in /proc/PID/status. They are not five copies of the same thing — each answers a different question, and the differences are where every real-world capability bug lives.

Set/proc fieldThe question it answers
PermittedCapPrmWhat may this process switch on? The ceiling for Effective.
EffectiveCapEffWhat is switched on right now? This is the set the kernel actually checks.
BoundingCapBndWhat may this process ever acquire, including across exec? Can only shrink.
InheritableCapInhWhat may be kept across exec — but only if the new file also lists it. Legacy; rarely what you want.
AmbientCapAmbWhat is kept across exec of an ordinary file. Added in Linux 4.3, and the one that finally made this usable.

A file has three of its own, stored in an extended attribute: a permitted set, an inheritable set, and a single effective bit. That is what cap_net_raw=ep means — e is the effective bit, p is the permitted set.

The counter-intuitive rule that catches everyone: capabilities do not survive exec by default. Give a shell script's process CAP_NET_BIND_SERVICE and then have it run python3 — the Python process has nothing. This is deliberate. If privilege flowed freely across exec, every privileged program would hand full power to anything it launched, including /bin/sh. So the kernel throws it away and demands you say explicitly how it should come back: either on the file (a file capability) or on the process (the ambient set).

This is also why "the container runs as root but the command still says Operation not permitted" is such a common ticket. Root got you a full CapPrm at PID 1; whatever the entrypoint execed may hold much less.

Interview-grade detail. The exact transformation on exec is in capabilities(7), and being able to sketch it separates a strong candidate from someone who has only used --cap-add:

P'(permitted) = (P(inheritable) & F(inheritable)) | (F(permitted) & P(bounding)) | P'(ambient)

P'(effective) = F(effective) ? P'(permitted) : P'(ambient)

P'(ambient) = (file caps present, or setuid/setgid) ? 0 : P(ambient)

Three things fall straight out of those lines and are worth saying aloud. Inheritable alone does nothing — it is ANDed with the file's inheritable set, so both sides must agree. The bounding set gates the file's permitted set, which is why dropping a bounding capability defeats even a file that carries it. And ambient is wiped the moment you exec anything that has file capabilities or a setuid bit, so ambient can never be used to smuggle privilege into a setuid program.

Real-world analogy — the tool crib

A factory has a tool crib: a counter where you sign out tools for a job.

Permitted is what is signed out to you and sitting in your locker. Effective is the tool actually in your hand at this moment — the safety inspector only ever looks at your hands. Signing a tool out is not the same as using it, and good workers keep dangerous tools in the locker until the moment they need them.

Bounding is the crib's own card for you: the list of tools they are permitted to issue at all. The supervisor can cross items off that card, but can never write one back on. Once "angle grinder" is struck through, no future job can put it in your hands, no matter what the work order says.

Inheritable is the fussy one. It is a note saying "you may carry this tool to your next job — if that job's work order also lists it." Two documents must agree, which is why it so often does nothing.

Ambient is a tool belt you are wearing. When you walk off one job and onto the next, the belt comes with you. That is the whole difference, and it is why it had to be invented: everything else was designed to strip you at the door.

The rule that catches people out: when you walk onto a job that comes with its own issued kit — a setuid binary, or a file with its own capabilities — you are made to leave the belt at the door. The job's kit is the only kit.

Where the analogy stops working. A tool is one physical object; two people cannot hold it at once. Capabilities are copied freely, and a process holding CAP_NET_RAW takes nothing away from anyone else.

🧪 Exercise A2.1 — Put a capability on a file and watch an ordinary user bind port 80
bash
cd "$HOME"

# Where does your home live, and is that mount nosuid? (This matters - see below.)
findmnt -no FSTYPE,OPTIONS -T "$HOME" | head -1

# A private copy of a real binary. A copy carries NO file capabilities.
cp /usr/bin/python3 ~/capdemo
getcap ~/capdemo; echo "getcap printed nothing above, and exited $?"

# Try to grant a capability as an ordinary user
setcap cap_net_bind_service=ep ~/capdemo
echo "unprivileged setcap exit: $?"

# Now as root
sudo setcap cap_net_bind_service=ep ~/capdemo
getcap ~/capdemo

# Where does that live? In an extended attribute on the file itself.
sudo getfattr -n security.capability -d   ~/capdemo
sudo getfattr -n security.capability -e hex ~/capdemo

# The payoff: bind a privileged port as an ordinary user
BIND='import socket; s=socket.socket(); s.bind(("",80)); print("bound port 80")'
/usr/bin/python3 -c "$BIND"      # the packaged binary: no capability
~/capdemo       -c "$BIND"       # your copy: exactly one capability
Expected result — click to reveal (two deliberate failures)
plain text
ext4   rw,relatime
getcap printed nothing above, and exited 0
unable to set CAP_SETFCAP effective capability: Operation not permitted
unprivileged setcap exit: 1
/home/zaeem/capdemo cap_net_bind_service=ep

getfattr: Removing leading '/' from absolute path names
# file: home/zaeem/capdemo
security.capability=0sAQAAAgAEAAAAAAAAAAAAAAAAAAA=

getfattr: Removing leading '/' from absolute path names
# file: home/zaeem/capdemo
security.capability=0x0100000200040000000000000000000000000000

Traceback (most recent call last):
  File "<string>", line 1, in <module>
PermissionError: [Errno 13] Permission denied
bound port 80

What to read out of this.

getcap printing nothing and exiting 0 is the normal "no capabilities" answer. It is not an error, and scripts that test $? here will conclude the wrong thing. Test for empty output instead.

The first failure is the important one. Setting a file capability requires CAP_SETFCAP, so an ordinary user cannot do it — and the error says so by name: unable to set CAP_SETFCAP effective capability. If a user could grant capabilities to files, capabilities would not be a security mechanism at all.

Then the payoff, which is the point of the whole feature. The same Python interpreter, run by the same unprivileged user, on the same machine: the packaged one gets Permission denied on port 80, and your copy binds it. No sudo, no setuid, no root process anywhere. One capability, on one file. That is what web servers should have been doing for twenty years instead of starting as root and dropping privileges afterwards.

Read the hex, because it demystifies the whole thing. 0x01000002... is a small versioned structure, stored little-endian. The first four bytes 01 00 00 02 are 0x02000001 — revision 2, with the effective bit set (the e in =ep). The next four, 00 04 00 00, are 0x00000400 — bit 10 — and capability number 10 is CAP_NET_BIND_SERVICE. The whole "file capability" is twenty bytes of metadata; there is no magic in it.

Three places file capabilities silently do nothing. Each produces "I set it, getcap shows it, and it still does not work."

1. On a nosuid mount. nosuid disables setuid bits and file capabilities — the kernel ignores both. Several distributions mount /tmp as tmpfs with nosuid, which is why the exercise above uses your home directory. Check with findmnt -no OPTIONS -T <path>.

2. On NFS, FAT, exFAT and anything without the security.* extended-attribute namespace. The capability has nowhere to be stored, so setcap fails outright with Operation not supported. Red Hat documents NFS not carrying capabilities as by design, for both NFSv3 and NFSv4. This is a real deployment trap: a binary that works from a local disk stops working from a shared mount.

3. On shell and Python scripts. A script is not what gets execed — the interpreter is. Putting cap_net_raw=ep on myscript.sh achieves nothing, and putting it on /bin/bash would hand that capability to every script on the machine. There is no safe way to give a script a capability; wrap it in a real binary, or use the ambient set from a privileged launcher, as in the next exercise.

🧪 Exercise A2.2 — Watch privilege vanish across exec, then make it survive

Continues from A2.1 — ~/capdemo should still carry cap_net_bind_service=ep.

bash
# 1. What does the capability-carrying process hold ITSELF?
~/capdemo -c 'print(open("/proc/self/status").read())' | grep -E '^Cap'

# 2. What does a program that IT launches hold?
~/capdemo -c 'import subprocess; subprocess.run(
  ["grep","-E","^Cap(Prm|Eff)","/proc/self/status"])'

# 3. The ambient set: privilege that survives exec, with NO file capability anywhere
sudo setpriv --reuid="$USER" --regid="$USER" --clear-groups \
     --inh-caps=+net_bind_service --ambient-caps=+net_bind_service \
     /usr/bin/python3 -c '
import socket, subprocess
subprocess.run(["grep","-E","^Cap(Inh|Prm|Eff|Amb)","/proc/self/status"])
s = socket.socket(); s.bind(("",80)); print("bound port 80 with NO file capability")'

# 4. The same command with the ambient set left empty
sudo setpriv --reuid="$USER" --regid="$USER" --clear-groups \
     /usr/bin/python3 -c 'import socket; s=socket.socket(); s.bind(("",80)); print("bound")'

# Clean up
sudo setcap -r ~/capdemo; rm -f ~/capdemo
Expected result — click to reveal (one deliberate failure)
plain text
CapInh:	0000000000000000
CapPrm:	0000000000000400
CapEff:	0000000000000400
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000

CapPrm:	0000000000000000
CapEff:	0000000000000000

CapInh:	0000000000000400
CapPrm:	0000000000000400
CapEff:	0000000000000400
CapAmb:	0000000000000400
bound port 80 with NO file capability

Traceback (most recent call last):
  File "<string>", line 1, in <module>
PermissionError: [Errno 13] Permission denied

What to read out of this.

Block 1 versus block 2 is the entire lesson. The process that ran ~/capdemo holds 0000000000000400 — bit 10, CAP_NET_BIND_SERVICE — in both Permitted and Effective. The grep it launches, one exec later, holds zero. Nothing was revoked and nothing went wrong; this is the default. Privilege attached to a file stays with that file's process and goes no further.

Notice CapInh and CapAmb are both empty in block 1, even though the process is privileged. Being privileged and passing on privilege are separate things.

Block 3 is the ambient set doing its job. setpriv dropped to your unprivileged UID, put CAP_NET_BIND_SERVICE into the inheritable set, raised it into the ambient set, and execed the stock, unmodified /usr/bin/python3. All four fields show 400, and port 80 binds. No setcap, no setuid bit, no root process left running. This is how a modern service manager gives a daemon one privilege — it is exactly what AmbientCapabilities= in a systemd unit does.

CapInh had to be set too: ambient capabilities may only contain capabilities that are in both Permitted and Inheritable. Raising ambient without inheritable silently gets you nothing, and that is the usual reason AmbientCapabilities= "does not work".

Block 4 is the control. Identical command, ambient set left empty, and the bind fails. The difference between the two is four words on a command line and nothing else.

A3 · Capabilities in containers

A container that "runs as root" is not running as host root. The runtime keeps UID 0 but hands over a small subset of the key ring. Docker's default is 14 of the 41:

Kept by default (14)What it is for
CHOWN, FOWNER, FSETID, DAC_OVERRIDEFile ownership and permission work — package installs, chown in entrypoints
SETUID, SETGID, SETPCAPDropping to a service user after start
NET_BIND_SERVICEListening on port 80 inside the container
NET_RAWping — and the first one you should drop, see below
SYS_CHROOTchroot, used by some entrypoints
MKNODCreating device nodes
KILLSignalling processes it does not own
AUDIT_WRITEWriting audit records; needed by login-style tooling
SETFCAPSetting file capabilities inside the image

Everything else — SYS_ADMIN, SYS_MODULE, SYS_PTRACE, SYS_TIME, NET_ADMIN, SYS_BOOT — is already absent, which is why so many "run this as root" instructions fail inside a container even though id -u says 0.

CAP_NET_RAW is in the default set, and it is the one to drop first. It allows raw and packet sockets, which means a compromised container can forge ARP replies and DNS responses on whatever network it shares — attacking its neighbours without ever escaping itself. Almost no application needs it; ping is the usual excuse. --cap-drop=NET_RAW costs nothing and removes a whole class of lateral movement.

The pattern to write down, because it is what an interviewer wants to hear:

bash
# Docker: start from nothing, add back only what is proven necessary
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
           --security-opt no-new-privileges myimage
yaml
# Kubernetes: the same idea, in a securityContext
securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  allowPrivilegeEscalation: false     # this sets no_new_privs - Part B
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]
    add:  ["NET_BIND_SERVICE"]        # only if you truly bind a low port
Interview-grade detail. --cap-drop=ALL plus runAsNonRoot makes the add: list mostly pointless, and knowing why is a good signal. Capabilities are only consulted when a privileged operation is attempted; a process running as UID 1000 that never binds a low port never needs any. The better fix for port 80 is not NET_BIND_SERVICE at all — publish the container's port 8080 as the host's 80, or let the Service or ingress do the mapping. The capability is the answer when you cannot change the port; it is not the first answer.
Real-world analogy — the contractor's site pass

A building site issues site passes. The default contractor pass opens the site gate, the material store and the tool shed — enough to do ordinary work, and no more. It does not open the substation, the crane cab or the site office safe.

A contractor who arrives expecting "full access because I'm the boss on this job" finds the substation door closed. Nothing is broken and nobody revoked anything: the default pass never opened that door. That is docker run as root failing to mount.

Good site management goes one step further and issues a pass that opens nothing, then adds the one door this job needs. That is --cap-drop=ALL --cap-add=….

And the part that surprises people: a subcontractor's own site, next door, issues its own passes. On that site their pass opens everything — because it is their site. Bring the pass here and it opens nothing at all. That is a user namespace: full capabilities, valid only inside.

Where the analogy stops working. A pass is checked once, at the door. Capabilities are checked at every single privileged operation, which is why a process can hold a capability for an hour and never use it.

🧪 Exercise A3.1 — "Root" with all 41 capabilities that can do nothing

From Module 12: unshare -U -r puts you in a new user namespace, mapped to UID 0. Look at what it grants — and then at what that grant is worth.

bash
unshare -U -r bash -c '
  echo "uid inside: $(id -u)"
  grep -E "^Cap(Prm|Eff|Bnd)" /proc/self/status

  echo "--- try to read /etc/shadow:"
  cat /etc/shadow >/dev/null; echo "cat exit: $?"

  echo "--- try to change the hostname:"
  hostname newname; echo "hostname exit: $?"

  echo "--- try to mount a tmpfs:"
  mount -t tmpfs none /mnt; echo "mount exit: $?"
'
Expected result — click to reveal (three deliberate failures)
plain text
uid inside: 0
CapPrm:	000001ffffffffff
CapEff:	000001ffffffffff
CapBnd:	000001ffffffffff
--- try to read /etc/shadow:
cat: /etc/shadow: Permission denied
cat exit: 1
--- try to change the hostname:
hostname: you must be root to change the host name
hostname exit: 1
--- try to mount a tmpfs:
mount: /mnt: permission denied.
       dmesg(1) may have more information after failed mount system call.
mount exit: 32

What to read out of this.

All 41 capabilities, and three failures in a row. CapPrm, CapEff and CapBnd are 000001ffffffffff — the complete set — and id -u says 0. By the model most people carry, this process owns the machine. It cannot read /etc/shadow, cannot set the hostname, and cannot mount a filesystem.

The reason is that capabilities are scoped to a user namespace, not to the machine. Every one of those 41 is held in the namespace you just created, and that namespace owns nothing: no host files, no host UTS namespace, no host mounts. The kernel checks "do you hold CAP_SYS_ADMIN in the namespace that owns the thing you are touching", and the answer is no, every time.

/etc/shadow is the clearest case. It is owned by real UID 0. Your namespace maps your UID to 0, and real UID 0 is unmapped — so as far as this namespace is concerned the file belongs to a stranger, and CAP_DAC_OVERRIDE here does not apply to it.

The hostname failure names the mechanism out loud: you must be root to change the host name. CAP_SYS_ADMIN would allow it, but only in the UTS namespace you own — and you did not create one. Add --uts and it works instantly.

This is the foundation of rootless containers, and it is worth being able to explain in one sentence: rootless Podman gives a container a convincing UID 0 with a full capability set, and that root is powerless outside the namespaces it was given. Compare with Exercise A1.2, where a real host-root process held all 41 capabilities and could do real damage with any of them.

Now imagine this at 500 hosts. The audit worth automating is not "which containers are privileged" — everyone checks that. It is which containers hold SYS_ADMIN, NET_ADMIN or SYS_PTRACE without being privileged, because those pass a "privileged: false" policy check while carrying most of the risk. In Kubernetes that is a capabilities.add field on a workload; in Docker it is --cap-add. Report it per namespace, per team, with the requester's name attached, and the list shrinks on its own.

A4 · Interview questions — capabilities

Q. How do Linux capabilities contribute to container escapes?

Because a container is not a boundary — it is a set of restrictions, and capabilities are the ones that switch large parts of the kernel back on.

The three that matter most: CAP_SYS_ADMIN allows mount, so a container holding it can mount a host filesystem or abuse cgroup interfaces to run code on the host. CAP_SYS_MODULE allows loading a kernel module, which is not an escape route so much as being the kernel — there is nothing above it to escape to. CAP_SYS_PTRACE allows attaching to another process, which matters the moment the PID namespace is shared with the host.

Two more deserve naming. CAP_DAC_READ_SEARCH allows open_by_handle_at, the basis of the well-known Shocker escape, and it is not in Docker's default set for that reason. CAP_NET_RAW does not escape at all but allows spoofing on the shared network, which is often just as damaging.

The details that separate candidates: saying that the default 14 capabilities are chosen precisely so that none of them is an escape route on their own, so the interesting question is always what was added, not what a container has. And adding that capabilities are only the first of five independent restrictions — seccomp, the LSM and the device cgroup have to be discussed too, because --privileged removes all four at once and --cap-add=SYS_ADMIN removes only one.

Q. A container runs as root, and mount still fails with "Operation not permitted". Explain what is happening.

UID 0 is not what the kernel checks. mount requires CAP_SYS_ADMIN, and Docker's default set of 14 capabilities does not include it. The container's CapEff mask has that bit clear, so the check fails regardless of the UID.

How to prove it rather than assert it: grep Cap /proc/1/status inside the container and capsh --decode=<CapEff>. The absent name will be there in black and white.

The details that separate candidates: naming the error code. Operation not permitted is EPERM, which is what a capability check returns; Permission denied is EACCES, which is usually a permission-bit or LSM refusal. Being able to say "EPERM means look at capabilities and seccomp, EACCES means look at file modes, ownership and AppArmor" turns a guess into a triage path. And the right fix is almost never --cap-add=SYS_ADMIN — it is asking why the container is mounting anything, and mounting it from the host side instead.

Q. Your service must listen on port 443. Security will not let it run as root. What are your options, and which do you choose?

Four, roughly in the order you should consider them.

Do not bind a low port. Listen on 8443 and let the load balancer, the Kubernetes Service, or a docker -p 443:8443 publish handle the mapping. This is almost always the right answer and needs no privilege at all.

Lower the threshold. sysctl net.ipv4.ip_unprivileged_port_start=443 makes 443 an ordinary port. One line, machine-wide, and it removes the problem rather than working around it — but it applies to everything on that host, so it is a fleet decision, not an app decision.

Ambient capabilities from the service manager. In a systemd unit: AmbientCapabilities=CAP_NET_BIND_SERVICE with User=svc. The process starts unprivileged, holds exactly one capability, and no root process exists at any point.

A file capability. setcap cap_net_bind_service=ep /usr/bin/myapp. It works, but it is attached to a file that a package upgrade will silently replace, and it does nothing on nosuid mounts, NFS, or scripts.

The details that separate candidates: rejecting the fifth option out loud — "start as root and drop privileges after binding" — because that still requires a full-root process to exist, and every privilege-drop bug in UNIX history lives in that pattern. Also noting that AmbientCapabilities= requires the capability to be in the inheritable set as well, which systemd handles for you but setpriv does not.

Q. Name the capability sets and say what each is for.

A thread has five. Permitted is the ceiling of what it may switch on; Effective is what is switched on right now and is the set the kernel actually checks; Bounding is the ceiling across exec and can only ever shrink; Inheritable is a legacy mechanism that only works when the executed file's inheritable set agrees; Ambient (Linux 4.3) is what survives exec of an ordinary file, and is the one that made the feature usable.

A file has three: a permitted set, an inheritable set, and a single effective bit — which is why getcap prints things like cap_net_raw=ep.

The details that separate candidates: explaining why the default is to lose everything on exec — otherwise every privileged program would leak its privilege into anything it launched, starting with /bin/sh. And knowing the two traps: ambient is silently cleared when you exec a setuid binary or a file with its own capabilities, and ambient can only hold capabilities that are already in both Permitted and Inheritable.

Q. Why does capabilities(7) tell kernel developers to avoid CAP_SYS_ADMIN?

Because it stopped being a capability and became a catch-all. The man page's own words are "a vast proportion of existing capability checks are associated with this capability", followed by the instruction "Don't choose CAP_SYS_ADMIN if you can possibly avoid it!"

The measurement people quote comes from Michael Kerrisk's 2012 LWN article: in Linux 3.2, 451 of 1167 capability checks in the source were CAP_SYS_ADMIN — about 38%, later estimated at just over 45% by 5.2. Granting it is therefore not granting one privilege, it is granting an unbounded and growing set.

The details that separate candidates: citing the figure with its version and source rather than as a current fact — nobody recounts it each release, and quoting "38% of all capability checks" as though it were measured yesterday is a tell. Better still, pointing at the fix in progress: CAP_BPF and CAP_PERFMON were carved out of CAP_SYS_ADMIN in Linux 5.8 specifically so observability tooling would stop needing the junk drawer, and CAP_CHECKPOINT_RESTORE followed in 5.9.

Q. What is the practical difference between a setuid-root binary and a binary with file capabilities?

Blast radius. A setuid-root binary runs with all 41 capabilities and UID 0, so any bug in it is a full root compromise. A binary with cap_net_raw=ep runs as the calling user with one capability, so the same bug buys an attacker raw sockets and nothing else.

Both are stored on the file — the setuid bit in the mode, capabilities in the security.capability extended attribute — and both are ignored on a nosuid mount.

The details that separate candidates: knowing that capabilities are not a drop-in replacement, and why. They cannot be applied to scripts, because the kernel execs the interpreter. They are lost when a package manager replaces the file. They cannot be stored on NFS or FAT. And a program written for setuid usually calls setuid() to drop privilege, which requires CAP_SETUID — so converting it is real work, not a one-line change. That is why sudo, mount and passwd are still setuid on every distribution.


🚧 Part B · seccomp

B1 · no_new_privs, the switch everything else depends on

Part A ended with a hole. Capabilities restrict what a process may do — but the machine is covered in setuid-root binaries, and any restricted process that can run /usr/bin/sudo or /bin/mount can gain privilege back by execing one.

no_new_privs closes that hole with a single per-process bit. Once it is set, exec can never grant this process more privilege than it already has. Setuid bits are ignored, file capabilities are ignored, and LSM transitions that would raise privilege are refused. It arrived in Linux 3.5.

Three properties make it useful rather than merely nice:

PropertyWhy it matters
Inherited across fork, clone and execSet it once at the top of a process tree and every descendant has it
Cannot be unsetThere is no "off". A compromised process cannot undo it
Required for unprivileged seccompWithout CAP_SYS_ADMIN, you must set it before you may install a seccomp filter
Why seccomp demands it, which is the part worth understanding rather than memorising. A seccomp filter persists across exec. Without no_new_privs, an unprivileged user could install a filter that makes, say, setuid() silently return success instead of actually changing the UID, then exec a setuid-root program that calls setuid() to drop privilege — and that program would carry on believing it had dropped privilege when it had not. no_new_privs removes the setuid step from that chain, so the filter can only ever restrict a program the user could already run.
Real-world analogy — the one-way turnstile

A secure floor has a one-way turnstile at the entrance. You may walk in with whatever badge you are carrying. What the turnstile guarantees is that nothing on the other side can upgrade your badge. The visitor-badge machine on that floor still works, but it will only ever issue you something equal to or weaker than what you came in with.

It has no reverse gear. Once you have gone through, there is no procedure, no supervisor and no form that puts you back on the outside with a better badge — you would have to leave the building and start again as a new person. That is "cannot be unset, and is inherited by children".

Now the reason the two features are bolted together. The floor also has a rule that rewrites what certain phone calls do — dial 9 and you get the canteen instead of an outside line. Harmless for a visitor. But if someone could walk in, install that rewrite, and then upgrade to a director's badge, the director would be making decisions on rewritten phone calls without knowing it. The turnstile is what makes the call-rewriting rule safe to hand out to visitors.

Where the analogy stops working. A turnstile is a place; no_new_privs is a flag on you, and it travels with you into every room you enter for the rest of your life as that process.

🧪 Exercise B1.1 — Disarm a setuid binary with one flag
bash
# A setuid-root copy of `id`. Running it shows euid=0.
sudo cp /usr/bin/id ~/idcopy
sudo chmod u+s ~/idcopy
ls -l ~/idcopy

echo "--- normally: ---"
~/idcopy

echo "--- with no_new_privs set: ---"
setpriv --no-new-privs ~/idcopy

echo "--- and it is inherited by children: ---"
setpriv --no-new-privs bash -c 'grep NoNewPrivs /proc/self/status; ~/idcopy'

sudo rm -f ~/idcopy
Expected result — click to reveal
plain text
-rwsr-xr-x 1 root root 43744 Aug 22 10:41 /home/zaeem/idcopy
--- normally: ---
uid=1002(zaeem) gid=1003(zaeem) euid=0(root) groups=1003(zaeem)
--- with no_new_privs set: ---
uid=1002(zaeem) gid=1003(zaeem) groups=1003(zaeem)
--- and it is inherited by children: ---
NoNewPrivs:	1
uid=1002(zaeem) gid=1003(zaeem) groups=1003(zaeem)

What to read out of this.

The whole result is in one word: euid=0. In the first run the setuid bit did its job and the process became effectively root. In the second, the identical binary — same file, same s bit, same ls -l output — ran as a plain user. Nothing was changed on disk. One bit was set on the process before exec, and the kernel declined to apply the setuid bit.

The third block is the property that makes it deployable. setpriv --no-new-privs bash -c … set the bit on bash, and the ~/idcopy that bash launched was still disarmed — NoNewPrivs: 1 is right there in /proc/self/status. You set it once, as early as possible, and every descendant inherits it forever. There is no --no-new-privs=off to undo it, by design.

Where you have already met this without knowing. Docker's --security-opt no-new-privileges and Kubernetes' allowPrivilegeEscalation: false set exactly this bit. That Kubernetes field name causes real confusion in reviews: it does not mean "this pod cannot become privileged" and it does not drop any capability. It means precisely what you just watched — no setuid binary, and no file capability, inside this container can raise privilege beyond what the process already had.

Now imagine this at 500 hosts. allowPrivilegeEscalation: false is the cheapest single line in a securityContext: it needs no capability analysis, no profile authoring and no per-application tuning, and it breaks only workloads that genuinely depend on a setuid binary — which in a container image is nearly always a mistake anyway. Enforce it in admission control before you attempt anything harder. NoNewPrivs in /proc/PID/status is how you verify it is really on, rather than trusting the manifest.

B2 · What seccomp actually does

Capabilities answer "may this process perform this privileged operation?". seccomp asks a different and much blunter question: "is this process allowed to make this system call at all?"

Every request a program makes of the kernel — open a file, send a packet, create a process, load a module — is a system call, identified by a number. A seccomp filter is a small program the kernel runs before each system call, which looks at the number (and, if you want, the raw argument values) and returns a verdict.

There are two modes. Strict mode allows exactly four calls — read, write, _exit, sigreturn — and is a curiosity. Filter mode is the one everything uses, and is what Seccomp: 2 in /proc/PID/status means.

The verdict is one of eight actions, listed here from most to least severe. This is the table to know:

ActionWhat happensWhen you use it
SECCOMP_RET_KILL_PROCESSThe whole process dies with SIGSYSHard sandboxes. Added in Linux 4.14
SECCOMP_RET_KILL_THREADOnly the calling thread diesThe old SECCOMP_RET_KILL; rarely what you want
SECCOMP_RET_TRAPSIGSYS is raised, and the process may catch itEmulating a call in userspace
SECCOMP_RET_ERRNOThe call returns an error you choose, without runningThe default for container profiles — EPERM is the usual choice
SECCOMP_RET_USER_NOTIFA supervisor process is asked what to doAdded in Linux 5.0; how rootless runtimes emulate mount
SECCOMP_RET_TRACEA ptrace supervisor decidesDebuggers and syscall interception
SECCOMP_RET_LOGThe call is allowed and loggedAdded in 4.14. This is how you build a profile safely
SECCOMP_RET_ALLOWNothing happens; the call proceedsThe default action of a deny-list profile
The counter-intuitive part: seccomp does not care who you are. It runs before the capability check, before the file-permission check, before the LSM. A process that is UID 0 with all 41 capabilities and --cap-add=ALL will still be refused, because the filter never looks at identity — only at the call number. This is why seccomp is the only one of these mechanisms that meaningfully constrains a container that is already root.

It is also why SECCOMP_RET_ERRNO produces confusing bug reports. The application does not see "denied"; it sees an ordinary error from a call that normally works — EPERM from chmod, ENOSYS from something it feature-detects — and reports whatever nonsense that leads it to conclude.

A filter can only ever restrict. It can never grant. Filters are stacked, never replaced: install a second one and both run, with the most severe verdict winning. There is no removal, no relaxation and no "unset". Combined with no_new_privs, that is what makes it safe to let unprivileged processes sandbox themselves — the worst they can do to a future program is take things away.

The practical consequence: you cannot loosen a container's seccomp profile from inside the container. If a profile is wrong, it is fixed at the runtime, in the manifest, on redeploy.

Real-world analogy — the order pad in a restaurant kitchen

A restaurant kitchen can cook three hundred dishes. Capabilities are about who may order what: the head chef may order anything, a trainee may not order from the specials board.

seccomp is a different control entirely. It is a filter clipped over the order pad that decides which dishes may be written down at all. It does not look at who is holding the pen. The head chef, the owner, the person who built the restaurant — write "lobster" on a pad with the lobster line struck out and the ticket never reaches the kitchen.

And the striking-out is done in different styles. Some lines come back stamped "unavailable" and service carries on — that is SCMP_ACT_ERRNO, and it is why a container often behaves oddly rather than crashing. Some lines cause the whole table to be thrown out — that is KILL_PROCESS. And some are simply noted in the log book and served anyway — that is SECCOMP_RET_LOG, which is how you find out what the kitchen actually orders before you strike anything out.

Once a filter is clipped on, it cannot be unclipped, and clipping a second one on top only ever removes more dishes.

Where the analogy stops working. An order pad is one list; a real seccomp filter is a small program that can also inspect the arguments — the equivalent of allowing "steak" but refusing "steak, raw". It cannot, however, follow a pointer, which is why it can filter clone flags but not a filename.

🧪 Exercise B2.1 — Block one system call and watch root fail

Needs Docker. Everything here is one command; nothing is installed on your machine.

bash
# 1. Is seccomp on in an ordinary container?
docker run --rm alpine grep -E '^Seccomp' /proc/self/status

# 2. And with it switched off?
docker run --rm --security-opt seccomp=unconfined alpine grep -E '^Seccomp' /proc/self/status

# 3. A profile that allows everything except changing file modes
cat > /tmp/nochmod.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    { "names": ["chmod","fchmod","fchmodat","fchmodat2"],
      "action": "SCMP_ACT_ERRNO",
      "errnoRet": 1 }
  ]
}
EOF
docker run --rm --security-opt seccomp=/tmp/nochmod.json alpine chmod 777 /tmp
echo "exit: $?"

# 4. The same thing as root, with EVERY capability
docker run --rm --user 0 --cap-add=ALL \
  --security-opt seccomp=/tmp/nochmod.json alpine chmod 777 /tmp
echo "exit: $?"

# 5. Change the verdict from "return an error" to "kill the process"
cat > /tmp/killchmod.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    { "names": ["chmod","fchmod","fchmodat","fchmodat2"],
      "action": "SCMP_ACT_KILL_PROCESS" }
  ]
}
EOF
docker run --rm --security-opt seccomp=/tmp/killchmod.json alpine chmod 777 /tmp
echo "exit: $?"
Expected result — click to reveal (three deliberate failures)
plain text
Seccomp:	2
Seccomp_filters:	1

Seccomp:	0
Seccomp_filters:	0

chmod: /tmp: Operation not permitted
exit: 1

chmod: /tmp: Operation not permitted
exit: 1

exit: 159

What to read out of this.

Seccomp: 2 is filter mode, and it is on by default in every Docker container — this is the check Docker's own documentation tells you to run. Seccomp: 0 is none. Seccomp_filters counts how many filters are stacked on the process, and exists since Linux 5.9; a container that has installed its own filter on top of the runtime's will show 2 here, which is a useful thing to notice.

Step 4 is the one to remember. --user 0 --cap-add=ALL is a container running as root with all 41 capabilities, and it gets the identical refusal. Nothing about identity is consulted. seccomp is the only control here that is not made irrelevant by being root, which is exactly why container runtimes turn it on by default and why removing it is such a large decision.

Step 5 shows the two verdict styles side by side. SCMP_ACT_ERRNO produced a normal-looking error message and exit 1 — the program ran to completion and simply failed, which is why a badly-tuned profile makes applications misbehave rather than crash, and why the resulting bug reports are so misleading. SCMP_ACT_KILL_PROCESS printed nothing at all and exited 159, which is 128 + 31, and signal 31 is SIGSYS. A container exiting 159 with no log line is very often a seccomp kill, and that number is worth committing to memory next to 137 (SIGKILL, from Module 09).

If step 1 shows Seccomp: 0 on your machine, the daemon was started with seccomp disabled, or you are on a kernel built without CONFIG_SECCOMP_FILTER. Check grep CONFIG_SECCOMP /boot/config-$(uname -r).

No Docker? The same demonstration works with systemd: sudo systemd-run --pty --wait -p SystemCallFilter='~chmod fchmod fchmodat' -p SystemCallErrorNumber=EPERM /bin/chmod 777 /tmp. The ~ makes it a deny-list, and SystemCallErrorNumber= is the ERRNO-versus-kill switch.

B3 · The profiles you will actually meet

You will almost never write a seccomp filter. You will choose between three or four ready-made ones, and the whole skill is knowing what each does.

Docker's default profile is on unless you turn it off. Docker describes it as providing "a sane default for running containers with seccomp" and disabling around 44 system calls out of 300+. It is written as an allow-list: the default action denies, and a long list of ordinary calls is permitted. What it takes away is the machinery for touching the kernel and the host — init_module, finit_module and delete_module; kexec_load; reboot; swapon and swapoff; mount and umount2; open_by_handle_at; keyctl; and clone/unshare when asked for new namespaces without CAP_SYS_ADMIN.

Kubernetes gives every container a seccompProfile with three possible types:

seccompProfile.typeWhat it means
RuntimeDefaultUse the container runtime's own default — in practice, the profile above
LocalhostUse a JSON profile from a file on the node, named by localhostProfile
UnconfinedNo filter at all. This is the default if you say nothing
yaml
securityContext:
  seccompProfile:
    type: RuntimeDefault
The trap that catches nearly everybody: Kubernetes does not apply a seccomp profile by default. A pod with no seccompProfile runs Unconfined — no filter — even though the same workload run under plain docker run would have Docker's default profile applied.

The SeccompDefault feature went alpha in 1.22, beta in 1.25 and GA in 1.27 — and this is where people misread the release notes. GA locked the feature gate on; it did not change the default behaviour. KEP-2413 says it plainly: "the enablement of a feature gate doesn't mean the default behavior will change. No default profile will be applied unless configured." To actually get it, a node's kubelet must be started with --seccomp-default or have seccompDefault: true in its configuration file.

So the honest statement, and a very good interview answer, is: RuntimeDefault-by-default is generally available but opt-in per node. Check it on your own cluster with kubectl get --raw /api/v1/nodes/<node>/proxy/configz — or just read /proc/1/status inside a pod and see whether Seccomp is 0 or 2.

Interview-grade detail — why the default action for unknown calls is ENOSYS and not EPERM. In late 2021 a wave of containers broke: new distribution images shipped a glibc that used the new clone3 system call, and older runtime profiles had never heard of it. The profiles returned EPERM — "you are not allowed" — so glibc concluded the call existed but was forbidden, and gave up. Had the profile returned ENOSYS — "no such system call" — glibc would have fallen back to plain clone and everything would have worked.

Runtimes changed their defaults to return ENOSYS for unrecognised calls for exactly this reason. The lesson generalises well beyond seccomp: a sandbox should lie in the direction the caller already knows how to handle. Being able to tell this story is a strong signal, because it shows you understand that seccomp errors surface as application bugs, not as security messages.

Real-world analogy — the hotel that never locks the minibar

Two hotels, same chain. In the first, every room's minibar is locked by default and the front desk unlocks it if you ask. In the second, a policy exists to lock them, the policy has been fully approved and rolled out — and the housekeeping team has to switch it on floor by floor. Until they do, the minibars stand open.

The first hotel is docker run. The second is Kubernetes: the feature is finished and generally available, and it still does nothing on a floor where nobody enabled it. A guest who assumes "the policy is live, so my minibar is locked" is wrong in a way no amount of reading the policy document will reveal — you have to go and try the door.

Where the analogy stops working. A minibar door is either locked or not. A seccomp profile is a long list, and the interesting failures are the calls in the middle: not locked shut, but answered wrongly enough that the guest concludes the hotel has no bar at all.

🧪 Exercise B3.1 — Three containers, three postures
bash
# The same image, three ways. Watch four fields.
for opts in "" "--privileged" "--cap-drop=ALL --security-opt no-new-privileges"; do
  echo "=== docker run $opts"
  docker run --rm $opts alpine \
    grep -E '^CapEff|^CapBnd|^NoNewPrivs|^Seccomp:' /proc/self/status
done

# Turn the default container's CapEff into names, on the host
capsh --decode=00000000a80425fb
Expected result — click to reveal
plain text
=== docker run 
CapEff:	00000000a80425fb
CapBnd:	00000000a80425fb
NoNewPrivs:	0
Seccomp:	2

=== docker run --privileged
CapEff:	000001ffffffffff
CapBnd:	000001ffffffffff
NoNewPrivs:	0
Seccomp:	0

=== docker run --cap-drop=ALL --security-opt no-new-privileges
CapEff:	0000000000000000
CapBnd:	0000000000000000
NoNewPrivs:	1
Seccomp:	2

0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,
cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,
cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

What to read out of this. Four numbers describe a container's entire privilege posture, and you can read them from any container on any machine without asking the runtime anything.

00000000a80425fb is worth recognising on sight. It decodes to exactly the 14 capabilities Docker grants by default — the list from section A3, confirmed by the kernel rather than by documentation. When you see that value in a /proc dump or an audit log, you are looking at a stock unprivileged container.

The privileged row is the whole argument against --privileged in three lines. CapEff jumps to all 41, and — the part people miss — Seccomp drops from 2 to 0. --privileged does not merely add capabilities; it switches the syscall filter off entirely, along with the AppArmor or SELinux profile and the device cgroup. It is five protections removed by one word — capabilities, seccomp, the LSM profile, the device cgroup and the masked paths — and only one of them is capabilities.

The hardened row is what a well-configured workload looks like: zero capabilities, a bounding set of zero so none can ever be regained, no_new_privs on, and the filter still in place. Note CapBnd is also zero — --cap-drop=ALL shrinks the ceiling, not just the current holding, so no setuid binary or file capability inside the image can put anything back.

Counter-intuitive: --privileged does not remove namespaces. That container still has its own PID, mount, network and UTS namespaces — --pid=host and friends are separate flags. It is "root on the host's kernel with a private view", which is why escaping still takes a deliberate step such as mounting a host block device, rather than simply looking around.

Now imagine this at 500 hosts. Build the profile from evidence, not from a list you found online. Run the workload with SCMP_ACT_LOG as the default action for everything you are considering blocking, collect what it actually calls from the audit log for a full business cycle — including the month-end job nobody remembers — and only then switch the action to ERRNO. A profile authored by guesswork breaks at 03:00 on the one code path you did not think of, and it breaks with a misleading error message rather than a security alert.

B4 · Interview questions — seccomp and no_new_privs

Q. Explain how seccomp, AppArmor and SELinux each improve container security, and how they differ.

They act at three different layers, which is the point of using more than one.

seccomp filters system calls by number and by raw argument values. It runs before every other check and ignores identity entirely, so it constrains a container that is already root. It cannot see filenames or paths, because it must not follow user-supplied pointers.

AppArmor is path-based mandatory access control: a profile says which files this program may read, write or execute, which capabilities it may use, and what networking it may do. It is what Ubuntu and Debian ship, and profiles are per-executable.

SELinux is label-based: every process and every object carries a label, and policy defines which label may act on which. It is what RHEL, Fedora and openSUSE ship, and it is stronger and considerably harder to author. For containers the important label is the type — a container process gets container_t, and host files it should not touch simply do not permit container_t.

The details that separate candidates: stating that these are independent and additive, and that a container is only as strong as all of them together — then pointing out that --privileged removes all three at once plus the device cgroup and the masked paths — five protections — which is why it is a far bigger step than --cap-add=ALL. Also worth saying: you can only run one major LSM of this kind at a time, so "AppArmor and SELinux" is a distribution choice, not a hardening choice.

Q. A container works fine with --security-opt seccomp=unconfined and fails without it. How do you find the blocked call?

Do not start by guessing. Reproduce it under strace -f -e trace=all inside the unconfined container and look for the call the failing code path makes just before it gives up — the filtered one will be present there and absent from the working trace's outcome.

Better, because it works in production: build a copy of the profile with defaultAction set to SCMP_ACT_LOG, run the workload, and read the audit log. Every call the container makes is recorded and none is blocked, so you learn the exact syscall name without an outage.

Then add just that call to the allow-list, redeploy and re-test. Never resolve the ticket by switching seccomp off; that trades a one-line profile change for the loss of the only control that still applies to a root process.

The details that separate candidates: knowing that the failure will not look like a security error. With SCMP_ACT_ERRNO the application sees EPERM from a call that normally succeeds, and reports whatever that leads it to conclude — "cannot create thread", "unsupported filesystem", or a plain hang. Mentioning the clone3 incident as the canonical example, and that ENOSYS versus EPERM as the default answer for unknown calls is what decides whether a library falls back gracefully, is a strong finish.

Q. What does allowPrivilegeEscalation: false actually do?

It sets the no_new_privs bit on the container's processes. From that moment, exec can never grant more privilege than the process already holds: setuid and setgid bits are ignored, file capabilities are ignored, and privilege-raising LSM transitions are refused.

It does not drop any capability, does not stop the container running as UID 0, and is not the same as privileged: false. A container can be root with fourteen capabilities and still have allowPrivilegeEscalation: false.

Verify it rather than trusting the manifest: grep NoNewPrivs /proc/1/status inside the pod should print 1.

The details that separate candidates: knowing that the bit is inherited and cannot be unset, which is why it is safe to set unconditionally and why it is the cheapest line in a securityContext — no capability analysis, no profile authoring. And knowing the one thing it genuinely breaks: an image that relies on a setuid helper, such as an old ping, or sudo in an entrypoint. That is nearly always a packaging mistake worth fixing rather than an exception worth granting.

Q. Why must an unprivileged process set no_new_privs before installing a seccomp filter?

Because a seccomp filter survives exec, and without no_new_privs an unprivileged user could aim one at a setuid-root program.

The attack is concrete. Install a filter that makes setuid() return success without doing anything, then exec a setuid-root binary whose first act is to drop privilege by calling setuid(). The call appears to succeed, the program believes it is now unprivileged, and it continues to run as root while behaving as though it were not. no_new_privs breaks the chain by making the setuid bit inoperative for that process, so a filter can only ever be aimed at programs the user could already run.

A process holding CAP_SYS_ADMIN may install a filter without the bit, since it could reach that state by other means anyway.

The details that separate candidates: framing it as the general principle rather than one trick — a mechanism that lets you change how the kernel answers a program must not be combinable with a mechanism that raises that program's privilege. no_new_privs is what makes unprivileged sandboxing safe at all, which is why the two shipped together — both landed in Linux 3.5, and the dedicated seccomp() system call, which until then had been a prctl() option, followed in 3.17.


🛡️ Part C · The rest of the sandbox

C1 · Mandatory access control: AppArmor and SELinux

Everything so far has been discretionary: file permissions belong to the file's owner, and root — or a capability — can override them. Mandatory access control is the opposite. A policy set by the administrator applies to a program regardless of what its owner or its UID would permit, and the program cannot opt out.

Linux implements this through Linux Security Modules, and you will meet two:

AppArmorSELinux
Ships onUbuntu, Debian, SLES (and openSUSE before 2025)RHEL, Fedora, CentOS Stream — and openSUSE, whose Tumbleweed switched its default in February 2025
Identifies things byPath/usr/sbin/nginx may read /var/www/**Label — a process of type container_t may read files of type container_file_t
Profiles live in/etc/apparmor.d/Compiled policy modules
Check status withaa-statusgetenforce, sestatus
Modesenforce / complain / kill / unconfinedenforcing / permissive / disabled
Feels likeReadable; you can write one in an afternoonStronger; authoring is a specialism

Both give you a learning modecomplain in AppArmor, permissive in SELinux — where violations are logged but allowed. That is how every real profile gets written, and it is the same idea as SCMP_ACT_LOG in Part B.

Counter-intuitive, and the reason so many people "just disable SELinux": an LSM can only ever remove access. It never grants any. So if a program works with the LSM disabled and fails with it enabled, the LSM is not the bug — it is reporting one. Turning it off does not fix anything; it stops the machine telling you.

The corollary is the practical advice: switch the service to permissive or complain, not the system to disabled, then read the denials and write the four lines of policy the application actually needs.

Real-world analogy — the building's own rulebook

Capabilities and file permissions are like keys and name plates: the office is yours, so you decide who comes in, and the building manager can come in regardless.

Mandatory access control is the fire code. It is not the manager's to waive and not the tenant's to waive. It says this door must never be blocked and this room may never store solvents — and it applies to the manager exactly as it applies to the newest tenant.

The two styles map neatly. AppArmor is a rulebook written by address: "the person in room 4B may enter the store room and the loading bay." SELinux is a rulebook written by badge colour: "anyone with a green badge may enter any room marked green." Address-based rules read easily; badge-based rules survive someone moving offices.

And the failure everyone recognises: the tenant whose delivery keeps getting refused, who concludes the fire code is broken and props the door open permanently. The door was refused for a reason. The fire code never gave anybody access — it only ever takes it away — so propping it open cannot make a delivery work that would not otherwise have worked; it only removes the record of why it failed.

Where the analogy stops working. A fire code is one document for one building. Only one major LSM of this kind can be active at a time, so which rulebook you are living under was decided by your distribution, not by you.

🧪 Exercise C1.1 — Find out what is confining you
bash
# Which security modules are active on this kernel, in order?
cat /sys/kernel/security/lsm 2>/dev/null || echo "securityfs not mounted"

# Ubuntu / Debian / SUSE:
sudo aa-status 2>/dev/null | grep -E 'module is loaded|profiles are'

# RHEL / Fedora / CentOS:
getenforce 2>/dev/null; sestatus 2>/dev/null | head -4

# What confines YOUR shell right now?
cat /proc/self/attr/current 2>/dev/null; echo

# And a container? (AppArmor host)
docker run --rm alpine             cat /proc/self/attr/current
docker run --rm --privileged alpine cat /proc/self/attr/current
Expected result — click to reveal (Ubuntu 24.04; yours will differ)
plain text
capability,landlock,lockdown,yama,apparmor,bpf

apparmor module is loaded.
73 profiles are loaded.
41 profiles are in enforce mode.
32 profiles are in complain mode.
0 profiles are in prompt mode.
0 profiles are in kill mode.
0 profiles are in unconfined mode.

unconfined

docker-default (enforce)
unconfined

What to read out of this.

/sys/kernel/security/lsm is the one reliable answer to "what is protecting this machine". The exact list differs by distribution and kernel — what matters is whether apparmor or selinux appears in it. On a RHEL-family host you would see selinux there and getenforce would print Enforcing.

Your login shell is unconfined, and that is normal. AppArmor confines named programs, not users. Your shell has no profile, so no policy applies to it.

The last two lines are the point. A stock container is confined by a profile called docker-default, in enforce mode — a restriction you did not ask for and probably did not know was there. It blocks writes to most of /proc and /sys, mounting, and ptrace outside the container.

With --privileged the same container reports unconfined. That single flag did not weaken the profile; it removed it. Together with Seccomp: 0 from Exercise B3.1, you have now watched --privileged switch off two entire independent defences, and you have the commands to prove it in a review.

If aa-status says "command not found", install apparmor-utils — or you are on a distribution that uses SELinux instead, and the getenforce line is your answer.

C2 · --privileged, and what an escape actually needs

A container is held together by several independent restrictions. It is worth listing them, because --privileged removes five of them at once and leaves the sixth alone — and knowing which is which is the difference between a useful answer and a scary one.

RestrictionWhat it doesRemoved by --privileged?
CapabilitiesCuts 41 down to 14Yes — all 41 are granted
seccompBlocks ~44 system callsYesSeccomp goes to 0
AppArmor / SELinuxdocker-default or container_tYes — becomes unconfined
Device cgroupOnly a handful of devices are usableYes — every host device appears
Masked and read-only paths/proc/kcore and friends hidden; /sys read-onlyYes — real files, writable /sys
NamespacesIts own PID, mount, network, UTS viewNo — these are separate flags
The counter-intuitive part. --privileged does not put the container in the host's namespaces. --pid=host, --net=host and --ipc=host are independent flags. So a privileged container still cannot see host processes and still has its own network stack — which is why people conclude it is "fine, it's still contained".

It is not contained. It is root on the host's kernel with a private view, holding every capability and no syscall filter — so it can mount the host's root filesystem, or write to /sys, and step out deliberately. The isolation that remains is a view, and a view is not a boundary. That distinction — visibility versus authority — is the single most useful sentence you can offer on this topic.

An escape in practice needs one of a very small number of things, and every one of them is visible in a manifest:

What was givenWhy it is an escape
The Docker socket (/var/run/docker.sock)Not an escape so much as a shortcut — anyone who can talk to it can start a privileged container. This is the most common one in the wild
hostPath: / or any writable host mountWrite a unit file, a cron job or an SSH key and wait
--privileged, or CAP_SYS_ADMINMount a host block device, or abuse cgroup interfaces to have the kernel run a program on the host
CAP_SYS_MODULELoad a kernel module. There is nothing above the kernel to escape to
CAP_DAC_READ_SEARCHEnables open_by_handle_at, which can reach files outside the container's root — the "Shocker" technique
hostPID: true plus CAP_SYS_PTRACEAttach to a host process and inject code into it
A kernel vulnerabilityThe only one you cannot see in a manifest — and the reason the other controls exist
🧪 Exercise C2.1 — See the two protections nobody mentions
bash
echo "=== default container ==="
docker run --rm alpine sh -c '
  ls -l /proc/kcore
  grep " /sys " /proc/self/mounts
  echo hi > /sys/kernel/uevent_helper; echo "write to /sys exit: $?"'

echo "=== privileged container ==="
docker run --rm --privileged alpine sh -c '
  ls -l /proc/kcore
  grep " /sys " /proc/self/mounts
  cat /proc/self/attr/current'
Expected result — click to reveal (one deliberate failure)
plain text
=== default container ===
crw-rw-rw-    1 root     root        1,   3 Aug 22 10:52 /proc/kcore
sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0
sh: can't create /sys/kernel/uevent_helper: Read-only file system
write to /sys exit: 1

=== privileged container ===
-r--------    1 root     root        140737486266368 Aug 22 10:52 /proc/kcore
sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0
unconfined

What to read out of this.

/proc/kcore is the whole of physical memory as a file. In the default container ls -l shows crw-rw-rw- … 1, 3 — character device major 1, minor 3, which is /dev/null. The runtime bind-mounted /dev/null over it. Reading it returns nothing, writing it discards. The same treatment is applied to /proc/keys, /proc/timer_list and /proc/sched_debug. Directories are masked a second way — an empty read-only tmpfs is mounted over them — so ls -ld /sys/firmware shows drwxrwxrwt … 40 rather than a character device.

In the privileged container it is the real file, and its size is the machine's physical address space — a number in the hundreds of billions. A process that can read that can read any secret held by any process on the host, without needing to escape anywhere.

The second line is the other quiet protection. /sys is mounted ro in a normal container, and the deliberate failure shows why that matters: /sys/kernel/uevent_helper is a file the kernel executes on the host when a device event occurs. Writable, it is a one-line escape. Privileged, /sys is rw.

Three lines, three defences, all removed by one flag — and none of them is "capabilities", which is the only one most people mention. Add Seccomp: 0 and unconfined from the earlier exercises and you can describe --privileged completely and precisely, which very few candidates can.

Now imagine this at 500 hosts. privileged: true should be an admission-control failure, not a code-review comment — and Pod Security Admission's baseline level already rejects it — along with hostPath volumes, hostPID/hostNetwork/hostIPC, and any capabilities.add outside a short allow-list that excludes SYS_ADMIN, SYS_MODULE, SYS_PTRACE and DAC_READ_SEARCH. That is the argument for enforcing baseline rather than hand-rolling a "not privileged" check. A policy that looks only at the privileged field passes every one of those patterns, and they cover most real container escapes.

C3 · The same tools on a plain host: systemd sandboxing

Everything in this module so far has been framed around containers, and most fleets still run a great deal that is not containerised: the agent, the exporter, the backup script, the vendor daemon. systemd exposes the same kernel primitives as unit directives, and a hardened unit is often less work than moving the thing into a container.

DirectiveUnderlying mechanismWhat it buys
NoNewPrivileges=yesno_new_privs (Part B)Setuid binaries and file capabilities stop working. Cheapest line here
CapabilityBoundingSet=Bounding set (Part A)Empty means none of the 41, permanently
AmbientCapabilities=Ambient set (Part A)Give an unprivileged user exactly one privilege — the port-443 answer
SystemCallFilter=@system-serviceseccomp (Part B)A curated allow-list; ~name makes it a deny-list instead
RestrictNamespaces=yesseccomp on unshare/cloneThe service cannot create namespaces — no containers from inside
ProtectSystem=strictMount namespace, read-only bind mountsThe entire filesystem is read-only except what you name
ProtectHome=yes, PrivateTmp=yesMount namespaceNo /home; a private /tmp that disappears on stop
PrivateDevices=yesMount namespace + device cgroupA minimal /dev — no disks, no raw hardware
ProtectKernelTunables/Modules/Logs=Mounts + capability dropsNo writing /proc/sys, no module loading, no reading the kernel log ring buffer (dmesg)
RestrictAddressFamilies=seccomp on socketRemoving AF_PACKET alone kills most sniffing and spoofing
MemoryDenyWriteExecute=yesseccomp on mmap/mprotectNo page is both writable and executable. Breaks JITs — test it

systemd-analyze security scores a unit from 0.0 to 10.0 and labels the result. The labels, worst to best, are DANGEROUS, UNSAFE, EXPOSED, MEDIUM, OK, SAFE, PERFECT — seven, not the four people usually remember.

Real-world analogy — the visiting engineer's badge

A vendor sends an engineer to service one machine on the factory floor. The lazy arrangement is to hand over a staff badge: full site access, canteen, offices, server room, for as long as they are on site. Nothing goes wrong most of the time, and everyone stops thinking about it. That is a daemon running as root with no restrictions.

The considered arrangement is a badge that opens the one door, expires at six, cannot be used to request another badge, and comes with an escort into any room that was not on the work order. That is a hardened unit — and none of it required moving the engineer to a different building.

systemd-analyze security is the badge audit: a list of every service on the machine ranked by how much a stolen badge would be worth. The value of the audit is not the score, it is the ordering — you start at the top.

Where the analogy stops working. A badge is issued once and reviewed rarely. Unit hardening is checked by the kernel on every single operation the service performs, which is why it can break a service months later on a code path nobody exercised.

🧪 Exercise C3.1 — Score a service, harden it, score it again
bash
# A throwaway unit with no restrictions at all
sudo tee /etc/systemd/system/demo-hardening.service >/dev/null <<'EOF'
[Unit]
Description=Deliberately unhardened demo
[Service]
ExecStart=/bin/sleep 3600
EOF
sudo systemctl daemon-reload

echo "--- before ---"
systemd-analyze security demo-hardening.service | tail -1
# Where systemd is not PID 1 (containers, CI), analyse the file instead:
#   systemd-analyze security --offline=true /etc/systemd/system/demo-hardening.service | tail -1

# The cheap wins, as a drop-in
sudo mkdir -p /etc/systemd/system/demo-hardening.service.d
sudo tee /etc/systemd/system/demo-hardening.service.d/hardening.conf >/dev/null <<'EOF'
[Service]
User=nobody
NoNewPrivileges=yes
CapabilityBoundingSet=
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
EOF
sudo systemctl daemon-reload

echo "--- after ---"
systemd-analyze security demo-hardening.service | tail -1

echo "--- the whole machine, worst first ---"
systemd-analyze security --no-pager | head -8

sudo rm -rf /etc/systemd/system/demo-hardening.service \
            /etc/systemd/system/demo-hardening.service.d
sudo systemctl daemon-reload
Expected result — click to reveal (scores vary by systemd version)
plain text
--- before ---
→ Overall exposure level for demo-hardening.service: 9.4 UNSAFE 😨

--- after ---
→ Overall exposure level for demo-hardening.service: 2.4 OK 🙂

--- the whole machine, worst first ---
UNIT                                 EXPOSURE PREDICATE HAPPY
containerd.service                        9.6 UNSAFE    😨
cron.service                              9.6 UNSAFE    😨
docker.service                            9.6 UNSAFE    😨
ssh.service                               9.6 UNSAFE    😨
systemd-udevd.service                     9.4 UNSAFE    😨
systemd-resolved.service                  2.0 OK        🙂

What to read out of this.

From 9.4 to 2.4, and not one line of the program changed. Every directive in that drop-in is a kernel feature you have already met in this module — no_new_privs, an empty bounding set, a seccomp allow-list, and a private mount namespace. systemd is a convenient front end, not a new mechanism.

Read the ✗ lines, not the number. The score is a weighted sum with no absolute meaning; the value is in the list of things not done. After hardening, eighteen items are still ✗ — run it without tail and read them. The largest are PrivateNetwork= (0.5), User=/DynamicUser= (0.4, because nobody is itself a poor choice for a service) and RestrictAddressFamilies=~AF_(INET|INET6) (0.3). Every one of them is honest: this service really can still reach the network, and really does run as a shared system user, because nothing told it otherwise.

The fleet view is the one to keep. systemd-analyze security with no arguments ranks every unit on the machine. Do not be alarmed that docker.service and sshd sit at 9.6: some services genuinely need the access, and the tool has no idea what a service is for. Its job is to give you an ordered list so that you harden the vendor agent that needs nothing before you spend a week on the one that needs everything.

A caution before you copy that drop-in onto something real. ProtectSystem=strict makes the entire filesystem read-only, so a service that writes anywhere needs ReadWritePaths= or a StateDirectory=. MemoryDenyWriteExecute=yes — deliberately left out above — breaks anything with a JIT, which includes most JVM, .NET and Node workloads. Harden one directive at a time, with a restart and a smoke test between each.

Now imagine this at 500 hosts. Export the exposure score per unit as a metric and alert on regression, not on absolute value. A unit that has been at 2.1 for a year and jumps to 8.9 after a package update has quietly lost its hardening — usually because the vendor shipped a new unit file that overwrote yours instead of a drop-in. That is a change you will otherwise find out about from an incident.

C4 · Interview questions — containers and hardening

Q. Scenario: a pod is running in privileged mode. What are the risks?

Name what it actually removes, in order, because "it's basically root on the host" is the answer everyone gives.

privileged: true grants all 41 capabilities, sets the seccomp profile to unconfined, sets the AppArmor or SELinux profile to unconfined, lifts the device cgroup so every host device is usable, unmasks /proc/kcore and friends, and mounts /sys read-write. Five protections, one field.

The concrete consequences: it can mount the node's root filesystem and read or write any file including /etc/shadow and every other pod's secrets; it can read all of physical memory through /proc/kcore; it can write /sys/kernel/uevent_helper to have the kernel execute a program on the node; and it can load a kernel module. On a shared node it is, in practice, cluster-admin, because the kubelet's credentials are on that disk.

The details that separate candidates: saying what it does not remove — namespaces. The pod still has its own PID, mount and network view; hostPID, hostNetwork and hostIPC are separate fields. That is why it "looks contained", and why the correct framing is visibility is not authority. Finish with the fix: Pod Security Admission at baseline rejects it at the API server, so it never becomes a code-review argument.

Q. How do you prevent container breakout attacks?

In the order the controls actually matter.

Do not run as root. runAsNonRoot: true with a real UID removes most of the value of anything an attacker finds. allowPrivilegeEscalation: false — one line, no_new_privs, no analysis needed. capabilities.drop: ["ALL"], adding back only what is proven necessary, and dropping NET_RAW even if you add nothing else. seccompProfile.type: RuntimeDefault, remembering that Kubernetes does not apply it unless you ask. readOnlyRootFilesystem: true with named writable volumes. Leave the LSM alone and let docker-default or container_t do its job.

Then the things that are not securityContext at all: no hostPath mounts, especially not / or the container socket; patch the node kernel, because the one escape you cannot see in a manifest is a kernel bug; and enforce all of it in admission control rather than review.

The details that separate candidates: pointing out that most real-world "escapes" are not kernel exploits at all — they are a mounted Docker socket, a writable hostPath, or an over-broad service account token, all of which are misconfiguration rather than vulnerability. And naming user namespaces as the structural fix: rootless containers make the container's UID 0 map to an unprivileged host UID, so a full capability set inside is worth nothing outside, exactly as in Exercise A3.1.

Q. A container works when SELinux is permissive and fails when it is enforcing. What do you do?

Not setenforce 0. An LSM only ever removes access, so it cannot be the cause of a failure that would otherwise have succeeded — it is reporting a policy gap, and disabling it deletes the report rather than the problem.

Read the denial: ausearch -m AVC -ts recent, or journalctl -t setroubleshoot. The AVC record names the source type, the target type and the operation, which is usually enough on its own. audit2why explains it in prose, and audit2allow will generate a policy module — read what it generates before loading it, because it will happily allow far more than you need.

The most common container case is a hostPath volume whose files are not labelled container_file_t. The fix is a label, not a policy: chcon -Rt container_file_t /path to test, semanage fcontext plus restorecon to make it survive a relabel. In Docker the :z and :Z volume suffixes do this for you.

The details that separate candidates: knowing that setenforce 0 is temporary and /etc/selinux/config is permanent, so a machine "fixed" with the former quietly re-breaks at the next reboot — which is the worst possible time to discover it. And offering the middle path: put the one domain in permissive mode with semanage permissive -a, rather than the whole system.

Q. A developer's pod runs as root. Walk me through fixing it.

Find out why first — the answer is nearly always "the base image's default user is root and nobody changed it", and occasionally "it writes to a directory only root can write".

Fix it in the image where you can: a USER 10001 line in the Dockerfile, and chown the directories the application writes during the build. Then enforce it in the manifest so a rebuild cannot undo it:

yaml
securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities: { drop: ["ALL"] }

runAsNonRoot: true is the important one, because it makes the kubelet refuse to start a container whose image user resolves to UID 0 — so this cannot regress silently on the next image build.

The details that separate candidates: handling the two things that break. Files baked into the image with root-only permissions need fixing at build time, not with an init container running as root. And if the application listens on port 80, the answer is to change it to 8080 rather than to add NET_BIND_SERVICE — the Service or ingress maps the port, and the capability is not needed at all. Mentioning that readOnlyRootFilesystem: true usually needs an emptyDir mounted at /tmp shows you have actually done it.


🔍 Part D · Diagnosing a refusal

D1 · Reading "Permission denied" properly

Six different mechanisms in this module can refuse an operation, and they mostly produce the same two error messages. The good news is that the two messages split the search space almost perfectly, and everything you need afterwards is in /proc.

Interview-grade detail — the errno tells you which layer said no.

EACCES ("Permission denied") comes from the ordinary discretionary check: owner, group and mode bits — or an LSM. Look at ls -l, at the process's UID, and at AppArmor or SELinux.

EPERM ("Operation not permitted") comes from a privileged check: a missing capability, or seccomp with SCMP_ACT_ERRNO. Look at CapEff and Seccomp.

Two more are worth recognising because they are frequently misread as permission problems. EROFS ("Read-only file system") is a mount option, not a permission — readOnlyRootFilesystem, ProtectSystem=strict, or a container's read-only /sys. ENOSYS ("Function not implemented") is often a seccomp filter deliberately pretending the call does not exist, so that the library falls back gracefully.

Being able to say "that's EPERM, so it's capabilities or seccomp, not file permissions" in the first thirty seconds of a debugging conversation is worth more than knowing any individual capability's name.

Diagram source
flowchart TD
  A["An operation was refused"] --> B{"Which errno?"}
  B -->|"EROFS"| R["A mount option.<br>findmnt -T path"]
  B -->|"ENOSYS"| S["Often seccomp<br>faking absence"]
  B -->|"EACCES"| C{"Does the UID<br>own the file?"}
  B -->|"EPERM"| D{"Seccomp: 2<br>in /proc/PID/status?"}
  C -->|"No"| C1["Mode bits.<br>ls -l, id"]
  C -->|"Yes"| C2["An LSM.<br>attr/current, ausearch"]
  D -->|"Yes"| D1["Suspect the filter.<br>Re-test unconfined"]
  D -->|"No"| E{"Is the bit set<br>in CapEff?"}
  E -->|"No"| E1["Missing capability.<br>capsh --decode"]
  E -->|"Yes"| E2["User namespace scope.<br>Wrong namespace owns it"]
Mermaid diagrams do not render until you switch the block to Preview. Click the code block, then use the Preview / Split control at its top right. Notion will not do it for you, and the block looks like plain text until you do.
Real-world analogy — four ways to be turned away at a door

You are refused entry to a room. There are four different reasons, they feel identical from where you are standing, and the words used tell you which one it was.

"This isn't your office" — the room belongs to someone else and you are not on the list. That is EACCES: ownership and mode bits.

"You don't have clearance for that" — the room is yours to enter, but this particular door needs a clearance you were never issued. That is EPERM: a missing capability.

"That request isn't on the form" — the receptionist will not even write the request down, no matter who you are or what clearance you hold. That is seccomp.

"The whole floor is sealed today" — nothing to do with you at all. That is EROFS.

The mistake people make is arguing about clearance when the answer was "wrong office". Read the exact words first, then investigate.

Where the analogy stops working. A receptionist can explain herself. The kernel returns a number, and everything above it invents the wording — which is why the same refusal reaches you as "permission denied", "cannot create thread" or a silent hang, depending on the library.

🧪 Exercise D1.1 — The posture dump, and three refusals
bash
# --- The block worth memorising: any process's full privilege posture ---
P=$$          # or a container's host PID, or 1
echo "== pid $P : $(tr '\0' ' ' < /proc/$P/cmdline)"
grep -E '^(Uid|Gid|CapEff|CapBnd|CapAmb|NoNewPrivs|Seccomp):' /proc/$P/status
printf 'CapEff decoded: '; capsh --decode=$(awk '/^CapEff/{print $2}' /proc/$P/status)
printf 'LSM confinement: '; { cat /proc/$P/attr/current 2>/dev/null || printf '(none)'; }; echo
getpcaps $P            # the one-line version of the CapEff decode

# --- Now three refusals that look the same and are not ---
echo "--- 1. mode bits (EACCES) ---"
echo x | sudo tee /tmp/rootonly >/dev/null; sudo chmod 600 /tmp/rootonly
cat /tmp/rootonly; echo "exit: $?"

echo "--- 2. missing capability (EPERM) ---"
sudo capsh --drop=cap_chown -- -c 'chown nobody /tmp/rootonly; echo "exit: $?"'

echo "--- 3. read-only mount (EROFS) ---"
sudo mkdir -p /tmp/ro && sudo mount -t tmpfs -o ro none /tmp/ro
touch /tmp/ro/x; echo "exit: $?"

sudo umount /tmp/ro; sudo rmdir /tmp/ro; sudo rm -f /tmp/rootonly
Expected result — click to reveal (three deliberate failures)
plain text
== pid 2593 : bash
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000
CapEff:	0000000000000000
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000
NoNewPrivs:	0
Seccomp:	0
CapEff decoded: 0x0000000000000000=
LSM confinement: unconfined
2593: =

--- 1. mode bits (EACCES) ---
cat: /tmp/rootonly: Permission denied
exit: 1
--- 2. missing capability (EPERM) ---
chown: changing ownership of '/tmp/rootonly': Operation not permitted
exit: 1
--- 3. read-only mount (EROFS) ---
touch: cannot touch '/tmp/ro/x': Read-only file system
exit: 1

What to read out of this.

Learn the first block as one unit. Six lines from /proc/PID/status plus a decode, and you have the complete privilege posture of any process on any machine — your shell, PID 1, or a container's process seen from the node. It works without the runtime, without kubectl, and without anything installed inside the container.

CapEff decoded: 0x0000000000000000= is the decode of an empty mask — the trailing = with nothing after it. That is correct output, not a broken command. getpcaps says the same thing more briefly: 2593: = is "this process holds nothing"; a root shell would print something like 2593: =ep.

LSM confinement is the one line that needs care. On a kernel with no active LSM the file contains the word kernel with no trailing newline, so without the echo the next command's output lands on the same line. On an AppArmor host it reads unconfined, and on SELinux it is a full label such as unconfined_u:unconfined_r:unconfined_t:s0.

Then the three refusals, which is the whole point. All three are exit status 1 from a program that could not do its job, and all three would appear in an application log as some variation of "permission problem". The wording is what separates them: Permission denied sent you to ls -l, Operation not permitted sent you to CapEff, and Read-only file system sent you to findmnt. Three completely different investigations, decided by reading the message rather than guessing.

Note that refusal 2 came from a UID-0 process, exactly as in Exercise A1.2 — so "run it as root and see if it works" would have confirmed the wrong theory here. That is why the posture dump comes first.

In a container, run the posture block against PID 1 from the node: P=$(host pid of the container); …. You get its capabilities, its seccomp state and its LSM profile without entering it, which matters when the image has no shell.

Now imagine this at 500 hosts. Put that posture block in your runbook as the first command for any "it works on my machine but not in the cluster" ticket, and make the ticket template ask for its output. Most of these tickets are resolved by the CapEff line alone, and having it up front removes an entire round trip. The second most useful line is Seccomp — because if it says 2 and the same command works with seccomp=unconfined, the investigation is over in one step.

🏁 Part E · Practice, docs and self-check

E1 · Production practice

Symptom in productionWhat is really happeningWhat to runThe fix
Container is root, and mount says Operation not permittedCAP_SYS_ADMIN is not in the default 14capsh --decode=<CapEff> insideMount it from the host side; do not add the capability
Container exits 159 with no log lineseccomp KILL_PROCESS — 128 + 31, SIGSYSRe-run with seccomp=unconfined to confirmFind the call with an SCMP_ACT_LOG profile, then allow just that one
App reports a nonsense error after a security rolloutseccomp SCMP_ACT_ERRNO — a normal call returned EPERMstrace -f unconfined, compareSame as above. Never resolve it by disabling seccomp
Works on Ubuntu nodes, fails on RHEL nodesDifferent LSM — AppArmor versus SELinuxcat /sys/kernel/security/lsm; ausearch -m AVC -ts recentLabel the volume (:Z, container_file_t), do not disable SELinux
Binary loses its capability after every package updateFile capabilities live on the file, and the file was replacedgetcap before and afterUse AmbientCapabilities= in the unit instead of setcap
setcap "works" but the program still cannot bind port 80The mount is nosuid, so file capabilities are ignoredfindmnt -no OPTIONS -T <path>Move the binary to a mount without nosuid
setcap fails outright with Operation not supportedNFS/FAT has no security.* xattr namespacefindmnt -no FSTYPE -T <path>Local filesystem, or ambient capabilities from the launcher
An entrypoint using sudo breaks after a hardening changeno_new_privs disables setuid binariesgrep NoNewPrivs /proc/1/statusRemove sudo from the image; run as the right user from the start
Pod passes the "not privileged" policy and is still dangerouscapabilities.add: [SYS_ADMIN], or a hostPath mountQuery manifests for capabilities.add and hostPathAdmission control on both, not just on privileged
Pods have no seccomp filter although the cluster is "on 1.29"SeccompDefault is GA but opt-in per nodegrep Seccomp /proc/1/status inside a podseccompDefault: true in kubelet config, or set RuntimeDefault per pod
Compromised container spoofs DNS for its neighboursCAP_NET_RAW is in the default setDecode CapEff; look for cap_net_raw--cap-drop=NET_RAW. Almost nothing needs it
Hardened unit's score jumps from 2.1 to 8.9 after an upgradeA new vendor unit file replaced yourssystemd-analyze security <unit>; systemctl cat <unit>Put hardening in a .d/ drop-in, and alert on score regression

E2 · Capstone — four hardening tickets

Four tickets. For each: what you run, in what order, what you expect to see, and what the answer is. Attempt them before opening the toggles.

Ticket 1. A vendor's monitoring agent ships a Helm chart with privileged: true. Your admission policy rejects it and the vendor's support answer is "the agent requires privileged mode." The rollout is blocked and there is a deadline. What do you do?

Ticket 2. After a platform-wide security rollout, one Java service began dying a few seconds after start. There is nothing in the application log. kubectl describe shows the container's last state as terminated with exit code 159. The same image runs fine in the old cluster.

Ticket 3. A log-shipping DaemonSet reads /var/log from the node via a hostPath. It works on every Ubuntu node and fails on every RHEL node with Permission denied, despite running as root with the same manifest.

Ticket 4. To save disk on a fleet, /opt was moved to an NFS mount. Since then the in-house collector, which used to bind port 443 as an unprivileged user, fails at start with "permission denied". Nobody changed the application, the unit file or the user.

Ticket 1 — worked answer

Do not argue about privileged. Find out which single operation they need.

Run it in a sandbox cluster with privileged: true and record what it actually does: strace -f -c on its main process, or simply read what it opens. Nearly always the real requirement is one of a very short list — reading /proc of other processes (needs hostPID, not privileged), reading /sys for hardware metrics (needs a read-only hostPath, not privileged), a raw socket for network metrics (CAP_NET_RAW), or perf counters (CAP_PERFMON, since 5.8, specifically so tools stop asking for CAP_SYS_ADMIN).

Then propose the minimum: capabilities: {drop: [ALL], add: [<the one>]}, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, seccompProfile: RuntimeDefault, and a read-only hostPath for exactly the paths it reads. Test it; it usually works first time, because privileged: true was in the chart to avoid a support burden, not because anyone measured it.

If it genuinely needs CAP_SYS_ADMIN, that is a decision with a name on it, not a default. Confine it to a dedicated node pool with a taint, give it its own service account with no cluster-wide read, and set a review date. Record in the exception that CAP_SYS_ADMIN was 38% of all capability checks in Linux 3.2 and just over 45% by 5.2, and that this is not "one extra capability".

What not to do: grant privileged: true "temporarily". The deadline is real and the review never happens.

Ticket 2 — worked answer

159 is 128 + 31, and signal 31 is SIGSYS. That is a seccomp kill.

The exit-code arithmetic is the whole diagnosis. A container killed by a signal exits 128 + N; you already know 137 as 128 + 9 (SIGKILL, usually the OOM killer, Module 09). 159 is SIGSYS, and inside a container that is essentially always seccomp — either a KILL_PROCESS or KILL_THREAD verdict, or a TRAP verdict for which the process installed no handler. There is no application log line because the process was killed between making a system call and returning from it — it never got the chance to write one.

Confirm in one step: re-run the pod with seccompProfile: {type: Unconfined}. If it lives, the diagnosis is certain.

Then find the call, without leaving it unconfined. Copy the node's profile, change defaultAction to SCMP_ACT_LOG, install it as a Localhost profile, and run the workload. Every call is recorded and none blocked; the audit log names the syscall. Add just that one to the allow-list and redeploy.

Expect the answer to be a JVM call. The usual culprits are membarrier, perf_event_open, process_vm_readv or a newer clone3 — the JVM feature-detects aggressively at startup, which is why this fails "a few seconds after start" rather than immediately.

And fix the class of bug, not the instance: the profile should use ERRNO rather than KILL as its default verdict wherever possible, and it should return ENOSYS for unknown calls so libraries fall back instead of giving up — the lesson of the clone3 breakage in 2021.

Ticket 3 — worked answer

Different distributions, different LSM. Ubuntu is AppArmor; RHEL is SELinux, and the volume is unlabelled.

Confirm the split first: cat /sys/kernel/security/lsm on one node of each kind. The RHEL nodes will list selinux and getenforce will say Enforcing.

Then read the denial rather than guessing: ausearch -m AVC -ts recent on the failing node. It will show a process of type container_t denied read on a file whose type is var_log_t — a host label that container processes are not permitted to touch.

Note what the error is not. It is EACCES, not EPERM, and the container is root with capabilities intact. Being root is irrelevant here: mandatory access control is not something a capability overrides. That is the definition of mandatory.

The fix is a label, not a policy. For a hostPath, the supported route is the SELinux options in the pod's securityContextseLinuxOptions with an appropriate type — or, for log collection specifically, using the node's container-log paths, which are already labelled for container access. In plain Docker the :z and :Z volume suffixes relabel for you.

What not to do: setenforce 0, or --security-opt label=disable. The first is silently temporary and reverts at reboot, at the worst possible moment. Both delete the report rather than the problem — an LSM never grants access, so it cannot be the reason something that should work does not.

Ticket 4 — worked answer

The binary had a file capability, and NFS cannot store one.

cap_net_bind_service=ep was on /opt/collector/bin/collector, which is how an unprivileged user was binding port 443. File capabilities live in the security.capability extended attribute, and NFS does not carry the security.* xattr namespace — Red Hat documents this as by design for both NFSv3 and NFSv4. Copying the binary onto NFS silently dropped the attribute.

Confirm in two commands: getcap /opt/collector/bin/collector now prints nothing, and sudo setcap cap_net_bind_service=ep <path> fails with Operation not supported. findmnt -no FSTYPE -T /opt confirms nfs4.

Watch for the same failure with a different cause, because it will look identical: if /opt had been a local mount with nosuid, getcap would happily show the capability and it would still be ignored, since nosuid disables file capabilities as well as setuid bits. findmnt -no FSTYPE,OPTIONS -T /opt distinguishes the two in one line.

The durable fix is to stop depending on the filesystem. Put AmbientCapabilities=CAP_NET_BIND_SERVICE in the systemd unit alongside User=collector. The capability is then granted by the service manager at start, survives package upgrades that replace the binary, and works from any filesystem. Better still, if you control the port: net.ipv4.ip_unprivileged_port_start=443, or listen on 8443 behind the load balancer, and need no privilege at all.

E3 · Documentation reference

TopicWhere to read itWhy this one
All 41 capabilities, and the exec rulescapabilities(7)Start here. The transformation formulas and the CAP_SYS_ADMIN warning are both in it
Why CAP_SYS_ADMIN is the way it isCAP_SYS_ADMIN: the new rootThe source of the 451-of-1167 figure, with its Linux 3.2 context
Reading and setting capabilitiescapsh(1) · getcap(8) · setcap(8) · getpcaps(8)--decode and --drop are the two you will use constantly
The Cap* fields in /procproc_pid_status(5)Defines CapInh/Prm/Eff/Bnd/Amb, NoNewPrivs and Seccomp precisely
no_new_privsPR_SET_NO_NEW_PRIVS(2const) · kernel docsThe 2const page is the authoritative one; prctl(2) now points at it
seccomp modes and return actionsseccomp(2) · Seccomp BPFThe action precedence order and the version each was added
Supervised syscallsseccomp_unotify(2)How rootless runtimes emulate mount; RET_USER_NOTIF since 5.0
Docker's profile and flagsSeccomp profiles · docker runThe "~44 out of 300+" wording, and what --privileged is defined to do
Kubernetes securityContextSecurity Context · seccomp referenceEvery field used in this module, with its exact meaning
Cluster-wide policyPod Security Standards · Linux kernel security constraintsWhat baseline and restricted actually require
AppArmorapparmor(7) · aa-status(8) · apparmor.d(5)Ubuntu's manpages, not man7 — these pages do not exist there
systemd sandboxingsystemd.exec(5) · systemd-analyze(1)Every directive in section C3, and the 0.0–10.0 exposure scale

E4 · Self-assessment

Answer these out loud before moving to Module 14. The section to reread is named after each.

  1. Why was root split into capabilities at all? What problem did setuid create? (A1)
  2. A process has UID 0 and cannot chown a file. Explain, in one sentence. (A1)
  3. How many capabilities does your machine have, and how would you find out without guessing? (A1)
  4. Why does capabilities(7) single out CAP_SYS_ADMIN, and where does the "451 of 1167" figure come from? (A1)
  5. Name the five thread capability sets and what each answers. (A2)
  6. What is the difference between CapPrm and CapBnd, and why does an unprivileged shell have one empty and the other full? (A1, A2)
  7. Why do capabilities disappear across exec by default, and what are the two ways to make them survive? (A2)
  8. Name three places a file capability is silently ignored. (A2)
  9. How many capabilities does Docker grant by default, and which one should you drop first? (A3)
  10. unshare -U -r gives you all 41 capabilities and you still cannot read /etc/shadow. Why? (A3)
  11. What exactly does no_new_privs do, and can it be turned off? (B1)
  12. Why must an unprivileged process set no_new_privs before installing a seccomp filter? (B1)
  13. What does Seccomp: 2 mean, and where do you read it? (B2)
  14. Name four seccomp return actions and when you would use each. (B2)
  15. Why can seccomp restrict a container that is root with all capabilities, when nothing else here can? (B2)
  16. Why should a filter return ENOSYS rather than EPERM for an unknown call? (B3)
  17. Is RuntimeDefault applied by default in Kubernetes? Answer carefully. (B3)
  18. List everything --privileged removes — and the one thing it does not. (C2)
  19. A container works with SELinux permissive and fails enforcing. Why is setenforce 0 the wrong fix? (C1)
  20. You get "Operation not permitted". What are the two candidate causes, and what do you check first? (D1)
  21. A container exits 159 with no logs. What happened, and how do you confirm it? (B2, E2)
  22. Which systemd directive gives an unprivileged service the ability to bind port 443, and what else must be true for it to work? (A2, C3)

E5 · Sources

Everything in this module was checked against these. Where a claim is unusual — UID 0 failing a chown, seccomp ignoring capabilities entirely, --privileged switching off five separate defences, Kubernetes not applying RuntimeDefault by default — the source below is the one to cite.

Manual pages

· capabilities(7) · capsh(1) · getcap(8) · setcap(8) · getpcaps(8) · cap_get_proc(3)

· seccomp(2) · seccomp_unotify(2) · prctl(2) · PR_SET_NO_NEW_PRIVS(2const)

· proc_pid_status(5) · user_namespaces(7) · systemd.exec(5) · systemd-analyze(1)

· AppArmor pages are not on man7.org: apparmor(7) · aa-status(8) · apparmor.d(5)

Kernel documentation

· Seccomp BPF · no_new_privs

Runtime and orchestrator documentation

· Docker seccomp profiles · docker run reference · Running containers

· Kubernetes Security Context · Restrict syscalls with seccomp · seccomp reference · Pod Security Standards · Linux kernel security constraints

Analysis

· CAP_SYS_ADMIN: the new root — Michael Kerrisk, LWN, March 2012. The 451-of-1167 measurement, on Linux 3.2.

Things you should verify on your own machines rather than trust here

· The number of capabilities (/proc/sys/kernel/cap_last_cap), your kernel's active LSM (/sys/kernel/security/lsm), whether your kubelet sets seccompDefault, and your Docker version's default capability set. All four vary, and all four are one command away.

Next: Module 14 — Performance Methodology & Tracing. Modules 07 to 13 each gave you a set of numbers: run queues, page faults, iowait, cgroup pressure, capability masks. Module 14 is about the part nobody teaches — which number to look at first, and how to go from "the service is slow" to a named cause without guessing, using a method rather than a favourite tool.
Spotted a mistake or want something added? Send me a note.