Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions src/uu/chmod/src/chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,12 @@ impl Chmoder {
)
.and(r);

// Recurse into subdirectories using the existing directory fd
// Recurse into subdirectories using the existing directory fd.
// Open with NoFollow unless `-L` was requested: this prevents a
// TOCTOU where an attacker swaps the just-stat'd directory for a
// symlink before the open and redirects the descent off-tree.
if meta.is_dir() {
match dir_fd.open_subdir(&entry_name, SymlinkBehavior::Follow) {
match dir_fd.open_subdir(&entry_name, should_follow_symlink.into()) {
Ok(child_dir_fd) => {
r = self.safe_traverse_dir(&child_dir_fd, &entry_path).and(r);
}
Expand Down
8 changes: 6 additions & 2 deletions src/uu/rm/src/platform/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,12 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt
continue;
}

// Recursively remove subdirectory using safe traversal
let child_dir_fd = match dir_fd.open_subdir(&entry_name, SymlinkBehavior::Follow) {
// Recursively remove subdirectory using safe traversal. rm never
// follows symlinks during recursion, so open with NoFollow: if an
// attacker swaps this just-stat'd directory for a symlink before the
// open, O_NOFOLLOW makes openat fail instead of descending off-tree
// and deleting unrelated files.
let child_dir_fd = match dir_fd.open_subdir(&entry_name, SymlinkBehavior::NoFollow) {
Ok(fd) => fd,
Err(e) => {
// If we can't open the subdirectory for safe traversal,
Expand Down
8 changes: 6 additions & 2 deletions src/uucore/src/lib/features/perms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,9 +535,13 @@ impl ChownExecutor {
);
}

// Recurse into subdirectories
// Recurse into subdirectories. Open with the same symlink behavior
// used for the stat above: with NoFollow (the default, `-P`/`-H`) an
// attacker that swaps the just-stat'd directory for a symlink between
// the stat and this open cannot redirect the descent off-tree
// (O_NOFOLLOW makes openat fail). Only follow when `-L` was requested.
if meta.is_dir() && (follow || !meta.file_type().is_symlink()) {
match dir_fd.open_subdir(&entry_name, SymlinkBehavior::Follow) {
match dir_fd.open_subdir(&entry_name, follow.into()) {
Ok(subdir_fd) => {
self.safe_traverse_dir(&subdir_fd, &entry_path, ret);
}
Expand Down
29 changes: 29 additions & 0 deletions src/uucore/src/lib/features/safe_traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,35 @@ mod tests {
assert!(subdir_fd.as_raw_fd() >= 0);
}

#[test]
fn test_dirfd_open_subdir_nofollow_refuses_symlink() {
// A symlink to a directory must NOT be opened as a subdir when NoFollow
// is requested. Recursive chown/chgrp/chmod/rm rely on this to refuse a
// directory that was swapped for a symlink mid-traversal (TOCTOU),
// instead of following it off-tree.
let temp_dir = TempDir::new().unwrap();
let real_dir = temp_dir.path().join("real");
fs::create_dir(&real_dir).unwrap();
symlink(&real_dir, temp_dir.path().join("link")).unwrap();

let parent_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap();

// NoFollow must reject the symlink (ELOOP).
let nofollow = parent_fd.open_subdir(OsStr::new("link"), SymlinkBehavior::NoFollow);
assert!(nofollow.is_err());

// Follow still resolves it (the explicit `-L` opt-in).
let follow = parent_fd.open_subdir(OsStr::new("link"), SymlinkBehavior::Follow);
assert!(follow.is_ok());

// A real directory is opened fine either way.
assert!(
parent_fd
.open_subdir(OsStr::new("real"), SymlinkBehavior::NoFollow)
.is_ok()
);
}

#[test]
fn test_dirfd_open_nonexistent_subdir() {
let temp_dir = TempDir::new().unwrap();
Expand Down
22 changes: 22 additions & 0 deletions util/check-safe-traversal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,24 @@ check_utility() {
fi
}

# Recursive descent must open every subdirectory relative to the parent dirfd
# WITH O_NOFOLLOW. Otherwise an attacker who swaps the just-stat'd directory for
# a symlink between the stat and the open redirects the descent off-tree (the
# chown/chgrp/chmod/rm recursive-descent TOCTOU). The top-level command-line
# argument is opened via AT_FDCWD and is allowed to follow, so it is excluded by
# matching only numeric-dirfd openat calls.
assert_descent_nofollow() {
local util="$1"
local log="$2"
local offenders
offenders=$(grep -E 'openat\([0-9]+, "[^"]*",[^)]*O_DIRECTORY' "$log" | grep -v 'O_NOFOLLOW' || true)
if [ -n "$offenders" ]; then
echo "$offenders"
fail_immediately "$util descends into subdirectories without O_NOFOLLOW (TOCTOU symlink-swap risk)"
fi
echo "✓ $util descent opens use O_NOFOLLOW"
}

# Get list of available utilities
if [ "$USE_MULTICALL" -eq 1 ]; then
AVAILABLE_UTILS=$($COREUTILS_BIN --list)
Expand All @@ -192,6 +210,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "rm"; then
if grep -qE 'statx\(AT_FDCWD, "[^"]*/' strace_rm_recursive_remove.log; then
fail_immediately "rm is using path-based statx (multi-component relative path); expected dirfd-relative newfstatat"
fi
assert_descent_nofollow "rm" strace_rm_recursive_remove.log
fi

# Test chmod - should use openat, fchmodat, newfstatat
Expand All @@ -203,6 +222,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then
if grep -q 'openat(AT_FDCWD, "test_chmod/' strace_chmod_recursive_chmod.log; then
fail_immediately "chmod recursed using AT_FDCWD with a multi-component path; expected dirfd-relative openat"
fi
assert_descent_nofollow "chmod" strace_chmod_recursive_chmod.log
fi

# Test chown - should use openat, fchownat, newfstatat
Expand All @@ -211,12 +231,14 @@ if echo "$AVAILABLE_UTILS" | grep -q "chown"; then
USER_ID=$(id -u)
GROUP_ID=$(id -g)
check_utility "chown" "openat,fchownat,newfstatat,chown,lchown" "openat fchownat" "-R $USER_ID:$GROUP_ID test_chown" "recursive_chown"
assert_descent_nofollow "chown" strace_chown_recursive_chown.log
fi

# Test chgrp - should use openat, fchownat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "chgrp"; then
cp -r test_dir test_chgrp
check_utility "chgrp" "openat,fchownat,newfstatat,chown,lchown" "openat fchownat" "-R $GROUP_ID test_chgrp" "recursive_chgrp"
assert_descent_nofollow "chgrp" strace_chgrp_recursive_chgrp.log
fi

# Test du - should use openat, newfstatat
Expand Down
Loading