Skip to content

Write /etc/shadow as 0640 root:shadow when the group exists#46

Open
r-vdp wants to merge 1 commit intonikstur:mainfrom
r-vdp:shadow-group-mode
Open

Write /etc/shadow as 0640 root:shadow when the group exists#46
r-vdp wants to merge 1 commit intonikstur:mainfrom
r-vdp:shadow-group-mode

Conversation

@r-vdp
Copy link
Copy Markdown

@r-vdp r-vdp commented Apr 17, 2026

Alternative to #41

userborn currently writes /etc/shadow as mode 0000, readable only via CAP_DAC_OVERRIDE.
That forces unix_chkpwd, the helper pam_unix spawns when a non-root process (screen lockers, polkit auth dialogs) needs to verify a password, to be installed setuid root.

Distros are split on this:

Debian/Ubuntu      0640 root:shadow   unix_chkpwd setgid shadow
NixOS (perl path)  0640 root:shadow   unix_chkpwd setuid root
Arch               0600 root:root     unix_chkpwd setuid root
Fedora             0000 root:root     unix_chkpwd setuid root
systemd-sysusers   0000 on first create, otherwise preserves existing

userborn is meant to be a drop-in for NixOS's update-users-groups.pl, which has written root:shadow 0640 since 2016 (NixOS/nixpkgs@fedd7cd).
The current 0000 is therefore a behavioural change.

The trade-off between the two models is:

0000 root:root + setuid-root unix_chkpwd
A code-exec bug in unix_chkpwd gives full root.
Wrong configuration of the shadow group does not grant access to the shadow file.

0640 root:shadow + setgid-shadow unix_chkpwd
A code-exec bug in unix_chkpwd yields gid shadow only: the attacker can read /etc/shadow and brute-force hashes offline, but cannot setuid, cannot write anything.
The trade-off is that gid shadow becomes a meaningful boundary that must be kept empty.
We can do so on NixOS using an assertion.

The Debian model trades a misconfiguration risk (something gaining gid shadow) that can leak password hashes for removing a root-equivalent setuid binary from the default install that could lead to code-exec as root.

This change enables but does not force the setgid model: if the config defines a shadow group (NixOS always does), /etc/shadow is written as 0640 owned by the shadow group.
If not, the previous 0000 behaviour is kept.
The gid is resolved from the in-memory group database userborn just built, so it works on first boot before /etc/group exists and is independent of host NSS.
The chown happens on the temp file before the atomic rename, so the final path never appears with a stale group.

References:

userborn currently writes /etc/shadow as mode 0000, readable only via
CAP_DAC_OVERRIDE. That forces unix_chkpwd, the helper pam_unix
spawns when a non-root process (screen lockers, polkit auth dialogs)
needs to verify a password, to be installed setuid root.

Distros are split on this:

  Debian/Ubuntu      0640 root:shadow   unix_chkpwd setgid shadow
  NixOS (perl path)  0640 root:shadow   unix_chkpwd setuid root
  Arch               0600 root:root     unix_chkpwd setuid root
  Fedora             0000 root:root     unix_chkpwd setuid root
  systemd-sysusers   0000 on first create, otherwise preserves existing

userborn is meant to be a drop-in for NixOS's update-users-groups.pl,
which has written root:shadow 0640 since 2016 (NixOS/nixpkgs fedd7cd).
The current 0000 is therefore a behavioural change.

The trade-off between the two models is:

  0000 root:root + setuid-root unix_chkpwd
    A code-exec bug in unix_chkpwd gives full root.
    Wrong configuration of the shadow group does not grant access to the
    shadow file.

  0640 root:shadow + setgid-shadow unix_chkpwd
    A code-exec bug in unix_chkpwd yields gid shadow only: the
    attacker can read /etc/shadow and brute-force hashes offline, but
    cannot setuid, cannot write anything.
    The trade-off is that gid shadow becomes a meaningful boundary that
    must be kept empty.
    We can do so on NixOS using an assertion.

The Debian model trades a misconfiguration risk (something
gaining gid shadow) that can leak password hashes for removing a
root-equivalent setuid binary from the default install that could lead
to code-exec as root.

This change enables but does not force the setgid model: if the config
defines a `shadow` group (NixOS always does), /etc/shadow is written as
0640 owned by the shadow group. If not, the previous 0000 behaviour is kept.
The gid is resolved from the in-memory group database userborn just built,
so it works on first boot before /etc/group exists and is independent of
host NSS. The chown happens on the temp file before the atomic rename,
so the final path never appears with a stale group.

References:
  Debian shadowconfig:
    https://salsa.debian.org/debian/shadow/-/blob/master/debian/shadowconfig
  Debian pam (sgid shadow on unix_chkpwd):
    https://salsa.debian.org/vorlon/pam/-/blob/master/debian/rules
  NixOS update-users-groups.pl:
    nixos/modules/config/update-users-groups.pl:317-322
r-vdp added a commit to r-vdp/nixpkgs that referenced this pull request Apr 17, 2026
unix_chkpwd only needs to read /etc/shadow, which both
update-users-groups.pl and (patched) userborn write 0640 root:shadow.
Running it setgid shadow bounds a code-exec bug to gid shadow (offline
hash brute-force) instead of full root. Debian/Ubuntu have shipped this
for years.

Depends on /etc/shadow being group-readable by `shadow`; the perl
activation already does this, userborn needs nikstur/userborn#46.
r-vdp added a commit to r-vdp/nixpkgs that referenced this pull request Apr 17, 2026
unix_chkpwd only needs to read /etc/shadow, which both
update-users-groups.pl and (patched) userborn write 0640 root:shadow.
Running it setgid shadow bounds a code-exec bug to gid shadow (offline
hash brute-force) instead of full root. Debian/Ubuntu have shipped this
for years.

Gid `shadow` thereby becomes equivalent to read access on all password
hashes, so add an assertion that the group has no members. The group
exists purely as a setgid target; nothing in nixpkgs adds to it. The
assertion also covers users.users.*.extraGroups (folded into members by
users-groups.nix) but cannot cover SupplementaryGroups= in unit files.

Depends on /etc/shadow being group-readable by `shadow`; the perl
activation already does this, userborn needs nikstur/userborn#46.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant