Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `chage -l` did not work for a user on their own account, the one mode the
tool allows them: `make install` put `chage` at `0755`, which cannot read
`/etc/shadow`, and the multicall binary dropped its privilege before running
it. Debian ships `chage` setgid `shadow`, Fedora and Arch setuid root; it
now follows `expiry` -- setgid `shadow`, or setuid root where the group is
absent, and an applet that keeps euid 0 in the multicall binary. What the
caller may do is still decided from the real uid, so a user gets their own
line and nothing else; the deployment suite now checks both halves of that

- `make install` failed on any system without a `shadow` group -- Fedora,
Arch, or a packager's chroot -- because `expiry` was installed setgid to a
group that was not there. On such systems `/etc/shadow` is readable by root
Expand Down
18 changes: 10 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd newusers \
# sbin is not on a normal user's PATH, so `passwd` there is `command not
# found`. This is where GNU shadow puts them -- verified against the Debian
# package, which ships passwd, chage, chfn, chsh and newgrp in /usr/bin and
# everything else in /usr/sbin. chage is here for `chage -l`, the one mode a
# user may run on their own account.
# login lives in bin too, and is root-only without being setuid: getty runs
# it as root, and it refuses to run as anyone else.
USER_BIN_TOOLS = chage login
# expiry reads the caller's own /etc/shadow line and nothing else, and the GNU
# suite ships it setgid shadow -- enough to read the file, no more.
SETGID_SHADOW_TOOLS = expiry
# everything else in /usr/sbin.
# login lives in bin, and is root-only without being setuid: getty runs it as
# root, and it refuses to run as anyone else.
USER_BIN_TOOLS = login
# Tools a user runs on their own account that have to read /etc/shadow for
# it: expiry reads the caller's line to judge it, chage -l reads it to print
# it. Neither writes anything for a caller who is not root -- both check the
# real uid -- so the GNU suite ships them setgid shadow, enough to read the
# file and no more.
SETGID_SHADOW_TOOLS = expiry chage

# The group allowed to read /etc/shadow. Debian and Ubuntu have one, `shadow`,
# and that is what the tools above are made setgid to. Fedora and Arch have no
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ default-in-Ubuntu in under 3 years. This project follows that playbook.
| `userdel` | **Implemented.** Remove from all system files, optional home/mail cleanup. |
| `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. |
| `chage` | **Implemented.** Password aging management; `-l` for a user's own account, setgid `shadow`. |
| `groupadd` | **Implemented.** Auto GID allocation, system groups, force mode. |
| `groupdel` | **Implemented.** Primary group usage check. |
| `groupmod` | **Implemented.** GID change, rename, password. |
Expand Down Expand Up @@ -152,8 +152,8 @@ docker compose run --rm debian cargo build --release

Default install: 28 standalone per-tool binaries with least-privilege setuid
layout matching GNU shadow-utils. Only `passwd`, `chfn`, `chsh`, `newgrp`,
`gpasswd`, `sg`, `newuidmap` and `newgidmap` are installed setuid-root, `expiry` is setgid `shadow`; the other 19 are plain `0755`.
On a system with no `shadow` group -- Fedora, Arch -- `expiry` is installed setuid-root instead, the way those systems ship the GNU tools that read `/etc/shadow` for a user; a group under another name is named with `SHADOW_GROUP=`.
`gpasswd`, `sg`, `newuidmap` and `newgidmap` are installed setuid-root, `expiry` and `chage` are setgid `shadow` (enough to read a user's own `/etc/shadow` line, which `expiry` and `chage -l` need); the other 18 are plain `0755`.
On a system with no `shadow` group -- Fedora, Arch -- those two are installed setuid-root instead, the way those systems ship the GNU `chage`; a group under another name is named with `SHADOW_GROUP=`.

```shell
sudo make install PREFIX=/usr/local
Expand Down Expand Up @@ -212,8 +212,8 @@ for tool in passwd chfn chsh newgrp gpasswd sg chage chpasswd chgpasswd newusers
done
```

Mode `4755` is what the eight setuid applets need; the others give the
privilege up before running. Run `shadow-rs --list` to see the applets a
Mode `4755` is what the ten applets that keep euid 0 need; the others give
the privilege up before running. Run `shadow-rs --list` to see the applets a
given build contains, and `sha256sum -c uu_shadow-*.tar.gz.sha256` to verify
a download.

Expand Down
5 changes: 4 additions & 1 deletion docs/man/chage.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ itself expires. The fields live in /etc/shadow (see **shadow**(5)) and are
counted in **days since 1970-01-01**, not in dates.

**chage -l** prints those fields in a readable form and is the only mode a
normal user may run, and only for their own account. Every other mode requires
normal user may run, and only for their own account. Reading /etc/shadow for
that is why the binary is installed setgid *shadow* (setuid root on systems
with no such group); what the caller may do is decided from their real uid, so
the privilege buys a user nothing beyond their own line. Every other mode requires
the superuser.

## OPTIONS
Expand Down
29 changes: 17 additions & 12 deletions src/bin/shadow-rs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,18 @@ use std::process::ExitCode;

type Applet = fn(&[OsString]) -> i32;

/// Applets that keep euid 0 for an unprivileged caller: the same eight that
/// `make install` marks setuid. `sg` is here for the reason `newgrp` is --
/// it has to read `/etc/gshadow` to check a group password, and the GNU suite
/// ships it as a symlink to the setuid `newgrp` for exactly that. The two id
/// mappers are here because writing a namespace's map beyond the caller's
/// own id needs `CAP_SETUID`/`CAP_SETGID` in the parent namespace. `expiry`
/// is setgid `shadow` in the per-tool install, which is only enough to read
/// `/etc/shadow`; a single setuid binary has nothing that narrow to offer, so
/// it keeps euid 0 here and reads the one line it needs.
const SETUID_APPLETS: [&str; 9] = [
/// Applets that keep euid 0 for an unprivileged caller: the eight that
/// `make install` marks setuid, and the two it marks setgid `shadow`. `sg` is
/// here for the reason `newgrp` is -- it has to read `/etc/gshadow` to check
/// a group password, and the GNU suite ships it as a symlink to the setuid
/// `newgrp` for exactly that. The two id mappers are here because writing a
/// namespace's map beyond the caller's own id needs `CAP_SETUID`/`CAP_SETGID`
/// in the parent namespace. `expiry` and `chage -l` read the caller's own
/// `/etc/shadow` line; setgid `shadow` is enough for that in the per-tool
/// install, but a single setuid binary has nothing that narrow to offer, so
/// they keep euid 0 here. Both decide what the caller may do from the real
/// uid, so a user gets that one line and nothing else.
const SETUID_APPLETS: [&str; 10] = [
"passwd",
"chfn",
"chsh",
Expand All @@ -45,6 +47,7 @@ const SETUID_APPLETS: [&str; 9] = [
"newuidmap",
"newgidmap",
"expiry",
"chage",
];

/// Every applet compiled into this binary, by name, in `--list` order.
Expand Down Expand Up @@ -313,8 +316,9 @@ mod tests {
assert!(names.iter().all(|n| ALL_TOOLS.contains(n)));
}

// Exactly the tools that the per-tool install marks setuid keep the
// privilege; every other applet gives it up before running.
// Exactly the tools that the per-tool install marks setuid or setgid
// `shadow` keep the privilege; every other applet gives it up before
// running.
#[test]
fn only_self_service_tools_keep_privilege() {
for tool in ALL_TOOLS {
Expand All @@ -331,6 +335,7 @@ mod tests {
| "newuidmap"
| "newgidmap"
| "expiry"
| "chage"
),
"{tool}"
);
Expand Down
11 changes: 10 additions & 1 deletion tests/e2e/deploy-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -872,12 +872,21 @@ test_gpasswd_group_admin() {
# ── usermod subordinate ids, and expiry ────────────────────────────

test_subid_and_expiry() {
section "usermod -v/-V/-w/-W, and expiry"
section "usermod -v/-V/-w/-W, expiry, and chage -l as a user"

userdel -r ex_user 2>/dev/null || true
assert_ok "useradd -m ex_user" useradd -m -s /bin/sh ex_user
assert_ok "set a password" bash -c "printf 'ex_user:oldpw\n' | chpasswd"

# chage -l is the one mode a user may run on their own account, and it
# has to read /etc/shadow to answer: the tool keeps euid 0 for that. The
# real uid still decides what the caller may do.
assert_contains "chage -l on one's own account answers a user" "Last password change" \
su -s /bin/sh ex_user -c 'chage -l ex_user'
assert_fail "chage -l on another account is refused" su -s /bin/sh ex_user -c 'chage -l root'
assert_fail "chage -M as a user is refused" su -s /bin/sh ex_user -c 'chage -M 5 ex_user'
assert_fail "chage --root as a user is refused" su -s /bin/sh ex_user -c 'chage --root / -l ex_user'

assert_ok "usermod -v grants a subuid range" usermod -v 500000-500999 ex_user
assert_file_contains "the range is in /etc/subuid" /etc/subuid '^ex_user:500000:1000$'
assert_ok "usermod -V of half of it splits the entry" usermod -V 500000-500499 ex_user
Expand Down