[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-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 657ed934e8)
Reviewed-on: https://go-review.googlesource.com/c/go/+/749920
This commit is contained in:
Damien Neil
2026-02-26 09:54:33 -08:00
committed by Cherry Mui
parent 0ee4ab4c3f
commit 4091800393
14 changed files with 219 additions and 22 deletions

View File

@@ -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)
})
}

View 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.

View 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.

View 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
}

View 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.

View 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
}

View 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"))

View 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)

View 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)
}
})
}

View 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
}

View 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)

24
src/os/statat.go Normal file
View File

@@ -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)
}

12
src/os/statat_other.go Normal file
View File

@@ -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)
}

20
src/os/statat_unix.go Normal file
View File

@@ -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
}