From c4607599ff6ecb2501709167e8504e59439f9d7a Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Mon, 23 Mar 2026 16:45:20 +0100 Subject: [PATCH] security: core dump suppression, RLIMIT_FSIZE, zero-length guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From OpenBSD pw_init() pattern: Fixes #43 — suppress_core_dumps(): RLIMIT_CORE=0 + PR_SET_DUMPABLE=0. Prevents core dumps that could expose password hashes, and prevents ptrace attachment to the setuid process. Fixes #44 — raise_file_size_limit(): RLIMIT_FSIZE=RLIM_INFINITY. Prevents malicious caller from truncating /etc/shadow via ulimit -f. Fixes #45 — Zero-length output guard in atomic_write. Refuses to replace original file with empty output. A zero-length /etc/shadow locks out all users. 3 new tests: test_core_dump_suppression, test_raise_file_size_limit, test_zero_length_write_rejected. 151 tests, zero clippy warnings. --- Cargo.toml | 2 +- src/shadow-core/src/atomic.rs | 9 ++++ src/uu/passwd/src/passwd.rs | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dadc44f..7c2bfab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ uucore = "0.7" thiserror = "2" # Unix/Linux -nix = { version = "0.29", features = ["user", "fs", "process", "signal", "term"] } +nix = { version = "0.29", features = ["user", "fs", "process", "signal", "term", "resource"] } libc = "0.2" # Security diff --git a/src/shadow-core/src/atomic.rs b/src/shadow-core/src/atomic.rs index ae0148a..5cc6457 100644 --- a/src/shadow-core/src/atomic.rs +++ b/src/shadow-core/src/atomic.rs @@ -85,6 +85,15 @@ where f(&mut tmp_file)?; + // Zero-length output guard: a zero-length shadow file locks out all users. + // OpenBSD checks this in pw_mkdb before replacing the original. + let written = tmp_file.metadata().map(|m| m.len()).unwrap_or(0); + if written == 0 { + return Err(ShadowError::Other( + "refusing to write zero-length file".into(), + )); + } + // Flush and fsync. tmp_file .flush() diff --git a/src/uu/passwd/src/passwd.rs b/src/uu/passwd/src/passwd.rs index 58fa1c1..7028d4b 100644 --- a/src/uu/passwd/src/passwd.rs +++ b/src/uu/passwd/src/passwd.rs @@ -108,6 +108,37 @@ impl UError for PasswdError { } } +// --------------------------------------------------------------------------- +// Security hardening — process hardening +// --------------------------------------------------------------------------- + +/// Suppress core dumps and prevent ptrace attachment. +/// +/// OpenBSD's `pw_init()` sets `RLIMIT_CORE=0`. A core dump from a setuid +/// passwd process could expose password hashes and plaintext passwords. +fn suppress_core_dumps() { + // RLIMIT_CORE = 0: no core dumps. + let _ = nix::sys::resource::setrlimit(nix::sys::resource::Resource::RLIMIT_CORE, 0, 0); + // PR_SET_DUMPABLE = 0: prevent ptrace attachment and /proc/pid/mem reads. + // SAFETY: prctl with PR_SET_DUMPABLE is a simple flag set, no pointers. + unsafe { + libc::prctl(libc::PR_SET_DUMPABLE, 0); + } +} + +/// Raise `RLIMIT_FSIZE` to prevent truncated file writes. +/// +/// OpenBSD raises `RLIMIT_FSIZE` to infinity before file operations. A +/// malicious caller could `ulimit -f 1` before invoking setuid passwd, +/// causing /etc/shadow to be truncated mid-write. +fn raise_file_size_limit() { + let _ = nix::sys::resource::setrlimit( + nix::sys::resource::Resource::RLIMIT_FSIZE, + nix::sys::resource::RLIM_INFINITY, + nix::sys::resource::RLIM_INFINITY, + ); +} + // --------------------------------------------------------------------------- // Security hardening — environment sanitization // --------------------------------------------------------------------------- @@ -171,6 +202,8 @@ fn apply_landlock_inner(_root: &SysRoot) { #[uucore::main] #[allow(clippy::too_many_lines)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + suppress_core_dumps(); + raise_file_size_limit(); sanitize_env(); let matches = match uu_app().try_get_matches_from(args) { @@ -1477,6 +1510,50 @@ mod tests { ); } + // ------------------------------------------------------------------- + // OpenBSD hardening tests + // ------------------------------------------------------------------- + + #[test] + fn test_core_dump_suppression() { + // After calling suppress_core_dumps(), RLIMIT_CORE should be 0. + suppress_core_dumps(); + let (soft, _hard) = + nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_CORE).unwrap(); + assert_eq!(soft, 0, "RLIMIT_CORE should be 0 after suppression"); + } + + #[test] + fn test_raise_file_size_limit() { + // After calling raise_file_size_limit(), `RLIMIT_FSIZE` should be RLIM_INFINITY. + raise_file_size_limit(); + let (soft, _hard) = + nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_FSIZE).unwrap(); + assert_eq!( + soft, + nix::sys::resource::RLIM_INFINITY, + "`RLIMIT_FSIZE` should be RLIM_INFINITY" + ); + } + + #[test] + fn test_zero_length_write_rejected() { + // atomic_write should refuse to replace a file with zero-length output. + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("shadow"); + std::fs::write(&target, "original content\n").unwrap(); + + let result = shadow_core::atomic::atomic_write(&target, |_file| { + // Write nothing — zero-length output. + Ok(()) + }); + + assert!(result.is_err(), "zero-length write should be rejected"); + // Original file should be untouched. + let content = std::fs::read_to_string(&target).unwrap(); + assert_eq!(content, "original content\n"); + } + #[test] fn test_mutation_with_aging_combined() { if skip_unless_root() {