CoursesAdvanced container securityWhat "root in a container" really is

What "root in a container" really is

UID 0, and why it reaches the host kernel.

Advanced12 min · lesson 4 of 25

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.

terminal
# on the host, as an ordinary user (the docker DAEMON runs as root)
$ whoami; mkdir -p /tmp/host
alice
$ 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.

terminal
# read the map from inside a running container
$ docker run --rm alpine cat /proc/self/uid_map
0 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.

terminal
$ docker run --rm --user 1000:1000 -v /tmp/host:/mnt alpine \
sh -c 'id; echo owned > /mnt/pwned'
uid=1000 gid=1000 groups=1000
sh: 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.

terminal
# /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_map
0 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.

userns-remap is a floor, not a wall
The remap covers the whole daemon, and it has holes you can drive a truck through. --userns=host drops a container straight back into the host user namespace, so its root is real host root again. --privileged will not start under a remapped daemon unless you also pass --userns=host, and the moment you do, that container is host root too. Sharing a host namespace with --pid=host (the host's process ID namespace, which lets the container see every process on the box) or --network=host is flatly incompatible with a remapped container. So treat the daemon setting as a floor rather than a guarantee. Block those flags in platform policy, and when a container looks off, read its /proc/self/uid_map from the inside instead of trusting that the global config applied to it.
Same exploit, three blast radii
A process breaks out of its container
the runtime bug is already exploited; what UID does the attacker land as on the host?
Ran as root, no userns
Host UID 0: full compromise
can rewrite host binaries, read other containers' secrets, and pivot across the whole box. This is CVE-2024-21626 at its worst.
Ran as non-root (USER 1000)
Host UID 1000: unprivileged
lands as a plain user with no rights over root-owned files, the daemon socket, or its neighbours. Most escapes fizzle right here.
Root + userns-remap
Host UID 100000: mapped nobody
container root is a high unprivileged host UID; the escape owns nothing it didn't already legitimately own inside its namespace.
The UID the container ran as decides which branch you get. The exploit doesn't.

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

terminal
$ docker run -d --name asroot alpine sleep 300
$ ps -o user,pid,args -C sleep | head -5
USER PID COMMAND
root 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 -5
USER PID COMMAND
10001 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.

Quick check
01A container runs as root with no user namespace, and a host directory is bind-mounted into it at /data. An attacker gets code execution inside the container. What can they do to /data on the host?
Correct — Container UID 0 is host UID 0, so the attacker holds root's authority over every byte of that mounted directory.
Incorrect — No. Bind mounts are read-write unless you add :ro, and either way the writes land as host root.
Incorrect — A bind mount is a live window onto the host directory, not a copy. Isolation does not cover a path you deliberately mounted in.
Incorrect — That is the outcome a user namespace gives you, and there is not one here. Without remapping, host root owns the writes.
02Inside a container you run cat /proc/self/uid_map and it prints: 0 0 4294967295. What does that line tell you?
Incorrect — No. A remap would read something like 0 100000 65536. This line maps 0 to 0.
Correct — The first column (container UID) and the second (host UID) both start at 0 across the whole range, which is the fingerprint of no userns.
Incorrect — No. The third column is the size of the mapped UID range, not a quota on users.
Incorrect — No. This is a perfectly valid map. It is the identity case, meaning no remap.
03Your platform turns on userns-remap: default across the whole daemon. A team ships a container with --privileged --userns=host and argues the daemon-wide remap still keeps its root harmless. What's the real state of that container?
Incorrect — No. --userns=host opts that container out of the daemon remap completely.
Incorrect — No. They are honored. --privileged actually requires --userns=host to run at all under a remapped daemon.
Incorrect — No. Paired with --userns=host it lands the container in the host user namespace as real root.
Correct — userns-remap is a floor, not a wall. Flags like --userns=host punch straight through it, which is why platform policy has to block them.

Related