diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..e432ed8 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,19 @@ +name: Security audit + +# spell-checker:ignore (misc) rustsec + +on: + schedule: + - cron: "0 0 * * *" + push: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index 7955976..19d0198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,8 @@ tempfile = "3" [dependencies] clap = { workspace = true } clap_complete = { workspace = true, optional = true } +shadow-core = { path = "src/shadow-core" } +rustix = { workspace = true } # Tool crates (optional, enabled by features) passwd = { optional = true, version = "0.2.0", package = "uu_passwd", path = "src/uu/passwd" } diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs index 9ddd65e..13ba322 100644 --- a/src/bin/shadow-rs.rs +++ b/src/bin/shadow-rs.rs @@ -30,6 +30,18 @@ fn main() -> ExitCode { }) .unwrap_or_default(); + // In setuid context, reject spoofed argv[0] that doesn't match AT_EXECFN. + // Only enforced when euid != uid (setuid active) — non-setuid invocations + // are harmless since the caller already has full privileges. + let is_setuid = rustix::process::getuid() != rustix::process::geteuid(); + if is_setuid && !shadow_core::process::verify_argv0_matches_execfn(&binary_name) { + let _ = writeln!( + std::io::stderr().lock(), + "shadow-rs: argv[0] does not match executed binary, aborting" + ); + return ExitCode::FAILURE; + } + // Direct invocation via symlink (e.g., argv[0] = "passwd") if let Some(code) = dispatch(&binary_name, &args) { return to_exit_code(code); diff --git a/src/shadow-core/src/process.rs b/src/shadow-core/src/process.rs index 95dc54b..9ef24a3 100644 --- a/src/shadow-core/src/process.rs +++ b/src/shadow-core/src/process.rs @@ -265,3 +265,42 @@ pub fn getpwuid(uid: u32) -> io::Result> { pub fn lookup_username(uid: u32) -> io::Result> { Ok(getpwuid(uid)?.map(|e| e.name)) } + +// --------------------------------------------------------------------------- +// AT_EXECFN validation (multicall setuid hardening) +// --------------------------------------------------------------------------- + +/// Verify that `argv[0]` matches `AT_EXECFN` (the kernel-recorded executable path). +/// +/// In setuid context, an attacker can spoof `argv[0]` to route a multicall +/// binary to a different tool than the one the symlink points to. `AT_EXECFN` +/// from the ELF auxiliary vector records the real path the kernel executed, +/// which cannot be spoofed from userspace. +/// +/// Returns `true` if the basenames match (or if `AT_EXECFN` is unavailable). +/// Returns `false` if they differ — the caller should abort. +pub fn verify_argv0_matches_execfn(argv0: &str) -> bool { + // SAFETY: getauxval is a standard glibc/musl function. AT_EXECFN returns + // a pointer to a null-terminated string in the auxiliary vector, valid for + // the lifetime of the process. + let execfn_ptr = unsafe { libc::getauxval(libc::AT_EXECFN) } as *const libc::c_char; + if execfn_ptr.is_null() { + // AT_EXECFN not available (old kernel or non-Linux) — allow. + return true; + } + let execfn = unsafe { CStr::from_ptr(execfn_ptr) }; + let execfn = execfn.to_string_lossy(); + + // Compare basenames: argv[0] might be "passwd" while AT_EXECFN is + // "/usr/sbin/passwd" or "/usr/sbin/shadow-rs". + let argv0_base = std::path::Path::new(argv0) + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let execfn_base = std::path::Path::new(execfn.as_ref()) + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + argv0_base == execfn_base +}