Module 13 — Isolation: Capabilities & seccomp
Updated 22 August 2026
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)
From Module 02 — fork, 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 count is not a number to memorise — it is a file you read:
| Capability | What it lets a process do | Why you meet it |
| CAP_CHOWN | Change any file's owner | In Docker's default set; needed by package installs |
| CAP_DAC_OVERRIDE | Ignore file read/write/execute permission bits | The one that makes root "able to read anything" |
| CAP_NET_BIND_SERVICE | Bind a port below 1024 | The reason web servers used to start as root |
| CAP_NET_RAW | Raw and packet sockets | ping, tcpdump — and ARP/DNS spoofing from a compromised container |
| CAP_NET_ADMIN | Interfaces, routes, firewall rules | Anything that configures networking; CNI plugins |
| CAP_SYS_ADMIN | Mount, pivot_root, and a long tail of unrelated things | The junk drawer — see the warning below |
| CAP_SYS_PTRACE | Attach a debugger to another process | strace, gdb, and reading another process's memory |
| CAP_SYS_TIME | Set the system clock | NTP daemons |
| CAP_SYS_MODULE | Load and unload kernel modules | Effectively equal to full root — a module runs in the kernel |
| CAP_MKNOD | Create device nodes | In Docker's default set; combined with a host mount it is dangerous |
| CAP_SETUID / CAP_SETGID | Change UID/GID arbitrarily | How a service drops to an unprivileged user after start |
| CAP_SETFCAP / CAP_SETPCAP | Set capabilities on files / change your own bounding and inheritable sets | SETFCAP 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_PERFMON | Load BPF programs; use perf | Added in 5.8 so observability tools stop needing CAP_SYS_ADMIN |
| CAP_CHECKPOINT_RESTORE | Checkpoint and restore a process | Added in 5.9; currently the highest-numbered capability, number 40 |
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.
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
# 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
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/mountWhat 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.
🧪 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.
# 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)
--- 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: 1What 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 field | The question it answers |
| Permitted | CapPrm | What may this process switch on? The ceiling for Effective. |
| Effective | CapEff | What is switched on right now? This is the set the kernel actually checks. |
| Bounding | CapBnd | What may this process ever acquire, including across exec? Can only shrink. |
| Inheritable | CapInh | What may be kept across exec — but only if the new file also lists it. Legacy; rarely what you want. |
| Ambient | CapAmb | What 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.
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.
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.
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
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)
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 80What 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.
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.
# 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)
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 deniedWhat 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_OVERRIDE | File ownership and permission work — package installs, chown in entrypoints |
| SETUID, SETGID, SETPCAP | Dropping to a service user after start |
| NET_BIND_SERVICE | Listening on port 80 inside the container |
| NET_RAW | ping — and the first one you should drop, see below |
| SYS_CHROOT | chroot, used by some entrypoints |
| MKNOD | Creating device nodes |
| KILL | Signalling processes it does not own |
| AUDIT_WRITE | Writing audit records; needed by login-style tooling |
| SETFCAP | Setting 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.
The pattern to write down, because it is what an interviewer wants to hear:
# 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# 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 portA 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.
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)
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: 32What 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.
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:
| Property | Why it matters |
| Inherited across fork, clone and exec | Set it once at the top of a process tree and every descendant has it |
| Cannot be unset | There is no "off". A compromised process cannot undo it |
| Required for unprivileged seccomp | Without CAP_SYS_ADMIN, you must set it before you may install a seccomp filter |
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
# 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
-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.
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:
| Action | What happens | When you use it |
| SECCOMP_RET_KILL_PROCESS | The whole process dies with SIGSYS | Hard sandboxes. Added in Linux 4.14 |
| SECCOMP_RET_KILL_THREAD | Only the calling thread dies | The old SECCOMP_RET_KILL; rarely what you want |
| SECCOMP_RET_TRAP | SIGSYS is raised, and the process may catch it | Emulating a call in userspace |
| SECCOMP_RET_ERRNO | The call returns an error you choose, without running | The default for container profiles — EPERM is the usual choice |
| SECCOMP_RET_USER_NOTIF | A supervisor process is asked what to do | Added in Linux 5.0; how rootless runtimes emulate mount |
| SECCOMP_RET_TRACE | A ptrace supervisor decides | Debuggers and syscall interception |
| SECCOMP_RET_LOG | The call is allowed and logged | Added in 4.14. This is how you build a profile safely |
| SECCOMP_RET_ALLOW | Nothing happens; the call proceeds | The default action of a deny-list profile |
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.
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.
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.
# 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)
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: 159What 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.type | What it means |
| RuntimeDefault | Use the container runtime's own default — in practice, the profile above |
| Localhost | Use a JSON profile from a file on the node, named by localhostProfile |
| Unconfined | No filter at all. This is the default if you say nothing |
securityContext:
seccompProfile:
type: RuntimeDefaultThe 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.
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.
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
# 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
=== 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_setfcapWhat 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.
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:
| AppArmor | SELinux | |
| Ships on | Ubuntu, Debian, SLES (and openSUSE before 2025) | RHEL, Fedora, CentOS Stream — and openSUSE, whose Tumbleweed switched its default in February 2025 |
| Identifies things by | Path — /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 with | aa-status | getenforce, sestatus |
| Modes | enforce / complain / kill / unconfined | enforcing / permissive / disabled |
| Feels like | Readable; you can write one in an afternoon | Stronger; authoring is a specialism |
Both give you a learning mode — complain 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.
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.
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
# 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)
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)
unconfinedWhat 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.
| Restriction | What it does | Removed by --privileged? |
| Capabilities | Cuts 41 down to 14 | Yes — all 41 are granted |
| seccomp | Blocks ~44 system calls | Yes — Seccomp goes to 0 |
| AppArmor / SELinux | docker-default or container_t | Yes — becomes unconfined |
| Device cgroup | Only a handful of devices are usable | Yes — every host device appears |
| Masked and read-only paths | /proc/kcore and friends hidden; /sys read-only | Yes — real files, writable /sys |
| Namespaces | Its own PID, mount, network, UTS view | No — these are separate flags |
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 given | Why 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 mount | Write a unit file, a cron job or an SSH key and wait |
| --privileged, or CAP_SYS_ADMIN | Mount a host block device, or abuse cgroup interfaces to have the kernel run a program on the host |
| CAP_SYS_MODULE | Load a kernel module. There is nothing above the kernel to escape to |
| CAP_DAC_READ_SEARCH | Enables open_by_handle_at, which can reach files outside the container's root — the "Shocker" technique |
| hostPID: true plus CAP_SYS_PTRACE | Attach to a host process and inject code into it |
| A kernel vulnerability | The only one you cannot see in a manifest — and the reason the other controls exist |
🧪 Exercise C2.1 — See the two protections nobody mentions
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)
=== 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
unconfinedWhat 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.
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.
| Directive | Underlying mechanism | What it buys |
| NoNewPrivileges=yes | no_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-service | seccomp (Part B) | A curated allow-list; ~name makes it a deny-list instead |
| RestrictNamespaces=yes | seccomp on unshare/clone | The service cannot create namespaces — no containers from inside |
| ProtectSystem=strict | Mount namespace, read-only bind mounts | The entire filesystem is read-only except what you name |
| ProtectHome=yes, PrivateTmp=yes | Mount namespace | No /home; a private /tmp that disappears on stop |
| PrivateDevices=yes | Mount namespace + device cgroup | A minimal /dev — no disks, no raw hardware |
| ProtectKernelTunables/Modules/Logs= | Mounts + capability drops | No writing /proc/sys, no module loading, no reading the kernel log ring buffer (dmesg) |
| RestrictAddressFamilies= | seccomp on socket | Removing AF_PACKET alone kills most sniffing and spoofing |
| MemoryDenyWriteExecute=yes | seccomp on mmap/mprotect | No 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.
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
# 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)
--- 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.
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:
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.
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"]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
# --- 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)
== 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: 1What 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.
🏁 Part E · Practice, docs and self-check
E1 · Production practice
| Symptom in production | What is really happening | What to run | The fix |
| Container is root, and mount says Operation not permitted | CAP_SYS_ADMIN is not in the default 14 | capsh --decode=<CapEff> inside | Mount it from the host side; do not add the capability |
| Container exits 159 with no log line | seccomp KILL_PROCESS — 128 + 31, SIGSYS | Re-run with seccomp=unconfined to confirm | Find the call with an SCMP_ACT_LOG profile, then allow just that one |
| App reports a nonsense error after a security rollout | seccomp SCMP_ACT_ERRNO — a normal call returned EPERM | strace -f unconfined, compare | Same as above. Never resolve it by disabling seccomp |
| Works on Ubuntu nodes, fails on RHEL nodes | Different LSM — AppArmor versus SELinux | cat /sys/kernel/security/lsm; ausearch -m AVC -ts recent | Label the volume (:Z, container_file_t), do not disable SELinux |
| Binary loses its capability after every package update | File capabilities live on the file, and the file was replaced | getcap before and after | Use AmbientCapabilities= in the unit instead of setcap |
| setcap "works" but the program still cannot bind port 80 | The mount is nosuid, so file capabilities are ignored | findmnt -no OPTIONS -T <path> | Move the binary to a mount without nosuid |
| setcap fails outright with Operation not supported | NFS/FAT has no security.* xattr namespace | findmnt -no FSTYPE -T <path> | Local filesystem, or ambient capabilities from the launcher |
| An entrypoint using sudo breaks after a hardening change | no_new_privs disables setuid binaries | grep NoNewPrivs /proc/1/status | Remove sudo from the image; run as the right user from the start |
| Pod passes the "not privileged" policy and is still dangerous | capabilities.add: [SYS_ADMIN], or a hostPath mount | Query manifests for capabilities.add and hostPath | Admission 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 node | grep Seccomp /proc/1/status inside a pod | seccompDefault: true in kubelet config, or set RuntimeDefault per pod |
| Compromised container spoofs DNS for its neighbours | CAP_NET_RAW is in the default set | Decode 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 upgrade | A new vendor unit file replaced yours | systemd-analyze security <unit>; systemctl cat <unit> | Put hardening in a .d/ drop-in, and alert on score regression |
E2 · Capstone — four hardening tickets
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 securityContext — seLinuxOptions 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
| Topic | Where to read it | Why this one |
| All 41 capabilities, and the exec rules | capabilities(7) | Start here. The transformation formulas and the CAP_SYS_ADMIN warning are both in it |
| Why CAP_SYS_ADMIN is the way it is | CAP_SYS_ADMIN: the new root | The source of the 451-of-1167 figure, with its Linux 3.2 context |
| Reading and setting capabilities | capsh(1) · getcap(8) · setcap(8) · getpcaps(8) | --decode and --drop are the two you will use constantly |
| The Cap* fields in /proc | proc_pid_status(5) | Defines CapInh/Prm/Eff/Bnd/Amb, NoNewPrivs and Seccomp precisely |
| no_new_privs | PR_SET_NO_NEW_PRIVS(2const) · kernel docs | The 2const page is the authoritative one; prctl(2) now points at it |
| seccomp modes and return actions | seccomp(2) · Seccomp BPF | The action precedence order and the version each was added |
| Supervised syscalls | seccomp_unotify(2) | How rootless runtimes emulate mount; RET_USER_NOTIF since 5.0 |
| Docker's profile and flags | Seccomp profiles · docker run | The "~44 out of 300+" wording, and what --privileged is defined to do |
| Kubernetes securityContext | Security Context · seccomp reference | Every field used in this module, with its exact meaning |
| Cluster-wide policy | Pod Security Standards · Linux kernel security constraints | What baseline and restricted actually require |
| AppArmor | apparmor(7) · aa-status(8) · apparmor.d(5) | Ubuntu's manpages, not man7 — these pages do not exist there |
| systemd sandboxing | systemd.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.
- Why was root split into capabilities at all? What problem did setuid create? (A1)
- A process has UID 0 and cannot chown a file. Explain, in one sentence. (A1)
- How many capabilities does your machine have, and how would you find out without guessing? (A1)
- Why does capabilities(7) single out CAP_SYS_ADMIN, and where does the "451 of 1167" figure come from? (A1)
- Name the five thread capability sets and what each answers. (A2)
- What is the difference between CapPrm and CapBnd, and why does an unprivileged shell have one empty and the other full? (A1, A2)
- Why do capabilities disappear across exec by default, and what are the two ways to make them survive? (A2)
- Name three places a file capability is silently ignored. (A2)
- How many capabilities does Docker grant by default, and which one should you drop first? (A3)
- unshare -U -r gives you all 41 capabilities and you still cannot read /etc/shadow. Why? (A3)
- What exactly does no_new_privs do, and can it be turned off? (B1)
- Why must an unprivileged process set no_new_privs before installing a seccomp filter? (B1)
- What does Seccomp: 2 mean, and where do you read it? (B2)
- Name four seccomp return actions and when you would use each. (B2)
- Why can seccomp restrict a container that is root with all capabilities, when nothing else here can? (B2)
- Why should a filter return ENOSYS rather than EPERM for an unknown call? (B3)
- Is RuntimeDefault applied by default in Kubernetes? Answer carefully. (B3)
- List everything --privileged removes — and the one thing it does not. (C2)
- A container works with SELinux permissive and fails enforcing. Why is setenforce 0 the wrong fix? (C1)
- You get "Operation not permitted". What are the two candidate causes, and what do you check first? (D1)
- A container exits 159 with no logs. What happened, and how do you confirm it? (B2, E2)
- 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
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
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.