Skip to content

Image-layer file ownership appears as uid 0 inside the actor #2036

Closed

Description

@ptone

Summary

Files that an image layer assigns to a non-root owner show up as owned by uid 0 inside a gVisor actor. For example, a home directory created with useradd and chown -R in the Dockerfile appears as root-owned 0700, so the workload user cannot read its own home. Files created at runtime, and the DurableDir mount, show their correct owners, so the loss is specific to image-layer content.

Impact

  • Who hits it: any integrator whose image gives image content to a non-root user, such as a home directory, an application directory or a cache.
  • What breaks: the non-root workload cannot read or write its own image-provided files. Every image path meant to be non-root-owned stays root-owned.
  • Cost: a root-privileged chown pass at every actor start.
  • Severity: High.
  • Proposed priority: P1. It breaks standard image conventions for non-root workloads. The workaround needs root and only covers paths the integrator knows about.

Environment

  • Substrate d277088b (v1alpha).
  • GKE 1.36.x.
  • gVisor sandbox class, unprivileged WorkerPool workers.

Steps to reproduce / evidence

  1. Build the image (verified live) with these lines:

    RUN useradd -u 1000 -m agent && chown -R agent:agent /home/agent

    Under Docker, /home/agent and its contents are owned by 1000:1000.

  2. Check inside the actor (verified live). Create an actor from a template using that image, then run:

    $ stat -c '%a:%U:%G %n' /home/agent
    700:root:root /home/agent
    

    Every image-layer file under it (for example .bashrc) also appears as uid 0.

  3. Control (verified live). Two kinds of file keep their correct owner (uid 1000):

    • files the workload creates at runtime inside the actor;
    • the 9p-mounted DurableDir workspace.
  4. Candidate cause 1: the layer unpacker (code-read at d277088b; not verified live). Image layers are unpacked by unpackLayer in internal/imagecache/unpack.go. It is called from internal/imagecache/imagecache.go:712, and the image cache is created in atelet at cmd/atelet/main.go:206.

    • unpackLayer never applies the tar header's Uid/Gid: unpack.go has no Lchown/Chown call.
    • Regular files are created with root.OpenFile(name, …, mode), and directories with root.Mkdir(name, mode|0o700) (unpack.go:188 and :207). Both belong to the extracting process, which runs as uid 0.
    • By contrast, internal/tarutil does restore ownership (restoreMeta → lchownEntry, tarutil.go:450-454 and owner_linux.go:76-84). At this commit, though, tarutil is used for DurableDir and rootfs-upper snapshot archives, not for image layers.
  5. Candidate cause 2: identity mapping or overlay presentation (hypothesis, not confirmed).

    • The runsc gofer maps a full-range identity in a user namespace (the comment above ateomGvisorCapabilities, cmd/atecontroller/internal/controllers/workerpool_apply.go:327-333).
    • Overlay presentation or copy-up of lower-layer files could also contribute.

    If candidate 1 holds, these do not need to be involved.

Expected vs actual

  • Expected: image-layer files keep the uid/gid recorded in the layer tar, as under Docker, containerd and Kubernetes.
  • Actual: image-layer files appear as uid 0 inside the actor. Runtime-created files and DurableDir content are unaffected.

Workaround

At actor start, while still uid 0, an integrator can re-chown the uid-0 entries under the workload user's $HOME (and any other paths it knows should be non-root) to the workload user. An fd-based walk with no-follow semantics is advisable. Paths the integrator does not know about stay root-owned.

Additional detail

  • Suggested fix: restore the tar header ownership of image-layer entries, then confirm inside a sandbox that the gofer and overlay present the host-side owners unchanged.
    • The comment at unpack.go:103-107 says atelet unpacks "as plain root … without CAP_DAC_OVERRIDE". The atelet container drops all capabilities, so it has no CAP_CHOWN (see atelet drops all capabilities, so it cannot remove a DurableDir…).
    • The whiteoutSet metadata persisted per layer (unpack.go:40-90) records no ownership either.
    • So the fix is probably one of these: grant atelet CHOWN (and FSETID, if special bits are restored after the chown); or record the owners at unpack time and apply them in a context that already has CHOWN, as the ateom worker does.
    • In either case apply Lchown before Chmod, as tarutil.restoreMeta does, because chown clears setuid/setgid.
  • Quick diagnostic: stat a known non-root file in the host-side extracted layer directory (under atelet's image-cache directory). This separates an extraction-time loss (candidate 1) from a presentation-time loss (candidate 2).
  • Related issues:
    • Actor rootfs drops setuid, setgid and sticky bits from image layers (/tmp is 0777) (actor-rootfs-drops-special-mode-bits.md), which points at the same unpacker.
    • Actor rootfs / is mode 0700, so non-root users cannot traverse the root filesystem (actor-rootfs-root-dir-mode-0700.md).

Activity

  1. BenTheElder commented on Oct 7, 2026

    @BenTheElder
    Collaborator

    atelet has CAP_DAC_OVERRIDE now, I agree we should fix this.

  2. added a commit that references this issue on Oct 29, 2026
    5d226a5
  3. added a commit that references this issue on Oct 30, 2026
    0c662eb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions