Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .github/workflows/CICD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 56 additions & 42 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 opath chmods
// spell-checker:ignore atimensec mtimensec ctimensec opath chmods fakeroot fakechroot

#[cfg(test)]
use std::os::unix::ffi::OsStringExt;
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions util/check-common.sh
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions util/check-libc-interposition.sh
Original file line number Diff line number Diff line change
@@ -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!"
Loading
Loading