]> Cypherpunks repositories - gostls13.git/commitdiff
[release-branch.go1.26] os: avoid escape from Root via ReadDir or Readdir
authorDamien Neil <dneil@google.com>
Thu, 26 Feb 2026 17:54:33 +0000 (09:54 -0800)
committerCherry Mui <cherryyz@google.com>
Fri, 27 Feb 2026 21:48:25 +0000 (13:48 -0800)
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
Fixes CVE-2026-27139

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

14 files changed:
src/internal/poll/fstatat_unix.go [new file with mode: 0644]
src/os/dir_darwin.go
src/os/dir_unix.go
src/os/export_test.go
src/os/file.go
src/os/file_unix.go
src/os/os_test.go
src/os/os_unix_test.go
src/os/root_test.go
src/os/root_unix.go
src/os/stat.go
src/os/statat.go [new file with mode: 0644]
src/os/statat_other.go [new file with mode: 0644]
src/os/statat_unix.go [new file with mode: 0644]

diff --git a/src/internal/poll/fstatat_unix.go b/src/internal/poll/fstatat_unix.go
new file mode 100644 (file)
index 0000000..cde8551
--- /dev/null
@@ -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)
+       })
+}
index 91b67d8d61d1fc0f23a4cf0fce2db8364401efa8..b6ebca7e9e837fef3a537613a72a9151c9e3042c 100644 (file)
@@ -88,7 +88,7 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
                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 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
                        }
                        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.
index 87df3122d4eada8502b556ee422a4137e8292a7f..8f1aabec52b4c730441cff35c6742226a7071dc7 100644 (file)
@@ -138,7 +138,7 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
                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 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
                        }
                        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.
index 93b10898e0b4ba45e1ddfe50a563a4fd1e8b3e31..bea38c905a673fca91e6efc4285cdbc1c9e8d74d 100644 (file)
@@ -7,7 +7,6 @@ package os
 // Export for testing.
 
 var Atime = atime
-var LstatP = &lstat
 var ErrWriteAtInAppendMode = errWriteAtInAppendMode
 var ErrPatternHasSeparator = errPatternHasSeparator
 
@@ -16,3 +15,16 @@ func init() {
 }
 
 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
+}
index 66269c199e7d95309c72b41f2522232ffbbb6c09..80857240f5c7bbb12ca58ca9035469da66a5360e 100644 (file)
@@ -428,9 +428,6 @@ func openDir(name string) (*File, error) {
        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.
index 2074df70febc2eaad96a938990ccb065b875564d..6f9cab788b556005ff4ced5e62b9dd6ac5c5ce14 100644 (file)
@@ -63,6 +63,7 @@ type file struct {
        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 @@ func (d *unixDirent) Info() (FileInfo, error) {
        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
        }
index 47f4163220e4bbe00d2ef1565de7f83c107910d8..d95cf3adcc62a8ca3a1ee46b65cb6469ade0a1c1 100644 (file)
@@ -767,13 +767,12 @@ func TestReaddirStatFailures(t *testing.T) {
        }
 
        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"))
index 41feaf77e2cea4d1b3e48221815fb5e5942cb39b..6b55e09427b100bdc43556a38583b4e9e5057fa2 100644 (file)
@@ -196,15 +196,13 @@ func TestLchown(t *testing.T) {
 
 // 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)
index f9fbd11575e6a058683a49080863cef359e3ed46..71ce5e559a2dba48f4816b7eb3d919d2604f5d03 100644 (file)
@@ -1952,3 +1952,108 @@ func TestRootName(t *testing.T) {
                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)
+               }
+       })
+}
index c891e81b793bb5c3c4853107a817fa33a816d635..885a8353ebc455beb2854810a633b04bcbcb47a1 100644 (file)
@@ -104,6 +104,7 @@ func rootOpenFileNolog(root *Root, name string, flag int, perm FileMode) (*File,
                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
 }
 
index 50acb6dbdd12bdc90e23cf659f62aab66edd1585..5ef731f4f2637186a1ad91281e92316b03a16d7b 100644 (file)
@@ -25,3 +25,6 @@ func Lstat(name string) (FileInfo, error) {
        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 (file)
index 0000000..d460fe2
--- /dev/null
@@ -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 (file)
index 0000000..673ae21
--- /dev/null
@@ -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 (file)
index 0000000..80f89f9
--- /dev/null
@@ -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
+}