Skip to content

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

Closed
r-vdp wants to merge 1 commit into
nikstur:mainfrom
r-vdp:shadow-group-mode
Closed

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

Conversation

@r-vdp

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

Copy link
Copy Markdown

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.
@nikstur

nikstur commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Thank you for the write up! I'm not entirely convinced by this approach, however.

I think we should:

  • Change Userborn so that it creates the file with 0000 when it doesn't exist and preserve the permissions when it does (this is exactly what sysusers does).
  • Then if you need to change the permissions of /etc/shadow for some distro-specific mechanism this can be done via tmpfiles (e.g. z).

This would IMO fulfill all requirements.

My main motivation is to remove setuid from unixchk_pwd and have only setgid instead.

It's not clear to me why that would be desirable. It sounds like some Debian-specific oddity to me as opposed to a best-practice. See why Fedora changed it to 0000: https://fedoraproject.org/wiki/Features/LowerProcessCapabilities
I find their arguments more convincing.

To harden this part of NixOS, I think we should invest into account-utils to completely get rid of unix_chkpwd. This is already being worked on for NixOS: NixOS/nixpkgs#453557

@nikstur

nikstur commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Closing for #51. See reasoning here: #46 (comment)

@nikstur nikstur closed this Jun 21, 2026
@r-vdp r-vdp deleted the shadow-group-mode branch June 22, 2026 08:26
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.

2 participants