Rootless Docker and user namespaces: why container root is host root, and how to break the link
By default container UID 0 is host UID 0, so a bind-mount escape writes host files as root. User namespaces remap container root to an unprivileged /etc/subuid range; rootless Docker runs the daemon unprivileged, trading ~20-30% slirp4netns throughput for a smaller blast radius.
The incident started with a one-line convenience in a Compose file: a developer had bind-mounted the host’s /var/www into a container so edits showed up live, and the container — like almost every container — ran its process as root. Months later that image picked up a vulnerable dependency, an attacker got a shell inside the container, and whoami returned root. They chmod 777’d the mounted directory, dropped a web shell, then walked up the mount: every file the container wrote to /var/www landed on the host owned by UID 0, because container UID 0 is host UID 0. The mount namespace had isolated which paths the process could see, not who it was. Nothing in Docker’s defaults had said “this container’s root is the same root that owns your server.” The fix the team shipped that week was not a patched dependency — it was a userns remap that made the container’s root a powerless host UID in the 100000-range, so the same escape would have written files owned by a user that owns nothing.
The default nobody warns you about: root is shared
Ask yourself: if a container process escapes its filesystem view, what stops it from writing host files as root? Nothing — unless you understand exactly which namespaces Docker enables by default, and which one it leaves out.
A Linux container is just a process with namespaces and cgroups, not a VM. By default the user namespace is not one of those namespaces, which means there is no UID translation: the root (UID 0) inside the container is literally the same UID 0 the kernel uses on the host. The PID, mount, and network namespaces isolate what the process sees — its process tree, its filesystem view, its interfaces — but they do not change who it is. So the moment a container process touches something shared with the host, identity matters more than visibility. The classic channel is a bind mount: -v /var/www:/data gives the container a view of a real host directory, and any file the container’s root writes there is owned by host root. Drop a vulnerable image into that setup and a container compromise becomes a host-filesystem write with root ownership — no kernel exploit required, just the default that container root equals host root.
The kernel-level fix is the user namespace. It introduces a per-namespace UID/GID mapping so that UID 0 inside the namespace maps to some unprivileged UID on the host. The mapping is configured from /etc/subuid and /etc/subgid, which allocate each user a contiguous range — conventionally 65536 consecutive subordinate UIDs starting at a high base like 100000. With userns-remap enabled, container UID 0 becomes host UID 100000, container UID 1 becomes 100001, and so on across the whole 65536-wide range. A process that escapes the container as “root” is now host UID 100000, which owns nothing and has no host privileges. The blast radius of a full container compromise shrinks from “root on the host” to “an account that can’t even read other users’ files.”
A container runs its process as root and bind-mounts a host directory. With Docker's default settings (no userns-remap), an attacker gets a shell and writes a file into the mounted directory. Who owns that file on the host?
▸Why this works
Why is userns-remap off by default if it is this useful? Because the UID shift breaks assumptions. Files in a bind mount must be readable by the remapped range, so existing host files owned by UID 1000 are invisible to a container whose root is now host UID 100000 — you re-chown or accept the friction. Some workloads that genuinely need host UID identity (certain storage drivers, host-networked agents) stop working. Docker chose a permissive default that “just works” with mounts over a safe default that surprises people, and left remap as an opt-in. That trade is the whole reason this lesson exists: the convenient default is the insecure one.
Rootless Docker: no privileged daemon at all
When you enable userns-remap, you eliminate the worst bind-mount escape — but you may not realize the daemon itself is still running as root. That is the gap rootless Docker closes.
User-namespace remap still runs the Docker daemon as host root — it only remaps the containers. Rootless Docker goes further: the entire daemon and all containers run inside a single user’s user namespace, started with no root privilege anywhere. There is no root-owned dockerd, no root-owned socket, so even a daemon compromise lands as an unprivileged user. This closes the largest remaining target — historically a writable /var/run/docker.sock is an instant root-on-host primitive, because anyone who can talk to the daemon can start a container that mounts the host — and rootless makes that socket powerless.
The cost is paid mostly in networking. Without root, rootless Docker cannot create the usual veth/bridge plumbing, so it routes container traffic through a userspace TCP/IP stack — slirp4netns — which copies packets in and out of userspace and imposes a throughput penalty commonly measured around 20-30% versus the rootful bridge (mitigated by slirp4netns with sandbox=false or by pasta, but not eliminated). Binding privileged ports below 1024 also needs extra setup since the daemon lacks CAP_NET_BIND_SERVICE on the host. For a CI runner or a multi-tenant build host, paying a fifth of network throughput to delete “container escape equals host root” from the threat model is usually the right trade; for a throughput-bound network appliance it may not be.
What is the main runtime cost a team accepts when moving from rootful Docker to rootless Docker, and why?
- 01Explain why a container running as root with a bind mount is dangerous on default Docker, and exactly what userns-remap changes about it.
- 02What does rootless Docker add over userns-remap, and what is the concrete cost?
A container is a namespaced, cgroup-limited process, not a VM, and by default the user namespace is not among its namespaces — so there is no UID remapping and container UID 0 is host UID 0. The PID, mount, and network namespaces isolate what the process sees, not who it is, which is why a bind mount turns a container compromise into a host-filesystem write owned by root: identity, not visibility, is what leaks across a shared path. The kernel fix is the user namespace, configured from /etc/subuid and /etc/subgid, which allocate each user a contiguous range conventionally 65536 UIDs wide from a high base like 100000; with userns-remap on, container UID 0 maps to host UID 100000 and an escape lands as an account that owns nothing. Rootless Docker goes further and runs the whole daemon and its containers inside one unprivileged user namespace, so there is no root-owned dockerd or docker.sock to compromise — closing the writable-socket-equals-host-root primitive. The price is mostly networking: rootless cannot build the normal bridge, so traffic routes through the userspace slirp4netns stack at roughly a 20-30% throughput penalty, and privileged ports need extra setup. The defaults are convenient and unsafe by design — userns-remap is opt-in because the UID shift breaks bind-mount assumptions — so the senior move is to choose the safe configuration deliberately and pay its known, bounded cost. Now when you see a Compose file with a bind mount and no userns-remap, you know exactly what threat you are accepting and which lever to pull to remove it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.