From b6176f459ad7b84ea7fb8daab983f4cef644a119 Mon Sep 17 00:00:00 2001 From: Damien Neil Date: Mon, 23 Mar 2026 10:34:50 -0700 Subject: [release-branch.go1.26] 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 Reviewed-by: Neal Patel Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/3981 Commit-Queue: Damien Neil Reviewed-on: https://go-review.googlesource.com/c/go/+/763542 Auto-Submit: Gopher Robot Reviewed-by: Junyang Shao Reviewed-by: David Chase TryBot-Bypass: Gopher Robot --- src/cmd/dist/buildtool.go | 1 + src/internal/syscall/unix/at.go | 17 -------- src/internal/syscall/unix/at_sysnum_linux.go | 3 +- src/internal/syscall/unix/fchmodat_linux.go | 51 +++++++++++++++++++++++ src/internal/syscall/unix/fchmodat_other.go | 29 +++++++++++++ src/internal/syscall/unix/fchmodat_test.go | 62 ++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 src/internal/syscall/unix/fchmodat_linux.go create mode 100644 src/internal/syscall/unix/fchmodat_other.go create mode 100644 src/internal/syscall/unix/fchmodat_test.go 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 96272afc7b..359a2da995 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) + } +} -- cgit v1.3