Lesson 4.a / Module 4
Containers from the kernel up
A container is a process with a restricted view of one shared kernel. The isolation is a set of kernel features you configure, and most escapes come from configuration, not kernel bugs.
On this page
The idea in one minute#
A container is not a small virtual machine. It is an ordinary process on the host, running against the same kernel as every other process, that the kernel has been told to show a narrowed view of the system. That view is built from a handful of independent kernel features: namespaces limit what the process can see, cgroups limit what it can use, and capabilities, seccomp, and a Linux Security Module limit what it can do. Remove or widen any one of them and the process sees more of the host.
The consequence to act on: a container is a resource-management and
packaging boundary that can be configured into a security boundary, but is not
one by default. Most escapes are not exotic kernel exploits. They are a
capability left on, a seccomp profile turned off, a host path mounted, or the
privileged flag set, each of which hands code a documented, intended path
back to the host.
A picture#
flowchart LR P["<b>Container process</b><br/><small>attacker code</small>"] --> NS["<b>Namespaces</b><br/><small>what it can see</small>"] NS --> CAP["<b>Capabilities</b><br/><small>which privileged ops</small>"] CAP --> SC["<b>Seccomp</b><br/><small>which syscalls</small>"] SC --> LSM["<b>LSM: AppArmor/SELinux</b><br/><small>which objects</small>"] LSM --> K["<b>Shared host kernel</b><br/><small>one bug = escape</small>"] class P warn class NS,CAP,SC,LSM good class K memory classDef neutral fill:#faf9f5,stroke:#5f5e55,color:#141413,stroke-width:1.5px classDef compute fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px classDef memory fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px classDef queue fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px classDef io fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px classDef default fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px classDef warn fill:#f9e2e9,stroke:#b3154f,color:#141413,stroke-width:2px,stroke-dasharray:5 3 classDef good fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:2px
Every arrow is a layer you configure. The kernel at the end is shared with the host and with every other container on the machine.
How it really works#
Namespaces: what the process can see#
A Linux namespace gives a process its own instance of a global resource. The kernel maintains several kinds, and a container is a process placed in a fresh set of them: a mount namespace (its own filesystem tree), a PID namespace (its own process numbers, so its main process is PID 1), a network namespace (its own interfaces and routing table), UTS (its own hostname), IPC, cgroup, and the user namespace. Namespaces are what make a container look like a separate machine. They do nothing to limit what the process may do once it can reach a kernel interface.
Namespaces are also where sharing leaks isolation. A pod configured with
hostPID: true joins the host’s PID namespace and can see and signal every
process on the node; hostNetwork: true puts it on the host’s network stack,
past any per-pod network policy.
Compare the two pods below: the first gets fresh namespaces, the second shares the host’s and sees what the host sees.
flowchart LR
subgraph NODE["Node: one kernel"]
direction LR
subgraph PA["Default pod"]
A1("Container")
A2["<b>own PID, mount, net, UTS</b><br/><small>sees only itself, PID 1</small>"]
end
subgraph PB["Pod with hostPID + hostNetwork"]
B1("Container")
B2["<b>host PID + net namespace</b><br/><small>sees every node process</small>"]
end
H("Host processes and NICs")
end
A1 --> A2
B1 --> B2
A2 -.->|cannot see| H
B2 -->|"see, signal, sniff"| H
class A2 good
class B2 warn
classDef neutral fill:#faf9f5,stroke:#5f5e55,color:#141413,stroke-width:1.5px
classDef compute fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef memory fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef queue fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef io fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef default fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef warn fill:#f9e2e9,stroke:#b3154f,color:#141413,stroke-width:2px,stroke-dasharray:5 3
classDef good fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:2pxcgroups: what the process can use#
Control groups (cgroups, v2 on current kernels) cap CPU, memory, process count (pids), and I/O. They are the answer to a fork bomb or a crypto-miner: the workload is throttled or killed rather than taking down the node. cgroups are about availability and noisy-neighbor control, not confidentiality. A missing memory limit is a denial-of-service risk, not an escape.
Capabilities: splitting up root#
The kernel splits the historical all-or-nothing power of UID 0 into about forty
capabilities (see capabilities(7)). CAP_NET_BIND_SERVICE allows binding
to a low port; CAP_SYS_ADMIN is a catch-all that allows mounting filesystems
and much more; CAP_SYS_MODULE loads kernel modules; CAP_SYS_PTRACE traces
other processes. Container runtimes drop most capabilities by default. Docker,
for example, keeps a set of 14 and drops the rest, so a process running as root
inside a default container is a much weaker root than root on the host.
The danger is cap_add. Granting CAP_SYS_ADMIN to make one mount work hands
back a capability from which many escapes are built. CAP_SYS_MODULE lets the
container load code into the host kernel directly. Treat added capabilities as
removing a wall, not loosening a setting.
The map splits root’s power into what a default container keeps and the grants that turn container root back into host root.
mindmap
root((UID 0 power))
Kept by default
NET_BIND_SERVICE
bind a low port
CHOWN and FOWNER
file ownership
SETUID and SETGID
switch users
KILL
signal own processes
Dangerous to add
SYS_ADMIN
mount and much more
SYS_MODULE
load kernel code
SYS_PTRACE
trace other processes
DAC_READ_SEARCH
open files by handleSeccomp: which syscalls at all#
A capability says you may perform a class of privileged operation; seccomp
(secure computing mode) filters the system-call interface itself. A seccomp-BPF
profile lists syscalls and an action: allow, or return an error (ERRNO), or
kill the process. The runtime-default profile shipped by Docker and containerd
blocks on the order of 40 to 60 syscalls that a normal workload never needs,
among them init_module, finit_module, mount, open_by_handle_at, and
kexec_load. Several of those are the same syscalls that a held capability
would otherwise permit, so capabilities and seccomp are two independent locks
on the same doors.
Follow one mount call through both locks, first with the default capability
set and then after cap_add SYS_ADMIN.
sequenceDiagram
autonumber
participant P as Container process
participant S as Seccomp filter
participant C as Capability check
participant K as Host kernel
rect rgb(226, 239, 216)
Note over P,K: Default caps, runtime-default profile
P->>S: mount of a host block device
S-->>P: ERRNO, syscall blocked before the kernel runs it
end
rect rgb(249, 226, 233)
Note over P,K: cap_add SYS_ADMIN, same profile
P->>S: mount of a host block device
S->>C: allowed, profile gates mount on SYS_ADMIN
C->>K: SYS_ADMIN held, proceed
K-->>P: host block device mounted
endKubernetes historically ran pods with seccomp unconfined unless you asked
otherwise. Since v1.27 the RuntimeDefault profile can be enabled for a whole
node via --seccomp-default, and Pod Security Admission’s restricted level
requires a profile. A pod with no seccomp profile is a pod with the syscall
interface wide open.
LSMs: which objects, by policy#
A Linux Security Module adds a mandatory-access-control layer the kernel
consults after the standard checks. AppArmor confines a process by a
path-based profile; SELinux labels every process and object and allows only
matching labels. Container runtimes apply a default AppArmor profile (docker-default)
or an SELinux type (container_t) that stops a container process from writing
most of the host’s files even if the Unix permissions would allow it. This is
the layer that most often turns “root in the container reached a host file”
into “permission denied”.
User namespaces and rootless#
A user namespace maps user IDs inside the container to different IDs on the host. With it, UID 0 inside the container can be an unprivileged UID (say, 100000) on the host, so even if code breaks out of the filesystem view, it acts as nobody on the node. Rootless containers run the whole runtime as an unprivileged user on top of this. User namespaces are the single most effective change for turning a container into something closer to a boundary, and they are off by default in many Kubernetes setups as of 2026. When root in the container is root on the host, a host-path mount or a runtime-socket mount is game over.
The same write to a root-owned host file, with and without a user namespace remapping UID 0.
flowchart LR
R("uid 0 in container")
R --> M{"User namespace<br/>remap?"}
M -->|no| H0["<b>host uid 0</b><br/><small>real root</small>"]
M -->|yes| H1["<b>host uid 100000</b><br/><small>unprivileged</small>"]
H0 -->|"write root-owned host file"| W["<b>write succeeds</b><br/><small>host takeover</small>"]
H1 -->|"same write"| D["<b>permission denied</b>"]
class H0,W warn
class H1,D good
classDef neutral fill:#faf9f5,stroke:#5f5e55,color:#141413,stroke-width:1.5px
classDef compute fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef memory fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef queue fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef io fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef default fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef warn fill:#f9e2e9,stroke:#b3154f,color:#141413,stroke-width:2px,stroke-dasharray:5 3
classDef good fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:2pxWhy this is not a boundary by default#
Every layer above sits in front of one shared kernel. A virtual machine gives each guest its own kernel behind a hardware boundary; a container does not.
The stack shows why: every container’s controls sit above the same kernel, while each VM brings its own.
block-beta
columns 6
block:CT:3
columns 3
c1["Container A"] c2["Container B"] c3["Container C"]
ctl["Namespaces, caps, seccomp, LSM"]:3
hk["One shared host kernel"]:3
end
block:VMS:3
columns 2
v1["VM 1 app"] v2["VM 2 app"]
g1["Guest kernel 1"] g2["Guest kernel 2"]
hv["Hypervisor (hardware boundary)"]:2
hk2["Host kernel"]:2
end
hw["Hardware"]:6
classDef neutral fill:#faf9f5,stroke:#5f5e55,color:#141413,stroke-width:1.5px
classDef compute fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef memory fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef queue fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef io fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef warn fill:#f9e2e9,stroke:#b3154f,color:#141413,stroke-width:2px,stroke-dasharray:5 3
classDef good fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:2pxSo a single kernel vulnerability reachable through the allowed syscalls can escape any container on the host, and a single misconfiguration can open a documented path with no exploit at all:
--privilegeddisables seccomp and AppArmor, grants all capabilities, and exposes host devices. It is root on the host with extra steps.- A host-path mount of
/(or of a sensitive directory) lets a container that runs as host-root rewrite host files, including things like the kubelet’s credentials. - A mounted container-runtime socket (
/var/run/docker.sock,containerd.sock) lets the container ask the runtime to start a new, fully privileged container. It is an escape by design.
Each of the three misconfigurations is its own documented road to host root; none of them needs a kernel bug.
flowchart LR
A(["Code in container"])
A --> P["<b>--privileged</b><br/><small>all caps, no seccomp or AppArmor</small>"]
A --> M["<b>hostPath /</b><br/><small>as host root</small>"]
A --> S("docker.sock mounted")
P --> DEV["host devices,<br/>kernel modules"]
M --> KC@{ shape: doc, label: "rewrite host files,<br/>kubelet credentials" }
S --> NEW["start a new<br/>privileged container"]
DEV --> H("Host root")
KC --> H
NEW --> H
class P,M,DEV,KC,NEW warn
classDef neutral fill:#faf9f5,stroke:#5f5e55,color:#141413,stroke-width:1.5px
classDef compute fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef memory fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef queue fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef io fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef default fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:1.5px
classDef warn fill:#f9e2e9,stroke:#b3154f,color:#141413,stroke-width:2px,stroke-dasharray:5 3
classDef good fill:#e2efd8,stroke:#2b6a24,color:#141413,stroke-width:2pxCode#
The program models the three do layers (capabilities, a seccomp-style filter, and host-sharing settings) and reports which escape paths each configuration leaves open. It is a teaching model, not a real policy evaluator: the syscall lists and capability set follow the shape of Docker’s defaults but are trimmed.
"""container_audit.py: audit container settings the way the kernel enforces them.
Models capability sets, a seccomp-style syscall filter, and host-sharing
settings, then reports which paths to the host each configuration leaves open.
Standard library only; nothing here touches a real kernel or runtime.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# The 14 capabilities Docker grants by default (Docker run reference).
DEFAULT_CAPS = frozenset({
"CHOWN", "DAC_OVERRIDE", "FSETID", "FOWNER", "MKNOD", "NET_RAW", "SETGID",
"SETUID", "SETFCAP", "SETPCAP", "NET_BIND_SERVICE", "SYS_CHROOT", "KILL",
"AUDIT_WRITE",
})
ALL_CAPS = DEFAULT_CAPS | {"SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "DAC_READ_SEARCH",
"NET_ADMIN", "SYS_RAWIO", "BPF"}
# Syscalls a runtime-default style profile allows only when a capability is held,
# in the spirit of Docker's default profile.
CAP_GATED = {"mount": "SYS_ADMIN", "init_module": "SYS_MODULE",
"finit_module": "SYS_MODULE", "open_by_handle_at": "DAC_READ_SEARCH",
"bpf": "BPF"}
BASE_ALLOW = {"read", "write", "openat", "close", "mmap", "execve", "clone",
"futex", "socket", "connect", "ptrace"}
@dataclass(frozen=True)
class Filter:
name: str
default: str # action for a syscall no rule matches: "ALLOW" or "ERRNO"
allow: frozenset[str] = frozenset()
def decide(self, syscall: str, caps: frozenset[str]) -> str:
if self.default == "ALLOW":
return "ALLOW"
if syscall in self.allow:
return "ALLOW"
if CAP_GATED.get(syscall) in caps:
return "ALLOW"
return "ERRNO"
UNCONFINED = Filter("unconfined", "ALLOW")
RUNTIME_DEFAULT = Filter("runtime-default", "ERRNO", frozenset(BASE_ALLOW))
@dataclass
class Config:
name: str
caps: frozenset[str]
seccomp: Filter
root_maps_to_host_root: bool = True # False when a user namespace remaps uid 0
host_mounts: list[str] = field(default_factory=list)
host_pid: bool = False
# (path to the host, capability needed, syscall needed, extra condition)
PATHS = [
("mount host block devices", "SYS_ADMIN", "mount", None),
("load a kernel module", "SYS_MODULE", "finit_module", None),
("open host files by handle", "DAC_READ_SEARCH", "open_by_handle_at", None),
("trace host processes", "SYS_PTRACE", "ptrace", "host_pid"),
("write host files as root", None, "openat", "host_root_mount"),
("drive the container runtime", None, "connect", "runtime_socket"),
]
def open_paths(c: Config) -> list[str]:
found = []
for label, cap, syscall, cond in PATHS:
if cap and cap not in c.caps:
continue
if c.seccomp.decide(syscall, c.caps) != "ALLOW":
continue
if cond == "host_pid" and not c.host_pid:
continue
if cond == "host_root_mount" and not ("/" in c.host_mounts and c.root_maps_to_host_root):
continue
if cond == "runtime_socket" and "/run/containerd/containerd.sock" not in c.host_mounts:
continue
found.append(label)
return found
def main() -> None:
calls = ["read", "openat", "mount", "finit_module", "open_by_handle_at", "bpf"]
print(f"{'syscall':<18}{'unconfined':<12}{'default':<10}{'default+SYS_ADMIN'}")
for s in calls:
print(f"{s:<18}{UNCONFINED.decide(s, DEFAULT_CAPS):<12}"
f"{RUNTIME_DEFAULT.decide(s, DEFAULT_CAPS):<10}"
f"{RUNTIME_DEFAULT.decide(s, DEFAULT_CAPS | {'SYS_ADMIN'})}")
print()
configs = [
Config("restricted", frozenset(), RUNTIME_DEFAULT, root_maps_to_host_root=False),
Config("docker-default", DEFAULT_CAPS, RUNTIME_DEFAULT),
Config("k8s pod, no profile", DEFAULT_CAPS, UNCONFINED),
Config("cap_add SYS_ADMIN", DEFAULT_CAPS | {"SYS_ADMIN"}, RUNTIME_DEFAULT),
Config("hostPath / (rootless)", DEFAULT_CAPS, RUNTIME_DEFAULT,
root_maps_to_host_root=False, host_mounts=["/"]),
Config("hostPath /", DEFAULT_CAPS, RUNTIME_DEFAULT, host_mounts=["/"]),
Config("runtime socket", DEFAULT_CAPS, RUNTIME_DEFAULT,
host_mounts=["/run/containerd/containerd.sock"]),
Config("privileged + hostPID", ALL_CAPS, UNCONFINED, host_pid=True),
]
for c in configs:
paths = open_paths(c)
print(f"{c.name:<23}{len(c.caps):>2} caps {c.seccomp.name:<16}"
f"{len(paths)} open path(s)")
for p in paths:
print(f" - {p}")
if __name__ == "__main__":
main()Run it with python3 container_audit.py:
syscall unconfined default default+SYS_ADMIN
read ALLOW ALLOW ALLOW
openat ALLOW ALLOW ALLOW
mount ALLOW ERRNO ALLOW
finit_module ALLOW ERRNO ERRNO
open_by_handle_at ALLOW ERRNO ERRNO
bpf ALLOW ERRNO ERRNO
restricted 0 caps runtime-default 0 open path(s)
docker-default 14 caps runtime-default 0 open path(s)
k8s pod, no profile 14 caps unconfined 0 open path(s)
cap_add SYS_ADMIN 15 caps runtime-default 1 open path(s)
- mount host block devices
hostPath / (rootless) 14 caps runtime-default 0 open path(s)
hostPath / 14 caps runtime-default 1 open path(s)
- write host files as root
runtime socket 14 caps runtime-default 1 open path(s)
- drive the container runtime
privileged + hostPID 21 caps unconfined 4 open path(s)
- mount host block devices
- load a kernel module
- open host files by handle
- trace host processesTwo things stand out. The syscall table shows that the default seccomp profile
blocks mount on its own, and adding CAP_SYS_ADMIN re-enables it: the two
locks are independent, and widening either one is enough to reach the door.
Below, docker-default leaves no path open, while the same capabilities with a
host-path mount, a runtime socket, or no user-namespace remap each open exactly
one. The rootless row shows a host-path mount of / neutralized purely
because UID 0 no longer maps to host root.
The program’s second table as a chart: one widened setting opens one path, and
privileged plus hostPID opens four at once.
xychart-beta x-axis ["restricted", "docker-default", "no profile", "+SYS_ADMIN", "hostPath rootless", "hostPath /", "runtime sock", "privileged"] y-axis "Open paths to host" 0 --> 5 bar [0, 0, 0, 1, 0, 1, 1, 4]
Where it breaks#
- This model counts configuration paths, not kernel bugs. Its “0 open paths”
for
docker-defaultmeans no misconfiguration path, not that the configuration is unescapable. A kernel vulnerability reachable through an allowed syscall bypasses the whole table. That is the point of the next lesson on stronger isolation. - Capability interactions are simplified. Real escapes chain capabilities,
writable kernel interfaces (
/sys,/proc), and specific kernel versions in ways this does not model.CAP_SYS_ADMINin particular enables far more than one mount. - Namespaces and cgroups are only sketched. A real audit also checks for
hostNetwork, writable/procand/sysmounts, exposed devices, and the absence of resource limits. - Defaults drift. The default capability set, the seccomp profile contents,
and whether Kubernetes applies
RuntimeDefaultall change across versions. Check the versions you actually run; treat the numbers here as illustrative, current as of 2026.
Remember this#
- A container is a process on a shared kernel with a narrowed view, not a VM.
- Namespaces control what it sees; cgroups what it uses; capabilities, seccomp, and an LSM what it may do.
- Capabilities and seccomp are independent locks; widening either can reopen a syscall.
- Most escapes are configuration, not exploits:
privileged, host-path mounts, a mounted runtime socket,hostPID/hostNetwork. - User namespaces and rootless mode mean container root is not host root, which defuses many of those paths.
- Because the kernel is shared, one kernel bug escapes any container; defense in depth buys you the layers, not a wall.
Try it#
- Run
container_audit.py. Add a config for a pod withhostPID: truebut the runtime-default seccomp profile and default caps. Does the “trace host processes” path open? Explain why, using the capability column. - Add
CAP_SYS_MODULEtocap_addand confirm the module-load path opens. Then set the seccomp profile to one that blocksfinit_moduleanyway and show the path closes again, demonstrating the second lock. - On a machine with Docker, run
docker run --rm alpine grep Cap /proc/self/statusand decodeCapEffwithcapsh --decode=<hex>. Compare the set toDEFAULT_CAPS. - Write the YAML for a Kubernetes
securityContextthat setsrunAsNonRoot, drops all capabilities, and requires theRuntimeDefaultseccomp profile. Which escape rows would that close?
Check yourself#
Why is a default container not a security boundary, even though it has its own PID, mount, and network view?
Show answer
Those are namespaces, which only change what the process sees. The process still calls the one shared host kernel directly through the syscall interface. A kernel bug reachable through an allowed syscall, or a misconfiguration that grants a path to the host, escapes the namespaces entirely. Isolation of the view is not isolation of privilege.
Capabilities and seccomp both restrict privileged actions. Why keep both?
Show answer
They are independent locks on overlapping doors. A capability authorizes a class of operation; seccomp controls whether the syscall can be issued at all. A workload that holds
CAP_SYS_ADMINbut whose seccomp profile blocksmountstill cannot mount. Keeping both means a mistake in one (an accidentalcap_add, a profile set to unconfined) does not immediately open the door.Why does mounting the container runtime’s socket into a container defeat all the other controls?
Show answer
The socket is the control plane of the runtime. A process that can write to it can ask the runtime to launch a new container that is privileged, mounts the host filesystem, and runs as host root. No kernel exploit is involved; you have handed the workload the ability to create a maximally privileged sibling.
What does running with a user namespace (rootless) change about a host-path mount of
/?Show answer
UID 0 inside the container maps to an unprivileged UID on the host. So even though the host filesystem is mounted, the process acts as that unprivileged user against it and cannot rewrite root-owned host files. Without the user namespace, container root is host root and the mount is a direct takeover.
Sources#
- The Linux man-pages project,
namespaces(7),capabilities(7),seccomp(2),user_namespaces(7),cgroups(7). - Docker, Runtime privilege and Linux capabilities and Seccomp security profiles.
- Kubernetes, Configure a Security Context for a Pod or Container and Seccomp default (
--seccomp-default). - Kubernetes, User namespaces.
- AppArmor project, AppArmor wiki; SELinux, SELinux Project wiki.
← → previous and next lesson