seccomp: filter syscalls

RuntimeDefault and curated Localhost profiles.

Advanced14 min · lesson 11 of 24

A container is just a process sharing the host kernel. There's no little computer inside it; the isolation you picture is a few kernel features (namespaces, cgroups, capabilities) drawing lines around one ordinary process. Every file that process opens and every packet it sends goes through a syscall, a call down into that one shared kernel. There are roughly 400 of them. Your app probably touches 60. The rest are attack surface you never asked for: calls like mount, ptrace, kexec_load, and bpf that mostly exist so an attacker who already runs code in your container can reach for a kernel bug and climb out onto the node.

seccomp (secure computing mode) is the kernel feature that decides which syscalls a process is allowed to make. It works like a phone that only rings for numbers already in your contacts and drops the rest to voicemail. That's a syscall allow-list, and it's the cheapest real cut you can make to that 340-call surface. Here's what catches teams out: Kubernetes leaves every pod seccomp-unconfined by default. The filter your container runtime already ships is sitting right there, switched off, until you ask for it.

The one-line win: RuntimeDefault

Your runtime (containerd or CRI-O) already ships a curated seccomp profile that blocks the dangerous calls and leaves normal apps alone. It's the same profile Docker has shipped and hardened for years. RuntimeDefault just tells the kubelet to switch it on for the pod. It's the highest-value line in the whole spec: almost no chance of breaking a working app, and it's exactly what the restricted Pod Security Standard asks for. Set it at the pod level and every container inherits it, unless a container names its own seccompProfile, which then wins for that one container.

pod-seccomp.yaml
apiVersion: v1
kind: Pod
metadata: { name: web, namespace: payments }
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # the runtime's curated allow-list
containers:
- name: app
image: registry.internal/web:1.4.2
terminal
# apply, then prove two things: the profile is attached, and it blocks
$ kubectl apply -f pod-seccomp.yaml
pod/web created
$ kubectl get pod web -n payments \
-o jsonpath='{.spec.securityContext.seccompProfile.type}'
RuntimeDefault
# creating a user namespace calls unshare, which the default profile denies
$ kubectl exec -n payments web -- unshare --map-root-user --user
unshare: unshare failed: Operation not permitted

Force it on every pod, and prove a bare one bounces

One good pod is easy. The next engineer who applies a pod with no profile is the problem. Close that gap at admission, the moment a pod is created, before anything runs. Think of admission control as a bouncer checking a guest list at the door: a pod that fails the check never gets created, so there's nothing to clean up later. Pod Security Admission, or PSA, is built into the API server. Label a namespace to enforce the restricted standard, and any pod that doesn't meet it gets turned away at creation. A missing seccomp profile is one of the things restricted checks. For rules the built-in levels don't cover, Kyverno or Gatekeeper (the latter built on OPA, the Open Policy Agent) can demand one specific named profile.

terminal
# enforce the restricted standard in the namespace
$ kubectl label ns payments \
pod-security.kubernetes.io/enforce=restricted --overwrite
namespace/payments labeled
# a bare pod fails restricted on several controls, seccomp among them
$ kubectl run nofilter --image=registry.internal/web:1.4.2 -n payments
Error from server (Forbidden): pods "nofilter" is forbidden: violates
PodSecurity "restricted:latest": allowPrivilegeEscalation != false
(container "nofilter" must set securityContext.allowPrivilegeEscalation
=false), unrestricted capabilities (container "nofilter" must set
securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or
container "nofilter" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "nofilter" must set
securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

There's also a cluster-wide switch. Turn on the kubelet's SeccompDefault feature (the --seccomp-default flag, or seccompDefault: true in the kubelet config) and every pod that never names a profile gets RuntimeDefault instead of Unconfined. It's a strong default to reach for. It also changes behavior for every workload on the node at once, so roll it out node by node and watch for an app that was quietly leaning on a syscall the default profile blocks.

When RuntimeDefault isn't tight enough: a Localhost profile

RuntimeDefault fits almost everything. But a high-value target, say a payment signer or an internet-facing file parser, doesn't need most of what even the default allows. For those you cut a profile by hand that permits only the syscalls that one app actually uses. That's a Localhost profile: a JSON file you drop on every node, referenced by a path relative to the kubelet's seccomp directory. The kubelet reads from /var/lib/kubelet/seccomp/, so localhostProfile: profiles/web.json means /var/lib/kubelet/seccomp/profiles/web.json. It's the difference between the runtime's off-the-shelf guest list and one you write name by name.

You don't guess that list. You record it. Run the app under an audit profile whose default action is SCMP_ACT_LOG, which allows every call but writes a line to the kernel audit log for it. Exercise the app hard, then read back which syscalls it actually made. Reference the audit profile from the pod with seccompProfile: { type: Localhost, localhostProfile: profiles/audit.json }.

Building a real allow-list: observe, then deny
1Audit:SCMP_ACT_LOGrun the app, block nothing,…2Exercise everypathreal traffic, error paths, and…3Read the audit logcollect the syscall numbers…4Tighten:SCMP_ACT_ERRNOdefault-deny, allow only that…5Verifyre-run and confirm zero new…
You observe the allow-list, you never guess it. Keep the profile in LOG mode in production first, then flip the default action to ERRNO.
/var/lib/kubelet/seccomp/profiles/audit.json
{
"defaultAction": "SCMP_ACT_LOG",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": []
}
terminal
# with the app running under audit.json, read back what it called.
# auditd stores every SCMP_ACT_LOG hit as a type=SECCOMP record
$ sudo ausearch -m SECCOMP -i -ts today | head -6
----
type=SECCOMP msg=audit(07/27/2026 02:10:01.101:409) : ... pid=31245 ... syscall=execve ... code=log
----
type=SECCOMP msg=audit(07/27/2026 02:10:01.101:410) : ... pid=31245 ... syscall=arch_prctl ... code=log
----
type=SECCOMP msg=audit(07/27/2026 02:10:01.102:411) : ... pid=31245 ... syscall=mmap ... code=log
# now the whole list, deduplicated: this is what the profile has to allow
$ sudo ausearch -m SECCOMP -i -ts today \
| grep -o 'syscall=[a-z0-9_]*' | cut -d= -f2 | sort -u
arch_prctl
brk
clock_gettime
close
connect
execve
exit
exit_group
fstat
futex
getrandom
mmap
mprotect
munmap
newfstatat
openat
pread64
read
rseq
rt_sigaction
rt_sigprocmask
set_robust_list
set_tid_address
socket
write
# no auditd on the node? the kernel prints the same records itself,
# with the numeric type and raw syscall numbers (59 = execve)
$ sudo journalctl -k | grep 'type=1326' | tail -1
audit: type=1326 audit(1753581001.101:409): ... pid=31245 ... syscall=59 ... code=0x7ffc0000

Now flip the default action from log to deny, and allow exactly what the audit recorded. Twenty-five distinct calls, for a program whose day job is to open one socket and sign some bytes. Most of that list runs before your main function does: the dynamic loader asks for arch_prctl, mmap and mprotect to map the binary and its libraries, and glibc startup adds set_tid_address, rseq and a few more. Drop one of those because it looks like plumbing and the container dies the moment it starts, with an error that reads like a broken image rather than a broken profile. If a name on the list surprises you, find out which code path makes it before you allow it.

SCMP_ACT_ERRNO refuses anything not on the list and hands the process an EPERM (Operation not permitted) instead of killing it outright, so a blocked call looks like a plain permission error inside the container. Keep execve and execveat on the list, by the way: the profile is loaded just before your program is launched, so if the very call that starts it isn't allowed, the container never even boots.

/var/lib/kubelet/seccomp/profiles/web.json
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["execve", "execveat", "arch_prctl", "brk", "mmap",
"mprotect", "munmap", "openat", "read", "pread64",
"fstat", "newfstatat", "close", "socket", "connect",
"write", "futex", "rseq", "set_tid_address",
"set_robust_list", "rt_sigaction", "rt_sigprocmask",
"getrandom", "clock_gettime", "exit", "exit_group"],
"action": "SCMP_ACT_ALLOW" }
]
}
terminal
# the signer pod references localhostProfile: profiles/web.json
$ kubectl get pod signer -n payments \
-o jsonpath='{.spec.securityContext.seccompProfile.localhostProfile}'
profiles/web.json
# a syscall we deliberately left off the list is refused with EPERM
$ kubectl exec -n payments signer -- chmod 4755 /bin/busybox
chmod: /bin/busybox: Operation not permitted

Seccomp does its filtering inside the kernel, at syscall entry: the call arrives, the filter checks it against your profile, and a denied call never reaches the code that implements it. That is what shrinks the blast radius of many container-escape bugs, without you rewriting the app.

Custom profiles break quietly when incomplete. Record the allow list in git next to the Deployment, and test the app under load before you mandate Localhost.

Combining seccomp with dropped capabilities and a read-only root filesystem is how you turn "RCE in the app" into "stuck unprivileged process."

The failure you actually hit is a library upgrade. A new libc or a new crypto library starts calling something the audit run never saw, and the app returns "operation not permitted" from somewhere odd instead of failing cleanly. Version the profile file on the node, web-1.4.json beside web-1.5.json, so a rollback is a one-line edit to localhostProfile rather than a redeploy of every node.

Try this

Create a pod with RuntimeDefault, then try a bare pod without a profile under a restricted namespace and watch admission or runtime bounce it.

terminal
$ kubectl -n payments apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: seccomp-ok }
spec:
securityContext:
seccompProfile: { type: RuntimeDefault }
containers:
- name: c
image: busybox:1.36
command: ["sleep","3600"]
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
capabilities: { drop: ["ALL"] }
EOF
pod/seccomp-ok created
$ kubectl -n payments get pod seccomp-ok -o jsonpath='{.status.containerStatuses[0].state}'
{"running":{"startedAt":"2026-07-24T02:10:01Z"}}
$ kubectl -n payments exec seccomp-ok -- cat /proc/1/status | grep Seccomp
Seccomp: 2

Takeaway

RuntimeDefault is the one-line win. Enforce it with PSS or a policy engine, and only build Localhost profiles when you can prove the extra syscalls are required.

Quick check
01You built a Localhost profile from an audit run, shipped it as SCMP_ACT_ERRNO, and a week later the app crashes, but only when it hits a rare error path. Most likely cause?
Incorrect — That fails the pod at creation with a CreateContainerError, not a clean run that crashes later on one code path.
Correct — An allow-list is only as complete as the paths you drove while recording. Error handlers, signal handling, and cache warmup call syscalls the happy path never touches.
Incorrect — The runtime default doesn't override a profile you explicitly set; the Localhost profile is what applies.
Incorrect — Profiles don't expire; the file stays in effect for the life of the container.
02What seccomp posture does a Kubernetes pod run with by default, and what does seccompProfile.type: RuntimeDefault change?
Incorrect — the filter ships with the runtime but is switched off until you ask for it.
Incorrect — the default is unconfined, not deny-all.
Incorrect — a custom JSON file on the node is a Localhost profile, not RuntimeDefault.
Correct — Kubernetes leaves pods unconfined, and RuntimeDefault turns on the runtime's existing hardened allow-list.
03A pod sets seccompProfile.type: RuntimeDefault at the pod level. A teammate adds a sidecar that sets seccompProfile.type: Unconfined in that container's own securityContext. What's the effect?
Incorrect — this isn't a conflict; a container setting is allowed to override the pod default.
Incorrect — the pod-level value is only a default; the more specific container setting wins.
Correct — the most specific setting wins, so one Unconfined sidecar drops the filter for a process sharing the pod's network and usually its volumes.
Incorrect — only the container that names Unconfined is affected; the main container keeps RuntimeDefault.
A container-level profile silently overrides the pod default
You set RuntimeDefault on the pod and assume every container is covered. Then someone adds a sidecar with seccompProfile.type: Unconfined at the container level, and only that container runs with no filter at all. The pod-level profile is only a default, and the most specific setting wins. One unconfined sidecar reopens the entire syscall surface for a process that shares the pod's network and usually its volumes. Catch it the way you catch privileged containers: with an admission policy that rejects container-level Unconfined, not a code review that hopes someone spots it.

Related