[go] os: avoid escape from Root via ReadDir or Readdir

2 views
Skip to first unread message

Damien Neil (Gerrit)

unread,
Feb 26, 2026, 12:56:29 PM (yesterday) Feb 26
to goph...@pubsubhelper.golang.org, Nicholas Husin, golang-co...@googlegroups.com
Attention needed from Nicholas Husin

Damien Neil voted Commit-Queue+1

Commit-Queue+1
Open in Gerrit

Related details

Attention is currently required from:
  • Nicholas Husin
Submit Requirements:
  • requirement is not satisfiedCode-Review
  • requirement satisfiedNo-Unresolved-Comments
  • requirement is not satisfiedReview-Enforcement
  • requirement is not satisfiedTryBots-Pass
Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
Gerrit-MessageType: comment
Gerrit-Project: go
Gerrit-Branch: master
Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
Gerrit-Change-Number: 749480
Gerrit-PatchSet: 1
Gerrit-Owner: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
Gerrit-Attention: Nicholas Husin <n...@golang.org>
Gerrit-Comment-Date: Thu, 26 Feb 2026 17:56:26 +0000
Gerrit-HasComments: No
Gerrit-Has-Labels: Yes
unsatisfied_requirement
satisfied_requirement
open
diffy

Damien Neil (Gerrit)

unread,
Feb 26, 2026, 2:43:55 PM (yesterday) Feb 26
to goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com
Attention needed from Damien Neil and Nicholas Husin

Damien Neil uploaded new patchset

Damien Neil uploaded patch set #2 to this change.
Following approvals got outdated and were removed:
  • TryBots-Pass: LUCI-TryBot-Result-1 by Go LUCI
Open in Gerrit

Related details

Attention is currently required from:
  • Damien Neil
  • Nicholas Husin
Submit Requirements:
  • requirement is not satisfiedCode-Review
  • requirement satisfiedNo-Unresolved-Comments
  • requirement is not satisfiedReview-Enforcement
  • requirement is not satisfiedTryBots-Pass
Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
Gerrit-MessageType: newpatchset
Gerrit-Project: go
Gerrit-Branch: master
Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
Gerrit-Change-Number: 749480
Gerrit-PatchSet: 2
Gerrit-Owner: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
Gerrit-Attention: Damien Neil <dn...@google.com>
Gerrit-Attention: Nicholas Husin <n...@golang.org>
unsatisfied_requirement
satisfied_requirement
open
diffy

Damien Neil (Gerrit)

unread,
Feb 26, 2026, 2:44:08 PM (yesterday) Feb 26
to goph...@pubsubhelper.golang.org, Go LUCI, Nicholas Husin, golang-co...@googlegroups.com
Attention needed from Nicholas Husin

Damien Neil voted Commit-Queue+1

Commit-Queue+1
Open in Gerrit

Related details

Attention is currently required from:
  • Nicholas Husin
Submit Requirements:
  • requirement is not satisfiedCode-Review
  • requirement satisfiedNo-Unresolved-Comments
  • requirement is not satisfiedReview-Enforcement
  • requirement is not satisfiedTryBots-Pass
Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
Gerrit-MessageType: comment
Gerrit-Project: go
Gerrit-Branch: master
Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
Gerrit-Change-Number: 749480
Gerrit-PatchSet: 2
Gerrit-Owner: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
Gerrit-Attention: Nicholas Husin <n...@golang.org>
Gerrit-Comment-Date: Thu, 26 Feb 2026 19:44:04 +0000
Gerrit-HasComments: No
Gerrit-Has-Labels: Yes
unsatisfied_requirement
satisfied_requirement
open
diffy

Nicholas Husin (Gerrit)

unread,
Feb 26, 2026, 3:24:51 PM (24 hours ago) Feb 26
to Damien Neil, goph...@pubsubhelper.golang.org, Go LUCI, golang-co...@googlegroups.com
Attention needed from Damien Neil

Nicholas Husin voted and added 3 comments

Votes added by Nicholas Husin

Code-Review+2

3 comments

File src/os/dir_darwin.go
Line 92, Patchset 2 (Latest): println(f.name, de.Name(), err)
Nicholas Husin . unresolved

Accidentally added?

File src/os/file_unix.go
Line 478, Patchset 2 (Latest): if typ != ^FileMode(0) && !parent.inRoot {
Nicholas Husin . unresolved

This is unrelated to your CL, but I'm not sure I understand why `typ != ^FileMode(0)` is here.

Independent of your `!parent.inRoot`, it looks like the intent is to say "lazy lstat is okay to do as long as the `typ` bits are not all set to `1`". However, I don't see any scenario where a `FileMode` bits would all be set to `1`.

Anecdotally, removing this check does not cause any test to fail for me too.

File src/os/root_test.go
Line 2049, Patchset 2 (Latest): // File.Readdirnames, returningm []string
Nicholas Husin . unresolved

typo?

Open in Gerrit

Related details

Attention is currently required from:
  • Damien Neil
Submit Requirements:
  • requirement satisfiedCode-Review
  • requirement is not satisfiedNo-Unresolved-Comments
  • requirement is not satisfiedReview-Enforcement
  • requirement satisfiedTryBots-Pass
Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
Gerrit-MessageType: comment
Gerrit-Project: go
Gerrit-Branch: master
Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
Gerrit-Change-Number: 749480
Gerrit-PatchSet: 2
Gerrit-Owner: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Damien Neil <dn...@google.com>
Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
Gerrit-Attention: Damien Neil <dn...@google.com>
Gerrit-Comment-Date: Thu, 26 Feb 2026 20:24:46 +0000
Gerrit-HasComments: Yes
Gerrit-Has-Labels: Yes
satisfied_requirement
unsatisfied_requirement
open
diffy

Nicholas Husin (Gerrit)

unread,
Feb 26, 2026, 3:24:55 PM (24 hours ago) Feb 26
to Damien Neil, goph...@pubsubhelper.golang.org, Nicholas Husin, Go LUCI, golang-co...@googlegroups.com
Attention needed from Damien Neil

Nicholas Husin voted Code-Review+1

Code-Review+1
Open in Gerrit

Related details

Attention is currently required from:
  • Damien Neil
Submit Requirements:
    • requirement satisfiedCode-Review
    • requirement is not satisfiedNo-Unresolved-Comments
    • requirement satisfiedReview-Enforcement
    • requirement satisfiedTryBots-Pass
    Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
    Gerrit-MessageType: comment
    Gerrit-Project: go
    Gerrit-Branch: master
    Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
    Gerrit-Change-Number: 749480
    Gerrit-PatchSet: 2
    Gerrit-Owner: Damien Neil <dn...@google.com>
    Gerrit-Reviewer: Damien Neil <dn...@google.com>
    Gerrit-Reviewer: Nicholas Husin <hu...@google.com>
    Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
    Gerrit-Attention: Damien Neil <dn...@google.com>
    Gerrit-Comment-Date: Thu, 26 Feb 2026 20:24:52 +0000
    Gerrit-HasComments: No
    Gerrit-Has-Labels: Yes
    satisfied_requirement
    unsatisfied_requirement
    open
    diffy

    Damien Neil (Gerrit)

    unread,
    Feb 26, 2026, 6:27:41 PM (21 hours ago) Feb 26
    to goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com
    Attention needed from Damien Neil

    Damien Neil uploaded new patchset

    Damien Neil uploaded patch set #3 to this change.
    Following approvals got outdated and were removed:
    • TryBots-Pass: LUCI-TryBot-Result+1 by Go LUCI
    Open in Gerrit

    Related details

    Attention is currently required from:
    • Damien Neil
    Submit Requirements:
      • requirement satisfiedCode-Review
      • requirement is not satisfiedNo-Unresolved-Comments
      • requirement satisfiedReview-Enforcement
      • requirement is not satisfiedTryBots-Pass
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: newpatchset
      Gerrit-Project: go
      Gerrit-Branch: master
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749480
      Gerrit-PatchSet: 3
      satisfied_requirement
      unsatisfied_requirement
      open
      diffy

      Damien Neil (Gerrit)

      unread,
      Feb 26, 2026, 6:28:07 PM (21 hours ago) Feb 26
      to goph...@pubsubhelper.golang.org, Nicholas Husin, Nicholas Husin, Go LUCI, golang-co...@googlegroups.com

      Damien Neil voted and added 3 comments

      Votes added by Damien Neil

      Auto-Submit+1
      Commit-Queue+1

      3 comments

      File src/os/dir_darwin.go
      Line 92, Patchset 2: println(f.name, de.Name(), err)
      Nicholas Husin . resolved

      Accidentally added?

      Damien Neil

      Oops, fixed.

      File src/os/file_unix.go
      Line 478, Patchset 2: if typ != ^FileMode(0) && !parent.inRoot {
      Nicholas Husin . resolved

      This is unrelated to your CL, but I'm not sure I understand why `typ != ^FileMode(0)` is here.

      Independent of your `!parent.inRoot`, it looks like the intent is to say "lazy lstat is okay to do as long as the `typ` bits are not all set to `1`". However, I don't see any scenario where a `FileMode` bits would all be set to `1`.

      Anecdotally, removing this check does not cause any test to fail for me too.

      Damien Neil

      I think the typ bits are theoretically all set to 1 if we can't determine the file mode at all for some reason. For example, in dirent_linux.go we return `^FileMode(0)` if the type byte in the kernel-filled dirent buffer is something we don't recognize.

      I don't know how to make this happen in practice; I think it may be purely defensive.

      File src/os/root_test.go
      Line 2049, Patchset 2: // File.Readdirnames, returningm []string
      Nicholas Husin . resolved

      typo?

      Damien Neil

      typo!

      Open in Gerrit

      Related details

      Attention set is empty
      Submit Requirements:
      • requirement satisfiedCode-Review
      • requirement satisfiedNo-Unresolved-Comments
      • requirement satisfiedReview-Enforcement
      • requirement is not satisfiedTryBots-Pass
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: comment
      Gerrit-Project: go
      Gerrit-Branch: master
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749480
      Gerrit-PatchSet: 3
      Gerrit-Owner: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Nicholas Husin <hu...@google.com>
      Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
      Gerrit-Comment-Date: Thu, 26 Feb 2026 23:28:02 +0000
      Gerrit-HasComments: Yes
      Gerrit-Has-Labels: Yes
      Comment-In-Reply-To: Nicholas Husin <n...@golang.org>
      satisfied_requirement
      unsatisfied_requirement
      open
      diffy

      Damien Neil (Gerrit)

      unread,
      Feb 26, 2026, 6:46:31 PM (21 hours ago) Feb 26
      to goph...@pubsubhelper.golang.org, Go LUCI, Nicholas Husin, Nicholas Husin, golang-co...@googlegroups.com

      Damien Neil voted Commit-Queue+1

      Commit-Queue+1
      Open in Gerrit

      Related details

      Attention set is empty
      Submit Requirements:
      • requirement satisfiedCode-Review
      • requirement satisfiedNo-Unresolved-Comments
      • requirement satisfiedReview-Enforcement
      • requirement is not satisfiedTryBots-Pass
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: comment
      Gerrit-Project: go
      Gerrit-Branch: master
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749480
      Gerrit-PatchSet: 3
      Gerrit-Owner: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Nicholas Husin <hu...@google.com>
      Gerrit-Reviewer: Nicholas Husin <n...@golang.org>
      Gerrit-Comment-Date: Thu, 26 Feb 2026 23:46:27 +0000
      Gerrit-HasComments: No
      Gerrit-Has-Labels: Yes
      satisfied_requirement
      unsatisfied_requirement
      open
      diffy

      Gopher Robot (Gerrit)

      unread,
      Feb 26, 2026, 7:02:59 PM (20 hours ago) Feb 26
      to Damien Neil, goph...@pubsubhelper.golang.org, golang-...@googlegroups.com, Go LUCI, Nicholas Husin, Nicholas Husin, golang-co...@googlegroups.com

      Gopher Robot submitted the change with unreviewed changes

      Unreviewed changes

      2 is the latest approved patch-set.
      The change was submitted with unreviewed changes in the following files:

      ```
      The name of the file: src/os/root_test.go
      Insertions: 1, Deletions: 1.

      @@ -2046,7 +2046,7 @@
      }
      checkFileInfo(t, fileinfos[0])
      })
      - // File.Readdirnames, returningm []string
      + // File.Readdirnames, returning []string
      test("Readdirnames", func(t *testing.T, subdir *os.File) {
      names, err := subdir.Readdirnames(-1)
      if err != nil {
      ```
      ```
      The name of the file: src/os/dir_darwin.go
      Insertions: 0, Deletions: 1.

      @@ -89,7 +89,6 @@
      names = append(names, string(name))
      } else if mode == readdirDirEntry {
      de, err := newUnixDirent(f, string(name), dtToType(dirent.Type))
      - println(f.name, de.Name(), err)
      if IsNotExist(err) {
      // File disappeared between readdir and stat.
      // Treat as if it didn't exist.
      ```

      Change information

      Commit message:
      os: avoid escape from Root via ReadDir or Readdir

      When reading the contents of a directory using
      File.ReadDir or File.Readdir, the os.FileInfo was
      populated on Unix platforms using lstat.
      This lstat call is vulnerable to a TOCTOU race
      and could escape the root.

      For example:
      - Open the directory "dir" within a Root.
      This directory contains a file named "file".
      - Use File.ReadDir to list the contents of "dir",
      receiving a os.DirEntry for "dir/file".
      - Replace "dir" with a symlink to "/etc".
      - Use DirEntry.Info to retrieve the FileInfo for "dir/file".
      This FileInfo contains information on "/etc/file" instead.

      This escape permits identifying the presence or absence of
      files outside a Root, as well as retreiving stat metadata
      (size, mode, modification time, etc.) for files outside a Root.

      This escape does not permit reading or writing to files
      outside a Root.

      Fixes #77827
      Fixes CVE-2026-27139
      Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Reviewed-by: Nicholas Husin <hu...@google.com>
      Reviewed-by: Nicholas Husin <n...@golang.org>
      Auto-Submit: Damien Neil <dn...@google.com>
      Files:
      • A src/internal/poll/fstatat_unix.go
      • M src/os/dir_darwin.go
      • M src/os/dir_unix.go
      • M src/os/export_test.go
      • M src/os/file.go
      • M src/os/file_unix.go
      • M src/os/os_test.go
      • M src/os/os_unix_test.go
      • M src/os/root_test.go
      • M src/os/root_unix.go
      • M src/os/stat.go
      • A src/os/statat.go
      • A src/os/statat_other.go
      • A src/os/statat_unix.go
      Change size: M
      Delta: 14 files changed, 219 insertions(+), 22 deletions(-)
      Branch: refs/heads/master
      Submit Requirements:
      • requirement satisfiedCode-Review: +2 by Nicholas Husin, +1 by Nicholas Husin
      • requirement satisfiedTryBots-Pass: LUCI-TryBot-Result+1 by Go LUCI
      Open in Gerrit
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: merged
      Gerrit-Project: go
      Gerrit-Branch: master
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749480
      Gerrit-PatchSet: 4
      Gerrit-Owner: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Gopher Robot <go...@golang.org>
      open
      diffy
      satisfied_requirement

      Damien Neil (Gerrit)

      unread,
      11:13 AM (4 hours ago) 11:13 AM
      to goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com

      Damien Neil has uploaded the change for review

      Commit message

      [release-branch.go1.25] os: avoid escape from Root via ReadDir or Readdir


      When reading the contents of a directory using
      File.ReadDir or File.Readdir, the os.FileInfo was
      populated on Unix platforms using lstat.
      This lstat call is vulnerable to a TOCTOU race
      and could escape the root.

      For example:
      - Open the directory "dir" within a Root.
      This directory contains a file named "file".
      - Use File.ReadDir to list the contents of "dir",
      receiving a os.DirEntry for "dir/file".
      - Replace "dir" with a symlink to "/etc".
      - Use DirEntry.Info to retrieve the FileInfo for "dir/file".
      This FileInfo contains information on "/etc/file" instead.

      This escape permits identifying the presence or absence of
      files outside a Root, as well as retreiving stat metadata
      (size, mode, modification time, etc.) for files outside a Root.

      This escape does not permit reading or writing to files
      outside a Root.

      For #77827
      Fixes #77833

      Fixes CVE-2026-27139

      Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Reviewed-on: https://go-review.googlesource.com/c/go/+/749480
      LUCI-TryBot-Result: Go LUCI <golang...@luci-project-accounts.iam.gserviceaccount.com>
      Reviewed-by: Nicholas Husin <hu...@google.com>
      Reviewed-by: Nicholas Husin <n...@golang.org>
      Auto-Submit: Damien Neil <dn...@google.com>
      (cherry picked from commit 657ed934e85dc575aad51356c4b437961e7c1313)

      Change diff

      diff --git a/src/internal/poll/fstatat_unix.go b/src/internal/poll/fstatat_unix.go
      new file mode 100644
      index 0000000..cde8551
      --- /dev/null
      +++ b/src/internal/poll/fstatat_unix.go
      @@ -0,0 +1,22 @@
      +// Copyright 2026 The Go Authors. All rights reserved.
      +// Use of this source code is governed by a BSD-style
      +// license that can be found in the LICENSE file.
      +
      +//go:build unix || wasip1
      +
      +package poll
      +
      +import (
      + "internal/syscall/unix"
      + "syscall"
      +)
      +
      +func (fd *FD) Fstatat(name string, s *syscall.Stat_t, flags int) error {
      + if err := fd.incref(); err != nil {
      + return err
      + }
      + defer fd.decref()
      + return ignoringEINTR(func() error {
      + return unix.Fstatat(fd.Sysfd, name, s, flags)
      + })
      +}
      diff --git a/src/os/dir_darwin.go b/src/os/dir_darwin.go
      index 91b67d8..b6ebca7 100644
      --- a/src/os/dir_darwin.go
      +++ b/src/os/dir_darwin.go
      @@ -88,7 +88,7 @@
      if mode == readdirName {

      names = append(names, string(name))
      } else if mode == readdirDirEntry {
      -			de, err := newUnixDirent(f.name, string(name), dtToType(dirent.Type))
      + de, err := newUnixDirent(f, string(name), dtToType(dirent.Type))

      if IsNotExist(err) {
      // File disappeared between readdir and stat.
      // Treat as if it didn't exist.
      @@ -99,7 +99,7 @@
      }
      dirents = append(dirents, de)
      } else {
      - info, err := lstat(f.name + "/" + string(name))
      + info, err := f.lstatat(string(name))
      if IsNotExist(err) {
      // File disappeared between readdir + stat.

      // Treat as if it didn't exist.
      diff --git a/src/os/dir_unix.go b/src/os/dir_unix.go
      index 87df312..8f1aabe 100644
      --- a/src/os/dir_unix.go
      +++ b/src/os/dir_unix.go
      @@ -138,7 +138,7 @@
      if mode == readdirName {

      names = append(names, string(name))
      } else if mode == readdirDirEntry {
      -			de, err := newUnixDirent(f.name, string(name), direntType(rec))
      + de, err := newUnixDirent(f, string(name), direntType(rec))

      if IsNotExist(err) {
      // File disappeared between readdir and stat.
      // Treat as if it didn't exist.
      @@ -149,7 +149,7 @@
      }
      dirents = append(dirents, de)
      } else {
      - info, err := lstat(f.name + "/" + string(name))
      + info, err := f.lstatat(string(name))
      if IsNotExist(err) {
      // File disappeared between readdir + stat.

      // Treat as if it didn't exist.
      diff --git a/src/os/export_test.go b/src/os/export_test.go
      index 93b1089..bea38c9 100644
      --- a/src/os/export_test.go
      +++ b/src/os/export_test.go
      @@ -7,7 +7,6 @@
      // Export for testing.

      var Atime = atime
      -var LstatP = &lstat
      var ErrWriteAtInAppendMode = errWriteAtInAppendMode
      var ErrPatternHasSeparator = errPatternHasSeparator

      @@ -16,3 +15,16 @@
      }

      var ExportReadFileContents = readFileContents
      +
      +// cleanuper stands in for *testing.T, since we can't import testing in os.
      +type cleanuper interface {
      + Cleanup(func())
      +}
      +
      +func SetStatHook(t cleanuper, f func(f *File, name string) (FileInfo, error)) {
      + oldstathook := stathook
      + t.Cleanup(func() {
      + stathook = oldstathook
      + })
      + stathook = f
      +}
      diff --git a/src/os/file.go b/src/os/file.go
      index 66269c1..8085724 100644
      --- a/src/os/file.go
      +++ b/src/os/file.go
      @@ -428,9 +428,6 @@
      return openDirNolog(name)
      }

      -// lstat is overridden in tests.
      -var lstat = Lstat
      -
      // Rename renames (moves) oldpath to newpath.
      // If newpath already exists and is not a directory, Rename replaces it.
      // If newpath already exists and is a directory, Rename returns an error.
      diff --git a/src/os/file_unix.go b/src/os/file_unix.go
      index 2074df7..6f9cab78 100644
      --- a/src/os/file_unix.go
      +++ b/src/os/file_unix.go
      @@ -63,6 +63,7 @@
      nonblock bool // whether we set nonblocking mode
      stdoutOrErr bool // whether this is stdout or stderr
      appendMode bool // whether file is opened for appending
      + inRoot bool // whether file is opened in a Root
      }

      // fd is the Unix implementation of Fd.
      @@ -458,24 +459,27 @@
      if d.info != nil {
      return d.info, nil
      }
      - return lstat(d.parent + "/" + d.name)
      + return Lstat(d.parent + "/" + d.name)
      }

      func (d *unixDirent) String() string {
      return fs.FormatDirEntry(d)
      }

      -func newUnixDirent(parent, name string, typ FileMode) (DirEntry, error) {
      +func newUnixDirent(parent *File, name string, typ FileMode) (DirEntry, error) {
      ude := &unixDirent{
      - parent: parent,
      + parent: parent.name,
      name: name,
      typ: typ,
      }
      - if typ != ^FileMode(0) {
      + // When the parent file was opened in a Root,
      + // we cannot use a lazy lstat to load the FileInfo.
      + // Use lstatat here.
      + if typ != ^FileMode(0) && !parent.inRoot {
      return ude, nil
      }

      - info, err := lstat(parent + "/" + name)
      + info, err := parent.lstatat(name)
      if err != nil {
      return nil, err
      }
      diff --git a/src/os/os_test.go b/src/os/os_test.go
      index 9f6eb13e..13db353 100644
      --- a/src/os/os_test.go
      +++ b/src/os/os_test.go
      @@ -767,13 +767,12 @@
      }

      var xerr error // error to return for x
      - *LstatP = func(path string) (FileInfo, error) {
      + SetStatHook(t, func(f *File, path string) (FileInfo, error) {
      if xerr != nil && strings.HasSuffix(path, "x") {
      return nil, xerr
      }
      - return Lstat(path)
      - }
      - defer func() { *LstatP = Lstat }()
      + return nil, nil
      + })

      dir := t.TempDir()
      touch(t, filepath.Join(dir, "good1"))
      diff --git a/src/os/os_unix_test.go b/src/os/os_unix_test.go
      index 41feaf7..6b55e09 100644
      --- a/src/os/os_unix_test.go
      +++ b/src/os/os_unix_test.go
      @@ -196,15 +196,13 @@

      // Issue 16919: Readdir must return a non-empty slice or an error.
      func TestReaddirRemoveRace(t *testing.T) {
      - oldStat := *LstatP
      - defer func() { *LstatP = oldStat }()
      - *LstatP = func(name string) (FileInfo, error) {
      + SetStatHook(t, func(f *File, name string) (FileInfo, error) {
      if strings.HasSuffix(name, "some-file") {
      // Act like it's been deleted.
      return nil, ErrNotExist
      }
      - return oldStat(name)
      - }
      + return nil, nil
      + })
      dir := t.TempDir()
      if err := WriteFile(filepath.Join(dir, "some-file"), []byte("hello"), 0644); err != nil {
      t.Fatal(err)
      diff --git a/src/os/root_test.go b/src/os/root_test.go
      index f9fbd11..71ce5e5 100644
      --- a/src/os/root_test.go
      +++ b/src/os/root_test.go
      @@ -1952,3 +1952,108 @@
      t.Errorf(`root.OpenRoot("dir").Name() = %q, want %q`, got, want)
      }
      }
      +
      +// TestRootNoLstat verifies that we do not use lstat (possibly escaping the root)
      +// when reading directories in a Root.
      +func TestRootNoLstat(t *testing.T) {
      + if runtime.GOARCH == "wasm" {
      + t.Skip("wasm lacks fstatat")
      + }
      +
      + dir := makefs(t, []string{
      + "subdir/",
      + })
      + const size = 42
      + contents := strings.Repeat("x", size)
      + if err := os.WriteFile(dir+"/subdir/file", []byte(contents), 0666); err != nil {
      + t.Fatal(err)
      + }
      + root, err := os.OpenRoot(dir)
      + if err != nil {
      + t.Fatal(err)
      + }
      + defer root.Close()
      +
      + test := func(name string, fn func(t *testing.T, f *os.File)) {
      + t.Run(name, func(t *testing.T) {
      + os.SetStatHook(t, func(f *os.File, name string) (os.FileInfo, error) {
      + if f == nil {
      + t.Errorf("unexpected Lstat(%q)", name)
      + }
      + return nil, nil
      + })
      + f, err := root.Open("subdir")
      + if err != nil {
      + t.Fatal(err)
      + }
      + defer f.Close()
      + fn(t, f)
      + })
      + }
      +
      + checkFileInfo := func(t *testing.T, fi fs.FileInfo) {
      + t.Helper()
      + if got, want := fi.Name(), "file"; got != want {
      + t.Errorf("FileInfo.Name() = %q, want %q", got, want)
      + }
      + if got, want := fi.Size(), int64(size); got != want {
      + t.Errorf("FileInfo.Size() = %v, want %v", got, want)
      + }
      + }
      + checkDirEntry := func(t *testing.T, d fs.DirEntry) {
      + t.Helper()
      + if got, want := d.Name(), "file"; got != want {
      + t.Errorf("DirEntry.Name() = %q, want %q", got, want)
      + }
      + if got, want := d.IsDir(), false; got != want {
      + t.Errorf("DirEntry.IsDir() = %v, want %v", got, want)
      + }
      + fi, err := d.Info()
      + if err != nil {
      + t.Fatalf("DirEntry.Info() = _, %v", err)
      + }
      + checkFileInfo(t, fi)
      + }
      +
      + test("Stat", func(t *testing.T, subdir *os.File) {
      + fi, err := subdir.Stat()
      + if err != nil {
      + t.Fatal(err)
      + }
      + if !fi.IsDir() {
      + t.Fatalf(`Open("subdir").Stat().IsDir() = false, want true`)
      + }
      + })
      + // File.ReadDir, returning []DirEntry
      + test("ReadDirEntry", func(t *testing.T, subdir *os.File) {
      + dirents, err := subdir.ReadDir(-1)
      + if err != nil {
      + t.Fatal(err)
      + }
      + if len(dirents) != 1 {
      + t.Fatalf(`Open("subdir").ReadDir(-1) = {%v}, want {file}`, dirents)
      + }
      + checkDirEntry(t, dirents[0])
      + })
      + // File.Readdir, returning []FileInfo
      + test("ReadFileInfo", func(t *testing.T, subdir *os.File) {
      + fileinfos, err := subdir.Readdir(-1)
      + if err != nil {
      + t.Fatal(err)
      + }
      + if len(fileinfos) != 1 {
      + t.Fatalf(`Open("subdir").Readdir(-1) = {%v}, want {file}`, fileinfos)
      + }
      + checkFileInfo(t, fileinfos[0])
      + })

      + // File.Readdirnames, returning []string
      +	test("Readdirnames", func(t *testing.T, subdir *os.File) {
      + names, err := subdir.Readdirnames(-1)
      + if err != nil {
      + t.Fatal(err)
      + }
      + if got, want := names, []string{"file"}; !slices.Equal(got, want) {
      + t.Fatalf(`Open("subdir").Readdirnames(-1) = %q, want %q`, got, want)
      + }
      + })
      +}
      diff --git a/src/os/root_unix.go b/src/os/root_unix.go
      index c891e81..885a835 100644
      --- a/src/os/root_unix.go
      +++ b/src/os/root_unix.go
      @@ -104,6 +104,7 @@
      return nil, &PathError{Op: "openat", Path: name, Err: err}
      }
      f := newFile(fd, joinPath(root.Name(), name), kindOpenFile, unix.HasNonblockFlag(flag))
      + f.inRoot = true
      return f, nil
      }

      diff --git a/src/os/stat.go b/src/os/stat.go
      index 50acb6d..5ef731f 100644
      --- a/src/os/stat.go
      +++ b/src/os/stat.go
      @@ -25,3 +25,6 @@
      testlog.Stat(name)
      return lstatNolog(name)
      }
      +
      +// stathook is set in tests
      +var stathook func(f *File, name string) (FileInfo, error)
      diff --git a/src/os/statat.go b/src/os/statat.go
      new file mode 100644
      index 0000000..d460fe2
      --- /dev/null
      +++ b/src/os/statat.go
      @@ -0,0 +1,24 @@
      +// Copyright 2026 The Go Authors. All rights reserved.
      +// Use of this source code is governed by a BSD-style
      +// license that can be found in the LICENSE file.
      +
      +//go:build !windows
      +
      +package os
      +
      +import (
      + "internal/testlog"
      +)
      +
      +func (f *File) lstatat(name string) (FileInfo, error) {
      + if stathook != nil {
      + fi, err := stathook(f, name)
      + if fi != nil || err != nil {
      + return fi, err
      + }
      + }
      + if log := testlog.Logger(); log != nil {
      + log.Stat(joinPath(f.Name(), name))
      + }
      + return f.lstatatNolog(name)
      +}
      diff --git a/src/os/statat_other.go b/src/os/statat_other.go
      new file mode 100644
      index 0000000..673ae21
      --- /dev/null
      +++ b/src/os/statat_other.go
      @@ -0,0 +1,12 @@
      +// Copyright 2026 The Go Authors. All rights reserved.
      +// Use of this source code is governed by a BSD-style
      +// license that can be found in the LICENSE file.
      +
      +//go:build (js && wasm) || plan9
      +
      +package os
      +
      +func (f *File) lstatatNolog(name string) (FileInfo, error) {
      + // These platforms don't have fstatat, so use stat instead.
      + return Lstat(f.name + "/" + name)
      +}
      diff --git a/src/os/statat_unix.go b/src/os/statat_unix.go
      new file mode 100644
      index 0000000..80f89f9
      --- /dev/null
      +++ b/src/os/statat_unix.go
      @@ -0,0 +1,20 @@
      +// Copyright 2026 The Go Authors. All rights reserved.
      +// Use of this source code is governed by a BSD-style
      +// license that can be found in the LICENSE file.
      +
      +//go:build aix || darwin || dragonfly || freebsd || wasip1 || linux || netbsd || openbsd || solaris
      +
      +package os
      +
      +import (
      + "internal/syscall/unix"
      +)
      +
      +func (f *File) lstatatNolog(name string) (FileInfo, error) {
      + var fs fileStat
      + if err := f.pfd.Fstatat(name, &fs.sys, unix.AT_SYMLINK_NOFOLLOW); err != nil {
      + return nil, f.wrapErr("fstatat", err)
      + }
      + fillFileStatFromSys(&fs, name)
      + return &fs, nil
      +}

      Change information

      Files:
      • A src/internal/poll/fstatat_unix.go
      • M src/os/dir_darwin.go
      • M src/os/dir_unix.go
      • M src/os/export_test.go
      • M src/os/file.go
      • M src/os/file_unix.go
      • M src/os/os_test.go
      • M src/os/os_unix_test.go
      • M src/os/root_test.go
      • M src/os/root_unix.go
      • M src/os/stat.go
      • A src/os/statat.go
      • A src/os/statat_other.go
      • A src/os/statat_unix.go
      Change size: M
      Delta: 14 files changed, 219 insertions(+), 22 deletions(-)

      Related details

      Attention set is empty
      Submit Requirements:
      • requirement is not satisfiedCode-Review
      • requirement satisfiedNo-Unresolved-Comments
      • requirement is not satisfiedReview-Enforcement
      • requirement is not satisfiedTryBots-Pass
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: newchange
      Gerrit-Project: go
      Gerrit-Branch: release-branch.go1.25
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749920
      unsatisfied_requirement
      satisfied_requirement
      open
      diffy

      Damien Neil (Gerrit)

      unread,
      11:13 AM (4 hours ago) 11:13 AM
      to goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com

      Damien Neil voted Commit-Queue+1

      Commit-Queue+1
      Open in Gerrit

      Related details

      Attention set is empty
      Submit Requirements:
      • requirement is not satisfiedCode-Review
      • requirement satisfiedNo-Unresolved-Comments
      • requirement is not satisfiedReview-Enforcement
      • requirement is not satisfiedTryBots-Pass
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: comment
      Gerrit-Project: go
      Gerrit-Branch: release-branch.go1.25
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749920
      Gerrit-PatchSet: 1
      Gerrit-Owner: Damien Neil <dn...@google.com>
      Gerrit-Reviewer: Damien Neil <dn...@google.com>
      Gerrit-Comment-Date: Fri, 27 Feb 2026 16:13:30 +0000
      Gerrit-HasComments: No
      Gerrit-Has-Labels: Yes
      unsatisfied_requirement
      satisfied_requirement
      open
      diffy

      Damien Neil (Gerrit)

      unread,
      11:14 AM (4 hours ago) 11:14 AM
      to goph...@pubsubhelper.golang.org, golang-co...@googlegroups.com

      Damien Neil has uploaded the change for review

      Commit message

      [release-branch.go1.26] os: avoid escape from Root via ReadDir or Readdir


      When reading the contents of a directory using
      File.ReadDir or File.Readdir, the os.FileInfo was
      populated on Unix platforms using lstat.
      This lstat call is vulnerable to a TOCTOU race
      and could escape the root.

      For example:
      - Open the directory "dir" within a Root.
      This directory contains a file named "file".
      - Use File.ReadDir to list the contents of "dir",
      receiving a os.DirEntry for "dir/file".
      - Replace "dir" with a symlink to "/etc".
      - Use DirEntry.Info to retrieve the FileInfo for "dir/file".
      This FileInfo contains information on "/etc/file" instead.

      This escape permits identifying the presence or absence of
      files outside a Root, as well as retreiving stat metadata
      (size, mode, modification time, etc.) for files outside a Root.

      This escape does not permit reading or writing to files
      outside a Root.

      For #77827
      Fixes #77834

      Change diff

      index 47f4163..d95cf3a 100644
      Open in Gerrit

      Related details

      Attention set is empty
      Submit Requirements:
      • requirement is not satisfiedCode-Review
      • requirement satisfiedNo-Unresolved-Comments
      • requirement is not satisfiedReview-Enforcement
      • requirement is not satisfiedTryBots-Pass
      Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
      Gerrit-MessageType: newchange
      Gerrit-Project: go
      Gerrit-Branch: release-branch.go1.26
      Gerrit-Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
      Gerrit-Change-Number: 749822
      unsatisfied_requirement
      satisfied_requirement
      open
      diffy
      Reply all
      Reply to author
      Forward
      0 new messages