Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43831d3
chmod: fix TOCTOU race in recursive traversal
mattsu2020 Apr 19, 2026
91acda9
fix: define SYS_fchmodat2 locally for aarch64 musl compatibility
mattsu2020 Apr 20, 2026
dc36169
fix: Correct formatting in safe_chmod_file call for file symlinks
mattsu2020 Apr 20, 2026
1984ecd
ci: recognize fchmodat2 as safe traversal syscall
mattsu2020 Apr 20, 2026
c3defca
chmod: fix TOCTOU race in recursive traversal
mattsu2020 Apr 19, 2026
6e95706
fix: define SYS_fchmodat2 locally for aarch64 musl compatibility
mattsu2020 Apr 20, 2026
46f0caf
fix: Correct formatting in safe_chmod_file call for file symlinks
mattsu2020 Apr 20, 2026
e2698f4
ci: recognize fchmodat2 as safe traversal syscall
mattsu2020 Apr 20, 2026
3d18f76
Merge branch 'chmod_fix' of https://github.com/mattsu2020/coreutils i…
mattsu2020 Apr 20, 2026
7b59f70
fix: address PR review blockers for chmod TOCTOU fix
mattsu2020 Apr 20, 2026
105e7bf
fix: improve chmod TOCTOU fix robustness and documentation
mattsu2020 Apr 20, 2026
629dc36
refactor: replace unsafe libc calls with rustix safe wrappers in chmo…
mattsu2020 Apr 21, 2026
c1497fa
style: format imports and chain method calls in safe_traversal tests
mattsu2020 Apr 21, 2026
640fc64
style: format imports and chain method calls in safe_traversal tests
mattsu2020 Apr 21, 2026
698e339
Merge branch 'chmod_fix' of https://github.com/mattsu2020/coreutils i…
mattsu2020 Apr 21, 2026
3d3fbc9
Merge branch 'chmod_fix' of https://github.com/mattsu2020/coreutils i…
mattsu2020 Apr 21, 2026
c7acfab
Merge branch 'chmod_fix' of https://github.com/mattsu2020/coreutils i…
mattsu2020 Apr 21, 2026
1b08640
refactor: rename variable 'opath' to 'chmods' in safe_traversal.rs
mattsu2020 Apr 21, 2026
d3ca31b
refactor: rename variable 'opath' to 'chmods' in safe_traversal.rs
mattsu2020 Apr 21, 2026
ce69b39
Merge branch 'chmod_fix' of https://github.com/mattsu2020/coreutils i…
mattsu2020 Apr 21, 2026
bf8303d
Apply suggestion from @oech3
mattsu2020 Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions src/uu/chmod/src/chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,18 @@ impl Chmoder {
.handle_symlink_during_safe_recursion(&entry_path, dir_fd, &entry_name)
.and(r);
} else {
// For regular files and directories, chmod them
// For regular files and directories, chmod them.
// Always use NoFollow: we already confirmed via stat that the entry
// is not a symlink, so this prevents TOCTOU races where an attacker
// replaces the entry with a symlink between stat and chmod.
r = self
.safe_chmod_file(&entry_path, dir_fd, &entry_name, meta.mode() & 0o7777)
.safe_chmod_file(
&entry_path,
dir_fd,
&entry_name,
meta.mode() & 0o7777,
SymlinkBehavior::NoFollow,
)
.and(r);

// Recurse into subdirectories using the existing directory fd
Expand Down Expand Up @@ -555,13 +564,23 @@ impl Chmoder {
// During recursion, determine behavior based on traversal mode
match self.traverse_symlinks {
TraverseSymlinks::All => {
// Follow all symlinks during recursion
// Follow all symlinks during recursion.
// NOTE: This path-based stat + Follow is only reachable when the user
// explicitly specifies -L (--dereference). In that case the user has
// consciously opted in to following symlinks, so a path-based stat
// followed by Follow is the intended behavior and not a TOCTOU concern.
// Check if the symlink target is a directory, but handle dangling symlinks gracefully
match fs::metadata(path) {
Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false),
Ok(meta) => {
// It's a file symlink, chmod it using safe traversal
self.safe_chmod_file(path, dir_fd, entry_name, meta.mode() & 0o7777)
self.safe_chmod_file(
path,
dir_fd,
entry_name,
meta.mode() & 0o7777,
self.dereference.into(),
)
}
Err(_) => {
// Dangling symlink, chmod it without dereferencing
Expand All @@ -584,13 +603,13 @@ impl Chmoder {
dir_fd: &DirFd,
entry_name: &std::ffi::OsStr,
current_mode: u32,
symlink_behavior: SymlinkBehavior,
) -> UResult<()> {
// Calculate the new mode using the helper method
let (new_mode, _) = self.calculate_new_mode(current_mode, file_path.is_dir())?;

// Use safe traversal to change the mode
let follow_symlinks = self.dereference;
if let Err(_e) = dir_fd.chmod_at(entry_name, new_mode, follow_symlinks.into()) {
if let Err(_e) = dir_fd.chmod_at(entry_name, new_mode, symlink_behavior) {
if self.verbose {
println!(
"failed to change mode of {} to {new_mode:o}",
Expand Down
152 changes: 145 additions & 7 deletions src/uucore/src/lib/features/safe_traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//
// spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat smallfile
// spell-checker:ignore RAII dirfd fchownat fchown FchmodatFlags fchmodat fchmod mkdirat CREAT WRONLY ELOOP ENOTDIR
// spell-checker:ignore atimensec mtimensec ctimensec
// spell-checker:ignore atimensec mtimensec ctimensec opath chmods

#[cfg(test)]
use std::os::unix::ffi::OsStringExt;
Expand Down Expand Up @@ -293,21 +293,121 @@ impl DirFd {
mode: u32,
symlink_behavior: SymlinkBehavior,
) -> io::Result<()> {
let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;

// --- fchmodat2 path (Linux 6.6+, asm-generic arches only) ---
// Uses the raw mode value directly; no nix::Mode conversion needed.
// Only enabled on asm-generic architectures where syscall number 452 is
// correct (x86_64, x86, arm, aarch64, riscv). MIPS/SPARC/PowerPC/Alpha
// use different numbering and are not supported until libc exposes
// SYS_fchmodat2 for them.
#[cfg(all(
target_os = "linux",
any(
target_arch = "x86_64",
target_arch = "x86",
target_arch = "arm",
target_arch = "aarch64",
target_arch = "riscv64",
target_arch = "riscv32",
),
))]
if matches!(symlink_behavior, SymlinkBehavior::NoFollow) {
use std::sync::atomic::{AtomicBool, Ordering};

// Cache: if fchmodat2 returned ENOSYS once, the kernel is too old
// and will never support it. Skip the syscall on subsequent calls.
static FCHMODAT2_UNAVAILABLE: AtomicBool = AtomicBool::new(false);

if !FCHMODAT2_UNAVAILABLE.load(Ordering::Relaxed) {
// Syscall number for fchmodat2 on asm-generic architectures.
const SYS_FCHMODAT2: libc::c_long = 452;
// SAFETY: syscall(2) is an FFI call. We pass valid arguments:
// - fd: valid open file descriptor
// - name: valid C string pointer (name_cstr lives for the duration)
// - mode: valid mode_t value
// - flags: AT_SYMLINK_NOFOLLOW (valid flag for fchmodat2)
let res = unsafe {
libc::syscall(
SYS_FCHMODAT2,
self.fd.as_raw_fd(),
name_cstr.as_ptr(),
mode as libc::mode_t,
libc::AT_SYMLINK_NOFOLLOW,
)
};
if res == 0 {
return Ok(());
}
let err = io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::ENOSYS) => {
FCHMODAT2_UNAVAILABLE.store(true, Ordering::Relaxed);
// Fall through to fchmodat
}
_ => return Err(err),
}
}
}

// --- fchmodat fallback path ---
// nix::Mode conversion is needed here because fchmodat() requires it.
let nix_mode = Mode::from_bits_truncate(mode as libc::mode_t);

let flags = if symlink_behavior.should_follow() {
FchmodatFlags::FollowSymlink
} else {
FchmodatFlags::NoFollowSymlink
};

let mode = Mode::from_bits_truncate(mode as libc::mode_t);
match fchmodat(&self.fd, name_cstr.as_c_str(), nix_mode, flags) {
Ok(()) => Ok(()),
Err(e)
if !symlink_behavior.should_follow()
&& (e == nix::errno::Errno::EOPNOTSUPP || e == nix::errno::Errno::ENOTSUP) =>
{
// musl does not emulate AT_SYMLINK_NOFOLLOW via /proc/self/fd
// like glibc does, so fchmodat returns EOPNOTSUPP on old kernels.
// Fall back to O_PATH + /proc/self/fd/{fd} + fchmod.
#[cfg(target_os = "linux")]
{
self.chmod_at_via_opath(name_cstr.as_c_str(), mode)
}
#[cfg(not(target_os = "linux"))]
{
Err(io::Error::from_raw_os_error(e as i32))
}
}
Err(e) => Err(io::Error::from_raw_os_error(e as i32)),
}
}

let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
/// O_PATH-based fallback for chmod when fchmodat with AT_SYMLINK_NOFOLLOW
/// is not available (musl on kernel < 6.6).
///
/// Opens the file with O_PATH|O_NOFOLLOW to get an fd without following
/// symlinks, then chmods via /proc/self/fd/{fd}. This avoids the TOCTOU
/// race because the fd pins the inode.
///
#[cfg(target_os = "linux")]
fn chmod_at_via_opath(&self, name: &std::ffi::CStr, mode: u32) -> io::Result<()> {
use rustix::fs::{Mode, OFlags, chmod, openat};

fchmodat(&self.fd, name_cstr.as_c_str(), mode, flags)
.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
let fd = openat(
&self.fd,
name,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?;

Ok(())
let proc_path = format!("/proc/self/fd/{}\0", fd.as_raw_fd());
let proc_cstr = std::ffi::CStr::from_bytes_with_nul(proc_path.as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid proc path"))?;

chmod(proc_cstr, Mode::from_bits_truncate(mode))
.map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))
}

/// Change mode of this directory
Expand Down Expand Up @@ -800,6 +900,7 @@ impl std::os::unix::fs::MetadataExt for Metadata {
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::symlink;
use std::os::unix::io::IntoRawFd;
use tempfile::TempDir;
Expand Down Expand Up @@ -1227,6 +1328,43 @@ mod tests {
assert!(result_nofollow.is_err());
}

/// Verify that chmod_at with NoFollow does not change the symlink target's mode.
/// This test demonstrates that the TOCTOU race in recursive chmod is closed:
/// chmod on a symlink entry should not affect the target file.
#[test]
fn test_chmod_at_nofollow_preserves_target_mode() {
let temp_dir = TempDir::new().unwrap();

// Create a sentinel file outside the traversal directory
let sentinel = temp_dir.path().join("sentinel");
fs::write(&sentinel, "victim").unwrap();
let sentinel_mode = fs::symlink_metadata(&sentinel).unwrap().mode();

// Create a subdirectory with a symlink pointing to the sentinel
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let link = subdir.join("link");
symlink(&sentinel, &link).unwrap();

// Open the subdirectory and chmod the symlink entry with NoFollow
let dir_fd = DirFd::open(&subdir, SymlinkBehavior::Follow).unwrap();
let result = dir_fd.chmod_at(OsStr::new("link"), 0o777, SymlinkBehavior::NoFollow);

// On Linux 6.6+ (fchmodat2), the chmod should succeed without affecting the target.
// On older kernels, fchmodat with AT_SYMLINK_NOFOLLOW returns EOPNOTSUPP/ENOTSUP,
// which is acceptable — the important thing is the target is NOT modified.
if let Ok(()) = result {
// fchmodat2 succeeded: verify sentinel mode is unchanged
let new_sentinel_mode = fs::symlink_metadata(&sentinel).unwrap().mode();
assert_eq!(
new_sentinel_mode, sentinel_mode,
"sentinel mode should not change when chmod'ing symlink with NoFollow"
);
}
// If result is Err (EOPNOTSUPP on old kernels), the target is also unchanged,
// which is the correct behavior — no silent modification.
}

#[test]
fn test_open_nofollow_fails_on_symlink() {
let temp_dir = TempDir::new().unwrap();
Expand Down
30 changes: 22 additions & 8 deletions util/check-safe-traversal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,26 @@ check_utility() {
# Check for expected safe syscalls
local found_safe=0
for syscall in $expected_syscalls; do
if grep -q "$syscall" "$strace_log"; then
echo "✓ Found $syscall() (safe traversal)"
found_safe=$((found_safe + 1))
else
fail_immediately "Missing $syscall() (safe traversal not active for $util)"
fi
# fchmodat2 is the modern replacement for fchmodat on Linux 6.6+
# Accept either as a valid safe traversal syscall
case "$syscall" in
fchmodat)
if grep -qE "fchmodat2?\(" "$strace_log"; then
echo "✓ Found fchmodat/fchmodat2() (safe traversal)"
found_safe=$((found_safe + 1))
else
fail_immediately "Missing fchmodat() or fchmodat2() (safe traversal not active for $util)"
fi
;;
*)
if grep -q "$syscall" "$strace_log"; then
echo "✓ Found $syscall() (safe traversal)"
found_safe=$((found_safe + 1))
else
fail_immediately "Missing $syscall() (safe traversal not active for $util)"
fi
;;
esac
done

# Count detailed syscall statistics
Expand All @@ -95,7 +109,7 @@ check_utility() {

openat_count=$(grep -c "openat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
unlinkat_count=$(grep -c "unlinkat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
fchmodat_count=$(grep -c "fchmodat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove fchmodat ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern counts both together. We have expanded the fchmodat count to include fchmodat2 rather than excluding it.

fchmodat_count=$(grep -cE "fchmodat2?\(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
fchownat_count=$(grep -c "fchownat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
newfstatat_count=$(grep -c "newfstatat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
renameat_count=$(grep -c "renameat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
Expand Down Expand Up @@ -183,7 +197,7 @@ fi
# Test chmod - should use openat, fchmodat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then
cp -r test_dir test_chmod
check_utility "chmod" "openat,fchmodat,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod"
check_utility "chmod" "openat,fchmodat,fchmodat2,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod"

# Additional regression guard: ensure recursion uses dirfd-relative openat, not AT_FDCWD with a multi-component path
if grep -q 'openat(AT_FDCWD, "test_chmod/' strace_chmod_recursive_chmod.log; then
Expand Down
Loading