//nefariousplan

CVE-2026-41651: Polkit Authorized the Slot, Not the Value

patterns

cve

proof of concept

I cloned both public PoCs of CVE-2026-41651 yesterday morning. They are byte-for-byte identical except for the ASCII banner. One README ends with "This repository is for educational and defensive security purposes only. No exploit code or malicious instructions are included." The script in the same directory drops a SUID root bash into /var/tmp and execs into it with -p.

I ran the script on a default Ubuntu 22.04 lab VM. The race never won. Apt told me why: 1.2.5-2ubuntu3.1 ships with a Debian patch named Do-not-allow-re-invoking-methods-on-non-new-txn.patch, the same name as the upstream fix commit. The earlier 1.2.5-2ubuntu2 was still in jammy/main archives. I pinned to it, restarted PackageKit, ran the script again. First attempt:

uid=1001(pwn) gid=1001(pwn) euid=0(root) groups=1001(pwn)
root

That's the unprivileged pwn user, executing through a SUID bash that PackageKit installed for them, asking the kernel who they are.

The two messages

The PoC's race fire is sixteen lines:

def fire_race(conn, tid, dummy, payload):
    conn.call(
        PK_BUS, tid, TX_IFACE, "InstallFiles",
        GLib.Variant("(tas)", (FLAG_SIMULATE, [dummy])),
        None, Gio.DBusCallFlags.NONE, -1, None, None
    )
    conn.call(
        PK_BUS, tid, TX_IFACE, "InstallFiles",
        GLib.Variant("(tas)", (FLAG_NONE, [payload])),
        None, Gio.DBusCallFlags.NONE, -1, None, None
    )
    conn.flush_sync(None)

Two D-Bus method invocations on the same transaction object. Both fired async (no callbacks waited on), both flushed in a single TCP write to dbus-daemon. The first carries FLAG_SIMULATE=4 and the path of a dummy .deb. The second carries FLAG_NONE=0 and the path of a payload .deb whose postinst runs install -m 4755 /bin/bash /var/tmp/.suid_bash. Both messages arrive at packagekitd before it can finish processing either.

In the journal, packagekitd logs one authorization:

PackageKit[9149]: uid 1001 is trying to obtain org.freedesktop.packagekit.package-install-untrusted auth (only_trusted:0)
PackageKit[9149]: uid 1001 obtained auth for org.freedesktop.packagekit.package-install-untrusted
PackageKit[9149]: APTcc parent process running...

One auth obtained. Two installs queued. The authorization was for the path PackageKit had cached when it called polkit. The install was for the path that replaced it after polkit answered.

The mechanism

Three bugs compose, all in src/pk-transaction.c of the unpatched 1.2.5 source.

Bug one is at line 4036. pk_transaction_install_files() writes the caller-supplied transaction_flags to transaction->cached_transaction_flags without checking what state the transaction is in. A second call writes again, even if the transaction has already been authorized and is RUNNING. The flags are mutable cached state with no guard.

Bug two is at lines 873-882. pk_transaction_set_state() has a state-machine table that silently rejects backward transitions. If a transaction is RUNNING and something tries to push it back to WAITING_FOR_AUTH, the table refuses. The function returns successfully without changing the state. The flag overwrite from bug one already happened. The state machine "protected" an invariant that the caller had already worked around.

Bug three is at lines 2273-2277. The scheduler's idle callback reads cached_transaction_flags at dispatch time, not at authorization time. Whatever is in that field when the install actually starts is what the install operates on. The auth check that ran 200 milliseconds earlier is a memory of an authorization for a value that no longer exists.

The race window is between bug one (the second InstallFiles call writes the attacker's flags) and bug three (the scheduler reads those flags). With my polkit policy auto-granting the request, the window narrowed to milliseconds and the race rarely won. With Ubuntu's default policy of asking for admin password through an interactive agent, polkit blocks for as long as the user takes to type, and the window opens to seconds. In neither case is the race "non-deterministic" in the dangerous sense; it is well-shaped and tunable by the attacker.

The patch

The fix in 1.3.5 is one if-statement. From commit 76cfb675fb31acc3ad5595d4380bfff56d2a8697:

/* All action methods below must only be invoked once on a new transaction.
 * Reject any attempt to re-invoke them after the transaction has been initialized,
 * preventing situations where a second D-Bus call could overwrite transaction flags
 * (or other cached state) after authorization has already been granted for the previous
 * request based on the old parameters. */
if (transaction->state != PK_TRANSACTION_STATE_NEW) {
    g_dbus_method_invocation_return_error (invocation,
                                           PK_TRANSACTION_ERROR,
                                           PK_TRANSACTION_ERROR_INVALID_STATE,
                                           "cannot call %s on transaction %s: "
                                           "already in state %s",
                                           method_name,
                                           transaction->tid,
                                           pk_transaction_state_to_string (transaction->state));
    return;
}

The patch comment is direct. "Cached parameters can not be changed on an already running transaction or a transaction that is waiting for authorization. It also prevents backwards state transitions in case a client misbehaves." That last clause is the architectural admission. The original design treated D-Bus clients as cooperators of the state machine. The fix restores the state-machine guard at the dispatch layer, where it should have lived.

The patched function rejects any second call to any action method on a transaction that has left PK_TRANSACTION_STATE_NEW. The race no longer has a write side.

The Ubuntu backport

I confirmed against two source trees on the same lab VM. Upstream-clean PackageKit 1.2.5 with no Debian patches has no state guard in pk_transaction_method_call; the only PK_TRANSACTION_ERROR_INVALID_STATE references in that function are reachable only from unrelated state-transition paths. Ubuntu's 1.2.5-2ubuntu3.1 has the guard at line 4826, identical wording to the upstream commit. The Debian patch is named Do-not-allow-re-invoking-methods-on-non-new-txn.patch, the same headline string Matthias Klumpp used in the upstream commit message. Telekom's disclosure on 2026-04-22 was followed by Ubuntu, Debian, and Fedora pushing the backport within hours. A user who ran apt update && apt upgrade once between 2026-04-22 and yesterday is no longer vulnerable. A user who did not is. The patch is small enough to backport in a single CI run.

The two PoCs

The two public PoCs were uploaded to GitHub on 2026-04-25, hours apart, by different accounts. Both target the same vulnerability with byte-identical exploit scripts: same imports, same package-build logic for dpkg-deb and rpmbuild, same _find_suid_dir() walk through /var/tmp, /dev/shm, /tmp, $HOME filtered by mount flags, same os.execl into the SUID bash with -p. The diff between the two .py files is the ASCII banner and the author attribution comment.

The READMEs are not. One repository's README ends with the disclaimer this post opened on. The same repository ships the working SUID-root drop. The other README opens with "Purple Team Assessment Artifact. Authorized use only." and includes the full attack chain diagram, an IOC table covering filesystem and process and D-Bus and audit-log artifacts, three pages of SIEM pseudo-rules, and an auditctl rule set defenders can paste verbatim.

A defender reading the second repository can ship detection in an afternoon. A defender reading the first sees the disclaimer, takes it at face value, and learns nothing. The second author calls the work what it is. The first wraps it in language designed to absolve the publication of being a publication. Both repositories are public on GitHub. Both will be cloned by attackers within the same day. Only one of them will help blue. The first is Disclaimer Wrapped Campaign Kit in its sharpest form: identical bytes, opposite stances, the disclaimer doing none of the work the disclaimer claims to do.

The pattern

I am calling this Auth Pins The Slot, Not The Value. Polkit's job is to authorize an action; it does that correctly. The mistake is upstream of polkit: PackageKit asked polkit to authorize an action whose parameters lived in a mutable cache that the same caller could rewrite before the answer came back. Polkit's authorization survived the mutation. The post-mutation parameters consumed the authorization the pre-mutation parameters had earned.

The shape generalizes. Any framework where authorization is requested for a request-handle (a transaction, a session, a slot) and the request-handle's contents can be mutated post-auth-request and pre-auth-completion is at risk. The fix is uniform: the authorization must pin the value, not the slot. Either copy the parameters at auth-request time and re-validate at consumption time, or block all writes to the cached state from the moment auth is requested until the answer is bound.

CVE-2026-41651 is the canonical exhibit. The PoC's success rate is a function of how slow polkit is, which is a function of whether an admin agent is in the loop. Both extremes (fast auto-grant, slow human typing) are exploitable; the race window just expands or contracts.

The kinship

This sits next to Trust Inversion. Polkit is the trust artifact every Linux desktop relies on for privileged action authorization. The bug in PackageKit turns polkit's grant into the attacker's primitive. Polkit did its job correctly; the design that called it failed to keep the parameters polkit was authorizing intact long enough for the auth to mean anything.

It is also the inverse of TOCTOU That Isn't. Our taxonomy has a pattern naming the cases where "race" is the wrong label and the bug is actually a deterministic preemption. CVE-2026-41651 is what TOCTOU That Isn't sometimes mislabels as itself: a real race, between two D-Bus messages flushed in a single write, both arriving inside packagekitd's mainloop before it advances the transaction state. The window is short, but it is a window. The fix is at the dispatch layer because the dispatch layer is where the window closes.

Closing

I am writing this from a lab where one apt-get update separates the working exploit from the rejection. That distance is also five-and-a-half lines of C.

Polkit obtained one authorization. PackageKit ran two installs. The authorization was for the file the user saw. The install was for the file that replaced it.