Skip to content

Commit a07e076

Browse files
committed
chmod: fix TOCTOU race in recursive traversal
Use fchmodat2 (Linux 6.6+) with AT_SYMLINK_NOFOLLOW to prevent an attacker from replacing a directory entry with a symlink between the stat and chmod calls. Falls back to fchmodat on older kernels. - Restrict fchmodat2 (syscall 452) to asm-generic architectures only (x86_64, x86, arm, aarch64, riscv) - Add SAFETY comment on unsafe syscall block per project convention - Add O_PATH + /proc/self/fd fallback for musl on kernel < 6.6 - Cache ENOSYS result with AtomicBool to skip fchmodat2 on old kernels - Remove unnecessary nix::Mode round-trip on the fchmodat2 path - safe_chmod_file() takes explicit SymlinkBehavior parameter - Always pass NoFollow for regular entries during recursion - Document residual TOCTOU in symlink branch as intentional for -L - Add test verifying NoFollow chmod doesn't modify symlink target - Update check-safe-traversal.sh to recognize fchmodat2
1 parent 8cc048b commit a07e076

3 files changed

Lines changed: 202 additions & 20 deletions

File tree

src/uu/chmod/src/chmod.rs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -519,9 +519,18 @@ impl Chmoder {
519519
.handle_symlink_during_safe_recursion(&entry_path, dir_fd, &entry_name)
520520
.and(r);
521521
} else {
522-
// For regular files and directories, chmod them
522+
// For regular files and directories, chmod them.
523+
// Always use NoFollow: we already confirmed via stat that the entry
524+
// is not a symlink, so this prevents TOCTOU races where an attacker
525+
// replaces the entry with a symlink between stat and chmod.
523526
r = self
524-
.safe_chmod_file(&entry_path, dir_fd, &entry_name, meta.mode() & 0o7777)
527+
.safe_chmod_file(
528+
&entry_path,
529+
dir_fd,
530+
&entry_name,
531+
meta.mode() & 0o7777,
532+
SymlinkBehavior::NoFollow,
533+
)
525534
.and(r);
526535

527536
// Recurse into subdirectories using the existing directory fd
@@ -555,13 +564,23 @@ impl Chmoder {
555564
// During recursion, determine behavior based on traversal mode
556565
match self.traverse_symlinks {
557566
TraverseSymlinks::All => {
558-
// Follow all symlinks during recursion
567+
// Follow all symlinks during recursion.
568+
// NOTE: This path-based stat + Follow is only reachable when the user
569+
// explicitly specifies -L (--dereference). In that case the user has
570+
// consciously opted in to following symlinks, so a path-based stat
571+
// followed by Follow is the intended behavior and not a TOCTOU concern.
559572
// Check if the symlink target is a directory, but handle dangling symlinks gracefully
560573
match fs::metadata(path) {
561574
Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false),
562575
Ok(meta) => {
563576
// It's a file symlink, chmod it using safe traversal
564-
self.safe_chmod_file(path, dir_fd, entry_name, meta.mode() & 0o7777)
577+
self.safe_chmod_file(
578+
path,
579+
dir_fd,
580+
entry_name,
581+
meta.mode() & 0o7777,
582+
self.dereference.into(),
583+
)
565584
}
566585
Err(_) => {
567586
// Dangling symlink, chmod it without dereferencing
@@ -584,13 +603,13 @@ impl Chmoder {
584603
dir_fd: &DirFd,
585604
entry_name: &std::ffi::OsStr,
586605
current_mode: u32,
606+
symlink_behavior: SymlinkBehavior,
587607
) -> UResult<()> {
588608
// Calculate the new mode using the helper method
589609
let (new_mode, _) = self.calculate_new_mode(current_mode, file_path.is_dir())?;
590610

591611
// Use safe traversal to change the mode
592-
let follow_symlinks = self.dereference;
593-
if let Err(_e) = dir_fd.chmod_at(entry_name, new_mode, follow_symlinks.into()) {
612+
if let Err(_e) = dir_fd.chmod_at(entry_name, new_mode, symlink_behavior) {
594613
if self.verbose {
595614
println!(
596615
"failed to change mode of {} to {new_mode:o}",

src/uucore/src/lib/features/safe_traversal.rs

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -293,21 +293,128 @@ impl DirFd {
293293
mode: u32,
294294
symlink_behavior: SymlinkBehavior,
295295
) -> io::Result<()> {
296+
let name_cstr =
297+
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
298+
299+
// --- fchmodat2 path (Linux 6.6+, asm-generic arches only) ---
300+
// Uses the raw mode value directly; no nix::Mode conversion needed.
301+
// Only enabled on asm-generic architectures where syscall number 452 is
302+
// correct (x86_64, x86, arm, aarch64, riscv). MIPS/SPARC/PowerPC/Alpha
303+
// use different numbering and are not supported until libc exposes
304+
// SYS_fchmodat2 for them.
305+
#[cfg(all(
306+
target_os = "linux",
307+
any(
308+
target_arch = "x86_64",
309+
target_arch = "x86",
310+
target_arch = "arm",
311+
target_arch = "aarch64",
312+
target_arch = "riscv64",
313+
target_arch = "riscv32",
314+
),
315+
))]
316+
if matches!(symlink_behavior, SymlinkBehavior::NoFollow) {
317+
use std::sync::atomic::{AtomicBool, Ordering};
318+
319+
// Cache: if fchmodat2 returned ENOSYS once, the kernel is too old
320+
// and will never support it. Skip the syscall on subsequent calls.
321+
static FCHMODAT2_UNAVAILABLE: AtomicBool = AtomicBool::new(false);
322+
323+
if !FCHMODAT2_UNAVAILABLE.load(Ordering::Relaxed) {
324+
// Syscall number for fchmodat2 on asm-generic architectures.
325+
const SYS_FCHMODAT2: libc::c_long = 452;
326+
// SAFETY: syscall(2) is an FFI call. We pass valid arguments:
327+
// - fd: valid open file descriptor
328+
// - name: valid C string pointer (name_cstr lives for the duration)
329+
// - mode: valid mode_t value
330+
// - flags: AT_SYMLINK_NOFOLLOW (valid flag for fchmodat2)
331+
let res = unsafe {
332+
libc::syscall(
333+
SYS_FCHMODAT2,
334+
self.fd.as_raw_fd(),
335+
name_cstr.as_ptr(),
336+
mode as libc::mode_t,
337+
libc::AT_SYMLINK_NOFOLLOW,
338+
)
339+
};
340+
if res == 0 {
341+
return Ok(());
342+
}
343+
let err = io::Error::last_os_error();
344+
match err.raw_os_error() {
345+
Some(libc::ENOSYS) => {
346+
FCHMODAT2_UNAVAILABLE.store(true, Ordering::Relaxed);
347+
// Fall through to fchmodat
348+
}
349+
_ => return Err(err),
350+
}
351+
}
352+
}
353+
354+
// --- fchmodat fallback path ---
355+
// nix::Mode conversion is needed here because fchmodat() requires it.
356+
let nix_mode = Mode::from_bits_truncate(mode as libc::mode_t);
357+
296358
let flags = if symlink_behavior.should_follow() {
297359
FchmodatFlags::FollowSymlink
298360
} else {
299361
FchmodatFlags::NoFollowSymlink
300362
};
301363

302-
let mode = Mode::from_bits_truncate(mode as libc::mode_t);
364+
match fchmodat(&self.fd, name_cstr.as_c_str(), nix_mode, flags) {
365+
Ok(()) => Ok(()),
366+
Err(e)
367+
if !symlink_behavior.should_follow()
368+
&& (e == nix::errno::Errno::EOPNOTSUPP || e == nix::errno::Errno::ENOTSUP) =>
369+
{
370+
// musl does not emulate AT_SYMLINK_NOFOLLOW via /proc/self/fd
371+
// like glibc does, so fchmodat returns EOPNOTSUPP on old kernels.
372+
// Fall back to O_PATH + /proc/self/fd/{fd} + fchmod.
373+
#[cfg(target_os = "linux")]
374+
{
375+
self.chmod_at_via_opath(name_cstr.as_c_str(), mode)
376+
}
377+
#[cfg(not(target_os = "linux"))]
378+
{
379+
Err(io::Error::from_raw_os_error(e as i32))
380+
}
381+
}
382+
Err(e) => Err(io::Error::from_raw_os_error(e as i32)),
383+
}
384+
}
303385

304-
let name_cstr =
305-
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
386+
/// O_PATH-based fallback for chmod when fchmodat with AT_SYMLINK_NOFOLLOW
387+
/// is not available (musl on kernel < 6.6).
388+
///
389+
/// Opens the file with O_PATH|O_NOFOLLOW to get an fd without following
390+
/// symlinks, then chmods via /proc/self/fd/{fd}. This avoids the TOCTOU
391+
/// race because the fd pins the inode.
392+
#[cfg(target_os = "linux")]
393+
fn chmod_at_via_opath(&self, name: &CStr, mode: u32) -> io::Result<()> {
394+
use std::os::unix::io::FromRawFd;
395+
396+
let fd = unsafe {
397+
libc::openat(
398+
self.fd.as_raw_fd(),
399+
name.as_ptr(),
400+
libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
401+
)
402+
};
403+
if fd < 0 {
404+
return Err(io::Error::last_os_error());
405+
}
406+
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
306407

307-
fchmodat(&self.fd, name_cstr.as_c_str(), mode, flags)
308-
.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
408+
let proc_path = format!("/proc/self/fd/{}\0", fd.as_raw_fd());
409+
let proc_cstr = CStr::from_bytes_with_nul(proc_path.as_bytes())
410+
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid proc path"))?;
309411

310-
Ok(())
412+
let ret = unsafe { libc::chmod(proc_cstr.as_ptr(), mode as libc::mode_t) };
413+
if ret < 0 {
414+
Err(io::Error::last_os_error())
415+
} else {
416+
Ok(())
417+
}
311418
}
312419

313420
/// Change mode of this directory
@@ -801,6 +908,7 @@ mod tests {
801908
use super::*;
802909
use std::fs;
803910
use std::os::unix::fs::symlink;
911+
use std::os::unix::fs::MetadataExt;
804912
use std::os::unix::io::IntoRawFd;
805913
use tempfile::TempDir;
806914

@@ -1227,6 +1335,47 @@ mod tests {
12271335
assert!(result_nofollow.is_err());
12281336
}
12291337

1338+
/// Verify that chmod_at with NoFollow does not change the symlink target's mode.
1339+
/// This test demonstrates that the TOCTOU race in recursive chmod is closed:
1340+
/// chmod on a symlink entry should not affect the target file.
1341+
#[test]
1342+
fn test_chmod_at_nofollow_preserves_target_mode() {
1343+
let temp_dir = TempDir::new().unwrap();
1344+
1345+
// Create a sentinel file outside the traversal directory
1346+
let sentinel = temp_dir.path().join("sentinel");
1347+
fs::write(&sentinel, "victim").unwrap();
1348+
let sentinel_mode = fs::symlink_metadata(&sentinel)
1349+
.unwrap()
1350+
.mode();
1351+
1352+
// Create a subdirectory with a symlink pointing to the sentinel
1353+
let subdir = temp_dir.path().join("subdir");
1354+
fs::create_dir(&subdir).unwrap();
1355+
let link = subdir.join("link");
1356+
symlink(&sentinel, &link).unwrap();
1357+
1358+
// Open the subdirectory and chmod the symlink entry with NoFollow
1359+
let dir_fd = DirFd::open(&subdir, SymlinkBehavior::Follow).unwrap();
1360+
let result = dir_fd.chmod_at(OsStr::new("link"), 0o777, SymlinkBehavior::NoFollow);
1361+
1362+
// On Linux 6.6+ (fchmodat2), the chmod should succeed without affecting the target.
1363+
// On older kernels, fchmodat with AT_SYMLINK_NOFOLLOW returns EOPNOTSUPP/ENOTSUP,
1364+
// which is acceptable — the important thing is the target is NOT modified.
1365+
if let Ok(()) = result {
1366+
// fchmodat2 succeeded: verify sentinel mode is unchanged
1367+
let new_sentinel_mode = fs::symlink_metadata(&sentinel)
1368+
.unwrap()
1369+
.mode();
1370+
assert_eq!(
1371+
new_sentinel_mode, sentinel_mode,
1372+
"sentinel mode should not change when chmod'ing symlink with NoFollow"
1373+
);
1374+
}
1375+
// If result is Err (EOPNOTSUPP on old kernels), the target is also unchanged,
1376+
// which is the correct behavior — no silent modification.
1377+
}
1378+
12301379
#[test]
12311380
fn test_open_nofollow_fails_on_symlink() {
12321381
let temp_dir = TempDir::new().unwrap();

util/check-safe-traversal.sh

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,26 @@ check_utility() {
8181
# Check for expected safe syscalls
8282
local found_safe=0
8383
for syscall in $expected_syscalls; do
84-
if grep -q "$syscall" "$strace_log"; then
85-
echo "✓ Found $syscall() (safe traversal)"
86-
found_safe=$((found_safe + 1))
87-
else
88-
fail_immediately "Missing $syscall() (safe traversal not active for $util)"
89-
fi
84+
# fchmodat2 is the modern replacement for fchmodat on Linux 6.6+
85+
# Accept either as a valid safe traversal syscall
86+
case "$syscall" in
87+
fchmodat)
88+
if grep -qE "fchmodat2?\(" "$strace_log"; then
89+
echo "✓ Found fchmodat/fchmodat2() (safe traversal)"
90+
found_safe=$((found_safe + 1))
91+
else
92+
fail_immediately "Missing fchmodat() or fchmodat2() (safe traversal not active for $util)"
93+
fi
94+
;;
95+
*)
96+
if grep -q "$syscall" "$strace_log"; then
97+
echo "✓ Found $syscall() (safe traversal)"
98+
found_safe=$((found_safe + 1))
99+
else
100+
fail_immediately "Missing $syscall() (safe traversal not active for $util)"
101+
fi
102+
;;
103+
esac
90104
done
91105

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

96110
openat_count=$(grep -c "openat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
97111
unlinkat_count=$(grep -c "unlinkat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
98-
fchmodat_count=$(grep -c "fchmodat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
112+
fchmodat_count=$(grep -cE "fchmodat2?\(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
99113
fchownat_count=$(grep -c "fchownat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
100114
newfstatat_count=$(grep -c "newfstatat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
101115
renameat_count=$(grep -c "renameat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
@@ -183,7 +197,7 @@ fi
183197
# Test chmod - should use openat, fchmodat, newfstatat
184198
if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then
185199
cp -r test_dir test_chmod
186-
check_utility "chmod" "openat,fchmodat,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod"
200+
check_utility "chmod" "openat,fchmodat,fchmodat2,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod"
187201

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

0 commit comments

Comments
 (0)