Merge pull request #118 from shadow-utils-rs/docs/audit-fixes

docs: fix stale claims, add missing usermod -p flag
This commit is contained in:
Pierre Warnier
2026-04-03 15:33:09 +02:00
committed by GitHub
6 changed files with 67 additions and 145 deletions
+25
View File
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `usermod -p/--password` flag for setting pre-hashed passwords (#114)
- End-to-end deployment tests in Docker: 117 assertions covering symlink
dispatch, setuid, PAM, Landlock, nscd, and Ansible interop (#102, #115)
- Docker multi-distro CI in GitHub Actions (debian, alpine, fedora)
- Shell completion generation for bash, zsh, fish (#106)
- Renovate for automated dependency updates
- `rust-toolchain.toml` for contributor convenience
- `feat_common_core` feature alias (all 14 tools)
### Changed
- Cargo.toml metadata aligned with uutils ecosystem conventions
- Tool crate descriptions normalized to `"tool ~ (shadow-rs) verb phrase"` format
- Edition 2024 consistently applied across root and workspace packages
### Fixed
- Password hash validation rejects `:`, `\n`, `\r` (field injection prevention)
- Error on missing shadow entry in usermod (was silent no-op)
- `days_since_epoch()` centralized in shadow-core (was duplicated)
## [0.1.0] - 2026-03-24
### Added
+3 -2
View File
@@ -91,8 +91,9 @@ Utilities must be embeddable. Return `UResult<()>` from `uumain`. The
### `unsafe`
Only for FFI (nix crate for syscalls, PAM crate for PAM). Every `unsafe` block
must have a `// SAFETY:` comment explaining why it's sound.
Denied at the workspace level (`unsafe_code = "deny"`). Only two FFI boundary
modules are exempted: `shadow_core::pam` (PAM C library) and `shadow_core::crypt`
(POSIX crypt(3)). Every `unsafe` block must have a `// SAFETY:` comment.
### `str`, `OsStr` & `Path`
+8 -4
View File
@@ -22,8 +22,9 @@ passwords, and groups on every Linux system.
shadow-utils runs as **root or setuid-root on every Linux system**. It parses
user-supplied input, writes to `/etc/passwd`, `/etc/shadow`, `/etc/group`, and
has had recent CVEs (CVE-2023-4641: password leak in memory, CVE-2024-56433:
subuid collision enabling account takeover). There is **no Rust
reimplementation** — not in uutils, not in Prossimo/Trifecta, not on crates.io.
subuid collision enabling account takeover). Until shadow-rs, there was **no
Rust reimplementation** — not in uutils, not in Prossimo/Trifecta, not on
crates.io.
[sudo-rs](https://github.com/trifectatechfoundation/sudo-rs) proved the model:
an independent Rust rewrite of a privilege-boundary tool can go from zero to
@@ -42,6 +43,9 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook.
- **Well-tested**: unit tests, property-based tests (`proptest`), integration
tests, fuzz targets for all parsers. Tested on Debian, Alpine (musl), and
Fedora (SELinux).
- **Hardened**: Landlock filesystem sandboxing, signal blocking during
critical sections, core dump suppression, environment sanitization,
privilege drop during PAM.
- **Auditable**: small dependency tree, `cargo-deny` license and advisory
checks, no GPL dependencies.
@@ -49,11 +53,11 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook.
| Tool | Status |
|------|--------|
| `passwd` | **All 17 flags implemented.** Drop-in for GNU passwd. PAM password change, `--root`, `--quiet`, `--stdin`. Output bit-for-bit identical with GNU. |
| `passwd` | **All 16 flags implemented.** Drop-in for GNU passwd. PAM password change, Landlock sandboxing, `--root`, `--quiet`, `--stdin`. Output bit-for-bit identical with GNU. |
| `pwck` | **All checks implemented.** Drop-in for GNU pwck. Bit-for-bit identical output. |
| `useradd` | **Implemented.** UID/GID allocation, home dir + skel, shadow entry, group creation. |
| `userdel` | **Implemented.** Remove from all system files, optional home/mail cleanup. |
| `usermod` | **Implemented.** Modify all properties, group membership, lock/unlock. |
| `usermod` | **Implemented.** Modify all properties, group membership, lock/unlock, set pre-hashed password. |
| `chpasswd` | **Implemented.** Batch password change from stdin. |
| `chage` | **Implemented.** Password aging management, `-l` list mode. |
| `groupadd` | **Implemented.** Auto GID allocation, system groups, force mode. |
+12 -66
View File
@@ -11,79 +11,25 @@ Source: `cvsweb.openbsd.org/src/usr.bin/passwd/` and `src/lib/libutil/passwd.c`.
|---|---------|--------|
| Signal blocking during file writes | #38`SignalBlocker` RAII |
| Privilege drop during PAM conversation | #39`PrivDrop` RAII |
| Environment sanitization | #40`sanitize_env()` |
| Landlock stub | #41 — documented, needs crate dep |
| Environment sanitization | #40`sanitized_env()` / `harden_process()` |
| Landlock filesystem restriction | #41`apply_landlock()` in passwd |
| Absolute paths for subprocesses | #20`/usr/sbin/nscd` |
| Password zeroing | #7`zeroize` crate |
| Secure temp file permissions | #19`0o600` from creation |
| TOCTOU-resistant locking | #18 — lock-via-hard-link |
| Core dump suppression | #43`suppress_core_dumps()` in hardening.rs |
| Resource limit hardening | #44`raise_file_size_limit()` in hardening.rs |
| Zero-length output guard | #45 — in `atomic_write` |
| setuid(0) consolidation | #47 — before file operations in passwd |
| User enumeration prevention | #49 — early permission check in passwd |
| Password input interrupt handling | #48 — custom SIGINT handler removed; signal blocking covers critical sections |
| Umask reset | #51`UmaskGuard` RAII in lock/tmp creation |
### Not Yet Implemented
#### CRITICAL: Core Dump Suppression
OpenBSD's `pw_init()` sets `RLIMIT_CORE` to 0. A core dump from a setuid
passwd process could expose password hashes and plaintext passwords.
```rust
nix::sys::resource::setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
```
Also: `prctl(PR_SET_DUMPABLE, 0)` prevents ptrace attachment.
#### HIGH: Resource Limit Hardening
OpenBSD raises `RLIMIT_FSIZE` to infinity before file writes. A malicious
caller could `ulimit -f 1` before invoking setuid passwd, truncating
`/etc/shadow` mid-write.
```rust
nix::sys::resource::setrlimit(Resource::RLIMIT_FSIZE, RLIM_INFINITY, RLIM_INFINITY)?;
```
#### HIGH: setuid(0) Before File Operations
OpenBSD calls `setuid(0)` before the critical section to consolidate both
real and effective UID to root. Some filesystem configurations check real UID.
#### MEDIUM: Zero-Length Output Guard
OpenBSD checks that the output file is non-zero-length before replacing the
original. A zero-length `/etc/shadow` locks out all users.
```rust
// In atomic_write, after the closure runs:
if tmp_file.metadata()?.len() == 0 {
return Err(ShadowError::Other("refusing to write zero-length file"));
}
```
#### MEDIUM: User Enumeration Prevention
OpenBSD rejects non-root callers targeting other usernames before PAM auth.
Our current flow lets PAM auth fail, potentially leaking timing information
about whether the account exists.
#### MEDIUM: Clean SIGINT Handler During Password Input
OpenBSD's `kbintr` handler uses `_exit(0)` and `dprintf` (async-signal-safe).
Prints "Password unchanged." and exits cleanly. Our PAM EchoGuard may not
run its Drop destructor on signal-induced termination.
#### LOW: Umask Reset
OpenBSD saves/restores umask around lock file creation. Defense-in-depth
against edge cases where umask interacts with file permissions.
## Implementation Priority
**Immediate** (before any release):
1. Core dump suppression — 5 lines
2. Resource limit hardening — 10 lines
3. Zero-length output guard — 5 lines in `atomic_write`
**Next sprint**:
4. setuid(0) consolidation
5. User enumeration prevention
6. SIGINT handler for password input
**Roadmap**:
7. Full Landlock implementation
8. seccomp-bpf filter
9. Umask handling
#### MEDIUM: Seccomp-BPF
Restrict syscalls to only what passwd needs after initialization.
Complex but effective — sudo-rs uses this approach.
## File References
+15 -73
View File
@@ -1,8 +1,8 @@
# Security Hardening Roadmap
Techniques to adopt from OpenBSD and best practices for setuid-root tools.
Techniques adopted from OpenBSD and best practices for setuid-root tools.
## Already Implemented
## Implemented
- [x] `caller_is_root()` uses `getuid()` not `geteuid()` for authorization
- [x] Atomic file writes with `fsync` + `rename`
@@ -13,78 +13,20 @@ Techniques to adopt from OpenBSD and best practices for setuid-root tools.
- [x] Absolute paths for subprocess execution (`/usr/sbin/nscd`)
- [x] PAM delegation (no custom password hashing)
- [x] `TmpGuard` drop pattern (no leaked temp files)
- [x] Signal blocking during file writes (#38`SignalBlocker` RAII)
- [x] Environment sanitization (#40`sanitized_env()` / `harden_process()`)
- [x] Privilege drop during PAM conversation (#39`PrivDrop` RAII)
- [x] Core dump suppression (#43`suppress_core_dumps()`)
- [x] Resource limit hardening (#44`raise_file_size_limit()`)
- [x] Zero-length output guard (#45 — in `atomic_write`)
- [x] setuid(0) consolidation (#47 — before file operations)
- [x] Password input interrupt handling (#48 — custom SIGINT handler removed; signal blocking during file writes covers critical sections)
- [x] User enumeration prevention (#49 — early permission check for non-root callers)
- [x] O_CLOEXEC on file descriptors (#50)
- [x] Umask reset (#51`UmaskGuard` RAII)
- [x] Landlock filesystem restriction (#41`apply_landlock()` in passwd)
## Phase 1: Quick Wins
### Signal Blocking During File Writes
Block `SIGINT`/`SIGTERM`/`SIGHUP` during the critical section between
lock acquisition and lock release. Prevents partial shadow file updates.
```rust
use nix::sys::signal::{SigSet, SigmaskHow, sigprocmask};
let mut oldset = SigSet::empty();
let mut blockset = SigSet::empty();
blockset.add(Signal::SIGINT);
blockset.add(Signal::SIGTERM);
blockset.add(Signal::SIGHUP);
sigprocmask(SigmaskHow::SIG_BLOCK, Some(&blockset), Some(&mut oldset))?;
// ... critical section: lock, write, rename, unlock ...
sigprocmask(SigmaskHow::SIG_SETMASK, Some(&oldset), None)?;
```
### Environment Sanitization
Clear the environment on startup for setuid binaries, keeping only:
- `PATH=/usr/bin:/bin`
- `TERM`
- `LANG`/`LC_*`
```rust
fn sanitize_env() {
let keep = ["TERM", "LANG", "LC_ALL", "LC_MESSAGES"];
let saved: Vec<_> = keep.iter()
.filter_map(|k| std::env::var(k).ok().map(|v| (*k, v)))
.collect();
// Clear everything
for (key, _) in std::env::vars() {
std::env::remove_var(&key);
}
// Restore kept vars + safe PATH
std::env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin");
for (key, val) in saved {
std::env::set_var(key, val);
}
}
```
### Privilege Drop During PAM Conversation
Drop effective UID to caller's real UID during the PAM conversation,
re-elevate only for file writes:
```rust
let caller_uid = nix::unistd::getuid();
nix::unistd::seteuid(caller_uid)?; // drop privs
pam.authenticate(0)?;
pam.chauthtok(0)?;
nix::unistd::seteuid(Uid::from_raw(0))?; // re-elevate for file write
```
## Phase 2: Linux-Specific Hardening
### Landlock (Linux 5.13+)
Restrict filesystem access to only the files we need:
```rust
// Only allow: /etc/passwd, /etc/shadow, /etc/shadow.lock, /dev/tty
let ruleset = Ruleset::new()
.handle_access(AccessFs::ReadFile | AccessFs::WriteFile)?
.create()?;
ruleset.add_rule(PathBeneath::new(PathFd::new("/etc/")?, AccessFs::all()))?;
ruleset.add_rule(PathBeneath::new(PathFd::new("/dev/tty")?, AccessFs::all()))?;
ruleset.restrict_self()?;
```
## Not Yet Implemented
### Seccomp-BPF
Restrict syscalls to only what passwd needs after initialization.
+4
View File
@@ -44,6 +44,10 @@ changes that are specified on the command line.
**-L**, **--lock**
: Lock the user's password by prepending a '!' to the shadow password.
**-p**, **--password** *PASSWORD*
: Set the user's password to the specified pre-hashed value. The hash
must not contain ':', '\\n', or '\\r' characters.
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.