Seccomp and AppArmor for Kubernetes Workloads
RuntimeDefault seccomp and AppArmor annotations for Kubernetes: what syscalls they block, why they do not stop API abuse, and when audit mode is lying to you.
A reverse shell that calls mount or unshare is a seccomp problem. A reverse shell that curls the API server with the projected token is not. Kubernetes seccomp AppArmor sit on the kernel side of that split.
This is not a substitute for signed images, NetworkPolicy, or admission. Those patterns are in cloud-native application security. This page is the two LSMs you actually put on the Pod spec, and the attacks they will not notice.
Userspace process
│ allowed syscalls only
▼
seccomp filter → AppArmor profile → kernel
│
└── HTTPS to kube-apiserver / STS (seccomp does not care)
Seccomp profiles RuntimeDefault
Pod Security Admission restricted already wants seccompProfile.type: RuntimeDefault (or a localhost profile). Unconfined is the finding.
apiVersion: v1
kind: Pod
metadata:
name: payments-api
namespace: payments
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: ghcr.io/example/payments@sha256:…
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
RuntimeDefault is containerd/CRI-O’s stock profile: deny unshare, mount, reboot, a chunk of bpf, kexec_load, and other “I am becoming the node” calls. It still allows execve, connect, open, read, write. That is enough for a web app and enough for a worm that talks HTTP.
Localhost plus localhostProfile: profiles/payments.json is the next step when you have a recorded syscall set (crictl / seccomp-operator / a gVisor-style list). Do not copy a profile from a blog that blocks clone and then wonder why Go’s runtime dies.
seccompProfile:
type: Localhost
localhostProfile: operator/default/payments-api.json
The profile file must exist on the node. Autopilot and managed node images will not include your custom JSON unless you use a mechanism the provider supports (often: you don’t, so stay on RuntimeDefault).
Unconfined on a single sidecar undoes the Pod-level profile for that container. Istio/Linkerd old templates did this. Pin a mesh version that runs under RuntimeDefault, or accept that the sidecar is your weakest container.
# Catch Unconfined in rendered manifests
yq -e '
.. | select(has("seccompProfile")) | .seccompProfile
| select(.type == "Unconfined")
' rendered.yaml && exit 1
SELinux is a third LSM (OpenShift, some RHEL nodes). Do not mix “we set RuntimeDefault” with “we thought that was SELinux.” Check sestatus / node OS.
AppArmor annotations
AppArmor is not a securityContext field in stable Kubernetes. It is still an annotation on the Pod:
apiVersion: v1
kind: Pod
metadata:
name: payments-api
namespace: payments
annotations:
container.apparmor.security.beta.kubernetes.io/app: runtime/default
spec:
containers:
- name: app
image: ghcr.io/example/payments@sha256:…
runtime/default is the runtime’s stock profile (similar idea to seccomp RuntimeDefault). A custom profile is a name you loaded on the node (apparmor_parser). Same node-file problem as localhost seccomp.
What AppArmor adds over seccomp: path and socket rules — “this process may not write /etc,” “may not use raw sockets.” Seccomp is syscall number. AppArmor is LSM labels on files. A Python interpreter that should only read /app can be told so; seccomp cannot express that.
GKE: COS supports AppArmor. EKS: Amazon Linux 2023 / Bottlerocket often do not load AppArmor. AKS: Ubuntu nodes do. If you enforce the annotation cluster-wide, Bottlerocket node groups will fail scheduling or ignore the profile. Split policy by node OS, or only require AppArmor where cat /sys/module/apparmor/parameters/enabled is Y.
# Node check (DaemonSet or SSH on Standard)
cat /sys/module/apparmor/parameters/enabled
sudo aa-status | head
A custom profile that deny /proc/1/** sounds clever until the liveness probe’s exec needs /proc. Test in a namespace with enforce only after complain is quiet.
What they do not stop (API abuse)
Compromise model: attacker gets RCE in payments-api as UID 65532, RuntimeDefault + AppArmor runtime/default, no extra caps.
Still available:
| Action | Blocked by seccomp/AppArmor? | Actually blocked by |
|---|---|---|
Read /var/run/secrets/.../token and call kube-apiserver |
No | automountServiceAccountToken: false, tight Role |
HTTPS to sts.amazonaws.com with IRSA token |
No | IAM role scope |
| Exfiltrate via the app’s own outbound 443 | No | NetworkPolicy / egress proxy |
kubectl exec into this pod from a stolen human kubeconfig |
N/A (not this process) | RBAC on pods/exec |
Exploit a kernel 0-day via an allowed syscall (read on a buggy driver) |
Maybe not | Node patching, gVisor/Kata |
Seccomp will stop a noisy container-escape script that tries mount -t overlay or unshare -U. It will not stop “I am the app, I already have the cloud role.” If the security review stops at RuntimeDefault, you hardened the syscall table and left the identity plane open. Bound tokens and Role scope are the RBAC production checklist.
Do not file “enable seccomp” as the fix for a public Service with cluster-admin on the SA. That is category error.
Enforcement vs audit
PSA audit / warn on seccomp does not load a filter. It only annotates events when a pod would have failed restricted. The process is still Unconfined.
AppArmor complain (profile in complain mode, or a policy tool that only logs) writes to audit logs and allows the access. Teams leave complain on for a year because “the profiler isn’t done.” That is Unconfined with extra syslog.
| Mode | What the kernel does | Honest use |
|---|---|---|
| seccomp RuntimeDefault / localhost | SIGSYS or EPERM on deny | Production |
| seccomp Unconfined + PSA audit | Nothing | Migration telemetry only |
| AppArmor enforce | Deny + audit | Production on AppArmor nodes |
| AppArmor complain | Allow + audit | Time-boxed profile build, expiry date on the ticket |
Admission should fail Unconfined on app namespaces. ValidatingAdmissionPolicy or your policy engine: seccompProfile.type != Unconfined. Audit-only PSA is a dashboard, not a control.
RuntimeDefault will break a surprise mknod or nested container builder. That is the point. Run those jobs in a dedicated namespace with a documented localhost profile, not by flipping the whole cluster to Unconfined.
Checklist
- App namespaces: Pod
securityContext.seccompProfile.typeisRuntimeDefaultorLocalhost, neverUnconfined - Sidecars inherit the same rule; mesh version confirmed under RuntimeDefault
- Custom localhost profiles live on the node image you actually run (or you stay on RuntimeDefault)
- AppArmor annotation required only on node pools where AppArmor is loaded
- Complaining/audit modes have an expiry; production is enforce
- SA tokens and IAM roles reviewed — LSM is not the control for API/STS
- CI greps rendered YAML for
type: Unconfinedand extra capabilities
RuntimeDefault is cheap. Identity and network are the rest of cloud-native application security. Do not let a green CIS “seccomp” row close a finding that was always on the API.