From 8cce3ab20c49a5c3c9fa8e97ad47335c3ccd2620 Mon Sep 17 00:00:00 2001 From: Damien Neil Date: Thu, 26 Feb 2026 09:54:33 -0800 Subject: [PATCH] [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 Fixes CVE-2026-27139 Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964 Reviewed-on: https://go-review.googlesource.com/c/go/+/749480 LUCI-TryBot-Result: Go LUCI Reviewed-by: Nicholas Husin Reviewed-by: Nicholas Husin Auto-Submit: Damien Neil (cherry picked from commit 657ed934e85dc575aad51356c4b437961e7c1313) Reviewed-on: https://go-review.googlesource.com/c/go/+/749822 --- src/internal/poll/fstatat_unix.go | 22 +++++++ src/os/dir_darwin.go | 4 +- src/os/dir_unix.go | 4 +- src/os/export_test.go | 14 +++- src/os/file.go | 3 - src/os/file_unix.go | 14 ++-- src/os/os_test.go | 7 +- src/os/os_unix_test.go | 8 +-- src/os/root_test.go | 105 ++++++++++++++++++++++++++++++ src/os/root_unix.go | 1 + src/os/stat.go | 3 + src/os/statat.go | 24 +++++++ src/os/statat_other.go | 12 ++++ src/os/statat_unix.go | 20 ++++++ 14 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 src/internal/poll/fstatat_unix.go create mode 100644 src/os/statat.go create mode 100644 src/os/statat_other.go create mode 100644 src/os/statat_unix.go diff --git a/src/internal/poll/fstatat_unix.go b/src/internal/poll/fstatat_unix.go new file mode 100644 index 0000000000..cde8551a77 --- /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 91b67d8d61..b6ebca7e9e 100644 --- a/src/os/dir_darwin.go +++ b/src/os/dir_darwin.go @@ -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. diff --git a/src/os/dir_unix.go b/src/os/dir_unix.go index 87df3122d4..8f1aabec52 100644 --- a/src/os/dir_unix.go +++ b/src/os/dir_unix.go @@ -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. diff --git a/src/os/export_test.go b/src/os/export_test.go index 93b10898e0..bea38c905a 100644 --- a/src/os/export_test.go +++ b/src/os/export_test.go @@ -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 +} diff --git a/src/os/file.go b/src/os/file.go index 66269c199e..80857240f5 100644 --- a/src/os/file.go +++ b/src/os/file.go @@ -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. diff --git a/src/os/file_unix.go b/src/os/file_unix.go index 2074df70fe..6f9cab788b 100644 --- a/src/os/file_unix.go +++ b/src/os/file_unix.go @@ -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 } diff --git a/src/os/os_test.go b/src/os/os_test.go index 47f4163220..d95cf3adcc 100644 --- a/src/os/os_test.go +++ b/src/os/os_test.go @@ -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")) diff --git a/src/os/os_unix_test.go b/src/os/os_unix_test.go index 41feaf77e2..6b55e09427 100644 --- a/src/os/os_unix_test.go +++ b/src/os/os_unix_test.go @@ -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) diff --git a/src/os/root_test.go b/src/os/root_test.go index f9fbd11575..71ce5e559a 100644 --- a/src/os/root_test.go +++ b/src/os/root_test.go @@ -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) + } + }) +} diff --git a/src/os/root_unix.go b/src/os/root_unix.go index c891e81b79..885a8353eb 100644 --- a/src/os/root_unix.go +++ b/src/os/root_unix.go @@ -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 } diff --git a/src/os/stat.go b/src/os/stat.go index 50acb6dbdd..5ef731f4f2 100644 --- a/src/os/stat.go +++ b/src/os/stat.go @@ -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 index 0000000000..d460fe2798 --- /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 0000000000..673ae21ac1 --- /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 0000000000..80f89f95c4 --- /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 +} -- 2.52.0