Container Internals and Runtime Engineering

Seccomp, AppArmor, and SELinux

You want to restrict which syscalls a container can make, so that even if it's compromised, it can't do much. Three technologies can do this. Which one, and how?

Namespaces limit what a process can see, cgroups limit what it can use, and capabilities limit what privileged operations it can perform. This lesson adds the last layer of the kernel-level boundary: restricting the process at the syscall and access-control level, so that even a compromised, capable process is boxed in. Three mechanisms do this, and they are complementary rather than competing. The scenario's answer is not "pick one," it is "these operate at different layers and you use them together as defense in depth."

KEY CONCEPT

Every interaction a container has with the kernel is a syscall. Namespaces and capabilities decide whether an operation is allowed in principle; seccomp decides which syscalls the process may even attempt, and the LSMs (AppArmor, SELinux) decide which specific objects it may touch. Layering them means a single bypass at one layer still meets another wall.

seccomp: filter the syscalls themselves

seccomp-bpf attaches a BPF program to a process that the kernel consults on every syscall. The program inspects the syscall number (and sometimes arguments) and returns an action: allow, return an error (SCMP_ACT_ERRNO), or kill the process (SCMP_ACT_KILL). It is the narrowest, most direct control: it shrinks the set of kernel entry points the process can reach at all.

Docker and containerd apply a default seccomp profile to every container. It allows the large majority of the roughly 300-plus syscalls an ordinary program needs and blocks a few dozen dangerous or rarely-legitimate ones, for example reboot, mount, swapon, kexec_load, init_module, and old-kernel key-management calls. That default alone removes a meaningful slice of kernel attack surface for free.

# See that the default profile is active (not "unconfined")
docker inspect --format '{{ .HostConfig.SecurityOpt }}' <container>

# The dangerous but educational opposite: turn the filter off
docker run --security-opt seccomp=unconfined ...   # do NOT do this in production

# Apply your own tighter profile
docker run --security-opt seccomp=./my-profile.json ...

The point of a custom profile is to go further than the default: allow only the syscalls a specific workload actually issues. Lesson 7.3 covers engineering these profiles in depth.

AppArmor and SELinux: control which objects, not which syscalls

seccomp does not know about files or paths; it only sees syscall numbers. The Linux Security Modules (LSMs) fill that gap by mediating access to specific objects. Two implementations dominate, and a host runs one of them.

  • AppArmor is path-based. A profile says "this program may read /etc/nginx, write /var/log/nginx, and nothing else." Containerd ships a docker-default (or cri-containerd.apparmor.d) profile. This is the default on Debian and Ubuntu.
  • SELinux is label-based. Every process and every file carries a security label; a policy of type-enforcement rules says which process types may touch which object types. For containers it also uses MCS (Multi-Category Security): the runtime assigns each container a unique category pair, so container A is labeled differently from container B and the kernel forbids A from reading B's files even if the paths line up. This is the default on RHEL, Fedora, and CentOS Stream.
# SELinux: see the container process label and its MCS categories
ps -eZ | grep container
# system_u:system_r:container_t:s0:c123,c456  ... the c123,c456 is this container's unique MCS pair

The practical difference: AppArmor reasons about paths, SELinux reasons about labels. SELinux is stricter and harder to misconfigure into a bypass, but more work to write policy for; AppArmor is easier to read and author. You do not choose between them per container; you get whichever the host runs.

How the layers stack

Two complementary layers: seccomp vs the LSMs

seccomp

Filters syscalls (the kernel entry points)

Question answeredWhich syscalls may this process even attempt?
GranularitySyscall number, sometimes arguments
Blind toFiles, paths, and object identity
Example winA blocked mount syscall stops a whole class of escape
AppArmor / SELinux (LSM)

Mediates access to specific objects

Question answeredWhich files, devices, and objects may this process touch?
GranularityPaths (AppArmor) or labels (SELinux MCS)
Blind toRaw syscall numbers unrelated to objects
Example winMCS stops container A from reading container B files

Defense in depth means these run at the same time as namespaces and capabilities. Suppose an attacker gains code execution in a container and even holds a capability they should not. seccomp may still block the specific syscall they need; if not, the LSM may still deny access to the object they target; if not, the user namespace may still mean their "root" is an unprivileged host UID. Every independent layer is another thing that has to fail before a compromise reaches the host.

Common mistakes

  • Running seccomp=unconfined to make something work. This removes the syscall filter entirely for a one-syscall problem. Find the blocked syscall and write a profile that allows exactly it.
  • Assuming seccomp understands files. It does not. If you need to restrict which paths a process reads or writes, that is an LSM job, not seccomp.
  • Disabling SELinux because a container had a permission error. setenforce 0 turns off protection host-wide. The right fix is a targeted policy or the correct label, not disabling the whole system.
  • Thinking you pick one of the three. They operate at different layers. A hardened container uses the default (or a tighter) seccomp profile, the host's LSM, dropped capabilities, and namespaces, all together.
  • Blocking syscalls blindly. An over-tight profile breaks the app: block clone or futex and threading dies; block a newer syscall and a fresh glibc build fails at startup. Tighten from real observation, not guesswork.

The interview answer

A seccomp profile reduces attack surface by cutting the number of kernel entry points a process can reach: fewer reachable syscalls means fewer bugs and fewer privileged operations available to an attacker who lands code execution. Good candidates to block are syscalls a normal application never needs but attackers love: mount, reboot, swapon, kexec_load, init_module / finit_module, ptrace, and old key-management calls. The risk is over-blocking: block clone or futex and multithreaded apps break; block a syscall a newer libc started using and the program fails to start. So you do not guess. You determine the real syscall set by tracing the workload (strace, seccomp audit logging, or a seccomp-notify recorder) under representative load, allow that set plus a safety margin, and test before enforcing. That method is the subject of Lesson 7.3.

Summary

seccomp filters the syscalls a process may attempt; AppArmor and SELinux (the LSMs) mediate which objects it may access, by path or by label with MCS isolating containers from each other. Container runtimes apply a default seccomp profile and an LSM profile out of the box, and these stack with namespaces, cgroups, and capabilities as defense in depth: several independent walls, each of which an attacker must defeat. This closes Module 1. You now hold the four primitives, visibility, resources, privilege, and syscall/object confinement, that every later topic (images, runtimes, escape, and runtime security) builds on.

KNOWLEDGE CHECK

An interviewer asks how you would stop a compromised container from mounting a host filesystem and from reading another container's files. Which combination is correct, and why not just one mechanism?

INTERVIEW QUESTION

How does a seccomp profile reduce container attack surface? What syscalls would you block and what breaks if you block the wrong ones?