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 theargv
andenvp
pointer lists run from 0x7fff2ec882c8 to 0x7fff2ec88438 in contiguous steps of 8 bytes each time. There is no unused memry betweenargc[]
andenvp[]
. - Both the
argv
list and theenvp
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 asLD_PRELOAD
andLD_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. Thepkexec
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 thesetuid
bit set, of whichpkexec
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 thesetuid
bit from thepkexec
executable file then this bug will no longer be exploitable, becausepkexec
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