Virtualization · Containers

The Shared Kernel Boundary and What Containers Cannot Isolate

Why container isolation is a partition of kernel state rather than a hardware boundary, which attack surfaces remain, and how the layers of defence are composed.

The security argument for containers is not that they isolate like virtual machines. It is that they reduce the authority of a process to the subset of kernel operations its workload needs, while sharing the kernel’s code and data structures with everything else on the host. Those are different claims, and conflating them produces both overconfidence and unnecessary pessimism.

A partition of state, not a boundary of code

A virtual machine runs its own kernel. A container runs the host’s kernel, and the kernel is not partitioned: the syscall table, the scheduler, the memory allocator, the network stack implementation, and every driver are shared. What a container gets is a private set of tables, one per namespace type, that determine which objects the process can name.

The distinction is exact. A namespaced process can create a socket, but it runs the same socket code as the host. It can read a file in its own mount namespace, but the page cache holding that file is shared with any other process that mapped the same inode. It can allocate memory, and the allocation comes from the same physical pages. A bug in any of that shared code is reachable from the container.

That is the whole reason the shared-kernel boundary matters: the attack surface of a container is the attack surface of the host kernel, filtered by the namespaces, capabilities, and system call filters applied to it.

The syscall interface is the primary surface

A container’s workload can invoke any system call the kernel implements, unless something stops it. The number of calls is large: Linux exposes on the order of three hundred fifty to four hundred system calls on a common architecture, and each has parameters that the kernel must validate. Historically, the vulnerabilities that permitted container escape were in that validation: an unchecked length, a type confusion in a structure copied from user space, a reference count that could be made to underflow.

The defence is to reduce the set of reachable calls. seccomp in filter mode takes a BPF program that inspects the syscall number, its arguments, and the calling architecture, and returns an action: allow, kill the process, return an error, or trap to a supervisor. The filter is installed by the process itself with prctl or seccomp, is inherited across execve, and cannot be removed once applied, which makes it a one-way ratchet.

/* Allow only four syscalls, kill on anything else.
   Offsets are relative to the instruction after the jump. */
struct sock_filter filter[] = {
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read,       3, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write,      2, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit,       1, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
};
struct sock_fprog prog = { .len = 7, .filter = filter };
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0)
    die("no_new_privs");
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) < 0)
    die("seccomp");

The filter’s correctness is limited by what BPF can see. It sees the syscall number and the raw argument values, but not memory the arguments point at. It cannot inspect a pathname string, so it cannot allow open on some paths and deny others; that requires a path-based mechanism. It also cannot follow a file descriptor to the object it refers to. Filters that try to reason about pointer arguments must instead inspect the pointed-to memory, which requires SECCOMP_RET_TRACE and a supervisor that reads the tracee’s memory, at a cost of a context switch per call.

The architecture check is a known pitfall. A filter must verify that seccomp_data.arch is the expected value before matching syscall numbers, because a 32-bit process on a 64-bit kernel has a different numbering and can reach calls the filter did not intend to permit. Filters that omit the check are bypassable by executing 32-bit code.

PR_SET_NO_NEW_PRIVS is a prerequisite rather than a detail. Without it, a setuid binary executed after the filter is installed could gain privileges the filter was meant to prevent, so the kernel requires the flag before allowing an unprivileged process to install a filter. With it set, execve can never grant privileges beyond what the process already had.

Layers that compose rather than replace

No single mechanism bounds a container. A practical stack has several, each closing a different gap.

User namespaces bound the interpretation of IDs, so a capability held inside applies to objects owned by the namespace rather than to host objects. They do not bound which syscalls are reachable.

Capability sets bound the operations that check for privilege. Dropping CAP_SYS_ADMIN removes the ability to mount, to create namespaces, and to configure a long list of kernel subsystems. It does not remove the ability to call the syscalls; it removes their effect. The sets and their interaction with user namespaces are described in Capability Sets and UID Mapping in Rootless Containers.

Seccomp filters bound the syscall set itself, including calls that need no capability. They are the layer that closes surface area rather than authority.

Landlock provides path-based and port-based access control for unprivileged processes. It restricts file hierarchy access and TCP port binding for the process and its descendants, and it composes with the other layers by intersecting with them rather than replacing them.

LSMs such as SELinux and AppArmor apply mandatory policy on top of the discretionary checks, with labels that survive execve and are enforced on every access. Their value is that the policy is external to the container image: a compromised process cannot change its own label.

Read-only mounts and a read-only root remove persistence, so a compromise cannot modify the filesystem the container will restart from. A writable tmpfs provides scratch space without giving access to the image.

The layers multiply their restrictions only where they overlap. Seccomp cannot enforce paths; Landlock cannot restrict syscalls; capabilities cannot stop a syscall that needs no privilege. A stack that omits one of them leaves the gap that layer was closing.

The parts that stay shared

Even a well-restricted container shares state with the host, and the sharing is observable.

CPU and memory bandwidth are shared by construction. A container’s limits, set through the cgroup files described in Memory and CPU Limits Under the cgroup v2 Unified Hierarchy, bound its own consumption, not the host’s contention, and cache and memory bandwidth are not partitioned. Side-channel attacks that infer secrets from timing exploit exactly this: two processes on the same core share cache lines, and the attacker does not need to escape the container to measure them.

The page cache is shared and its contents are not namespaced. Whether a file’s pages are cached is a host-wide fact, and a container can influence the cache for files it cannot read. This is a performance channel as much as a security one.

Kernel data structures such as the dentry and inode caches, the network connection tables, and the process table are shared, and their size is bounded by host memory rather than container limits in some cases. A container creating millions of dentries consumes host memory that may be charged outside its cgroup.

/proc and /sys present filtered views, not separate copies. A procfs mounted in the container’s PID namespace shows only that namespace’s processes, but the same kernel structures are behind it.

Time, randomness, and the clock are global. A container cannot have its own clock, and time namespace support is limited.

The kernel version is the host’s, reported identically inside every container. This is why a container cannot use a kernel feature the host lacks, and why a kernel upgrade affects every container at once.

When a stronger boundary is required

The decision to use a virtual machine, a sandboxed runtime, or a user-space kernel instead of a plain container follows from what is being defended against. If the threat is a misconfigured workload, the layers above are sufficient: the workload has no path to host resources it was not given, and a compromise stays within the container.

If the threat is hostile code deliberately targeting the kernel, the shared-kernel model is the wrong shape. A virtual machine gives the guest its own kernel, so a kernel exploit in the guest compromises the guest and then has to escape the hypervisor, which is a much smaller interface. A user-space kernel or a sandboxed runtime intercepts syscalls and reimplements them in a memory-safe language, which removes the kernel’s C attack surface at the cost of syscall overhead and incomplete coverage of the interface.

The intermediate designs combine the two. A runtime that runs containers inside a lightweight virtual machine keeps the container interface and the image format, and puts each pod or each container in its own guest kernel. The trade is a per-pod kernel and a hypervisor boundary in exchange for the memory and startup cost of a VM. A runtime that applies seccomp, capabilities, LSMs, and user namespaces without a VM gets lower overhead and a boundary that is only as strong as the kernel’s syscall validation.

Where the boundary actually falls

Containers provide isolation of names, not of code. Every mechanism in the stack reduces the set of kernel objects a process can reach or the set of operations it can perform on them. None of them changes the fact that the code performing those operations is shared with every other container and with the host.

The practical consequence is that container security is a matter of minimising reachable interface, not of trusting a boundary. The syscall filter, the capability set, the mount policy, and the user mapping together define what a compromised workload can do, and each of them is independently checkable from the running process: /proc/<pid>/status shows the capability sets, /proc/<pid>/attr/current shows the LSM label, /proc/<pid>/status reports Seccomp, and the namespace links name the namespaces the process belongs to. What cannot be checked from inside is whether the kernel’s handling of the remaining interface is correct, and that is the part no container configuration can improve.

References

  1. seccomp(2) — Linux man-pages project
  2. Linux kernel documentation — Seccomp filter — Linux kernel documentation
  3. capabilities(7) — Linux man-pages project
  4. Landlock: unprivileged access control — Linux kernel documentation