diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 3c46cd5d3c7..7ed2538abc3 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1034,6 +1034,24 @@ jobs: - name: Run safe traversal verification run: ./util/check-safe-traversal.sh + test_libc_interposition: + name: libc Interposition Check + runs-on: ubuntu-latest + needs: [ min_version, deps ] + + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install fakeroot + run: sudo apt-get update && sudo apt-get install -y fakeroot + - name: Build utilities that must stay interposable + run: cargo build --profile=release-small -p uu_chmod -p uu_chown + - name: Run libc interposition verification + run: ./util/check-libc-interposition.sh + test_toctou: name: TOCTOU Security Check runs-on: ubuntu-latest diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index cc2dba8bbbb..7374de994b5 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -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 opath chmods +// spell-checker:ignore atimensec mtimensec ctimensec opath chmods fakeroot fakechroot #[cfg(test)] use std::os::unix::ffi::OsStringExt; @@ -289,6 +289,10 @@ impl DirFd { } /// Change mode of a file relative to this directory + /// + /// Goes through the libc `fchmodat()` symbol, which `LD_PRELOAD` tools + /// (fakeroot, fakechroot, pseudo) interpose and a raw syscall would bypass. + /// glibc issues `fchmodat2` from there anyway, so nothing is lost. pub fn chmod_at( &self, name: &OsStr, @@ -298,7 +302,38 @@ impl DirFd { let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; - // --- fchmodat2 path (Linux 6.6+, asm-generic arches only) --- + let flags = if symlink_behavior.should_follow() { + FchmodatFlags::FollowSymlink + } else { + FchmodatFlags::NoFollowSymlink + }; + + // nix rather than rustix: rustix defaults to its linux_raw backend, so + // its `chmod` is a raw syscall that no LD_PRELOAD wrapper can see. + // A libc that cannot honor AT_SYMLINK_NOFOLLOW reports it rather than + // following the symlink, so falling back on that error is safe. + // Only the non-Linux tail below consumes this; on Linux the O_PATH + // fallback takes over instead. + #[cfg_attr(target_os = "linux", allow(unused_variables))] + let libc_err = match fchmodat( + &self.fd, + name_cstr.as_c_str(), + Mode::from_bits_truncate(mode as libc::mode_t), + flags, + ) { + Ok(()) => return Ok(()), + Err(e) + if !symlink_behavior.should_follow() + && (e == nix::errno::Errno::ENOSYS + || e == nix::errno::Errno::EOPNOTSUPP + || e == nix::errno::Errno::ENOTSUP) => + { + io::Error::from_raw_os_error(e as i32) + } + Err(e) => return Err(io::Error::from_raw_os_error(e as i32)), + }; + + // --- fchmodat2 fallback (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 @@ -315,7 +350,7 @@ impl DirFd { 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 @@ -346,42 +381,20 @@ impl DirFd { match err.raw_os_error() { Some(libc::ENOSYS) => { FCHMODAT2_UNAVAILABLE.store(true, Ordering::Relaxed); - // Fall through to fchmodat + // Fall through to the O_PATH fallback } _ => 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 - }; - - 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)), + #[cfg(target_os = "linux")] + { + self.chmod_at_via_opath(name_cstr.as_c_str(), mode) + } + #[cfg(not(target_os = "linux"))] + { + Err(libc_err) } } @@ -394,22 +407,23 @@ impl DirFd { /// #[cfg(target_os = "linux")] fn chmod_at_via_opath(&self, name: &core::ffi::CStr, mode: u32) -> io::Result<()> { - use rustix::fs::{Mode, OFlags, chmod, openat}; + // Same reason as in chmod_at: rustix's linux_raw backend would make + // these raw syscalls, invisible to LD_PRELOAD wrappers. + use std::os::unix::fs::PermissionsExt; let fd = openat( &self.fd, name, - OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC, Mode::empty(), ) - .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?; + .map_err(|e| io::Error::from_raw_os_error(e as i32))?; - let proc_path = format!("/proc/self/fd/{}\0", fd.as_raw_fd()); - let proc_cstr = core::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())) + // set_permissions goes through the libc chmod() symbol. + fs::set_permissions( + format!("/proc/self/fd/{}", fd.as_raw_fd()), + fs::Permissions::from_mode(mode), + ) } /// Change mode of this directory diff --git a/util/check-common.sh b/util/check-common.sh new file mode 100755 index 00000000000..78b9ea4661a --- /dev/null +++ b/util/check-common.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# Shared setup for the util/check-*.sh syscall verification scripts +# (check-safe-traversal.sh, check-toctou.sh, check-libc-interposition.sh). +# +# Source it after setting CHECK_UTILS to the utilities the caller exercises: +# +# CHECK_UTILS="mkfifo touch head" +# . "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" +# +# It provides $PROJECT_ROOT, a $TEMP_DIR cleaned up on exit, fail_immediately(), +# require_command(), and util_cmd()/have_util() to resolve a utility to a +# runnable command whether the tree was built as individual binaries or as the +# multicall binary. + +: "${PROFILE:=release-small}" +export PROFILE + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_DIR="$PROJECT_ROOT/target/$PROFILE" +TEMP_DIR=$(mktemp -d) + +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +fail_immediately() { + echo "❌ FAILED: $1" + # Only the strace-based checks leave logs behind; mention them if they exist. + if compgen -G "$TEMP_DIR/strace_*.log" >/dev/null; then + echo "" + echo "Debug information available in: $TEMP_DIR/strace_*.log" + fi + exit 1 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Error: $1 is required to run these checks" + exit 1 + fi +} + +# Prefer individual binaries: they are what CI builds, and tracing them avoids +# the multicall dispatch noise. Fall back to the multicall binary otherwise. +detect_binaries() { + local util + for util in $CHECK_UTILS; do + if [ -f "$BIN_DIR/$util" ]; then + echo "Using individual binaries" + USE_MULTICALL=0 + return + fi + done + + if [ -f "$BIN_DIR/coreutils" ]; then + echo "Using multicall binary" + USE_MULTICALL=1 + COREUTILS_BIN="$BIN_DIR/coreutils" + MULTICALL_UTILS=$("$COREUTILS_BIN" --list) + return + fi + + echo "Error: No binaries found. Please build first with 'cargo build --profile=$PROFILE'" + exit 1 +} + +# Resolve a utility to a runnable command, or return 1 when it was not built. +# A multicall binary built without the unix feature set has no chmod or chown +# in it, so ask it what it actually dispatches. +util_cmd() { + local util="$1" + if [ "$USE_MULTICALL" -eq 1 ]; then + grep -qx "$util" <<<"$MULTICALL_UTILS" || return 1 + echo "$COREUTILS_BIN $util" + else + [ -f "$BIN_DIR/$util" ] || return 1 + echo "$BIN_DIR/$util" + fi +} + +have_util() { + util_cmd "$1" >/dev/null +} + +detect_binaries diff --git a/util/check-libc-interposition.sh b/util/check-libc-interposition.sh new file mode 100755 index 00000000000..b09191ee600 --- /dev/null +++ b/util/check-libc-interposition.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# +# Check that utilities reach the kernel through libc symbols rather than raw +# syscalls. +# +# LD_PRELOAD wrappers (fakeroot, fakechroot, pseudo) are an essential part of +# distribution build tooling, and they interpose libc symbols. A hand-written +# syscall(2) goes around them: the change lands on disk, but their bookkeeping +# never sees it, so every later stat() reports the old state -- silently, with +# an empty stderr and exit status 0 (issue #14028). +# +# spell-checker:ignore fakeroot fakechroot + +set -e + +echo "=== libc Interposition Verification ===" + +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="chmod chown" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" + +require_command fakeroot + +# Run a utility under fakeroot and compare what fakeroot's database reports +# afterwards against what the utility asked for. A raw syscall leaves the +# database stale, so the observed value is the one from before the run. +# +# $1 test name, $2 command to run (relative to the tree), $3 stat format, +# $4 expected value +assert_visible_under_fakeroot() { + local name="$1" cmd="$2" format="$3" expected="$4" observed tree + + tree="$TEMP_DIR/$name" + mkdir -p "$tree/vendor" + + # The chown makes fakeroot track both inodes, so what it reports back comes + # from its database rather than straight from the filesystem. + observed=$(cd "$tree" && fakeroot sh -c \ + "$CHOWN_CMD -R 7:11 . && $cmd && /usr/bin/stat -c $format vendor") + + if [ "$observed" != "$expected" ]; then + fail_immediately "$name: fakeroot reports '$observed', expected '$expected' -- the change must go through a libc symbol, not a raw syscall (issue #14028)" + fi + echo "✓ $name is visible to LD_PRELOAD wrappers" +} + +# chown itself is the vehicle for every other check, so it has to be present. +CHOWN_CMD=$(util_cmd chown) || { + echo "Error: chown was not built, cannot set up the checks." + echo "Build it with 'cargo build --profile=${PROFILE} -p uu_chmod -p uu_chown'" + exit 1 +} +assert_visible_under_fakeroot "chown -R" "true" "%u:%g" "7:11" + +if CHMOD_CMD=$(util_cmd chmod); then + assert_visible_under_fakeroot "chmod -R" "$CHMOD_CMD -R 2751 ." "%a" "2751" +else + echo "⚠ chmod not built, skipping" +fi + +echo "" +echo "=== Summary ===" +echo "All checked utilities go through libc!" diff --git a/util/check-safe-traversal.sh b/util/check-safe-traversal.sh index b49a4ed4a0c..c52deafb148 100755 --- a/util/check-safe-traversal.sh +++ b/util/check-safe-traversal.sh @@ -6,41 +6,13 @@ set -e -: ${PROFILE:=release-small} -export PROFILE - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -TEMP_DIR=$(mktemp -d) - -# Function to exit immediately on error -fail_immediately() { - echo "❌ FAILED: $1" - echo "" - echo "Debug information available in: $TEMP_DIR/strace_*.log" - exit 1 -} - -cleanup() { - rm -rf "$TEMP_DIR" -} -trap cleanup EXIT - echo "=== Safe Traversal Verification ===" -# Assume binaries are already built (for CI usage) -# Prefer individual binaries for more accurate testing -if [ -f "$PROJECT_ROOT/target/${PROFILE}/rm" ]; then - echo "Using individual binaries" - USE_MULTICALL=0 -elif [ -f "$PROJECT_ROOT/target/${PROFILE}/coreutils" ]; then - echo "Using multicall binary" - USE_MULTICALL=1 - COREUTILS_BIN="$PROJECT_ROOT/target/${PROFILE}/coreutils" -else - echo "Error: No binaries found. Please build first with 'cargo build --profile=${PROFILE}'" - exit 1 -fi +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="rm chmod chown chgrp du mv cp chcon split" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" + +require_command strace cd "$TEMP_DIR" @@ -63,20 +35,12 @@ check_utility() { local strace_log="strace_${util}_${test_name}.log" - # Choose binary to use - if [ "$USE_MULTICALL" -eq 1 ]; then - local util_cmd="$COREUTILS_BIN $util" - else - local util_path="$PROJECT_ROOT/target/${PROFILE}/$util" - if [ ! -f "$util_path" ]; then - fail_immediately "$util binary not found at $util_path" - fi - local util_cmd="$util_path" - fi + local cmd + cmd=$(util_cmd "$util") || fail_immediately "$util binary not found in $BIN_DIR" # Run utility under strace strace -f -e trace="$trace_syscalls" -o "$strace_log" \ - $util_cmd $test_args 2>/dev/null || true + $cmd $test_args 2>/dev/null || true cat $strace_log # Check for expected safe syscalls local found_safe=0 @@ -186,20 +150,8 @@ assert_descent_nofollow() { echo "✓ $util descent opens use O_NOFOLLOW" } -# Get list of available utilities -if [ "$USE_MULTICALL" -eq 1 ]; then - AVAILABLE_UTILS=$($COREUTILS_BIN --list) -else - AVAILABLE_UTILS="" - for util in rm chmod chown chgrp du mv cp chcon split; do - if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then - AVAILABLE_UTILS="$AVAILABLE_UTILS $util" - fi - done -fi - # Test rm - should use openat, unlinkat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "rm"; then +if have_util rm; then cp -r test_dir test_rm check_utility "rm" "openat,unlinkat,newfstatat,unlink,rmdir" "openat" "-rf test_rm" "recursive_remove" @@ -214,7 +166,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "rm"; then fi # Test chmod - should use openat, fchmodat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then +if have_util chmod; then cp -r test_dir test_chmod check_utility "chmod" "openat,fchmodat,fchmodat2,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod" @@ -226,7 +178,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then fi # Test chown - should use openat, fchownat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "chown"; then +if have_util chown; then cp -r test_dir test_chown USER_ID=$(id -u) GROUP_ID=$(id -g) @@ -235,7 +187,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "chown"; then fi # Test chgrp - should use openat, fchownat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "chgrp"; then +if have_util 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 @@ -247,12 +199,8 @@ fi # rename/symlink race could redirect a privileged recursive relabel off-tree # (issue #11402). This holds even without SELinux: the fd-anchored open happens # before the SELinux get/set, so the syscalls are observable regardless. -if echo "$AVAILABLE_UTILS" | grep -q "chcon"; then - if [ "$USE_MULTICALL" -eq 1 ]; then - chcon_cmd="$COREUTILS_BIN chcon" - else - chcon_cmd="$PROJECT_ROOT/target/${PROFILE}/chcon" - fi +if have_util chcon; then + chcon_cmd=$(util_cmd chcon) mkdir -p chcon_tree/sub echo a > chcon_tree/file @@ -286,13 +234,13 @@ if echo "$AVAILABLE_UTILS" | grep -q "chcon"; then fi # Test du - should use openat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "du"; then +if have_util du; then cp -r test_dir test_du check_utility "du" "openat,newfstatat,stat,lstat" "openat" "-a test_du" "directory_usage" fi # Test mv - should use openat, renameat for directory moves -if echo "$AVAILABLE_UTILS" | grep -q "mv"; then +if have_util mv; then mkdir -p test_mv_src/sub echo "test" > test_mv_src/file.txt echo "test" > test_mv_src/sub/file2.txt @@ -302,12 +250,8 @@ fi # cp invariant checks. Both #10011 (restrictive 0600 destination mode) and # #10017 (O_NOFOLLOW on the -P source) need to hold; verify each on its own # strace. -if echo "$AVAILABLE_UTILS" | grep -q "cp"; then - if [ "$USE_MULTICALL" -eq 1 ]; then - cp_cmd="$COREUTILS_BIN cp" - else - cp_cmd="$PROJECT_ROOT/target/${PROFILE}/cp" - fi +if have_util cp; then + cp_cmd=$(util_cmd cp) # #10011: destination created with mode 0600 so other users cannot open # the file through its umask-derived initial mode before cp narrows it. @@ -343,12 +287,8 @@ fi # O_CREAT|O_EXCL and only ever truncates via ftruncate after an fd-based check # that the opened output is not the input -- so a split that would overwrite its # own input is refused. -if echo "$AVAILABLE_UTILS" | grep -q "split"; then - if [ "$USE_MULTICALL" -eq 1 ]; then - split_cmd="$COREUTILS_BIN split" - else - split_cmd="$PROJECT_ROOT/target/${PROFILE}/split" - fi +if have_util split; then + split_cmd=$(util_cmd split) printf '0123456789abcdef' > split_input strace -f -e trace=openat -o strace_split_output_open.log \ @@ -380,7 +320,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "split"; then fi # mv cross-device (EXDEV) must use fd-based *xattr ops (issue #10014). -if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then +if have_util mv && [ -d /dev/shm ]; then # Need different filesystems for the EXDEV fallback to fire. temp_fs_id=$(stat -f -c %i "$TEMP_DIR" 2>/dev/null || echo "") shm_fs_id=$(stat -f -c %i /dev/shm 2>/dev/null || echo "") @@ -394,11 +334,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then cross_dst=$(mktemp -u -p /dev/shm cross_dst.XXXXXX) echo "cross-device payload" > "$cross_src" if setfattr -n user.tag -v pinned "$cross_src" 2>/dev/null; then - if [ "$USE_MULTICALL" -eq 1 ]; then - mv_cmd="$COREUTILS_BIN mv" - else - mv_cmd="$PROJECT_ROOT/target/${PROFILE}/mv" - fi + mv_cmd=$(util_cmd mv) strace -f -e trace='%file,fgetxattr,fsetxattr,flistxattr,getxattr,setxattr,listxattr' \ -o strace_mv_xattr.log \ $mv_cmd "$cross_src" "$cross_dst" 2>/dev/null || true @@ -431,7 +367,7 @@ fi # attacker racing in a planted symlink would otherwise let the copy write # through to the symlink's target. rustix may emit either open(2) or # openat(2) depending on the path, so the check accepts both. -if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then +if have_util mv && [ -d /dev/shm ]; then temp_fs_id=$(stat -f -c %i "$TEMP_DIR" 2>/dev/null || echo "") shm_fs_id=$(stat -f -c %i /dev/shm 2>/dev/null || echo "") if [ -z "$temp_fs_id" ] || [ -z "$shm_fs_id" ] || [ "$temp_fs_id" = "$shm_fs_id" ]; then @@ -442,11 +378,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then echo "payload" > "$nofollow_src" echo "existing" > "$nofollow_dst" - if [ "$USE_MULTICALL" -eq 1 ]; then - mv_cmd="$COREUTILS_BIN mv" - else - mv_cmd="$PROJECT_ROOT/target/${PROFILE}/mv" - fi + mv_cmd=$(util_cmd mv) strace -f -e trace=open,openat,openat2 -o strace_mv_nofollow.log \ $mv_cmd -f "$nofollow_src" "$nofollow_dst" 2>/dev/null || true @@ -473,7 +405,7 @@ fi # of the parent directory cannot redirect the temp-and-rename dance, and # the temp name must come from /dev/urandom rather than a guessable # pid+nanos pattern. -if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then +if have_util mv && [ -d /dev/shm ]; then temp_fs_id=$(stat -f -c %i "$TEMP_DIR" 2>/dev/null || echo "") shm_fs_id=$(stat -f -c %i /dev/shm 2>/dev/null || echo "") if [ -z "$temp_fs_id" ] || [ -z "$shm_fs_id" ] || [ "$temp_fs_id" = "$shm_fs_id" ]; then @@ -485,11 +417,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then # Pre-existing dest forces the EEXIST branch into create_symlink_replace. ln -s /elsewhere "$sym_dst" - if [ "$USE_MULTICALL" -eq 1 ]; then - mv_cmd="$COREUTILS_BIN mv" - else - mv_cmd="$PROJECT_ROOT/target/${PROFILE}/mv" - fi + mv_cmd=$(util_cmd mv) strace -f -e trace=openat,symlink,symlinkat,rename,renameat,renameat2,unlink,unlinkat,read \ -o strace_mv_symlink_replace.log \ $mv_cmd "$sym_src" "$sym_dst" 2>/dev/null || true @@ -529,8 +457,10 @@ echo "Checking for dangerous path resolution patterns..." echo "Checking path resolution frequency..." for log in strace_*.log; do if [ -f "$log" ]; then - path_resolutions=$(grep -c "test_" "$log" 2>/dev/null || echo "0") - if [ "$path_resolutions" -gt 20 ]; then + # grep -c already prints 0 when nothing matches, so a `|| echo 0` + # here would make the variable "0\n0" and break the comparison. + path_resolutions=$(grep -c "test_" "$log" 2>/dev/null || true) + if [ "${path_resolutions:-0}" -gt 20 ]; then echo "⚠ $log: High path resolution count ($path_resolutions) - potential TOCTOU risk" fi fi diff --git a/util/check-toctou.sh b/util/check-toctou.sh index 975bdcf92a0..5ff3e980f54 100755 --- a/util/check-toctou.sh +++ b/util/check-toctou.sh @@ -14,67 +14,20 @@ set -e -: ${PROFILE:=release-small} -export PROFILE - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -TEMP_DIR=$(mktemp -d) - -fail_immediately() { - echo "❌ FAILED: $1" - echo "" - echo "Debug information available in: $TEMP_DIR/strace_*.log" - exit 1 -} - -cleanup() { - rm -rf "$TEMP_DIR" -} -trap cleanup EXIT - echo "=== TOCTOU Verification ===" -if [ -f "$PROJECT_ROOT/target/${PROFILE}/coreutils" ]; then - echo "Using multicall binary" - USE_MULTICALL=1 - COREUTILS_BIN="$PROJECT_ROOT/target/${PROFILE}/coreutils" -elif [ -f "$PROJECT_ROOT/target/${PROFILE}/mkfifo" ]; then - echo "Using individual binaries" - USE_MULTICALL=0 -else - echo "Error: No binaries found. Build first with 'cargo build --profile=${PROFILE}'" - exit 1 -fi +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="mkfifo touch head" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" cd "$TEMP_DIR" -util_cmd() { - if [ "$USE_MULTICALL" -eq 1 ]; then - echo "$COREUTILS_BIN $1" - else - echo "$PROJECT_ROOT/target/${PROFILE}/$1" - fi -} - -if [ "$USE_MULTICALL" -eq 1 ]; then - AVAILABLE_UTILS=$($COREUTILS_BIN --list) -else - AVAILABLE_UTILS="" - for util in mkfifo touch head; do - if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then - AVAILABLE_UTILS="$AVAILABLE_UTILS $util" - fi - done -fi - # mkfifo must not call a path-based chmod after creating the FIFO: the # second syscall would re-resolve the path and could be redirected by an # attacker who swaps the FIFO for a symlink in between (issue #10020). # After the fix, the kernel applies the requested mode atomically via # mkfifo with cleared umask. -if echo "$AVAILABLE_UTILS" | grep -q "mkfifo"; then - mkfifo_cmd=$(util_cmd mkfifo) +if mkfifo_cmd=$(util_cmd mkfifo); then rm -f test_fifo # mkfifo(3)/mkfifoat(3) are libc wrappers; the underlying syscall # is mknodat (or mknod on older kernels). Trace those plus any @@ -106,10 +59,9 @@ fi # Test touch - creating a file must use O_CREAT but never O_TRUNC, so that a # symlink planted in the metadata-check/open race window (#10019) is not # truncated. This observes the flags directly, which integration tests cannot. -if echo "$AVAILABLE_UTILS" | grep -q "touch"; then +if touch_cmd=$(util_cmd touch); then echo "" echo "Testing touch (create_no_truncate)..." - touch_cmd=$(util_cmd touch) strace -f -e trace=openat -o strace_touch_create.log $touch_cmd test_touch_new 2>/dev/null || true cat strace_touch_create.log if ! grep -q 'openat(.*test_touch_new.*O_CREAT' strace_touch_create.log; then @@ -126,10 +78,9 @@ fi # descriptor (fstat/statx on the fd), not from a separate path-based stat # performed before the open. A path stat followed by an open is a TOCTOU # window (#11972): the object named by the path can be swapped in between. -if echo "$AVAILABLE_UTILS" | grep -q "head"; then +if head_cmd=$(util_cmd head); then echo "" echo "Testing head (fstat_after_open)..." - head_cmd=$(util_cmd head) echo "headtest" > test_head_file.txt strace -f -e trace=openat,fstat,newfstatat,statx,stat,lstat \ -o strace_head_metadata.log $head_cmd -c 4 test_head_file.txt 2>/dev/null || true