Impact
When systemd-machined >= v259 (or v258 with a custom polkit policy that allows register-machine access) is running on a desktop system, an unprivileged user logged in a desktop graphical session can kill arbitrary processes, even privileged ones.
- versions older than v259 are not affected, unless unprivileged access is granted for the
register-machine polkit action via a local, custom policy config file
- versions older than v258 are not affected
- unrelated to the systemd service manager (pid 1 or user session managers)
- systemd-machined is not typically installed by default, and is typically in an optional, separate package (e.g.: systemd-container)
- terminal-only or remote sessions (e.g.: ssh) are not affected
Patches
v262 (b7769aa)
v261.2 (8eb162d)
v260.4 (51f9f9f)
v259.8 (93bf2fd)
v258.10 (cb4907c)
Workarounds
Restricting access to privileged users only via a Polkit rule blocks the escalation path. Create /etc/polkit-1/rules.d/machined-register.rules with content:
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.machine1.register-machine" &&
subject.user != "root") {
return polkit.Result.AUTH_ADMIN_KEEP;
}
});
References
Original report follows
Claim. systemd-machined lets a local unprivileged, active-session user deliver an arbitrary signal, as root, to any non-PID-1 process it does not own. vl_method_register() ownership-checks the machine's leader pidref but never the attacker-supplied supervisor pidref; io.systemd.Machine.Kill with whom:"supervisor" then funnels machine->supervisor into pidref_kill() from root context, and the manage-machines polkit gate on Kill is short-circuited by varlink_check_good_user() because machine->uid equals the registering caller's own uid.
Sink (file:line at HEAD 5a80137a).
-
src/machine/machine-varlink.c — vl_method_register(). The leader is ownership-checked; the check is gated at machine-varlink.c:203:
if (manager->runtime_scope != RUNTIME_SCOPE_USER && machine->uid != 0 && !sender_is_admin) {
r = process_is_owned_by_uid(&machine->leader, machine->uid);
if (r < 0)
return r;
if (r == 0)
return sd_varlink_error(link, SD_VARLINK_ERROR_PERMISSION_DENIED, NULL);
}
The supervisor pidref is taken in (dispatched via supervisorProcessId, machine->supervisor = TAKE_PIDREF(client_pidref)) with no corresponding process_is_owned_by_uid(&machine->supervisor, machine->uid). Note the upstream-added sender_is_admin admin fast-path in this same conjunction is an admin short-circuit — it does not close the gap for the unprivileged attacker, who reaches this branch with sender_is_admin false and is still only leader-checked.
-
src/machine/machine.c — machine_kill(), KILL_SUPERVISOR branch: return pidref_kill(&m->supervisor, signo); (runs as root). machine_pidref() only refuses pid == 1.
-
src/shared/bus-polkit.c — varlink_verify_polkit_async_full() → varlink_check_good_user(link, machine->uid) returns 1 when the peer uid equals machine->uid, so the manage-machines polkit check on Kill is skipped for the machine's own registrant.
Attacker + trust boundary. Local unprivileged active-session user. Boundary: local_unpriv_to_root_daemon (systemd docs/SECURITY.md). The attacker's own session legitimately satisfies the org.freedesktop.machine1.register-machine action (upstream policy implicit active: yes).
Exact observable. No crash — an authorization boundary crossing. A root-owned, non-PID-1 process receives the attacker's signal (e.g. SIGKILL) and is reaped ("Killed"). The discriminator is the asymmetry: the identical root-owned PID is rejected when supplied as a leader (ownership check intact) but accepted when supplied as a supervisor (ownership check missing), and the subsequent root-context Kill succeeds.
Minimal reproduction. With a real systemd-machined (system scope, dbus+polkit enabled) owning org.freedesktop.machine1, acting as an unprivileged user (uid 1001) via varlinkctl:
- Negative control: Register a machine whose leader is a root-owned PID → machined returns
Permission denied (leader ownership check fires).
- Attack, step A: Register a machine with
leader = one of the attacker's own PIDs and supervisor = a root-owned target PID (e.g. a root-owned setsid sleep 9000) → Register succeeds (exit 0); the supervisor pidref is accepted with no ownership check.
- Attack, step B: Call
io.systemd.Machine.Kill with whom:"supervisor", signal:9 → returns exit 0, and the OS reaps the root-owned target ("<pid> Killed").
The root-owned PID is killed by the unprivileged user through root machined. (Honest caveat, carried from the underlying proof: the distro used to fire this end-to-end shipped polkit 0.105, which does not understand the modern unix-process pidfd subject field systemd sends, so machined's live register-machine polkit query errors on a tooling-version mismatch. pkcheck --action-id org.freedesktop.machine1.register-machine independently returns exit 0 for the active session, confirming the precondition is genuinely granted; a POC-only shim modeled that grant to run end-to-end. The shim touches only the register precondition gate — the supervisor/leader/Kill/varlink_check_good_user logic is byte-identical to HEAD, and the negative control proves leader ownership enforcement was not weakened.)
Concrete fix. Add process_is_owned_by_uid(&machine->supervisor, machine->uid) mirroring the leader check (reject on r == 0 with SD_VARLINK_ERROR_PERMISSION_DENIED), and do not let varlink_check_good_user() waive the manage-machines gate for the Kill path.
Impact
When systemd-machined >= v259 (or v258 with a custom
polkitpolicy that allowsregister-machineaccess) is running on a desktop system, an unprivileged user logged in a desktop graphical session can kill arbitrary processes, even privileged ones.register-machinepolkit action via a local, custom policy config filePatches
v262 (b7769aa)
v261.2 (8eb162d)
v260.4 (51f9f9f)
v259.8 (93bf2fd)
v258.10 (cb4907c)
Workarounds
Restricting access to privileged users only via a Polkit rule blocks the escalation path. Create
/etc/polkit-1/rules.d/machined-register.ruleswith content:References
Original report follows
Claim.
systemd-machinedlets a local unprivileged, active-session user deliver an arbitrary signal, as root, to any non-PID-1 process it does not own.vl_method_register()ownership-checks the machine's leader pidref but never the attacker-supplied supervisor pidref;io.systemd.Machine.Killwithwhom:"supervisor"then funnelsmachine->supervisorintopidref_kill()from root context, and themanage-machinespolkit gate on Kill is short-circuited byvarlink_check_good_user()becausemachine->uidequals the registering caller's own uid.Sink (file:line at HEAD
5a80137a).src/machine/machine-varlink.c—vl_method_register(). The leader is ownership-checked; the check is gated atmachine-varlink.c:203:The supervisor pidref is taken in (dispatched via
supervisorProcessId,machine->supervisor = TAKE_PIDREF(client_pidref)) with no correspondingprocess_is_owned_by_uid(&machine->supervisor, machine->uid). Note the upstream-addedsender_is_adminadmin fast-path in this same conjunction is an admin short-circuit — it does not close the gap for the unprivileged attacker, who reaches this branch withsender_is_adminfalse and is still only leader-checked.src/machine/machine.c—machine_kill(),KILL_SUPERVISORbranch:return pidref_kill(&m->supervisor, signo);(runs as root).machine_pidref()only refusespid == 1.src/shared/bus-polkit.c—varlink_verify_polkit_async_full()→varlink_check_good_user(link, machine->uid)returns 1 when the peer uid equalsmachine->uid, so themanage-machinespolkit check on Kill is skipped for the machine's own registrant.Attacker + trust boundary. Local unprivileged active-session user. Boundary:
local_unpriv_to_root_daemon(systemddocs/SECURITY.md). The attacker's own session legitimately satisfies theorg.freedesktop.machine1.register-machineaction (upstream policyimplicit active: yes).Exact observable. No crash — an authorization boundary crossing. A root-owned, non-PID-1 process receives the attacker's signal (e.g. SIGKILL) and is reaped ("Killed"). The discriminator is the asymmetry: the identical root-owned PID is rejected when supplied as a leader (ownership check intact) but accepted when supplied as a supervisor (ownership check missing), and the subsequent root-context
Killsucceeds.Minimal reproduction. With a real
systemd-machined(system scope,dbus+polkitenabled) owningorg.freedesktop.machine1, acting as an unprivileged user (uid 1001) viavarlinkctl:Permission denied(leader ownership check fires).leader= one of the attacker's own PIDs andsupervisor= a root-owned target PID (e.g. a root-ownedsetsid sleep 9000) → Register succeeds (exit 0); the supervisor pidref is accepted with no ownership check.io.systemd.Machine.Killwithwhom:"supervisor",signal:9→ returns exit 0, and the OS reaps the root-owned target ("<pid> Killed").The root-owned PID is killed by the unprivileged user through root machined. (Honest caveat, carried from the underlying proof: the distro used to fire this end-to-end shipped polkit 0.105, which does not understand the modern
unix-processpidfdsubject field systemd sends, so machined's live register-machine polkit query errors on a tooling-version mismatch.pkcheck --action-id org.freedesktop.machine1.register-machineindependently returns exit 0 for the active session, confirming the precondition is genuinely granted; a POC-only shim modeled that grant to run end-to-end. The shim touches only the register precondition gate — the supervisor/leader/Kill/varlink_check_good_userlogic is byte-identical to HEAD, and the negative control proves leader ownership enforcement was not weakened.)Concrete fix. Add
process_is_owned_by_uid(&machine->supervisor, machine->uid)mirroring the leader check (reject onr == 0withSD_VARLINK_ERROR_PERMISSION_DENIED), and do not letvarlink_check_good_user()waive themanage-machinesgate for the Kill path.