aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDamien Neil <dneil@google.com>2026-03-23 10:34:50 -0700
committerGopher Robot <gobot@golang.org>2026-04-07 17:04:10 -0700
commitc3c65602d639e60ed6071fee22979b7e3ece1a30 (patch)
treec40a9a169a157dc9842302ce6a3a7e1a533690aa
parent524b8606a8e4e8f6e37f77c7d601fdf44497b928 (diff)
downloadgo-c3c65602d639e60ed6071fee22979b7e3ece1a30.tar.xz
internal/syscall/unix: properly support AT_SYMLINK_NOFOLLOW on Linux
On Linux, the fchmodat syscall silently ignores the AT_SYMLINK_NOFOLLOW flag. Change the Linux Fchmodat function to use the fstatat2 syscall (added in Linux 6.6) when available. When fstatat2 is not available, use the same workaround as GNU libc and musl, which is to open the target file with O_PATH and then chmod it via /proc/self/fd. This change fixes an os.Root escape, where Root.Chmod could follow a symlink and act on a file outside of the root. Root.Chmod checks to see if its target is a symlink before calling fchmodat, so this escape requires the target to be replaced with a symlink in between the initial check and the fchmodat. Thanks to Uuganbayar Lkhamsuren (https://github.com/uug4na) for reporting this issue. Fixes CVE-2026-32282 Fixes #78293 Change-Id: Ie487be1a853b341a77b42ae0c59301d46a6a6964 Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/3900 Reviewed-by: Damien Neil <dneil@google.com> Reviewed-by: Neal Patel <nealpatel@google.com> Reviewed-on: https://go-review.googlesource.com/c/go/+/763761 TryBot-Bypass: David Chase <drchase@google.com> Auto-Submit: David Chase <drchase@google.com>
-rw-r--r--src/cmd/dist/buildtool.go1
-rw-r--r--src/internal/syscall/unix/at.go17
-rw-r--r--src/internal/syscall/unix/at_sysnum_linux.go3
-rw-r--r--src/internal/syscall/unix/fchmodat_linux.go51
-rw-r--r--src/internal/syscall/unix/fchmodat_other.go29
-rw-r--r--src/internal/syscall/unix/fchmodat_test.go62
6 files changed, 145 insertions, 18 deletions
diff --git a/src/cmd/dist/buildtool.go b/src/cmd/dist/buildtool.go
index 62cd937692..2da9a17636 100644
--- a/src/cmd/dist/buildtool.go
+++ b/src/cmd/dist/buildtool.go
@@ -92,6 +92,7 @@ var bootstrapDirs = []string{
"internal/race",
"internal/runtime/gc",
"internal/saferio",
+ "internal/strconv",
"internal/syscall/unix",
"internal/types/errors",
"internal/unsafeheader",
diff --git a/src/internal/syscall/unix/at.go b/src/internal/syscall/unix/at.go
index 7505750ea8..ee6802a502 100644
--- a/src/internal/syscall/unix/at.go
+++ b/src/internal/syscall/unix/at.go
@@ -80,23 +80,6 @@ func Mkdirat(dirfd int, path string, mode uint32) error {
return nil
}
-func Fchmodat(dirfd int, path string, mode uint32, flags int) error {
- p, err := syscall.BytePtrFromString(path)
- if err != nil {
- return err
- }
- _, _, errno := syscall.Syscall6(fchmodatTrap,
- uintptr(dirfd),
- uintptr(unsafe.Pointer(p)),
- uintptr(mode),
- uintptr(flags),
- 0, 0)
- if errno != 0 {
- return errno
- }
- return nil
-}
-
func Fchownat(dirfd int, path string, uid, gid int, flags int) error {
p, err := syscall.BytePtrFromString(path)
if err != nil {
diff --git a/src/internal/syscall/unix/at_sysnum_linux.go b/src/internal/syscall/unix/at_sysnum_linux.go
index bb7f244fe2..d260a239a9 100644
--- a/src/internal/syscall/unix/at_sysnum_linux.go
+++ b/src/internal/syscall/unix/at_sysnum_linux.go
@@ -11,7 +11,6 @@ const (
openatTrap uintptr = syscall.SYS_OPENAT
readlinkatTrap uintptr = syscall.SYS_READLINKAT
mkdiratTrap uintptr = syscall.SYS_MKDIRAT
- fchmodatTrap uintptr = syscall.SYS_FCHMODAT
fchownatTrap uintptr = syscall.SYS_FCHOWNAT
linkatTrap uintptr = syscall.SYS_LINKAT
symlinkatTrap uintptr = syscall.SYS_SYMLINKAT
@@ -24,4 +23,6 @@ const (
AT_SYMLINK_NOFOLLOW = 0x100
UTIME_OMIT = 0x3ffffffe
+
+ O_PATH = 0x200000
)
diff --git a/src/internal/syscall/unix/fchmodat_linux.go b/src/internal/syscall/unix/fchmodat_linux.go
new file mode 100644
index 0000000000..786ec29df2
--- /dev/null
+++ b/src/internal/syscall/unix/fchmodat_linux.go
@@ -0,0 +1,51 @@
+// 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 linux
+
+package unix
+
+import (
+ "internal/strconv"
+ "syscall"
+)
+
+func Fchmodat(dirfd int, path string, mode uint32, flags int) error {
+ // On Linux, the fchmodat syscall silently ignores the AT_SYMLINK_NOFOLLOW flag.
+ // We need to use fchmodat2 instead.
+ // syscall.Fchmodat handles this.
+ if err := syscall.Fchmodat(dirfd, path, mode, flags); err != syscall.EOPNOTSUPP {
+ return err
+ }
+
+ // This kernel doesn't appear to support fchmodat2 (added in Linux 6.6).
+ // We can't fall back to Fchmod, because it requires write permissions on the file.
+ // Instead, use the same workaround as GNU libc and musl, which is to open the file
+ // and then fchmodat the FD in /proc/self/fd.
+ // See: https://lwn.net/Articles/939217/
+ fd, err := Openat(dirfd, path, O_PATH|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ return err
+ }
+ defer syscall.Close(fd)
+ procPath := "/proc/self/fd/" + strconv.Itoa(fd)
+
+ // Check to see if this file is a symlink.
+ // (We passed O_NOFOLLOW above, but O_PATH|O_NOFOLLOW will open a symlink.)
+ var st syscall.Stat_t
+ if err := syscall.Stat(procPath, &st); err != nil {
+ if err == syscall.ENOENT {
+ // /proc has probably not been mounted. Give up.
+ return syscall.EOPNOTSUPP
+ }
+ return err
+ }
+ if st.Mode&syscall.S_IFMT == syscall.S_IFLNK {
+ // fchmodat on the proc FD for a symlink apparently gives inconsistent
+ // results, so just refuse to try.
+ return syscall.EOPNOTSUPP
+ }
+
+ return syscall.Fchmodat(AT_FDCWD, procPath, mode, flags&^AT_SYMLINK_NOFOLLOW)
+}
diff --git a/src/internal/syscall/unix/fchmodat_other.go b/src/internal/syscall/unix/fchmodat_other.go
new file mode 100644
index 0000000000..76f478c4ae
--- /dev/null
+++ b/src/internal/syscall/unix/fchmodat_other.go
@@ -0,0 +1,29 @@
+// 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 dragonfly || freebsd || netbsd || (openbsd && mips64)
+
+package unix
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+func Fchmodat(dirfd int, path string, mode uint32, flags int) error {
+ p, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return err
+ }
+ _, _, errno := syscall.Syscall6(fchmodatTrap,
+ uintptr(dirfd),
+ uintptr(unsafe.Pointer(p)),
+ uintptr(mode),
+ uintptr(flags),
+ 0, 0)
+ if errno != 0 {
+ return errno
+ }
+ return nil
+}
diff --git a/src/internal/syscall/unix/fchmodat_test.go b/src/internal/syscall/unix/fchmodat_test.go
new file mode 100644
index 0000000000..49a098535d
--- /dev/null
+++ b/src/internal/syscall/unix/fchmodat_test.go
@@ -0,0 +1,62 @@
+// 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 unix_test
+
+import (
+ "internal/syscall/unix"
+ "os"
+ "runtime"
+ "testing"
+)
+
+// TestFchmodAtSymlinkNofollow verifies that Fchmodat honors the AT_SYMLINK_NOFOLLOW flag.
+func TestFchmodatSymlinkNofollow(t *testing.T) {
+ if runtime.GOOS == "wasip1" {
+ t.Skip("wasip1 doesn't support chmod")
+ }
+
+ dir := t.TempDir()
+ filename := dir + "/file"
+ linkname := dir + "/symlink"
+ if err := os.WriteFile(filename, nil, 0o100); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filename, linkname); err != nil {
+ t.Fatal(err)
+ }
+
+ parent, err := os.Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer parent.Close()
+
+ lstatMode := func(path string) os.FileMode {
+ st, err := os.Lstat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return st.Mode()
+ }
+
+ // Fchmodat with no flags follows symlinks.
+ const mode1 = 0o200
+ if err := unix.Fchmodat(int(parent.Fd()), "symlink", mode1, 0); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := lstatMode(filename), os.FileMode(mode1); got != want {
+ t.Errorf("after Fchmodat(parent, symlink, %v, 0); mode = %v, want %v", mode1, got, want)
+ }
+
+ // Fchmodat with AT_SYMLINK_NOFOLLOW does not follow symlinks.
+ // The Fchmodat call may fail or chmod the symlink itself, depending on the kernel version.
+ const mode2 = 0o400
+ unix.Fchmodat(int(parent.Fd()), "symlink", mode2, unix.AT_SYMLINK_NOFOLLOW)
+ if got, want := lstatMode(filename), os.FileMode(mode1); got != want {
+ t.Errorf("after Fchmodat(parent, symlink, %v, AT_SYMLINK_NOFOLLOW); mode = %v, want %v", mode1, got, want)
+ }
+}