security: AT_EXECFN validation + audit.yml (#154, #155)

#154: Reject setuid multicall invocations where argv[0] doesn't match
AT_EXECFN from the ELF auxiliary vector. Prevents an attacker from
spoofing argv[0] to route the multicall binary to a different tool
in setuid context. Only enforced when euid != uid.

#155: Add daily cargo-audit workflow matching uutils/coreutils pattern.
Also triggers on Cargo.toml/Cargo.lock changes.

Fixes #154. Fixes #155.
This commit is contained in:
Pierre Warnier
2026-04-22 18:13:36 +02:00
parent 86af8fc45b
commit 4ecf63ec1f
4 changed files with 72 additions and 0 deletions
+19
View File
@@ -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 }}
+2
View File
@@ -74,6 +74,8 @@ tempfile = "3"
[dependencies] [dependencies]
clap = { workspace = true } clap = { workspace = true }
clap_complete = { workspace = true, optional = true } clap_complete = { workspace = true, optional = true }
shadow-core = { path = "src/shadow-core" }
rustix = { workspace = true }
# Tool crates (optional, enabled by features) # Tool crates (optional, enabled by features)
passwd = { optional = true, version = "0.2.0", package = "uu_passwd", path = "src/uu/passwd" } passwd = { optional = true, version = "0.2.0", package = "uu_passwd", path = "src/uu/passwd" }
+12
View File
@@ -30,6 +30,18 @@ fn main() -> ExitCode {
}) })
.unwrap_or_default(); .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") // Direct invocation via symlink (e.g., argv[0] = "passwd")
if let Some(code) = dispatch(&binary_name, &args) { if let Some(code) = dispatch(&binary_name, &args) {
return to_exit_code(code); return to_exit_code(code);
+39
View File
@@ -265,3 +265,42 @@ pub fn getpwuid(uid: u32) -> io::Result<Option<PwEntry>> {
pub fn lookup_username(uid: u32) -> io::Result<Option<String>> { pub fn lookup_username(uid: u32) -> io::Result<Option<String>> {
Ok(getpwuid(uid)?.map(|e| e.name)) 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
}