mirror of
https://github.com/golang/go.git
synced 2026-04-01 17:07:17 +09:00
[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:
22
src/internal/poll/fstatat_unix.go
Normal file
22
src/internal/poll/fstatat_unix.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
24
src/os/statat.go
Normal 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
12
src/os/statat_other.go
Normal 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
20
src/os/statat_unix.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user