“PwnKit” security bug gets you root on most Linux distros – what to do

Researchers at Qualys have revealed a now-patched security hole in a very widely used Linux security toolkit that’s included in almost every Linux distro out there.

The bug is officially known as CVE-2021-4034, but Qualys has given it a funky name, a logo and a web page of its own, dubbing it PwnKit.

The buggy code forms part of the Linux Polkit system, a popular way of allowing regular apps, which don’t run with any special privileges, to interact safely with other software or system services that need or have administrative superpowers.

For example, if you have a file manager that lets you take care of removable USB disks, the file manager will often need to negotiate with the operating system to ensure that you’re properly authorised to access those devices.

If you decide you want to wipe and reformat the disk, you might need root-level access to do so, and the Polkit system will help the file manager to negotiate those access rights temporarily, typically popping up a password dialog to verify your credentials.

If you’re a regular Linux user, you’ve probably seen Polkit-driven dialogs – indeed the text-based Polkit man page gives an old-school ASCII-art rendition of the way they typically look:

Polkit as an alternative sudo

What you might not know about Polkit is that, although it’s geared towards adding secure on-demand authentication for graphical apps, it comes with a handy command-line tool called pkexec, short for Polkit Execute.

Simply put, pkexec is a bit like the well-known sudo utility, where sudo is short for Set UID and Do a Command, inasmuch as it allows you to switch temporarily to a different user ID, typically root, or UID 0, the all-powerful superuser account.

In fact, you use pkexec in much the same way as you do sudo, adding pkexec to the start of a command line that you don’t have the right to run in order to get pkexec to launch it for you, assuming that Polkit thinks you’re authorised to do so:

# Regular users are locked out of the Polkit configuration directory... $ ls -l /etc/polkit-1/rules.d/
/bin/ls: cannot open directory '/etc/polkit-1/rules.d/': Permission denied # Using an account that hasn't been authorised to run "root level"
# commands via the Polkit configuration files... $ pkexec ls -l /etc/polkit-1/rules.d/
==== AUTHENTICATING FOR org.freedesktop.policykit.exec ====
Authentication is needed to run `/usr/bin/ls' as the super user
Authenticating as: root
Password: polkit-agent-helper-1: pam_authenticate failed: Authentication failure
==== AUTHENTICATION FAILED ====
Error executing command as another user: Not authorized This incident has been reported. # After adding a Polkit rule to permit our account to do "root" stuff,
# we get automatic, temporary authorisation to run as the root user... $ pkexec ls -l /etc/polkit-1/rules.d/
total 20
-rw-r--r-- 1 root root 360 Dec 31 2021 10-enable-powerdevil-discrete-gpu.rules
-rw-r--r-- 1 root root 512 Dec 31 2021 10-enable-session-power.rules
-rw-r--r-- 1 root root 812 Dec 31 2021 10-enable-upower-suspend.rules
-rw-r--r-- 1 root root 132 Dec 31 2021 10-org.freedesktop.NetworkManager.rules
-rw-r--r-- 1 root root 404 Dec 31 2021 20-plugdev-group-mount-override.rules
-rw-r--r-- 1 root root 542 Dec 31 2021 30-blueman-netdev-allow-access.rules # And if we put no command and no username on the command line, pkexec
# assumes that we want a shell, so it runs our preferred shell (bash),
# making us root (UID 0) until we exit back to the parent shell... $ pkexec bash-5.1# id
uid=0(root) gid=0(root) groups=0(root),...
exit
$ id
uid=1042(duck) gid=1042(duck) groups=1042(duck),...

As well as checking its access control rules (alluded to in the file listing above), pkexec also performs a range of other “security hardening” operations before it runs your chosen command with added privileges.

For example, consider this program, which prints out a list of its own command line arguments and environment variables:

/*----------------------------ENVP.C---------------------------*/ #include <stdio.h>
#include <string.h> void showlist(char* name, char* list[]) { int i = 0; while (1) { /* Print both the address where the pointer */ /* is stored and the address that it points to */ printf("%s %2d @ %p [%p]",name,i,list,*list); /* If the pointer isn't NULL then print the */ /* string that it actually points to as well */ if (*list != NULL) { printf(" -> %s",*list); } printf("\n"); /* List ends with NULL, so exit if we're there */ if (*list == NULL) { break; } /* Otherwise move on to the next item in list */ list++; i = i+1; } } /* Command-line C programs almost all start with a boilerplate */
/* function called main(), to which the runtime library */
/* supplies an argument count, the arguments given, and the */
/* environment variables of the process. */
/* argv[] lists the arguments; envp[] lists the env. variables */ int main(int argc, char* argv[], char* envp[]) { showlist("argv",argv); showlist("envp",envp); return 0;
}

If you compile this progam and run it, you’ll see something like this, with a laundry list of environment variables that reflect your own preferences and settings:

$ LD_LIBRARY_PATH=risky-variable GCONV_PATH=more-risk ./envp first second
argv 0 @ 0x7fff2ec882c8 [0x7fff2ec895b8] -> ./envp
argv 1 @ 0x7fff2ec882d0 [0x7fff2ec895bf] -> first
argv 2 @ 0x7fff2ec882d8 [0x7fff2ec895c5] -> second
argv 3 @ 0x7fff2ec882e0 [(nil)]
envp 0 @ 0x7fff2ec882e8 [0x7fff2ec895cc] -> GCONV_PATH=more-risk
envp 1 @ 0x7fff2ec882f0 [0x7fff2ec895e1] -> LD_LIBRARY_PATH=risky-variable
envp 2 @ 0x7fff2ec882f8 [0x7fff2ec89600] -> SHELL=/bin/bash
envp 3 @ 0x7fff2ec88300 [0x7fff2ec89610] -> WINDOWID=25165830
envp 4 @ 0x7fff2ec88308 [0x7fff2ec89622] -> COLORTERM=rxvt-xpm
[...]
envp 38 @ 0x7fff2ec88418 [0x7fff2ec89f07] -> PATH=/opt/redacted/bin:/home/duck/...
envp 39 @ 0x7fff2ec88420 [0x7fff2ec89fb9] -> MAIL=/var/mail/duck
envp 40 @ 0x7fff2ec88428 [0x7fff2ec89fcd] -> OLDPWD=/home/duck
envp 41 @ 0x7fff2ec88430 [0x7fff2ec89fe8] -> _=./envp
envp 42 @ 0x7fff2ec88438 [(nil)]

Note two things:

  • The pointer arrays for the arguments and environment variables are contiguous in memory. The NULL pointer at the end of the argument array, shown at memory address 0x7fff2ec882e0 as [(nil)], is followed immediately by the pointer to the first environment string (GCONV_PATH) at 0x7fff2ec882e8. Pointers are 8 bytes each on 64-bit Linux, and the argv and envp pointer lists run from 0x7fff2ec882c8 to 0x7fff2ec88438 in contiguous steps of 8 bytes each time. There is no unused memry between argc[] and envp[].
  • Both the argv list and the envp list are entirely under your control. You get to choose the arguments and to set any environment variables you like, including adding ones on the command line to use when running this command only. Some environment variables, such as LD_PRELOAD and LD_LIBRARY_PATH, can be used to modify the behaviour of the program you’re executing, including quietly and automatically loading additional commands or executable modules.

Let’s run the command again as root, by using pkexec:

$ LD_LIBRARY_PATH=risky-variable GCONV_PATH=more-risk pkexec ./envp first second
argv 0 @ 0x7ffdf900fc98 [0x7ffdf9010eec] -> /home/duck/Articles/pwnkit/./envp
argv 1 @ 0x7ffdf900fca0 [0x7ffdf9010f0e] -> first
argv 2 @ 0x7ffdf900fca8 [0x7ffdf9010f14] -> second
argv 3 @ 0x7ffdf900fcb0 [(nil)]
envp 0 @ 0x7ffdf900fcb8 [0x7ffdf9010f1b] -> SHELL=/bin/bash
envp 1 @ 0x7ffdf900fcc0 [0x7ffdf9010f2b] -> LANG=en_US.UTF-8
envp 2 @ 0x7ffdf900fcc8 [0x7ffdf9010f3c] -> LC_COLLATE=C
envp 3 @ 0x7ffdf900fcd0 [0x7ffdf9010f49] -> TERM=rxvt-unicode-256color
envp 4 @ 0x7ffdf900fcd8 [0x7ffdf9010f64] -> COLORTERM=rxvt-xpm
envp 5 @ 0x7ffdf900fce0 [0x7ffdf9010f77] -> PATH=/usr/sbin:/usr/bin:/sbin:/bin:/root/bin
envp 6 @ 0x7ffdf900fce8 [0x7ffdf9010fa4] -> LOGNAME=root
envp 7 @ 0x7ffdf900fcf0 [0x7ffdf9010fb1] -> USER=root
envp 8 @ 0x7ffdf900fcf8 [0x7ffdf9010fbb] -> HOME=/root
envp 9 @ 0x7ffdf900fd00 [0x7ffdf9010fc6] -> PKEXEC_UID=1000
envp 10 @ 0x7ffdf900fd08 [(nil)]

This time, you will notice that:

  • The command name (argv[0]) has been converted to a full pathname. The pkexec program does this right at the outset, to avoid ambiguity when the program runs with superuser powers. Note that this conversion happens before the underlying Polkit system intervenes to check whether you’re allowed to run the chosen program, and thus before any password prompts appear.
  • The list of environment variables has been trimmed and adjusted for security reasons. In particular, the operating system itself automatically prunes several known-bad environment variables from any program, such as pkexec, that has the privilege to promote other software to run as root. (In technical jargon, this means any program with the setuid bit set, of which pkexec is an example.)

Beware the buffer overflow

So far, so good.

Except that Qualys discovered that if you deliberately launch the pkexec program in such a way that the value of its own argv[0] parameter (by convention, set to the name of the program itself) is blanked out and set to NULL…

…then in the process of converting the command name you want to run (./envp above) into a full pathname (/home/duck/Articles/pwnkit/./envp), the pkexec startup code will perform a buffer overflow.

For security reasons, pkexec ought to detect that it was unusually launched with no command line arguments at all, not even its own name, and refuse to run.

Instead, pkexec blindly looks at what it thinks is argv[1] (usually, this would be the name of the command you are asking it to run as root), and tries to find that program on your path.

But if argv[0] was already NULL, then there are no command line arguments, and what pkexec thinks is argv[1] is actually envp[0], the first environment variable, because the argv[] and envp[] arrays are directly adjacent in memory.

So, if you set your first environment variable to be the name of a program that can be found on your PATH, and then run pkexec with no command arguments at all, not even argv[0], then the program will combine your path with value of the environment variable it mistakenly thinks is the name of the program you want to run…

…and write that “more secure” version of the “filename” back into what it thinks is argv[1], ready to run the chosen program via its full pathname, rather than a relative one.

Unfortunately, the modifed string written into argv[1] actually ends up in envp[0], which means that a rogue user could, in theory, exploit this argv-to-envp buffer misaligment to reintroduce dangerous environment variables that the operating system itself had already taken the trouble to expunge from memory.

Full elevation of privilege

To cut a long story short, Qualys researchers discovered a way to use a dangerously “reintroduced” environment variable of this sort to trick pkexec into running a program of their choice before the program got as far as verifying whether their account was entitled to use pkexec at all.

Because pkexec is a “setuid-root” program (this means that when you launch it, it magically runs as root rather than under your own account), any subprogram you can coerce it into launching will inherit superuser privileges.

This means that any user who already has access to your system, even if they’re logged in under an account with almost no power at all, could, in theory, use pkexec to promote themselves instantly to user ID 0: the root, or superuser, account.

The researchers wisely didn’t provide working proof-of-concept code, although as they wryly point out:

We will not publish our exploit immediately; however, please note that this vulnerability is trivially exploitable, and other researchers might publish their exploits shortly after the patches are available.

What to do?

  • Patch early, patch often. Many, if not most, Linux distros should have an update out already. You can (safely) run pkexec --version to check the version you’ve got. You want 0.120 or later.
  • If you can’t patch, consider demoting pkexec from its superpower privilege. If you remove the setuid bit from the pkexec executable file then this bug will no longer be exploitable, because pkexec won’t automatically launch with superuser powers. Anyone trying to exploit the bug would simply end up with the same privilege that they already had.

FIND AND FIX PKEXEC – HOW TO USE THE WORKAROUND

Finding pkexec on your path:

$ which pkexec
/usr/bin/pkexec <---probable location on most distros

Checking the version you have. Below 0.120 and you are probably vulnerable, at least on Linux:

$ /usr/bin/pkexec --version
pkexec version 0.120 <-- our distro already has the updated Polkit package

Checking the file mode bits. Note that the letter s in the first column stands for setuid, and means that when the file runs, it will automatically execute under the account name listed in column three as the owner of the file; in this case, that means root. In terminals with colour support, you may see the filename emphasised with a bright red background:

$ ls -l /usr/bin/pkexec
-rwsr-xr-x 1 root root 35544 2022-01-26 02:16 /usr/bin/pkexec*

Changing the setuid bit. Note how, after demoting the file by “subtracting” the letter s from the mode bits, the first column no longer contains an S-for-setuid marker. On a colour terminal, the dramatic red background will disappear too:

$ sudo chmod -s /usr/bin/pkexec Password: ***************
$ ls -l /usr/bin/pkexec
-rwxr-xr-x 1 root root 35544 2022-01-26 02:16 /usr/bin/pkexec* <-- setuid bit removed

Turning setuid back on. If you need to re-enable the root-acquiring powers of pkexec before getting the latest update, or if updating the Polkit package doesn’t restore the setuid bit automatically, you can use the chmod +s ... command (in a similar way to how you used -s above) in order to “add back” the letter s to the mode bits:

$ sudo chmod +s /usr/bin/pkexec Password: ***************
$ ls -l /usr/bin/pkexec
-rwsr-xr-x 1 root root 35544 2022-01-26 02:16 /usr/bin/pkexec* <-- setuid bit restored

go top