Container Security Basics
A container is a boundary made of kernel configuration, and configuration loosens. Here is how the wall between a container and its host actually breaks, and how to make it thicker.
Builds on
What you will learn
- -Explain why a container shares the host kernel, and what that means for the root user inside it
- -Identify the misconfigurations that turn a container into a direct path to the host, from privileged mode to a mounted Docker socket
- -Recover a secret left behind in an image's earlier layers, and weigh where an image came from
- -Shrink a container's blast radius with dropped capabilities, a non-root user, and a read-only filesystem
running-targets-in-docker ended on a comfortable note: stand up a deliberately vulnerable service in a , map one to reach it, and tear the whole thing down when the work is finished. The container kept the vulnerable service walled off from the of the machine. That success plants a dangerous idea, that the wall is solid.
It is thinner than it looks. A container is an ordinary process that the kernel has agreed to lie to about what it can see. The isolation is real, but it is assembled from configuration, and configuration gets loosened by people who are in a hurry. On an engagement, a inside a container is not the finish line. It is the start of one question that runs through this whole lesson: how much of the host is a single step away?
One kernel, shared by everything
what-containers-are drew the hard line between a container and a virtual machine. A virtual machine boots its own kernel; a container borrows the host's. give a process its own view of the process table, the , and the filesystem , and cgroups cap how much CPU and memory it can take. But every call that process makes still lands on the one kernel shared by every other process on the box.
That shared kernel is the whole security story. A virtual machine that gets fully owned is still fenced off by a hypervisor with its own kernel underneath. A container that gets fully owned is a process sitting on the same kernel as everything else, held back only by the namespace walls and the capability limits the kernel was asked to enforce. Weaken those, and there is nothing else beneath them.
Root inside is root outside
The root user inside a default container is not a lookalike. It is UID 0, the exact numeric that root has on the host. namespaces restrict where that UID 0 can reach, so container root cannot, by default, open the host's files or signal host processes. The limit lives in the namespace boundary, not in the identity. When a process crosses that boundary and touches a real host resource, through a bind mount, a shared namespace, or a kernel bug, it touches it as UID 0. No second permission check is waiting on the far side.
Docker softens this by default. A new container does not the full root toolkit. The strips most Linux capabilities and keeps a limited set, so container root can bind a low port or change a file's owner but cannot, for instance, load a kernel module. The kept set is readable from inside the container in the process status file.
$grep Cap /proc/self/statusThe hex values are capability bitmasks, one bit per capability. CapEff is the
effective set, the one in force. The value shown is what a default Docker
container commonly holds, and the exact number moves with the Docker and kernel
versions, so treat it as illustrative rather than a constant to memorize. The
comparison is what matters. A container started with --privileged shows every
bit set instead, a wall of fs, which means it carries the complete capability
set that host root carries.
A process running as root inside a default Docker container reads a file from a host directory that was bind-mounted into it. What identity does the kernel apply the read as?
The flags that open the door
docker run --privileged was built for the rare case of running Docker inside
Docker or driving hardware from a container. It hands over the full capability
set, turns off the default seccomp and AppArmor filters that normally block
dangerous system calls, and lets the container reach the host's devices,
including the raw disk under /dev. A can mount the host's
disk and read or rewrite any file on it. That is not an . It is the
documented behavior of a flag someone typed.
The mount that shows up most in the wild is the Docker .
/var/run/docker.sock is the Unix socket the Docker daemon listens on, and that
daemon runs as root on the host. Some containers get the socket bind-mounted in
so an app inside can manage other containers: a CI runner, a monitoring agent, a
build tool. Any process that can reach that socket can issue commands to the root
daemon.
$ls -la /var/run/docker.sockThe move from there is short. A container holding the socket asks the daemon to
start a brand new container, one that bind-mounts the host's / inside
itself, then reads or writes the host filesystem through that mount as root. The
first container never needed --privileged at all. It only needed a line to the
daemon that does.
The same shape repeats with other loosened settings. --pid=host drops the
process-table namespace, so the container sees and can signal every process on
the machine. A bind-mounted host path, -v /:/host at the extreme, is a direct
window into host files. Sharing the network namespace exposes services meant to
listen only on the host. Every one of these is a misconfiguration, and
misconfiguration is the common case: most real escapes are a flag or a mount
someone left open, not a clever exploit.
The rarer path attacks the shared kernel or the container runtime itself.
Because every container rides the same kernel, a bug reachable through a system
call can carry a process out of its namespaces. This is not hypothetical.
CVE-2019-5736 was a flaw in runc, the low-level runtime Docker uses to spawn
containers, that let a process inside a container overwrite the runc binary on
the host and run its own code as root the next time any container started. Bugs
like that get patched fast and are far less common than a stray --privileged,
but they are the reason a fully current, well-configured host still is not a hard
security boundary.
techniques belong to boxes under ownership or explicit written . An escape reaches the host and everything on it, which is exactly why it sits out of bounds without permission. A personal lab, an intentionally vulnerable practice target, or a system inside a signed are the places to run them.
An unprivileged container, started without --privileged, has /var/run/docker.sock bind-mounted inside it. Why is that close to a full host compromise?
The image is code from strangers
images-and- covered pulling an image by name from a . That
name hides a real question: who built it, and what is inside? docker pull redis
with no tag quietly means redis:latest from Docker Hub, a tag that can point at
a different image next month and is signed by whoever that repository.
Unofficial images, and images whose names sit one typo away from a popular one,
are a known way to get hostile code onto a host. Pinning an image to a content
digest, the name@sha256:... form, nails it to one exact set of bytes, so a
later push cannot swap the contents under a familiar tag.
Provenance has a second edge that catches even careful teams. From images-and-dockerfiles: an image is a of layers, and layers are additive. A file added in one layer and deleted in a later one is gone from the final filesystem but fully intact in the earlier layer, which still ships inside the image. A developer who pastes an key into a build step, uses it, and deletes it on the next line has not removed the key. They have buried it one layer down, where anyone who pulls the image can dig it back out.
$docker history --no-trunc redis:7docker history reads the recorded build command behind each layer, so the
typed into that pip install line is preserved forever in the image metadata,
and the rm of .env two layers later did nothing to the .env file already
captured by the earlier COPY. For files copied in rather than typed, unpacking
the image reaches them directly: docker save someimage:tag -o img.tar writes
the image to a tar archive, and the extracted layers are ordinary files to grep
through for password, token, BEGIN PRIVATE KEY, or a stray .env. Running
that grep against any pulled image is a genuine recon step, and it turns up more
than most teams would like.
A Dockerfile copies a private key into the image, uses it in a build step, then runs rm on the key in a later instruction. Someone pulls the finished image. Can they recover the key?
Give the container less
Hardening a container is mostly subtraction. A Dockerfile USER line runs the
process as a non-root account, so a bug inside is not instantly UID 0.
--cap-drop=ALL paired with a short --cap-add list hands back only the
capabilities the app truly needs. --read-only mounts the root filesystem
read-only, so an attacker who gets in cannot write tooling to disk.
--security-opt=no-new-privileges stops a process from gaining capabilities
through setuid binaries. Rootless Docker and user-namespace remapping go furthest,
mapping container root to an unprivileged host UID so that even container UID 0 is
nobody special on the host, which is the fix that finally breaks the
root-inside-is-root-outside problem this lesson opened on.
get handed in at run time instead of baked into the image. The environment keys and mounted volumes from docker-compose are one clean way to do it, and a dedicated secrets store is better still. Each of these choices turns a total compromise into a partial one, which is the whole game: not a perfect wall, a thicker one.
On a host with Docker, start a throwaway container and look at what it holds. Run
docker run --rm -it alpine sh to get a shell inside, then
grep CapEff /proc/self/status to see its capability mask. Note the value. Exit
with exit, start it again as docker run --rm --privileged -it alpine sh, and
run the same grep. Compare the two masks: the second is a wall of fs, the full
host capability set.
While inside either container, check for the two escape signals from this lesson.
Run ls -la /var/run/docker.sock to ask whether the daemon socket is mounted in,
and mount | grep -v overlay to see what host paths were bind-mounted. On a clean
throwaway container the socket is absent and no host paths are mounted, which is
exactly what a safe container should look like.
These are the checks that keep running the practice machines a controlled exercise instead of a reckless one: know what a container can reach before trusting it to hold something vulnerable. The same questions scale up when one host becomes a . security asks them of and service accounts rather than single containers, where a mounted socket becomes a reachable API and one over-permissioned workload can take the whole .