
Last topic was all watching. We learned what a syscall is, why every container on a box shares the host's one kernel, and how to see the calls a workload makes with strace and Tracee. I promised the payoff would come next: taking that list and telling the kernel to block everything else. This is that post.
The tool is seccomp, and it's one of those controls with a lovely ratio, a few lines of config for a genuinely smaller attack surface. But there's a catch that trips up almost everyone, and it's the reason I wanted to write this properly rather than paste a YAML snippet: Kubernetes does not turn seccomp on for you. Docker does. Kubernetes doesn't. So a pod you thought was sandboxed often isn't.
What seccomp actually does
Seccomp (secure computing) is a Linux kernel feature that filters which syscalls a process may make. You hand a process a profile, a list of allowed calls, and the kernel refuses everything not on it. That's the whole idea. It builds a little sandbox around the workload at the exact boundary we spent last topic looking at: the doorway between the program and the kernel.
Why bother? Because Linux offers over 400 syscalls, and a typical application uses a small fraction of them. Every call you leave enabled but never use is just sitting there, available to an attacker who lands inside your container. This is least privilege again, applied one level deeper than users and firewalls, down at the kernel interface. Block the calls the app doesn't need, and if it's ever compromised, the attacker finds the dangerous tools already taken away.
An app that only ever needs a couple of dozen syscalls has no business being able to callmount,rebootorkexec_load. Seccomp is how you take those away without touching the app.
The stakes aren't hypothetical. Dirty COW (CVE-2016-5195) was a race condition in how the kernel handled copy-on-write memory; attackers used it to write to files they should only have been able to read, escalate to root, and in container setups break out to the host. It's the canonical reminder that a container reaching a buggy kernel through the wrong syscall is a real path to the whole machine. A tight seccomp profile is one of the layers that makes that harder.
The three modes (and where filter mode came from)
Seccomp isn't new. Strict mode arrived back in 2005 (kernel 2.6.12), and the flexible filter mode we actually use, the one that reads a BPF profile, landed in kernel 3.5 in 2012. There are three modes worth knowing by name, because Kubernetes and the tools report them by number:
No filter. The process can make any syscall the kernel offers.
Only four calls: read, write, exit, sigreturn. Too tight for real apps.
A profile decides call-by-call. This is the useful one, and what your containers use.
Seccomp: 2 or filtering, it means a filter profile is active.You can confirm your kernel even supports filter mode before relying on it:
grep -i seccomp /boot/config-$(uname -r)
# CONFIG_HAVE_ARCH_SECCOMP_FILTER=y
# CONFIG_SECCOMP_FILTER=y
# CONFIG_SECCOMP=y
Three ys and you're good. On any modern distro this is a formality, but it's a nice five-second check before you go blaming a profile that was never going to load.
Docker already does this (which is the trap)
Here's where the false sense of security creeps in. When your host supports seccomp, Docker applies a sensible default profile to every container automatically, a JSON whitelist that allows roughly 60 common syscalls and blocks the dangerous rest. You can watch it work. This little whale container tries to change the system clock and the kernel says no:
settimeofday isn't on Docker's default allow-list, so it fails, and /proc/1/status confirms a filter is on.To actually see what the default profile takes away, I like amicontained (a little container-introspection tool). Run it as a plain Docker container and it reports the filter is on and counts what's blocked:
docker run r.j3ss.co/amicontained amicontained
# Seccomp: filtering
# Blocked Syscalls (64):
# MOUNT UMOUNT2 REBOOT SWAPON SWAPOFF SETTIMEOFDAY
# INIT_MODULE DELETE_MODULE FINIT_MODULE KEXEC_LOAD
# UNSHARE SETNS PROCESS_VM_READV ADD_KEY KEYCTL ...
Look at that block-list: mounting filesystems, rebooting, loading kernel modules, changing the clock, joining other namespaces. Exactly the breakout-flavoured calls we flagged in the last topic. Docker takes them off the table for you. And that's the trap, because you assume Kubernetes inherits the same protection. It doesn't.
Kubernetes runs your pods Unconfined
Deploy the very same image as a pod and the picture changes. By default, unless your cluster has been set up otherwise, a Kubernetes pod runs with seccomp set to Unconfined, no filter at all.
kubectl run amicontained --image=r.j3ss.co/amicontained -- amicontained
kubectl logs amicontained
| Where it runs | Seccomp | Syscalls blocked |
|---|---|---|
Plain docker run | filtering (2) | 64 |
| Default Kubernetes pod | disabled | 21 |
Read that table twice, because it's the entire reason this post exists. The Docker protection you were relying on does not follow the image into Kubernetes. The pod's remaining protections come from dropped capabilities and namespaces, useful, but not the syscall filter you thought you had. For years this was pure surprise; even now, unless the cluster's kubelet has the SeccompDefault setting switched on, every pod you launch is Unconfined until you say otherwise.
Turning it on: one line
The good news is the fix is tiny. Add a seccompProfile to the pod's security context and set it to RuntimeDefault, which tells Kubernetes to use the container runtime's built-in profile, the same curated filter Docker was giving you:
apiVersion: v1
kind: Pod
metadata:
name: amicontained
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # <-- the whole fix
containers:
- name: amicontained
image: r.j3ss.co/amicontained
args: ["amicontained"]
securityContext:
allowPrivilegeEscalation: false
Apply it, check the logs, and now amicontained reports Seccomp: filtering with the full ~64 dangerous calls blocked again, parity with Docker, in one line. I've paired it with allowPrivilegeEscalation: false here because they belong together: one stops the process gaining new privileges, the other shrinks the calls it can make at all. Cheap, and they compound.
seccompProfile: { type: RuntimeDefault } on your pods (or enable it cluster-wide). It's the difference between the table's "disabled / 21" and "filtering / 64" for basically no effort or app impact.The three profile types
That type field takes one of three values, and it's worth knowing all three:
Use the runtime's built-in filter. The easy, sensible win for almost every pod.
No filter at all. The current default. Only choose it deliberately, and rarely.
Load a custom JSON profile from the node via localhostProfile. For when you want to go tighter than default.
Writing a custom profile: whitelist beats blacklist
A seccomp profile is just JSON with three parts that matter: the architectures it applies to, a list of syscalls with an action each, and a defaultAction for everything not listed. That last field is the whole personality of the profile, and it splits into two philosophies.
Both work, but they are not equally safe. A whitelist denies by default and allows a named set, so a dangerous syscall you'd never heard of stays blocked because you didn't list it. A blacklist allows by default and only blocks what you name, so the one nasty call you forgot is wide open. Blacklists are easier to write and that's exactly why they bite, you're betting you thought of every bad call, and you didn't. Prefer whitelists.
How to build the right list: audit first
So a whitelist is safest, but how do you know which calls to allow without breaking the app? You don't guess, that's the mistake I warned about last topic. You audit. Seccomp has a special action, SCMP_ACT_LOG, that allows every call but logs it. Run the app under a log-everything profile, put it through its normal paces, and the kernel writes down exactly what it used.
# audit.json - allow everything, but record it
{ "defaultAction": "SCMP_ACT_LOG" }
Reference it as a Localhost profile (the path is relative to the kubelet's seccomp directory, usually /var/lib/kubelet/seccomp), run the workload, then read what it called from the node's log:
grep -i syscall /var/log/syslog
# ... comm="runc:[2:INIT]" syscall=257 ... (257 = openat)
# ... syscall=35 ... (35 = nanosleep)
Between those audit logs and a Tracee session watching the container, you get the real, finite list of calls the workload actually makes. Turn that into a whitelist, and you've got a profile that fits the app like a glove, everything it needs allowed, everything else denied.
{ "defaultAction": "SCMP_ACT_ERRNO" } blocks even the calls a container needs to start, so the pod never runs, you'll see it stuck in ContainerCannotRun. A profile can absolutely be too tight. That's why you audit to find the real list instead of guessing at zero.Run under SCMP_ACT_LOG. Allow all, record all.
Read the syscalls from syslog, or watch with Tracee.
Allow exactly those calls; defaultAction denies the rest.
Load it as a Localhost profile and confirm the pod runs.
Where this leaves the attacker
From the offensive chair, the first thing I now check inside any container is grep Seccomp /proc/1/status. A 0 is a small celebration: no filter, so the breakout-flavoured calls, mount, unshare, setns, might all work. A 2 means someone did their homework, and my usual escape tools quietly return "operation not permitted". That single number tells me how much of my playbook just got deleted. Turning it from a 0 into a 2 is one of the cheapest wins a defender has.
seccompProfile: RuntimeDefault to every pod → audit with SCMP_ACT_LOG + Tracee, then ship a whitelist for anything that deserves tighter.Where this leaves things
So that's the two-parter closed. Last topic we learned to watch the door between a program and the kernel; this topic we learned to shut most of it. Seccomp filters syscalls to a set you choose; Docker applies a good default automatically; Kubernetes leaves your pods Unconfined until you add seccompProfile; whitelists beat blacklists because the call you forget stays shut; and when you want a bespoke profile, you audit with SCMP_ACT_LOG and Tracee rather than guessing.
The honest bit: RuntimeDefault everywhere is easy and I'll happily push it on anyone. Writing per-workload whitelists is where I'm still slow, getting the list right takes real testing, and too tight means a pod that won't boot at 2am. So my practical stance right now is: RuntimeDefault as the baseline on everything, custom profiles only for the handful of workloads that genuinely justify the effort. If you're running tailored seccomp profiles at scale and have a sane way to generate and maintain them, that's the bit I'd most like to learn, tell me how you keep them from rotting.
FAQ
What is seccomp?
Seccomp (secure computing) is a Linux kernel feature that filters which system calls a process is allowed to make. You give a process a profile listing the syscalls it may use, and the kernel blocks the rest. It wraps a sandbox around the workload and shrinks what an attacker can do if they land inside it.
Does Kubernetes enable seccomp by default?
No. Unless you set a seccompProfile or your cluster has the SeccompDefault kubelet setting turned on, a pod runs Unconfined, with no seccomp filter at all. Docker applies a default filter to plain containers, but that protection does not carry over to Kubernetes pods automatically, which surprises a lot of people.
What is the difference between a whitelist and blacklist seccomp profile?
A whitelist sets defaultAction to block (SCMP_ACT_ERRNO) and lists only the syscalls it allows, so anything you forget is denied. A blacklist allows everything by default and only names calls to block. Whitelists are safer because forgetting a dangerous syscall leaves it blocked, not open.
What are the seccomp profile types in a Kubernetes pod?
Three: RuntimeDefault uses the container runtime's built-in profile (the easy win); Unconfined applies no filter at all (the default); and Localhost loads a custom JSON profile from the node, referenced by localhostProfile relative to the kubelet's seccomp directory.
How do I build a custom seccomp profile for my app?
Run the app in audit mode with a profile whose defaultAction is SCMP_ACT_LOG, exercise its normal behaviour, then read the logged syscalls from the node's syslog (or watch with Tracee). Turn that list into a whitelist profile that allows exactly those calls and denies the rest, then load it as a Localhost profile.
Related reading
- Topic 24: Linux Syscalls, strace & Tracee (part one, how to see the calls this post blocks)
- Topic 23: Least Privilege, Two Ways (the same principle, for identity and network)
- Topic 16: Securing the Docker Daemon (why the shared kernel makes containers different)
- Browse the whole Kubernetes Journey