Qualys Security Advisory pwnkit: Local Privilege Escalation in polkit's pkexec (CVE-2021-4034) ======================================================================== Contents ======================================================================== Summary Analysis Exploitation Acknowledgments Timeline ======================================================================== Summary ======================================================================== We discovered a Local Privilege Escalation (from any user to root) in polkit's pkexec, a SUID-root program that is installed by default on every major Linux distribution: "Polkit (formerly PolicyKit) is a component for controlling system-wide privileges in Unix-like operating systems. It provides an organized way for non-privileged processes to communicate with privileged ones. [...] It is also possible to use polkit to execute commands with elevated privileges using the command pkexec followed by the command intended to be executed (with root permission)." (Wikipedia) This vulnerability is an attacker's dream come true: - pkexec is installed by default on all major Linux distributions (we exploited Ubuntu, Debian, Fedora, CentOS, and other distributions are probably also exploitable); - pkexec is vulnerable since its creation, in May 2009 (commit c8c3d83, "Add a pkexec(1) command"); - any unprivileged local user can exploit this vulnerability to obtain full root privileges; - although this vulnerability is technically a memory corruption, it is exploitable instantly, reliably, in an architecture-independent way; - and it is exploitable even if the polkit daemon itself is not running. 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. If no patches are available for your operating system, you can remove the SUID-bit from pkexec as a temporary mitigation; for example: # chmod 0755 /usr/bin/pkexec This vulnerability is one of our most beautiful discoveries; to honor its memory, we recommend listening to DJ Pone's "Falken's Maze" (double pun intended) while reading this advisory. Thank you very much! ======================================================================== Analysis ======================================================================== pkexec is a sudo-like, SUID-root program, described as follows by its man page: ------------------------------------------------------------------------ NAME pkexec - Execute a command as another user SYNOPSIS pkexec [--version] [--disable-internal-agent] [--help] pkexec [--user username] PROGRAM [ARGUMENTS...] DESCRIPTION pkexec allows an authorized user to execute PROGRAM as another user. If PROGRAM is not specified, the default shell will be run. If username is not specified, then the program will be executed as the administrative super user, root. ------------------------------------------------------------------------ The beginning of pkexec's main() function processes the command-line arguments (lines 534-568), and searches for the program to be executed (if its path is not absolute) in the directories of the PATH environment variable (lines 610-640): ------------------------------------------------------------------------ 435 main (int argc, char *argv[]) 436 { ... 534 for (n = 1; n < (guint) argc; n++) 535 { ... 568 } ... 610 path = g_strdup (argv[n]); ... 629 if (path[0] != '/') 630 { ... 632 s = g_find_program_in_path (path); ... 639 argv[n] = path = s; 640 } ------------------------------------------------------------------------ Unfortunately, if the number of command-line arguments argc is 0 (if the argument list argv that we pass to execve() is empty, i.e. {NULL}), then argv[0] is NULL (the argument list's terminator) and: - at line 534, the integer n is permanently set to 1; - at line 610, the pointer path is read out-of-bounds from argv[1]; - at line 639, the pointer s is written out-of-bounds to argv[1]. But what exactly is read from and written to this out-of-bounds argv[1]? To answer this question, we must digress briefly. When we execve() a new program, the kernel copies our argument and environment strings and pointers (argv and envp) to the end of the new program's stack; for example: |---------+---------+-----+------------|---------+---------+-----+------------| | argv[0] | argv[1] | ... | argv[argc] | envp[0] | envp[1] | ... | envp[envc] | |----|----+----|----+-----+-----|------|----|----+----|----+-----+-----|------| V V V V V V "program" "-option" NULL "value" "PATH=name" NULL Clearly (because the argv and envp pointers are contiguous in memory), if argc is 0, then the out-of-bounds argv[1] is actually envp[0], the pointer to our first environment variable, "value". Consequently: - at line 610, the path of the program to be executed is read out-of-bounds from argv[1] (i.e. envp[0]), and points to "value"; - at line 632, this path "value" is passed to g_find_program_in_path() (because "value" does not start with a slash, at line 629); - g_find_program_in_path() searches for an executable file named "value" in the directories of our PATH environment variable; - if such an executable file is found, its full path is returned to pkexec's main() function (at line 632); - and at line 639, this full path is written out-of-bounds to argv[1] (i.e. envp[0]), thus overwriting our first environment variable. More precisely: - if our PATH environment variable is "PATH=name", and if the directory "name" exists (in the current working directory) and contains an executable file named "value", then a pointer to the string "name/value" is written out-of-bounds to envp[0]; - or, if our PATH is "PATH=name=.", and if the directory "name=." exists and contains an executable file named "value", then a pointer to the string "name=./value" is written out-of-bounds to envp[0]. In other words, this out-of-bounds write allows us to re-introduce an "unsecure" environment variable (for example, LD_PRELOAD) into pkexec's environment; these "unsecure" variables are normally removed (by ld.so) from the environment of SUID programs before the main() function is called. We will exploit this powerful primitive in the following section. Last-minute note: polkit also supports non-Linux operating systems such as Solaris and *BSD, but we have not investigated their exploitability; however, we note that OpenBSD is not exploitable, because its kernel refuses to execve() a program if argc is 0. ======================================================================== Exploitation ======================================================================== Our question is: to successfully exploit this vulnerability, which "unsecure" variable should we re-introduce into pkexec's environment? Our options are limited, because shortly after the out-of-bounds write (at line 639), pkexec completely clears its environment (at line 702): ------------------------------------------------------------------------ 639 argv[n] = path = s; ... 657 for (n = 0; environment_variables_to_save[n] != NULL; n++) 658 { 659 const gchar *key = environment_variables_to_save[n]; ... 662 value = g_getenv (key); ... 670 if (!validate_environment_variable (key, value)) ... 675 } ... 702 if (clearenv () != 0) ------------------------------------------------------------------------ The answer to our question comes from pkexec's complexity: to print an error message to stderr, pkexec calls the GLib's function g_printerr() (note: the GLib is a GNOME library, not the GNU C Library, aka glibc); for example, the functions validate_environment_variable() and log_message() call g_printerr() (at lines 126 and 408-409): ------------------------------------------------------------------------ 88 log_message (gint level, 89 gboolean print_to_stderr, 90 const gchar *format, 91 ...) 92 { ... 125 if (print_to_stderr) 126 g_printerr ("%s\n", s); ------------------------------------------------------------------------ 383 validate_environment_variable (const gchar *key, 384 const gchar *value) 385 { ... 406 log_message (LOG_CRIT, TRUE, 407 "The value for the SHELL variable was not found the /etc/shells file"); 408 g_printerr ("\n" 409 "This incident has been reported.\n"); ------------------------------------------------------------------------ g_printerr() normally prints UTF-8 error messages, but it can print messages in another charset if the environment variable CHARSET is not UTF-8 (note: CHARSET is not security sensitive, it is not an "unsecure" environment variable). To convert messages from UTF-8 to another charset, g_printerr() calls the glibc's function iconv_open(). To convert messages from one charset to another, iconv_open() executes small shared libraries; normally, these triplets ("from" charset, "to" charset, and library name) are read from a default configuration file, /usr/lib/gconv/gconv-modules. Alternatively, the environment variable GCONV_PATH can force iconv_open() to read another configuration file; naturally, GCONV_PATH is one of the "unsecure" environment variables (because it leads to the execution of arbitrary libraries), and is therefore removed by ld.so from the environment of SUID programs. Unfortunately, CVE-2021-4034 allows us to re-introduce GCONV_PATH into pkexec's environment, and to execute our own shared library, as root. Important: this exploitation technique leaves traces in the logs (either "The value for the SHELL variable was not found the /etc/shells file" or "The value for environment variable [...] contains suscipious content"). However, please note that this vulnerability is also exploitable without leaving any traces in the logs, but this is left as an exercise for the interested reader. For further discussions about pkexec, GLib, and GCONV_PATH, please refer to the following posts by Tavis Ormandy, Jakub Wilk, and Yuki Koike: https://www.openwall.com/lists/oss-security/2014/07/14/1 https://www.openwall.com/lists/oss-security/2017/06/23/8 https://hugeh0ge.github.io/2019/11/04/Getting-Arbitrary-Code-Execution-from-fopen-s-2nd-Argument/ ======================================================================== Acknowledgments ======================================================================== We thank polkit's authors, Red Hat Product Security, and the members of distros@openwall for their invaluable help with the disclosure of this vulnerability. We also thank Birdy Nam Nam for their inspiring work. ======================================================================== Timeline ======================================================================== 2021-11-18: Advisory sent to secalert@redhat. 2022-01-11: Advisory and patch sent to distros@openwall. 2022-01-25: Coordinated Release Date (5:00 PM UTC).