← All chapters

Chapter 3

Why your container is not a boundary

Three ways out of a default container, and the one that no amount of hardening fixes.

Chapter 2 took a container apart into five kernel features. Now watch what happens when one of them is missing. The shortest escape from a container to full root on the host is three short commands, and none of them is clever: start a container with --privileged and the host's root filesystem bind-mounted in, chroot into that mount, and whoami answers root, on your actual host. We deliberately leave out the copy-pasteable line; this is a real host takeover, and the point is the anatomy, which follows.

No exploit is involved, and no Docker bug. Every line is a documented feature working exactly as designed: three of chapter 2's five walls taken down at the door, on purpose, by the person running the command.

Why it works

--privileged is the whole story. It hands back every capability the runtime normally drops, including CAP_SYS_ADMIN, the near-root one, and it disables the default seccomp-BPF profile, so the syscall filter that would have banned mount and friends is simply gone. The container is now a root process with root's full kit and no verb filter. The only thing still standing between it and the host is what it can see.

The -v /:/host bind mount removes that too. A container's private filesystem view is a mount namespace, it sees only what was mounted into it. Mount the host's root directory in and the namespace is defeated by consent: the host's entire filesystem is now inside the container's view. chroot /host pivots into it, and you are root on the host, reading /etc/shadow, writing to /root/.ssh/authorized_keys, doing anything at all. Three walls down, caps, seccomp, and the mount namespace, and the box is not a box.

The other two doors

--privileged is the loud one. There are two quieter doors that get left open far more often. The first is a mounted Docker socket, -v /var/run/docker.sock:/var/run/docker.sock, a line copied into countless CI configs and dev images. The socket is the daemon's full API. A process that can reach it does not need any capabilities of its own; it just politely asks the daemon, which runs as root on the host, to start a new --privileged container mounting /, and now you are back at the escape above. The second is simpler still: containers run as root by default. Root in the container is UID 0 to the kernel, and the moment any of the other walls slips, a lax capability set, a writable host path, a kernel bug, that UID 0 is the difference between a contained mistake and a host compromise.

The fair counterpoint

It would be wrong to stop there, because all three of these doors can be shut, and properly hardened containers are genuinely good security. Rootless Podman runs the whole engine as your unprivileged user, so there is no root daemon behind the socket to abuse. User namespaces map container-root to an unprivileged host UID, so UID 0 inside is nobody outside. The runtime drops capabilities to a minimal set and applies a default seccomp-BPF filter and an AppArmor profile on top. Do all of that, rootless, user-namespaced, caps dropped, seccomp and an LSM engaged, no privileged flag, no host mounts, and the easy escapes are closed. Hardened containers are real security. Do not let anyone tell you a well-configured container is worthless; for code you trust it is exactly right.

The one thing hardening cannot fix

There is a single door that no amount of container configuration can lock, and it is the reason for everything that follows. A hardened container still makes its syscalls into your kernel. If the code inside finds a local privilege-escalation bug in that kernel, a flaw in a syscall, a namespace edge case, an io_uring primitive, it escalates below every wall you built. Capabilities, seccomp, and the LSM all run on top of the kernel; a kernel LPE comes up underneath them, and every container on the box shares that one kernel. That limit is structural, the bottom line under chapter 1's fault line.

So the question sharpens: what do you do when the code is genuinely hostile, an autonomous agent running code it wrote, a dependency you have never read, and one kernel bug is all it would take? You stop sharing the kernel. In microVMs and the two walls we give the code its own kernel, and then watch the primitives from chapter 2 come back to build a second wall around the thing that provides it.

Reference these in the dictionary