security: core dump suppression, RLIMIT_FSIZE, zero-length guard

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.
This commit is contained in:
Pierre Warnier
2026-03-23 16:45:20 +01:00
parent 89bc4b4e6b
commit c4607599ff
3 changed files with 87 additions and 1 deletions
+1 -1
View File
@@ -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
+9
View File
@@ -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()
+77
View File
@@ -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() {