Skip to main content

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 //[*]):

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:

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:

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):

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:

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:

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:

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):

When the _init() function is called, a bash shell gets spawned. To compile this library, you can run the following two commands:

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:

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.

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:

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:

With the program compiled, let’s take it for a spin:

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:

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:

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:

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:

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:

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:

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:

Here’s the truncated output:

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:

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:

Here’s our updated exploit code:

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:

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.

 

 

Josiah Pierce

Josiah enjoys competing in Capture the Flag (CTF) competitions in his spare time and is interested in exploit development and reverse engineering.