From 029284d1758f5c06b38939d93efc59c229486919 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 7 Feb 2026 02:41:59 +0900 Subject: [PATCH] coreutils: Protect against env -a for security Co-authored-by: Etienne Cordonnier --- .../workspace.wordlist.txt | 2 ++ Cargo.lock | 1 + Cargo.toml | 8 ++++- src/common/validation.rs | 32 +++++++++++++++++-- tests/test_util_name.rs | 14 ++++++++ 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 1d3929832a2..39dc766a3e0 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -8,6 +8,7 @@ advapi32-sys aho-corasick backtrace blake2b_simd +rustix # * uutils project uutils @@ -360,6 +361,7 @@ uutests uutils # * function names +execfn getcwd # * other diff --git a/Cargo.lock b/Cargo.lock index 9ce15217915..4d25723793c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,6 +561,7 @@ dependencies = [ "rlimit", "rstest", "rustc-hash", + "rustix", "selinux", "sha1", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index a65caa1fbca..67ebf7738a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # coreutils (uutils) # * see the repository LICENSE, README, and CONTRIBUTING files for more information -# spell-checker:ignore (libs) bigdecimal datetime foldhash serde gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs interner +# spell-checker:ignore (libs) bigdecimal datetime foldhash serde gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs interner getauxval [package] name = "coreutils" @@ -428,6 +428,9 @@ rlimit = "0.11.0" rstest = "0.26.0" rustc-hash = "2.1.1" rust-ini = "0.21.0" +# binary name of coreutils can be hijacked by overriding getauxval via LD_PRELOAD +# So we use param and avoid libc backend +rustix = { version = "1.1.4", features = ["param"] } same-file = "1.0.6" self_cell = "1.0.4" selinux = "=0.6.0" @@ -595,6 +598,9 @@ who = { optional = true, version = "0.7.0", package = "uu_who", path = "src/uu/w whoami = { optional = true, version = "0.7.0", package = "uu_whoami", path = "src/uu/whoami" } yes = { optional = true, version = "0.7.0", package = "uu_yes", path = "src/uu/yes" } +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +rustix.workspace = true + # this breaks clippy linting with: "tests/by-util/test_factor_benches.rs: No such file or directory (os error 2)" # factor_benches = { optional = true, version = "0.0.0", package = "uu_factor_benches", path = "tests/benches/factor" } diff --git a/src/common/validation.rs b/src/common/validation.rs index a0a13b5df9a..b975c1b6c58 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore prefixcat testcat +// spell-checker:ignore memfd_create prefixcat testcat use std::ffi::{OsStr, OsString}; use std::io::{Write, stderr}; @@ -73,15 +73,41 @@ fn get_canonical_util_name(util_name: &str) -> &str { } /// Gets the binary path from command line arguments -/// # Panics /// Panics if the binary path cannot be determined +#[cfg(not(any(target_os = "linux", target_os = "android")))] pub fn binary_path(args: &mut impl Iterator) -> PathBuf { match args.next() { Some(ref s) if !s.is_empty() => PathBuf::from(s), + // the fallback is valid only for hardlinks _ => std::env::current_exe().unwrap(), } } - +/// Get actual binary path from kernel, not argv0, to prevent `env -a` from bypassing +/// AppArmor, SELinux policies on hard-linked binaries +#[cfg(any(target_os = "linux", target_os = "android"))] +pub fn binary_path(args: &mut impl Iterator) -> PathBuf { + use std::fs::File; + use std::io::Read; + use std::os::unix::ffi::OsStrExt; + let execfn = rustix::param::linux_execfn(); + let execfn_bytes = execfn.to_bytes(); + let exec_path = Path::new(OsStr::from_bytes(execfn_bytes)); + let argv0 = args.next().unwrap(); + let mut shebang_buf = [0u8; 2]; + // exec_path is wrong when called from shebang or memfd_create (/proc/self/fd/*) + // argv0 is not full-path when called from PATH. Avoid read for the case + if execfn_bytes.ends_with(argv0.as_bytes()) + || execfn_bytes.starts_with(b"/proc/") + || (File::open(Path::new(exec_path)) + .and_then(|mut f| f.read_exact(&mut shebang_buf)) + .is_ok() + && &shebang_buf == b"#!") + { + argv0.into() + } else { + exec_path.into() + } +} /// Extracts the binary name from a path pub fn name(binary_path: &Path) -> Option<&str> { binary_path.file_stem()?.to_str() diff --git a/tests/test_util_name.rs b/tests/test_util_name.rs index 17b9dc36e5e..53c3ef55334 100644 --- a/tests/test_util_name.rs +++ b/tests/test_util_name.rs @@ -26,6 +26,20 @@ fn init() { eprintln!("Setting UUTESTS_BINARY_PATH={TESTS_BINARY}"); } +#[test] +#[cfg(all(feature = "env", any(target_os = "linux", target_os = "android")))] +fn binary_name_protection() { + let ts = TestScenario::new("env"); + let bin = ts.bin_path.clone(); + ts.ucmd() + .arg("-a") + .arg("hijacked") + .arg(&bin) + .arg("--version") + .succeeds() + .stdout_contains("coreutils"); +} + #[test] #[cfg(feature = "ls")] fn execution_phrase_double() {