What "root in a container" really is
UID 0, and why it reaches the host kernel.
Run id inside a plain Alpine container (Alpine being a very small Linux image people reach for when they want something quick) and the answer comes back uid=0(root). No sudo. No password. Nothing you had to earn. It reads like a toy root, sandboxed, harmless, unable to touch anything outside its own little box. That reading is the most expensive mistake in container security. Unless a user namespace is switched on, the UID 0 (user ID zero) inside your container is the very same UID 0 the host kernel calls root. One account. One set of powers over everything the two of them share.
An apartment building shares one foundation, one water main, one electrical panel. Each flat has its own door, but nothing underneath those doors is separate. Containers work the same way. Every container on a machine calls into the one kernel the host is running, the kernel being the core of the operating system that actually hands out access to hardware, files and memory. A virtual machine (VM, a whole simulated computer with its own kernel and its own front door) is the opposite arrangement: separate building, separate foundation. Root is a status the kernel enforces, so the moment you are root against the shared kernel, you are root for the whole building. What a container has instead of walls is namespaces: one-way mirrors that let a process see only its own room. A namespace controls what a process can see. It never rechecks who that process is. Nothing sits between container UID 0 and host UID 0 quietly demoting you. Either you add that layer, or it is not there.
This is why people grade a container breakout by the user ID it lands on. In January 2024 a bug nicknamed Leaky Vessels turned up in runc, the low-level program Docker calls to actually start a container. Its public tracking number is a CVE (Common Vulnerabilities and Exposures identifier, the industry's way of naming one specific bug so everyone means the same thing), and here it is CVE-2024-21626. A container that could get hold of a leaked file descriptor, the kernel's numbered handle for a file or directory that is already open, could walk out onto the host. Run that escape from a root container and you arrive as host root, free to rewrite the binaries every other workload on the box depends on. Run the identical escape from a non-root container and you arrive as some unprivileged user with nothing worth stealing. Same bug either way. The size of the crater was decided by you, at docker run time.
Prove it with one file
You should not have to take my word for any of this. A bind mount is a window cut from the container onto a real folder on the host. No copy is made, the bytes are not relabelled, and both sides are looking at the same thing. So mount a host folder, write a file into it as container root, then walk around to the host side and see who owns it. The docker daemon, the background service that builds and runs every container on the machine, runs as root, and that is what lets the container write there at all.
# on the host, as an ordinary user (the docker DAEMON runs as root)$ whoami; mkdir -p /tmp/hostalice$ docker run --rm -v /tmp/host:/mnt alpine sh -c 'id; touch /mnt/pwned; ls -ln /mnt/pwned'uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)-rw-r--r-- 1 0 0 0 Jul 16 09:41 /mnt/pwned# now look from the host side, no container involved$ ls -ln /tmp/host/pwned-rw-r--r-- 1 0 0 0 Jul 16 09:41 /tmp/host/pwned # numeric owner 0 = REAL root, not alice
On the host side the file belongs to UID 0. Real root, not alice. The container never asked permission and never got demoted on the way through. It wrote as root because, as far as the kernel was concerned, it was root. Any host path you hand to a root container is a path that container can take ownership of outright. That is the whole risk in one command, and it is why an over-broad or long-forgotten -v mount keeps turning up in reviews.
Detection: is this container's root the host's root?
You can settle the question without touching a single file, by reading the container's UID map. It works like the exchange-rate board at a currency desk: for every user ID inside the container, it tells you what that ID becomes on the host. Three columns, left to right: the first ID inside, the first ID on the host, and how many IDs the range covers.
# read the map from inside a running container$ docker run --rm alpine cat /proc/self/uid_map0 0 4294967295# columns: container-UID host-UID range-size# "0 -> 0" across the whole range is the IDENTITY map: no user namespace at all.# from the host, spot containers that run as root to begin with$ docker inspect -f '{{.Name}} user=[{{.Config.User}}] userns=[{{.HostConfig.UsernsMode}}]' web/web user=[] userns=[] # empty User = root; empty UsernsMode = no remap
A map reading 0 0 4294967295 says container root and host root are the same account, all the way up the range. An empty User field in docker inspect says the image never dropped out of root. Read together, those two lines tell you whether a breakout from this container lands as host root or as a nobody. Wire both into an admission check or a scanner and you catch the dangerous case before it ships, rather than during the incident review.
Fix one: don't be root in the first place
The cheapest fix is one line. Add a dedicated user in the Dockerfile and switch to it with USER, or override it at run time with --user. The equivalence you saw a moment ago runs in both directions, and this time it works in your favour: UID 1000 inside the container is UID 1000 on the host, so a container that is not root cannot clobber a file that host root owns.
$ docker run --rm --user 1000:1000 -v /tmp/host:/mnt alpine \sh -c 'id; echo owned > /mnt/pwned'uid=1000 gid=1000 groups=1000sh: can't create /mnt/pwned: Permission denied
Same image, same mount, one extra flag, and the write dies on Permission denied. Host UID 1000 has no business overwriting a file host root created. In an image you control, bake it in with USER 1000 (or runAsNonRoot if your orchestrator offers it) so nobody has to remember a flag at the keyboard. That covers the common case. It does nothing for a workload that genuinely has to be root inside, which is what the next fix exists for.
Fix two: make container root a host nobody
A hotel keycard opens every door on your floor and nothing in the building next door. Same plastic, very different authority depending on which side of the wall you hold it up to. That is what a user namespace (userns for short) gives a container. Some processes really do need root inside, at least while they start up, to bind a low-numbered port or fix up file ownership before dropping down. For those, userns is the real answer. It shifts the container's whole range of user IDs onto a high, unprivileged block on the host, so container UID 0 becomes something like host UID 100000. Full root powers inside its own namespace. An ordinary nobody everywhere the host actually cares. You turn it on for the whole daemon in daemon.json.
# /etc/docker/daemon.json (then: sudo systemctl restart docker){"userns-remap": "default"}# same map, read again with the namespace now active:$ docker run --rm alpine cat /proc/self/uid_map0 100000 65536# container UID 0 -> host UID 100000, for a run of 65536 IDs.# and the original attack, replayed unchanged:$ docker run --rm -v /tmp/host:/mnt alpine sh -c 'touch /mnt/pwned3'touch: /mnt/pwned3: Permission denied
Container root is now host UID 100000, an account that owns nothing at all on the host, so the exact command that took ownership of a host file a minute ago fails outright. If the app has a legitimate reason to write that path, give the host directory to the mapped range (chown 100000:100000 /tmp/host) instead of handing it real root. Rootless Docker and Podman take the idea to the end of the line. The daemon itself runs as your own user, with the same remapping underneath, so there is no root-owned daemon left to attack either.
UID 0 inside a container is still UID 0 against the host kernel unless a user namespace rewrites it. That one sentence is why "but it's only root in the container" loses every serious design review. One shared kernel means one shared privilege model at the system call boundary, where a process asks the kernel to do something on its behalf.
Writable host mounts turn the theory into an incident on the same afternoon. Container root can rewrite host binaries, drop an SSH key (secure shell, the standard remote login tool) into somebody's home directory, or poison a cron job (the Unix scheduler that runs commands on a timer) sitting on the very path you bind-mounted for convenience. A read-only root filesystem and a non-root USER shrink the damage well before you get anywhere near remapping.
Inherit a fleet that runs everything as root and you want an order of operations, not a to-do list. Remap or go rootless at the daemon first, because that one change covers every container on the host at once. Then push a non-root USER into the images, one image at a time. That sequence buys down host compromise fastest.
After a change window, run the same check again. Confirm the control is still on, paste the command and its output into the ticket, and refuse to close the change if the reading moved. Give each workload the tightest scope it can still run in. That habit compounds across every host and every pipeline you own.
Try this
$ docker run -d --name asroot alpine sleep 300$ ps -o user,pid,args -C sleep | head -5USER PID COMMANDroot 31244 sleep 300$ docker rm -f asroot$ docker run -d --name asuser --user 10001 alpine sleep 300$ ps -o user,pid,args -C sleep | head -5USER PID COMMAND10001 31288 sleep 300$ docker rm -f asuser
Takeaway
Container root is host root on a shared kernel until a user namespace says otherwise. Drop to a numeric non-root USER in the image, keep host mounts read-only, and treat UID 0 as a temporary debugging state rather than the default you ship.