mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
docs: fix stale claims, add missing -p flag to usermod man page
- README: fix passwd flag count (17→16), add Landlock mention, add Hardened goal, update usermod description with -p, fix past tense - CONTRIBUTING: fix unsafe description to match actual workspace policy - CHANGELOG: add [Unreleased] section for post-v0.1.0 changes - OPENBSD-REFERENCE: move all implemented items to correct section - SECURITY-HARDENING: mark all 21 items as implemented - man/usermod: add -p/--password flag documentation
This commit is contained in:
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+11
-65
@@ -12,78 +12,24 @@ 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 |
|
||||
| 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 — constant-time PAM in passwd |
|
||||
| SIGINT handler during password input | #48 — "Password unchanged" + terminal restore |
|
||||
| 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
@@ -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 — `sanitize_env()`)
|
||||
- [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] SIGINT handler during password input (#48 — "Password unchanged")
|
||||
- [x] User enumeration prevention (#49 — constant-time PAM)
|
||||
- [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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user