Six Layers Between a Container and Your Host
A container is just a process wrapped in six kernel mechanisms. Walk through each one, see how it is inspected and broken, and know exactly which layer you are standing on when a breakout happens.
Most of the container write-ups I run into on engagements stop at “it is just a namespaced process,” then jump straight to a breakout one-liner. That gap is where people get surprised, because the one-liner only works because one specific layer, out of six, was left open. Before I write the next post in this series on abusing a CI/CD runner, I want the mechanism on the table: what actually stands between a container and the host it runs on, and how to check each layer yourself instead of taking my word for it.
Overview
A Linux container is an ordinary process wrapped in a stack of kernel features, and each layer can be loosened or tightened independently. There is no single “container” object in the kernel. Isolation is the sum of six mechanisms: namespaces, capabilities, cgroups, LSMs (AppArmor or SELinux), seccomp, and the user the process runs as.
The practical consequence: container security is a configuration problem. Every flag like --privileged, --pid=host, --cap-add, or seccomp=unconfined removes or weakens one specific layer, and you can inspect each layer with stock Linux tools from the host.
Mental model to keep in your head
- Namespaces decide what the process can see (filesystem, PIDs, network, hostname, users).
- Capabilities decide which slices of root power the process holds.
- Cgroups decide how much CPU, memory, PIDs and which devices it can consume.
- AppArmor / SELinux decide what it may touch, even as root (mandatory policy).
- Seccomp decides which syscalls it may make at all. The last line of defense.
- The user (root vs non-root, user namespace or not) decides how bad it is when the other layers fail.
All of these share one kernel with the host. That is the core difference from a VM, and it is why a kernel bug reachable from inside a container is a host bug.
A note on where this comes from: the structure follows Rory McCune’s six-part Container security fundamentals series on Datadog Security Labs (Feb to Sep 2023). I rewrote it in my own words, added the offensive notes and the breakout angle at the end, and updated the pieces that have moved since (Kubernetes user namespaces, seccomp defaults, runc support). Sources are linked at the bottom if you want the originals. Examples assume Docker on a plain Linux VM, not Docker Desktop, which hides the host behind its own VM, but the same six layers apply to containerd, CRI-O and Podman.
Lab setup: follow along
Everything below is meant to be run, not just read. Each command is paired with a What you’ll see note describing the real output, so you can check your results against it. Reproduce on a throwaway Linux VM (not Docker Desktop, which hides the host behind its own VM):
# A plain Ubuntu 22.04 / 24.04 VM.
# Install Docker
curl -fsSL https://get.docker.com | sudo sh
# Inspection tooling used throughout these notes
sudo apt update
sudo apt install -y util-linux libcap-ng-utils libcap2-bin cgroup-tools apparmor-utils
# util-linux -> lsns, nsenter, unshare, findmnt
# libcap-ng-utils -> pscap, filecap
# libcap2-bin -> setcap, getcap, capsh
# cgroup-tools -> lscgroup
# apparmor-utils -> aa-status (Debian/Ubuntu; SELinux hosts use sestatus instead)
# Images to experiment with
docker pull nginx:1.23.1
docker pull ubuntu:22.04
amicontained is a single static binary (download the release from its GitHub repo) that you run inside a container to see its own capabilities, seccomp mode and namespaces. Most host-side inspection needs root, so run with sudo. Where a command needs a container’s PID, get it once with docker inspect -f '{{.State.Pid}}' <container> and reuse it.
1. Containers are just processes
From the kernel’s point of view, docker run nginx is the same as starting nginx on the host: the nginx PIDs show up in the host’s ps output. What makes it a “container” is the set of restrictions applied to that process, not a different kind of process.
The runtime chain
dockerCLI talks todockerdover its API (usually/var/run/docker.sock).dockerdhands work tocontainerd.containerdstarts acontainerd-shim-runc-v2process per container.- The shim calls
runc, which sets up namespaces, cgroups, capabilities, LSM and seccomp, then execs the workload and exits. - The workload stays parented to the shim.
Why the shim exists: it lets containerd or dockerd restart (upgrade, crash) without killing every running container. Kubernetes nodes look the same minus dockerd (kubelet talks CRI to containerd or CRI-O).
Spotting a containerized process from the host
docker psis the obvious way, if you have Docker access.- Without Docker:
ps -ef --forestand look for processes parented tocontainerd-shim-runc-v2. One shim per container. ps -fC nginxlists processes by name regardless of whether they are contained.
/proc is your window into every container
/proc is a pseudo-filesystem the kernel populates with live process data. Anyone with sufficient privilege on the host can use it to inspect any container, no container tooling required.
| Path | What you get | Typical use |
|---|---|---|
/proc/<PID>/root | The container’s root filesystem as seen by that process | Read, edit or drop files in a distroless or hardened container from the host |
/proc/<PID>/environ | The process environment (NUL-separated) | Secrets passed as env vars (API keys, DB creds) are readable here |
/proc/<PID>/mountinfo | Mounts visible to the process | See overlay layers, bind mounts, hostPath volumes |
/proc/<PID>/cgroup | The cgroup slice/scope it lives in | Map a PID to its container/pod |
/proc/<PID>/status | Includes CapEff, CapPrm, CapBnd, Seccomp, NoNewPrivs | Quick check of capabilities and seccomp mode |
/proc/<PID>/ns/ | Symlinks to each namespace the process is in | Compare namespace inode numbers between processes |
Useful one-liners:
# PID of a container's main process
docker inspect -f '{{.State.Pid}}' <container>
# browse / edit its filesystem from the host
sudo ls /proc/<PID>/root
sudo touch /proc/<PID>/root/tmp/from-host
# dump its environment
sudo cat /proc/<PID>/environ | tr '\0' '\n'
# kill it like any other process (bypasses Docker; may fight restart policies)
sudo kill <PID>
What you’ll see. ls /proc/<PID>/root lists the container’s own root filesystem (bin, etc, usr, var…) straight from the host, with no docker exec. cat .../environ | tr '\0' '\n' prints the process environment as KEY=VALUE lines, so a secret passed as an env var (the classic TOP_SECRET=API_KEY demo) shows in plain text. After kill <PID>, docker ps no longer lists the container.
Security takeaways
- Host access means container access. Anyone who can read
/procas root on the node sees every container’s env vars, files and memory-adjacent data. Node compromise is cluster-workload compromise. - Env vars are a weak place for secrets. They leak through
/proc/<PID>/environ,docker inspect, crash dumps and child processes. Prefer mounted secret files or a secrets manager with short-lived credentials. - Forensics without container tools. You can investigate a running container (attacker-dropped files, modified configs) from the host via
/proc/<PID>/root, even when the image has no shell. - Detection angle. Host-level EDR and auditd see container processes as normal processes. Tie them back to containers using the shim parent and the cgroup path.
2. Namespaces: what a container can see
Namespaces give a process an isolated view of one kind of system resource. They are a native Linux feature, used by plenty of non-container daemons too, and they can be applied one at a time or in groups. A container is, in large part, a process placed into a fresh set of namespaces.
Linux has eight namespaces. By default Docker puts a new container into its own mnt, pid, net, ipc, and uts namespaces. It does not use the user namespace by default (that is the big one, see below), does not use the time namespace yet, and gives each container its own cgroup namespace.
The eight namespaces
| Namespace | Isolates | Default in Docker | Security note |
|---|---|---|---|
| mnt | Filesystem mount points | Yes | Container rootfs lives under /var/lib/docker on the host via OverlayFS. Lock down and monitor that directory. |
| pid | Process IDs | Yes | Container sees only its own processes; PID 1 reaping matters. Can be shared for debugging. |
| net | Interfaces, routes, ports, iptables | Yes | Each container gets its own stack. --network=host removes this entirely. |
| ipc | SysV IPC, POSIX message queues | Yes | Mostly niche; on by default for isolation. |
| uts | Hostname and domain name | Yes | Why containers have random hostnames. --uts=host shares the host’s. |
| cgroup | View of the cgroup hierarchy | Yes (own ns) | Stops cgroup-path info leaking about host services. |
| time | System clock offsets | No | runc has supported the time namespace since the 1.2.x series (OCI runtime-spec 1.2), but Docker/containerd don’t expose it by default. LXC does. |
| user | UID/GID mappings | No (off in Docker) | The most security-relevant. See below. |
Inspecting namespaces from the host
# list namespaces and how many processes are in each
sudo lsns
# enter a container's namespaces without the Docker CLI
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo nsenter --target $PID --mount ls / # mnt: see its rootfs
sudo nsenter --target $PID --net ip addr # net: see its IP, using the HOST's ip binary
sudo nsenter --target $PID --pid --mount ps -ef # pid+mnt: list its processes
# create a shell in brand-new namespaces (no Docker involved)
sudo unshare --pid --fork --mount-proc /bin/bash
What you’ll see. lsns prints a table (NS, TYPE, NPROCS, PID, COMMAND); after a docker run a fresh set of mnt / pid / net / uts / ipc rows appears for the container’s processes. nsenter --target <PID> --net ip addr shows the container’s eth0 with a 172.17.x.x address, printed by the host’s ip binary (so it works even on a stripped image). unshare --pid --fork --mount-proc /bin/bash drops you into a new PID namespace where your shell is a low PID and ps -ef sees almost nothing.
Key point for locked-down containers: with nsenter the tool (ip, ps, ls) comes from the host, so it works even when the image has been stripped of binaries. This is gold for both troubleshooting and incident response on distroless images.
Namespace sharing (and why it is dangerous)
Sharing is what attackers (and debuggers) exploit. Each shared namespace is a hole in one wall:
docker run --pid=container:<name> ...→ debug container sees the target’s processes.docker run --network=container:<name> ...→ shares the network stack; sniff its traffic, hit its localhost-only ports.--pid=host,--network=host,--uts=host,--ipc=host→ share the host’s namespace.--pid=hostlets a container see and signal every host process;--network=hostdrops network isolation completely.- In Kubernetes: all containers in a pod share the
net(andipc) namespace by design, which is why they reach each other onlocalhost.shareProcessNamespace: truesharespidacross the pod.hostPID,hostNetwork,hostIPCin the pod spec share the host’s, and are classic privilege-escalation findings. kubectl debugwith ephemeral containers attaches a debug container into a running pod’s namespaces;--target <container>also joins that container’s PID namespace.
The user namespace: the one that isn’t on by default
The user namespace maps UIDs/GIDs so a process can be root inside the namespace while being an unprivileged user on the host. Root-in-container then no longer equals root-on-host, which contains the blast radius of a breakout dramatically.
- Docker: off by default. Can be enabled (
userns-remap), with some feature trade-offs. - Podman: rootless mode uses user namespaces by default. A major reason rootless Podman is considered safer out of the box.
- Kubernetes: reached GA (stable) in v1.36 (April 2026), after alpha in 2022 and beta in 2024. The feature is enabled by default, but each pod still opts in with
hostUsers: false; the kernel maps the container’s UIDs (0 to 65535) to an unused, unprivileged range on the host, so container-root is not host-root. It can’t be combined withhostNetwork/hostPID/hostIPC, and needs a recent-enough kernel and runtime on the node. - Caveat: the ability for unprivileged users to create user namespaces has itself been an attack surface (e.g. CVE-2022-0185, a heap overflow reachable via new user namespaces). Some distros restrict it. Check
kernel.unprivileged_userns_clone(1 = allowed, 0 = blocked) anduser.max_user_namespaces.
Demo of the illusion and the reality:
unshare --fork --pid --mount-proc -U -r bash # shell where whoami says 'root'
# ...but from another host shell, ps shows it running as your normal UID,
# and it still cannot delete root-owned host files.
What you’ll see. Inside the new namespace, whoami says root and id shows uid=0(root). From a second host shell, though, ps -ef | grep bash shows that same process still running as your normal UID, and trying to edit a root-owned host file fails with Permission denied. The root is real only inside the namespace.
Bottom line: running containers without a user namespace means container-root is host-root the moment any other layer (a capability, a kernel bug, a bad bind mount) gives a path out. Enabling user namespaces, or simply running the workload as a non-root UID, is one of the highest-value hardening steps.
3. Linux capabilities: slicing up root
Historically Linux had a binary split: unprivileged users vs the all-powerful root (reached via sudo or setuid-root binaries). A bug in a setuid-root program meant full root. Capabilities break that monolith into ~41 distinct privileges (CAP_CHOWN through CAP_CHECKPOINT_RESTORE in current kernels) that can be granted to processes or files individually.
This is why, inside a default container, you are “root” but still get Operation not permitted for things like date -s (setting the clock needs CAP_SYS_TIME, which Docker drops). Container-root is root minus the capabilities Docker took away.
How Docker uses them
Docker grants a fixed default set of ~14 capabilities to every container, chosen so most workloads run while the obviously dangerous privileges are withheld. You tune this with --cap-add and --cap-drop.
# see what a host process holds (only shows processes WITH capabilities)
sudo pscap
# find files that carry file capabilities
sudo filecap -a 2>/dev/null
getcap /usr/bin/ping # e.g. cap_net_raw
# grant a capability to a binary instead of making it setuid-root
sudo setcap cap_net_bind_service=+ep ./webserver
# from inside a container, see your effective caps
amicontained
capsh --print
grep Cap /proc/self/status # CapEff/CapPrm/CapBnd as hex; decode with capsh --decode=<hex>
What you’ll see. pscap lists only processes that hold capabilities, with the set spelled out per process; with an nginx container running you’ll see its worker carrying roughly Docker’s 14-cap default set (chown, dac_override, fowner, setgid, setuid, net_bind_service, net_raw, and a few more). filecap -a shows binaries with file capabilities, such as ping holding net_raw. Run amicontained inside the container and it prints the same capability list plus seccomp mode and namespaces. grep Cap /proc/self/status shows the raw hex; decode it with capsh --decode=<hex>.
Capabilities worth knowing (offensive priority)
| Capability | Grants | Why it matters |
|---|---|---|
| CAP_SYS_ADMIN | Huge grab-bag: mount, pivot_root, many admin syscalls | The “new root.” Common breakout enabler; required by --privileged. Treat as game over. |
| CAP_SYS_PTRACE | ptrace other processes | Inject into / read memory of other processes, especially with a shared PID namespace. |
| CAP_SYS_MODULE | Load kernel modules | Load a malicious module = own the kernel = own the host. |
| CAP_DAC_READ_SEARCH | Bypass file read/permission checks | open_by_handle_at host-filesystem reads (the classic Shocker breakout). |
| CAP_DAC_OVERRIDE | Bypass file write/permission checks | Write files you shouldn’t, including on bind-mounted host paths. |
| CAP_NET_RAW | Raw/packet sockets | ARP spoofing, sniffing, crafting packets on the container network. In Docker’s default set. |
| CAP_NET_ADMIN | Configure networking | Change interfaces, routes, iptables. Not in the default set. |
| CAP_SYS_TIME | Set the system clock | Dropped by default; why date -s fails in a stock container. |
| CAP_NET_BIND_SERVICE | Bind ports < 1024 | Often no longer needed, see below. |
Two capabilities you can usually just drop
The series makes a nice point that two traditionally-needed caps are frequently redundant now, because the behavior moved to per-network-namespace sysctls:
- CAP_NET_RAW for
ping: modern kernels allow unprivileged ICMP via thenet.ipv4.ping_group_rangesysctl. Docker sets this in each container’s netns, sopingworks without the cap. Kubernetes marked it a “safe” sysctl from v1.18. - CAP_NET_BIND_SERVICE for low ports: controlled by
net.ipv4.ip_unprivileged_port_start. Host default is 1024, but Docker sets it to 0 inside containers, so unprivileged processes can bind port 80 without the capability.
Hardening with capabilities
- Drop everything, add back nothing if you can. If the app runs fine as a non-root user, it needs no capabilities:
--cap-drop=ALL. Test by dropping all and running the test suite, then add back only what actually breaks. - In Kubernetes:
securityContext.capabilities.drop: ["ALL"]and add specific ones only when proven necessary. - Never run
--privilegedfor convenience. It grants all capabilities, disables seccomp, and opens device access. It is the single most common container-escape finding. - Audit for
cap_addofSYS_ADMIN,SYS_PTRACE,SYS_MODULE,DAC_READ_SEARCH,NET_ADMINin Compose files, Dockerfiles and pod specs. Those are the ones that turn a container foothold into a host compromise.
4. Control groups (cgroups): how much it can consume
Namespaces and capabilities control what a process sees and may do. Cgroups control how much it can use: CPU, memory, PIDs, I/O, and which devices it can touch. For security the point is availability and device access: cgroups are the defense against a container starving the host (“noisy neighbor”) or mounting a denial-of-service, and they gate access to host devices.
v1 vs v2
- cgroup v2 is the current unified hierarchy (kernel 4.5, 2016; default in recent distros, e.g. Ubuntu 22.04). It is required for some features like rootless containers.
- Check which you have:
mount | grep cgroup. Onecgroup2line only = v2. A mix ofcgroup2and severalcgrouplines = both present (hybrid). Focus on v2 going forward.
Inspecting cgroups
# which cgroup a process belongs to
cat /proc/<PID>/cgroup # shows the slice/scope
# the controllable knobs for that cgroup (v2)
ls /sys/fs/cgroup/<slice>/<scope>/
# hierarchical views
systemd-cgls
lscgroup # from cgroup-utils
# map a Docker container to its cgroup
docker inspect -f '{{.State.Pid}}' <container>
What you’ll see. cat /proc/<PID>/cgroup prints the slice/scope path the process belongs to. Follow that path under /sys/fs/cgroup/... and open cpu.max: a container started with --cpus 0.5 reads 50000 100000, while an unrestricted one reads max 100000. systemd-cgls renders the whole hierarchy as an indented tree.maxwith and without–cpus` to show the knob changing.
Using cgroups as DoS protection
| Limit | Docker flag | Under the hood (v2) | Protects against |
|---|---|---|---|
| CPU | --cpus 0.5 | cpu.max = 50000 100000 | CPU exhaustion, cryptomining. Default is unlimited (max 100000). |
| Memory | --memory 256m | memory.max | OOM of the host, memory-hog DoS. |
| PIDs | --pids-limit 10 | pids.max | Fork bombs. Default is unlimited. |
| Devices | --device /dev/... | eBPF device program (v2) | Unwanted access to host hardware. |
Fork-bomb demo: docker run -it --pids-limit 10 ubuntu:22.04 /bin/bash, then the classic :(){ :|: & };:. The container hits 10 processes and errors out; the host stays responsive. Without --pids-limit, the same bomb can take the node down.
What you’ll see. With --pids-limit 10, the fork bomb fills the quota in a second and the container floods with bash: fork: retry: Resource temporarily unavailable, while your host shell stays fully responsive. Without the limit the same one-liner drives host load up hard, so only run it with the limit set.
Device access
Containers get a restricted default device list (see runc’s allowed-devices list). --device /dev/dm-0 adds a specific host device into the container. In cgroup v2, device access is enforced by eBPF programs, not the old device-cgroup files, so the usual text-file inspection doesn’t show it. Use bpftool to list the eBPF programs attached to a cgroup if you need to audit device access, though visibility is limited.
Security takeaways
- Set CPU, memory and especially PID limits on every workload. Unlimited PIDs = trivially available host DoS from any container.
- In Kubernetes, use resource
requests/limitsand aLimitRange/ResourceQuotaper namespace. Missing limits is both a reliability and a security gap. - Watch for
--device, and for--privileged(which opens broad device access). Device passthrough can expose block devices, which is a path to reading the host filesystem. - Cgroups are about availability and devices; they do not stop a breakout on their own. They complement, not replace, the other layers.
5. LSMs: AppArmor and SELinux
Everything so far is controlled by root. Mandatory Access Control (MAC) systems are the layer that constrains root itself. They are Linux Security Modules: AppArmor and SELinux are the two common ones.
MAC vs DAC. File permissions are Discretionary Access Control: the owner can change them. With MAC, the user, including root, cannot relax the policy on resources they own. The policy wins over ownership. The one escape hatch is that root can disable the whole MAC system, so it is a strong layer but not an absolute one.
Which one you get depends on distro: Debian/Ubuntu → AppArmor, Red Hat/Fedora → SELinux. Only one is typically enabled at a time.
AppArmor (path/profile based)
AppArmor applies profiles to processes. A profile restricts files, network and capabilities by path and rule.
sudo aa-status # is it loaded, how many profiles, what's enforcing
What you’ll see. aa-status reports that AppArmor is loaded, how many profiles are defined (e.g. 34), and how many are in enforce vs complain mode; with nothing containerized, usually no processes are confined. Start an nginx container and re-run it: several nginx processes now appear under the docker-default profile in enforce mode. With the custom profile applied, writing to /etc from inside the container returns Permission denied even as root.
- Docker ships a
docker-defaultprofile applied to every container automatically. It is deliberately moderate: enough protection without breaking common apps, so it is not as tight as a bespoke profile. - Start an nginx container and
aa-statusshows its processes in enforce mode under that profile.
Custom profile example (block writes to /etc even for root inside the container):
#include <tunables/global>
profile docker-block-etc flags=(attach_disconnected, mediate_deleted) {
#include <abstractions/base>
file,
deny /etc/** wl,
}
Load and apply:
sudo apparmor_parser -r /etc/apparmor.d/containers/docker-block-etc
docker run --rm -it --security-opt "apparmor=docker-block-etc" ubuntu:22.04 /bin/bash
# inside: writing to /etc now fails despite being root
Bane is a helper for authoring Docker AppArmor profiles with a simpler syntax and sensible base restrictions.
SELinux (label based)
SELinux labels every resource (files, ports, processes) with a type and enforces rules between labels, rather than using per-process path profiles. NSA-originated (2000), default on Red Hat-family systems.
sestatus # enabled?, policy name (targeted vs mls), current mode
sudo semanage login -l # how user logins map to SELinux users
ps -efZ # process labels, e.g. container_runtime_t on dockerd/containerd
ls -alZ # file labels
What you’ll see. sestatus prints SELinux status: enabled, Loaded policy name: targeted, and Current mode: enforcing. ps -efZ shows the type labels (container_runtime_t on dockerd/containerd, unconfined_t on your shell); ls -alZ shows file labels. With a host directory bind-mounted in, writing to it is blocked as Permission denied until you add --security-opt label:disable or load a udica policy.
- Modes:
enforcing(block + log),permissive(log only, good for building policy),disabled. - Policy types:
targeted(restrict only selected processes; the norm) vsmls(everything; too complex for general use). - By default ordinary users and root show as unconfined (
unconfined_t); container runtimes getcontainer_runtime_t. - Docker on RHEL/Fedora applies one general SELinux policy to all containers. Demo: mount the host home dir into a container and you are blocked from writing to it even as root; add
--security-opt label:disableand the write succeeds (SELinux off for that container). - udica generates a tailored SELinux policy from a running container:
docker inspect <c> | sudo udica <name>, load the module, restart with the new policy, and the specific access you need (e.g. that home dir) is allowed without disabling SELinux wholesale.
Security takeaways
- The default profiles (
docker-default, the general SELinux container policy) give real but moderate protection. High-value or higher-risk workloads deserve custom, tighter profiles. - MAC is the layer that still bites when a process is root and holds capabilities. It is worth the effort precisely because it constrains root.
- Red flags:
--security-opt apparmor=unconfined,--security-opt label:disable,--security-opt label=type:spc_t, or--privileged(which also loosens MAC). On Kubernetes, check podsecurityContext/annotations for unconfined or disabled MAC, andseLinuxOptionsthat widen access. - Cost is real: authoring and maintaining profiles at scale is a project. Decide by risk profile, as the series puts it, rather than turning it off because it is inconvenient.
6. Seccomp: the last line of defense
Seccomp (secure computing) filters restrict which syscalls a process may make. Syscalls are the interface between userspace and the kernel: open a file, spawn a process, mount something, all go through syscalls (300+ exist, and the set varies by CPU architecture, so ARM and AMD64 differ). Filters are BPF programs, so the rules can be fine-grained.
This is the layer that catches what the others miss. An action allowed by your capabilities can still be blocked here, which is why seccomp is called the “last line of defense” against container breakout.
Docker’s default filter
Docker applies a default seccomp profile to every new container, built as an allowlist: a set of permitted syscalls, with everything else denied. The allowlist design is deliberate, so a newly added, dangerous syscall is blocked by default rather than silently permitted.
It has blocked real exploits. The canonical example is CVE-2022-0185 (a kernel container-escape), which relies on the unshare syscall; Docker’s default profile blocks unshare, so the exploit path is closed on a stock container.
Demonstration:
# default profile: unshare is denied
docker run -it ubuntu:22.04 /bin/bash
unshare # -> Operation not permitted
# profile disabled: unshare now works (and so do the exploits it enables)
docker run --security-opt seccomp=unconfined -it ubuntu:22.04 /bin/bash
unshare # succeeds
What you’ll see. In a default container, unshare returns unshare: ... Operation not permitted, the syscall caught by Docker’s seccomp filter before capabilities even matter. Start the same image with --security-opt seccomp=unconfined and the identical command succeeds silently. To confirm the mode from inside, grep Seccomp /proc/self/status shows 2 (filtered) vs 0 (off).
That seccomp=unconfined flag is a big red flag in any config. Note also that --privileged disables seccomp as a side effect.
Custom profiles
Profiles are JSON. defaultAction decides what happens to a syscall that matches no rule. A block-everything skeleton:
{ "defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
"syscalls": [] }
SCMP_ACT_ERRNO returns an error (deny). Other actions exist (SCMP_ACT_ALLOW, SCMP_ACT_LOG, SCMP_ACT_TRAP, etc). A deny-all profile is useless on its own since every container needs syscalls, so the practical pattern is subtractive or additive from a known-good base.
Subtractive (recommended starting point): take Docker’s default profile and remove the syscalls you want gone. The series’ example is dropping the io_uring family, which has been a rich source of kernel CVEs:
"io_uring_enter",
"io_uring_register",
"io_uring_setup",
Remove those entries, save as no_io_uring.json, and apply:
docker run -it --security-opt seccomp=no_io_uring.json ubuntu:22.04 /bin/bash
Additive (tightest): audit the syscalls your app actually makes and build a profile that allows only those. Tools like Inspektor Gadget can record a workload’s syscalls and generate the profile for you.
Seccomp in Kubernetes
- Set the runtime default explicitly with
securityContext.seccompProfile.type: RuntimeDefault(pod or container level). The kubelet’s--seccomp-defaultflag applies that profile node-wide and has been stable since v1.27, so on recent clusters every pod can get the default filter without per-pod config. Older pods that set nothing historically ranunconfined, a common finding. - Use
type: Localhostwith alocalhostProfileto ship a custom profile. The Security Profiles Operator helps record and distribute profiles across a cluster.
Security takeaways
- Keep the default profile on. Flag
seccomp=unconfinedand--privilegedeverywhere. - In Kubernetes, set
RuntimeDefaultexplicitly; do not assume it is applied. - For sensitive workloads, move toward a tailored allowlist profile generated from real syscall traces. Blocking syscall families with a bad CVE history (like
io_uring) is a cheap, high-value win.
Where container hardening stops being enough
Every layer in this post, namespaces, capabilities, cgroups, MAC, seccomp and the user mapping, is still a shared-kernel control. The container and the host run on the same kernel, so a single kernel bug reachable through one allowed syscall can collapse all six at once, and no amount of profile tuning closes that gap completely. When you have to run genuinely untrusted code, the move is a stronger boundary that does not share the host kernel directly: a user-space kernel like gVisor, or a lightweight VM per workload (Firecracker, Kata Containers, or a cloud microVM). Those trade some performance and compatibility for a real isolation barrier. When that tradeoff is worth making, and how those sandboxes actually work, is the subject of a separate post on sandboxing.
7. Breakout angle, hardening, and cheat sheet
Red-team reading of the six layers
A breakout is almost always a misconfigured or disabled layer, not a novel exploit. On a foothold inside a container, enumerate the layers in this order:
- Who am I?
id. Root? Is there a user namespace? (cat /proc/self/uid_map, a mapping that doesn’t start0 0= userns in play.) - What can I do?
capsh --print/grep Cap /proc/self/status.SYS_ADMIN,SYS_PTRACE,SYS_MODULE,DAC_READ_SEARCH= escalation paths. - What’s shared? Check for
--pid=host/hostPID(ps auxshows host processes),hostNetwork,hostIPC, host mounts. - Is the box wide open?
--privilegedenumeration: capability set is full, seccomp is disabled, and/devis populated.amicontainedsummarizes this fast. - Mounts.
mount,cat /proc/mounts, look for the Docker socket (/var/run/docker.sock), host paths,/dev/sd*or/dev/dm-*block devices. - Seccomp/LSM status.
grep Seccomp /proc/self/status(2 = filtered),cat /proc/self/attr/currentfor AppArmor/SELinux context.
Classic escape conditions to look for: mounted docker.sock (control the daemon = own the host), --privileged, CAP_SYS_ADMIN + something to abuse (cgroup release_agent, core_pattern), CAP_SYS_MODULE, CAP_DAC_READ_SEARCH (Shocker-style), host filesystem bind-mounted, host PID + CAP_SYS_PTRACE.
Hardening checklist
- Run as a non-root UID (
USERin Dockerfile;runAsNonRoot: true). Removes most of the risk cheaply. - Enable user namespaces (userns-remap / rootless Podman) where feasible.
-
--cap-drop=ALL, add back only what the app proves it needs. - Never
--privileged. Never mount the Docker/containerd socket into a workload. - Set resource and PID limits on every container (CPU, memory,
--pids-limit/ k8s limits). - Keep the default seccomp profile; set
RuntimeDefaultexplicitly in Kubernetes; consider a tailored allowlist for sensitive apps. - Keep the MAC layer on (AppArmor
docker-defaultor SELinux enforcing); write custom profiles for high-value workloads. - Don’t share host namespaces (
hostPID/hostNetwork/hostIPC) unless truly required. - Read-only root filesystem (
--read-only,readOnlyRootFilesystem: true) where possible. -
no-new-privileges(--security-opt no-new-privileges,allowPrivilegeEscalation: false). - Keep secrets out of env vars; use mounted files or a secrets manager.
- Lock down and monitor
/var/lib/dockeron the host; protect node access, since node = all its workloads. - Patch the host kernel. One shared kernel means a kernel CVE is a container-escape CVE.
Command cheat sheet
| Goal | Command |
|---|---|
| Container PID on host | docker inspect -f '{{.State.Pid}}' <c> |
| Browse container rootfs from host | sudo ls /proc/<PID>/root |
| Dump container env (secrets) | sudo cat /proc/<PID>/environ | tr '\0' '\n' |
| List namespaces | sudo lsns |
| Enter a namespace | sudo nsenter --target <PID> --net ip addr |
| New namespaces, no Docker | sudo unshare --pid --fork --mount-proc /bin/bash |
| Host caps | sudo pscap / sudo filecap -a |
| Caps inside container | capsh --print / amicontained |
| cgroup of a process | cat /proc/<PID>/cgroup |
| cgroup version | mount | grep cgroup |
| AppArmor status | sudo aa-status |
| SELinux status | sestatus |
| Seccomp mode of a process | grep Seccomp /proc/<PID>/status |
| Run without seccomp (test) | docker run --security-opt seccomp=unconfined ... |
What’s coming next
This is the mechanics post. Next in the CI/CD series, I’ll walk through what a runner actually looks like from the inside once you’ve landed in one, and how a container that is one flag away from --privileged turns into a path off the build box and into whatever the pipeline is trusted to touch.
Everything in this series comes from authorised testing and my own lab setup. Run these commands on boxes you own or have explicit permission to test, not someone else’s build infrastructure.
If you have found a container config in the wild that broke one of these six layers in an interesting way, I’d like to hear about it. Feel free to DM me.
Sources
Rory McCune, Container security fundamentals, Datadog Security Labs (2023):
- Part 1: Exploring containers as processes
- Part 2: Isolation & namespaces
- Part 3: Capabilities
- Part 4: Cgroups
- Part 5: AppArmor and SELinux
- Part 6: seccomp
These notes follow the series’ structure but update the fast-moving Kubernetes and runtime details (user namespaces now GA, seccomp RuntimeDefault, runc time-namespace support) to current releases. Last reviewed October 2026.