Skip to content

Commit 657ed93

Browse files
neildgopherbot
authored andcommitted
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-on: https://go-review.googlesource.com/c/go/+/749480 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Nicholas Husin <[email protected]> Reviewed-by: Nicholas Husin <[email protected]> Auto-Submit: Damien Neil <[email protected]>
1 parent 753022f commit 657ed93

14 files changed

Lines changed: 219 additions & 22 deletions

src/internal/poll/fstatat_unix.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2026 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build unix || wasip1
6+
7+
package poll
8+
9+
import (
10+
"internal/syscall/unix"
11+
"syscall"
12+
)
13+
14+
func (fd *FD) Fstatat(name string, s *syscall.Stat_t, flags int) error {
15+
if err := fd.incref(); err != nil {
16+
return err
17+
}
18+
defer fd.decref()
19+
return ignoringEINTR(func() error {
20+
return unix.Fstatat(fd.Sysfd, name, s, flags)
21+
})
22+
}

src/os/dir_darwin.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
8888
if mode == readdirName {
8989
names = append(names, string(name))
9090
} else if mode == readdirDirEntry {
91-
de, err := newUnixDirent(f.name, string(name), dtToType(dirent.Type))
91+
de, err := newUnixDirent(f, string(name), dtToType(dirent.Type))
9292
if IsNotExist(err) {
9393
// File disappeared between readdir and stat.
9494
// Treat as if it didn't exist.
@@ -99,7 +99,7 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
9999
}
100100
dirents = append(dirents, de)
101101
} else {
102-
info, err := lstat(f.name + "/" + string(name))
102+
info, err := f.lstatat(string(name))
103103
if IsNotExist(err) {
104104
// File disappeared between readdir + stat.
105105
// Treat as if it didn't exist.

src/os/dir_unix.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
138138
if mode == readdirName {
139139
names = append(names, string(name))
140140
} else if mode == readdirDirEntry {
141-
de, err := newUnixDirent(f.name, string(name), direntType(rec))
141+
de, err := newUnixDirent(f, string(name), direntType(rec))
142142
if IsNotExist(err) {
143143
// File disappeared between readdir and stat.
144144
// Treat as if it didn't exist.
@@ -149,7 +149,7 @@ func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEn
149149
}
150150
dirents = append(dirents, de)
151151
} else {
152-
info, err := lstat(f.name + "/" + string(name))
152+
info, err := f.lstatat(string(name))
153153
if IsNotExist(err) {
154154
// File disappeared between readdir + stat.
155155
// Treat as if it didn't exist.

src/os/export_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package os
77
// Export for testing.
88

99
var Atime = atime
10-
var LstatP = &lstat
1110
var ErrWriteAtInAppendMode = errWriteAtInAppendMode
1211
var ErrPatternHasSeparator = errPatternHasSeparator
1312

@@ -16,3 +15,16 @@ func init() {
1615
}
1716

1817
var ExportReadFileContents = readFileContents
18+
19+
// cleanuper stands in for *testing.T, since we can't import testing in os.
20+
type cleanuper interface {
21+
Cleanup(func())
22+
}
23+
24+
func SetStatHook(t cleanuper, f func(f *File, name string) (FileInfo, error)) {
25+
oldstathook := stathook
26+
t.Cleanup(func() {
27+
stathook = oldstathook
28+
})
29+
stathook = f
30+
}

src/os/file.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -428,9 +428,6 @@ func openDir(name string) (*File, error) {
428428
return openDirNolog(name)
429429
}
430430

431-
// lstat is overridden in tests.
432-
var lstat = Lstat
433-
434431
// Rename renames (moves) oldpath to newpath.
435432
// If newpath already exists and is not a directory, Rename replaces it.
436433
// If newpath already exists and is a directory, Rename returns an error.

src/os/file_unix.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type file struct {
6363
nonblock bool // whether we set nonblocking mode
6464
stdoutOrErr bool // whether this is stdout or stderr
6565
appendMode bool // whether file is opened for appending
66+
inRoot bool // whether file is opened in a Root
6667
}
6768

6869
// fd is the Unix implementation of Fd.
@@ -458,24 +459,27 @@ func (d *unixDirent) Info() (FileInfo, error) {
458459
if d.info != nil {
459460
return d.info, nil
460461
}
461-
return lstat(d.parent + "/" + d.name)
462+
return Lstat(d.parent + "/" + d.name)
462463
}
463464

464465
func (d *unixDirent) String() string {
465466
return fs.FormatDirEntry(d)
466467
}
467468

468-
func newUnixDirent(parent, name string, typ FileMode) (DirEntry, error) {
469+
func newUnixDirent(parent *File, name string, typ FileMode) (DirEntry, error) {
469470
ude := &unixDirent{
470-
parent: parent,
471+
parent: parent.name,
471472
name: name,
472473
typ: typ,
473474
}
474-
if typ != ^FileMode(0) {
475+
// When the parent file was opened in a Root,
476+
// we cannot use a lazy lstat to load the FileInfo.
477+
// Use lstatat here.
478+
if typ != ^FileMode(0) && !parent.inRoot {
475479
return ude, nil
476480
}
477481

478-
info, err := lstat(parent + "/" + name)
482+
info, err := parent.lstatat(name)
479483
if err != nil {
480484
return nil, err
481485
}

src/os/os_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -767,13 +767,12 @@ func TestReaddirStatFailures(t *testing.T) {
767767
}
768768

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

778777
dir := t.TempDir()
779778
touch(t, filepath.Join(dir, "good1"))

src/os/os_unix_test.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,15 +196,13 @@ func TestLchown(t *testing.T) {
196196

197197
// Issue 16919: Readdir must return a non-empty slice or an error.
198198
func TestReaddirRemoveRace(t *testing.T) {
199-
oldStat := *LstatP
200-
defer func() { *LstatP = oldStat }()
201-
*LstatP = func(name string) (FileInfo, error) {
199+
SetStatHook(t, func(f *File, name string) (FileInfo, error) {
202200
if strings.HasSuffix(name, "some-file") {
203201
// Act like it's been deleted.
204202
return nil, ErrNotExist
205203
}
206-
return oldStat(name)
207-
}
204+
return nil, nil
205+
})
208206
dir := t.TempDir()
209207
if err := WriteFile(filepath.Join(dir, "some-file"), []byte("hello"), 0644); err != nil {
210208
t.Fatal(err)

src/os/root_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,3 +1952,108 @@ func TestRootName(t *testing.T) {
19521952
t.Errorf(`root.OpenRoot("dir").Name() = %q, want %q`, got, want)
19531953
}
19541954
}
1955+
1956+
// TestRootNoLstat verifies that we do not use lstat (possibly escaping the root)
1957+
// when reading directories in a Root.
1958+
func TestRootNoLstat(t *testing.T) {
1959+
if runtime.GOARCH == "wasm" {
1960+
t.Skip("wasm lacks fstatat")
1961+
}
1962+
1963+
dir := makefs(t, []string{
1964+
"subdir/",
1965+
})
1966+
const size = 42
1967+
contents := strings.Repeat("x", size)
1968+
if err := os.WriteFile(dir+"/subdir/file", []byte(contents), 0666); err != nil {
1969+
t.Fatal(err)
1970+
}
1971+
root, err := os.OpenRoot(dir)
1972+
if err != nil {
1973+
t.Fatal(err)
1974+
}
1975+
defer root.Close()
1976+
1977+
test := func(name string, fn func(t *testing.T, f *os.File)) {
1978+
t.Run(name, func(t *testing.T) {
1979+
os.SetStatHook(t, func(f *os.File, name string) (os.FileInfo, error) {
1980+
if f == nil {
1981+
t.Errorf("unexpected Lstat(%q)", name)
1982+
}
1983+
return nil, nil
1984+
})
1985+
f, err := root.Open("subdir")
1986+
if err != nil {
1987+
t.Fatal(err)
1988+
}
1989+
defer f.Close()
1990+
fn(t, f)
1991+
})
1992+
}
1993+
1994+
checkFileInfo := func(t *testing.T, fi fs.FileInfo) {
1995+
t.Helper()
1996+
if got, want := fi.Name(), "file"; got != want {
1997+
t.Errorf("FileInfo.Name() = %q, want %q", got, want)
1998+
}
1999+
if got, want := fi.Size(), int64(size); got != want {
2000+
t.Errorf("FileInfo.Size() = %v, want %v", got, want)
2001+
}
2002+
}
2003+
checkDirEntry := func(t *testing.T, d fs.DirEntry) {
2004+
t.Helper()
2005+
if got, want := d.Name(), "file"; got != want {
2006+
t.Errorf("DirEntry.Name() = %q, want %q", got, want)
2007+
}
2008+
if got, want := d.IsDir(), false; got != want {
2009+
t.Errorf("DirEntry.IsDir() = %v, want %v", got, want)
2010+
}
2011+
fi, err := d.Info()
2012+
if err != nil {
2013+
t.Fatalf("DirEntry.Info() = _, %v", err)
2014+
}
2015+
checkFileInfo(t, fi)
2016+
}
2017+
2018+
test("Stat", func(t *testing.T, subdir *os.File) {
2019+
fi, err := subdir.Stat()
2020+
if err != nil {
2021+
t.Fatal(err)
2022+
}
2023+
if !fi.IsDir() {
2024+
t.Fatalf(`Open("subdir").Stat().IsDir() = false, want true`)
2025+
}
2026+
})
2027+
// File.ReadDir, returning []DirEntry
2028+
test("ReadDirEntry", func(t *testing.T, subdir *os.File) {
2029+
dirents, err := subdir.ReadDir(-1)
2030+
if err != nil {
2031+
t.Fatal(err)
2032+
}
2033+
if len(dirents) != 1 {
2034+
t.Fatalf(`Open("subdir").ReadDir(-1) = {%v}, want {file}`, dirents)
2035+
}
2036+
checkDirEntry(t, dirents[0])
2037+
})
2038+
// File.Readdir, returning []FileInfo
2039+
test("ReadFileInfo", func(t *testing.T, subdir *os.File) {
2040+
fileinfos, err := subdir.Readdir(-1)
2041+
if err != nil {
2042+
t.Fatal(err)
2043+
}
2044+
if len(fileinfos) != 1 {
2045+
t.Fatalf(`Open("subdir").Readdir(-1) = {%v}, want {file}`, fileinfos)
2046+
}
2047+
checkFileInfo(t, fileinfos[0])
2048+
})
2049+
// File.Readdirnames, returning []string
2050+
test("Readdirnames", func(t *testing.T, subdir *os.File) {
2051+
names, err := subdir.Readdirnames(-1)
2052+
if err != nil {
2053+
t.Fatal(err)
2054+
}
2055+
if got, want := names, []string{"file"}; !slices.Equal(got, want) {
2056+
t.Fatalf(`Open("subdir").Readdirnames(-1) = %q, want %q`, got, want)
2057+
}
2058+
})
2059+
}

src/os/root_unix.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func rootOpenFileNolog(root *Root, name string, flag int, perm FileMode) (*File,
104104
return nil, &PathError{Op: "openat", Path: name, Err: err}
105105
}
106106
f := newFile(fd, joinPath(root.Name(), name), kindOpenFile, unix.HasNonblockFlag(flag))
107+
f.inRoot = true
107108
return f, nil
108109
}
109110

0 commit comments

Comments
 (0)