Summary
When userns-remap is enabled, the ToHost method in moby/sys/user/idtools.go incorrectly skips UID/GID remapping for non-root users whose container UID happens to equal the host-side root UID returned by RootPair(). This causes files owned by such users to be chown'd to the remapped root UID on the host, and thus appear as root:root inside the container.
Affected Component
github.com/moby/sys/user — IdentityMapping.ToHost() method
Steps to Reproduce
-
Configure Docker with userns-remap and /etc/subuid:
-
Pull an image that contains a non-root user with UID=1000 (e.g., mcr.microsoft.com/playwright:v1.55.1-jammy which creates a pwuser with UID 1000 via adduser).
-
Run the image:
docker run -it --rm mcr.microsoft.com/playwright:v1.55.1-jammy ls -l /home
Expected Behavior
drwxr-x--- 2 pwuser pwuser 57 Sep 23 2025 pwuser
Actual Behavior
drwxr-x--- 2 root root 73 Sep 23 2025 pwuser
Root Cause
In ToHost() (line 109-126 of idtools.go):
func (i IdentityMapping) ToHost(uid, gid int) (int, int, error) {
var err error
ruid, rgid := i.RootPair()
if uid != ruid {
ruid, err = toHost(uid, i.UIDMaps)
if err != nil {
return ruid, rgid, err
}
}
if gid != rgid {
rgid, err = toHost(gid, i.GIDMaps)
}
return ruid, rgid, err
}
The method assumes that if uid == RootPair().UID, then uid must be the container root (UID 0) that has already been mapped, and skips the toHost() call as an optimization. However, this assumption is incorrect when the subuid ParentID coincides with a non-root container UID.
For example, with subuid = rootless:1000:65536:
RootPair() returns (1000, 1000) (host-side root)
- Container root (UID 0) maps to host UID 1000
- A non-root user in the image with UID 1000 should map to host UID 2000
But ToHost(1000, 1000) sees uid == ruid (1000 == 1000), skips the toHost() call, and returns (1000, 1000) — the remapped root UID. The file gets chown'd to 1000:1000 on the host, which is the same as the remapped root, and appears as root:root inside the container.
This bug is especially common because:
UID=1000 is the default first non-root user on many Linux distributions (Ubuntu/Debian)
subuid ranges often start at 1000 when configured for rootless Docker
- Many Docker images create a user with UID=1000
Suggested Fix
The ToHost method should always perform the toHost() mapping, and only skip it for the actual container root (UID 0) — not for any arbitrary UID that happens to collide with RootPair().
Remove the optimization entirely. The performance impact is negligible:
func (i IdentityMapping) ToHost(uid, gid int) (int, int, error) {
ruid, err := toHost(uid, i.UIDMaps)
if err != nil {
return -1, -1, err
}
rgid, err := toHost(gid, i.GIDMaps)
return ruid, rgid, err
}
Summary
When
userns-remapis enabled, theToHostmethod inmoby/sys/user/idtools.goincorrectly skips UID/GID remapping for non-root users whose container UID happens to equal the host-side root UID returned byRootPair(). This causes files owned by such users to be chown'd to the remapped root UID on the host, and thus appear asroot:rootinside the container.Affected Component
github.com/moby/sys/user—IdentityMapping.ToHost()methodSteps to Reproduce
Configure Docker with
userns-remapand/etc/subuid:Pull an image that contains a non-root user with UID=1000 (e.g.,
mcr.microsoft.com/playwright:v1.55.1-jammywhich creates apwuserwith UID 1000 viaadduser).Run the image:
Expected Behavior
Actual Behavior
Root Cause
In
ToHost()(line 109-126 ofidtools.go):The method assumes that if
uid == RootPair().UID, thenuidmust be the container root (UID 0) that has already been mapped, and skips thetoHost()call as an optimization. However, this assumption is incorrect when thesubuidParentIDcoincides with a non-root container UID.For example, with
subuid=rootless:1000:65536:RootPair()returns(1000, 1000)(host-side root)But
ToHost(1000, 1000)seesuid == ruid(1000 == 1000), skips thetoHost()call, and returns(1000, 1000)— the remapped root UID. The file gets chown'd to1000:1000on the host, which is the same as the remapped root, and appears asroot:rootinside the container.This bug is especially common because:
UID=1000is the default first non-root user on many Linux distributions (Ubuntu/Debian)subuidranges often start at 1000 when configured for rootless DockerSuggested Fix
The
ToHostmethod should always perform thetoHost()mapping, and only skip it for the actual container root (UID 0) — not for any arbitrary UID that happens to collide withRootPair().Remove the optimization entirely. The performance impact is negligible: