Virtualization · Containers

Capability Sets and UID Mapping in Rootless Containers

How a user namespace turns UID 0 into an unprivileged identity, which capabilities survive the mapping, and why setuid binaries break in ways that look like permission bugs.

Running a container as an unprivileged user is not a matter of passing a flag. It requires constructing a user namespace in which the process appears to be root, mapping that appearance onto an identity the kernel will not confuse with the real one, and arranging for every file the container touches to be owned by an ID inside the mapping. The capability model then applies inside that namespace and nowhere else.

The mapping is the identity

A user namespace is created by unshare(CLONE_NEWUSER) or clone with the same flag. Immediately after creation the namespace has no mappings: every UID inside, including 0, is unmapped, and any operation that converts an inside ID to an outside ID fails. The process holds full capabilities in the new namespace, but those capabilities are useless until a mapping exists, because no resource is owned by an unmapped ID.

The mapping is written to /proc/<pid>/uid_map as lines of three numbers: the starting ID inside, the starting ID outside, and a count. A single line 0 1000 1 maps inside UID 0 to outside UID 1000 and nothing else. A line 0 100000 65536 maps a range: inside IDs 0 through 65535 correspond to outside IDs 100000 through 165535.

Only a process with CAP_SETUID in the parent namespace, or the process itself under specific conditions, may write the map. An unprivileged process can write its own map only once, only for a single line, and only if it first writes deny to /proc/self/setgroups; that last requirement exists to close a historical hole where a process could join a group it was not a member of. Mapping a range requires either the setuid helper newuidmap or a privileged parent, because a single unprivileged write cannot express a range.

The outside IDs must be ones the writing process is entitled to use. /etc/subuid assigns each user a range of subordinate IDs precisely for this purpose, and newuidmap checks the requested range against that file. Without that check, any user could claim any ID, and the whole isolation would collapse.

Inside root is not outside root

The kernel keeps two IDs for every process: the ID as seen inside its user namespace and the ID in the initial namespace. Permission checks on an object compare the process’s ID in the object’s owning namespace with the object’s owner ID in that namespace.

For a file on disk, the owning namespace is the initial one. A process whose inside UID is 0 and whose outside UID is 1000 is, from the file’s perspective, UID 1000. It can read and write files owned by 1000 and not files owned by real root, regardless of what id prints inside the container.

For resources created inside the namespace, such as a tmpfs mount or a socket, the owning namespace is the new one, and there the process really is 0 and holds the capabilities. This split is the entire security argument: a container can be root over its own objects and a nobody over everyone else’s. The creation order that makes it work, user namespace before mount namespace, is one of the ordering constraints in How Seven Namespaces Compose Into a Container.

The mapping also determines which files a rootless container can use at all. A container image’s files are owned by numeric IDs baked into the tar headers: 0 for root-owned files, 100 for a system user, and so on. Unpacking them as an unprivileged user requires the unpacking process to appear as those IDs. With a mapping of inside 0 to outside 100000 and a range of 65536, the runtime unpacks as inside UID 0, which the kernel translates to outside 100000, and the file on disk is owned by 100000. Every file in the image is therefore owned by an outside ID in the subordinate range, and the container’s processes, mapped the same way, can read them.

A file owned by outside ID 0 cannot be represented, because inside the namespace no ID maps to 0. That is why a rootless container cannot chown a file to an arbitrary ID outside its range, and why some images fail to unpack: they contain a file with an owner ID beyond the 65536-entry range, or they contain a device node that requires CAP_MKNOD against the initial namespace.

Capabilities are per-namespace, and the bounding set survives

Linux capabilities partition root’s privileges into distinct units: CAP_NET_BIND_SERVICE for ports below 1024, CAP_SYS_ADMIN for a large and uncomfortably broad collection of operations, CAP_DAC_OVERRIDE for bypassing file permission checks, and so on. A process has four sets: permitted (what it may use), effective (what is currently active), inheritable (what can be passed to children through execve), and bounding (the ceiling for everything, including file capabilities).

Creating a user namespace gives the creating process all capabilities in the new namespace, and the capability sets are reset so that the process holds the full set there. The bounding set, however, is inherited: a capability dropped from the bounding set before the namespace is created is not available inside it. This is the mechanism a runtime uses to restrict a rootless container: drop the dangerous capabilities from the bounding set first, then create the namespace.

Which capabilities are meaningless inside a user namespace is a separate question from which are held. CAP_SYS_ADMIN inside the namespace permits mounting certain filesystem types, creating other namespaces, and setting some sysctls, but not mounting a filesystem the kernel considers privileged, and not accessing a device owned by the initial namespace. CAP_NET_ADMIN in the namespace permits configuring interfaces that belong to the namespace’s network namespace, and nothing else. CAP_SYS_MODULE has no effect at all, since module loading is against the initial namespace’s kernel.

The practical list a runtime keeps by default is short: CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_FSETID, CAP_KILL, CAP_SETGID, CAP_SETUID, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_NET_RAW, CAP_SYS_CHROOT, CAP_MKNOD, CAP_AUDIT_WRITE, CAP_SETFCAP. Each of these is needed by ordinary software for ordinary reasons, and each is also an attack surface. CAP_DAC_OVERRIDE alone means the process can read any file its outside ID can reach, which for a mapped-range container is any file owned by an ID in the range.

Setuid binaries and the nosuid requirement

A setuid binary changes the process’s effective ID to the file’s owner at execve. In a user namespace, that transition is constrained: the kernel allows a setuid transition only if the target ID is mapped in the namespace. A binary owned by inside UID 0 is fine; a binary owned by an ID outside the map is not, and the exec fails or silently does not gain the privilege depending on the kernel version and the file’s capabilities.

The larger problem is nosuid mounting. A rootless container mounts its root filesystem as an unprivileged user, and the mount is performed with MS_NOSUID by necessity: without CAP_SYS_ADMIN in the initial namespace, the kernel refuses to honour setuid bits on mounts the user created, because otherwise any user could gain any mapped ID. The result is that sudo, passwd, su, and any other setuid helper in the image do not work, and the failure is a permission denied that looks like a misconfiguration.

This is not a defect to be fixed. A container that needs to change identity internally should use the mapping for that purpose rather than setuid: map a range, run the process as the inside ID it needs, and let the outside ID be the unprivileged one. Setuid exists to cross a privilege boundary on a shared system; in a namespace, the boundary is already drawn.

File capabilities and the security.capability attribute

File capabilities are stored in the security.capability extended attribute, which encodes the permitted and inheritable sets and a root ID. When a file with that attribute is executed, the process gains the listed capabilities in its permitted set, subject to the bounding set.

Inside a user namespace, file capabilities are honoured only if the file’s root ID is mapped and the capability is in the bounding set. A file capability with root ID 0 in the initial namespace means “grant this capability when executed by a process whose UID is 0 in the initial namespace”, and a namespaced process is not that. Executing such a binary inside a rootless container grants nothing.

Runtimes that need a capability inside the container, such as CAP_NET_BIND_SERVICE for a server on port 80, therefore do not rely on file capabilities. They set the capability in the process’s permitted and effective sets before execve, or they arrange for the port to be redirected. The --cap-add style of interface modifies the process’s sets directly, and the runtime applies them after the namespace is entered.

Where the isolation stops

A user namespace isolates ID interpretation, not kernel code. A vulnerability reachable from a syscall available to the namespaced process is a host vulnerability, and the surface is larger than for a non-namespaced unprivileged process because the process holds capabilities in its own namespace. This is the argument developed in The Shared Kernel Boundary and What Containers Cannot Isolate, and it is why some distributions restrict unprivileged user namespaces with a sysctl.

The mapping is also a shared resource. Subordinate ID ranges are allocated per user, and two users on the same host cannot be assigned overlapping ranges; /etc/subuid is the allocation table. A container runtime that creates many containers per user consumes one range per container in some designs, and the range must be large enough to cover the highest UID in any image, which is why 65536 is the customary size: it covers the 16-bit ID space that most images assume.

Nested namespaces add a layer of indirection. A container that creates its own user namespace maps IDs inside its namespace to IDs inside the parent’s, and the kernel resolves the chain at each permission check. The chain is bounded by the kernel’s nesting depth limit, and each level consumes an ID translation. A process three levels deep is checking three mappings for every file access, which is why deeply nested designs are avoided rather than optimised.

References

  1. user_namespaces(7) — Linux man-pages project
  2. capabilities(7) — Linux man-pages project
  3. subuid(5) — Linux man-pages project
  4. newuidmap(1) — Linux man-pages project