Skip to content

[userns-remap] Bug: ToHost incorrectly maps non-root container UID to root when it collides with RootPair #241

Description

@okhowang

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/userIdentityMapping.ToHost() method

Steps to Reproduce

  1. Configure Docker with userns-remap and /etc/subuid:

    rootless:1000:65536
    
  2. 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).

  3. 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
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions