What root in a container really is
UID 0 inside, the same UID outside, and what that means for mounts.
ls -ln on a directory you own shows a file you can't edit. Its owner is 0, it appeared a minute ago, and the program that wrote it was a container you started without sudo. Nothing went wrong. Without a user namespace, the UID 0 that id reports inside a container is the same UID 0 the host kernel treats as root. A bind mount hands that root a piece of the host's filesystem. You'll prove it from both sides on the main lab VM, then read the fields that tell you whether a given container runs as root. Everything happens under ~/lab/rootuser, which the cleanup removes.
A file written by container root
The directory belongs to ubuntu (UID 1000). Inside the container, id says root and the new file is owned by 0. On the host it's still owned by 0, and ubuntu, who owns the directory, gets Permission denied when trying to append to it. The kernel stores only numbers on files and processes. The previous lessons showed the container sharing the host's user namespace. With no user namespace in between, the container's 0 is written to disk as 0, and the host reads 0 as root. Being host UID 0 isn't the same as holding all of root's power. The default capability set, seccomp and AppArmor still limit what the process can ask the kernel to do ("Capabilities, cap-drop and no-new-privileges"). The UID decides every ownership and permission check on the host paths the container can reach.
Reading works the same way. The lab puts a marker file in a directory only host root can open, then bind-mounts that directory read-only into a container:
ubuntu can't read the marker. A container started by ubuntu reads it without trouble. dockerd runs as root and performs the mount, and the process inside is UID 0 holding CAP_DAC_OVERRIDE from the default capability set. Even with every capability dropped, root still passes the owner checks on root-owned files, as "Capabilities, cap-drop and no-new-privileges" showed. So any account that can start containers can read and write root-owned host paths through mounts. "The Docker socket and daemon hardening" treats that as the root-equivalence it is.
The same UID from both sides
ps on the host shows the container's sleep running as root. The container's /proc/self/uid_map reads 0 0 4294967295. UIDs starting at 0 inside map to UIDs starting at 0 outside, for the whole 32-bit range. That's the identity map, the map of a process that's in the host's initial user namespace. The user namespace inode is the host's 4026531837. A remapped container would show a different map, such as the 0 100000 65536 that "Namespaces from first principles" created with unshare.
Numeric IDs cross unchanged
The identity map works for every UID, not just 0. Run as UID 10001 and the same directory refuses your write; run as UID 1000 and it succeeds:
UID 10001 belongs to no account on this host. It gets only the "other" permissions of the directory (r-x) and can't create a file. UID 1000 has no name inside Alpine, but on the host it's ubuntu, the owner, so the file lands as ubuntu's. On most single-user machines and many servers UID 1000 is the first login account. A container told --user 1000 acts as that person on every path you mount into it. Use a dedicated, high, unshared UID such as 10001 or 65532. "Run as non-root" covers how to bake one into an image and keep volumes writable for it.
What the UID decides in a breakout
Container escapes are judged by the UID they land on. CVE-2024-21626 ("Leaky Vessels") is a good example. runc before 1.1.12 leaked a file descriptor for a host directory into the container. An image whose WORKDIR pointed at /proc/self/fd/<n>, or a docker exec with such a working directory, started a process with its current directory on the host's filesystem. What that process could then change was decided by its UID. As root without a user namespace, it could rewrite host binaries. As UID 10001, it could touch only what UID 10001 may touch, and on a normal host that's almost nothing. The lab engine's runc is well past the fix:
Finding root containers
Two sources answer "does this container run as root". docker inspect gives Config.User, which is the image's USER or the --user value, and HostConfig.UsernsMode. ps on the host gives you the real numeric UID:
An empty user=[] means the image never left root and nobody passed --user. An empty userns=[] means the host's user namespace. lab-root has both and runs as host root; lab-app runs as 10001. Config.User can hold a name that's only resolved inside the image, so in an audit the host-side ps value is the one you should trust. Clean up (the directory holds root-owned files, hence sudo):
Changing what UID 0 means
Running as a non-root user fixes the problem one image at a time. Two daemon-level options change what container root is for every container on a host. User-namespace remapping (userns-remap in daemon.json) runs containers in a user namespace whose UID 0 maps to a subordinate range from /etc/subuid. That's a range such as 100000 to 165535; the exact start depends on the entries already in that file. On Docker 29 it also turns off the containerd image store. Some flags, among them --privileged, need --userns host and are back to real root. "User-namespace remapping" works through that on the sec VM. Rootless Docker runs the daemon itself as an ordinary user inside a user namespace, so even the daemon isn't root. "Rootless Docker" covers its networking, storage and limits.
Try this
On the main lab VM, recreate ~/lab/rootuser/shared and start a container with --user 10001:10001 that runs sleep 600, like lab-app. Before you look, predict what its /proc/self/uid_map prints and whether its user namespace inode matches 4026531837. Check with the same docker exec and sudo readlink commands from the uidmap step, then remove the container and the directory.
Takeaway
A non-root container user and a remapped container root are two different fixes, and only the second changes what uid_map says. With the identity map 0 0 4294967295, a --user 1000 container wrote a file the host showed as ubuntu's, and a default root container read a mode-700 marker that ubuntu couldn't. Review every bind mount as host access granted to whatever UID the container runs as.
/srv/app, owned by deploy (UID 1000, mode 755). An attacker inside creates /srv/app/cron.sh. What does the host show?cat /proc/self/uid_map prints 0 0 4294967295. What does that tell you?--user 1000:1000 for a container that bind-mounts a host data directory. On that host, UID 1000 is the administrator's login account. What is the problem?