Sudunnit?
The bugs
CVE-2019-18634 was a vulnerability in sudo (<1.8.31) that allowed for a buffer overflow if pwfeedback was enabled. This vulnerability was due to two logic bugs in the rendering of star characters (*
):
- The program will treat line erase characters (0x00) as NUL bytes if they’re sent via pipe
- If the program fails to write backspace characters back to the pipe, it will reset the buffer size, BUT the pointer used to write to the buffer will not be reset properly.
To get a more detailed understanding of this, let’s look at the commit that fixed this bug. The commit, fa8ffeb17523494f0e8bb49a25e53635f4509078, contains a few various fixes, but these are the important changes:
@@ -395,15 +399,15 @@ getln(int fd, char *buf, size_t bufsiz, int feedback,
+ /* Only use feedback mode when we can disable echo. */
+ if (!neednl)
+ feedback = false;
The diff above fixes the first bug mentioned, where pwfeedback is not ignored when reading from a pipe or other non-writable devices. A later patch was pushed to completely disable pwfeedback outside of stdin. This prevents problems caused by a failure to write star characters back to a read-only source.
The second bug is a little more complicated:
...
while (cp > buf) {
if (write(fd, "\b \b", 3) == -1)
break;
- --cp;
+ cp--;
}
+ cp = buf;
left = bufsiz;
continue;
} else if (c == sudo_term_erase) {
if (cp > buf) {
- if (write(fd, "\b \b", 3) == -1)
- break;
- --cp;
+ ignore_result(write(fd, "\b \b", 3));
+ cp--;
left++;
}
...
The most important lines in the diff above are 9 and 17. Line 9 properly resets the char pointer to the start of the buffer, and 17 avoids a premature exit if erase characters are sent.
The exploit
This bug proved to be very challenging to exploit. Hat tip to Yuu and Anonymous_, who both helped a lot with investigating and building an exploit for this. Before I get into too much of the technical details of the exploit, I want to give a little bit of background on some of the unique aspects of this bug. Notably, this is not a stack-basedbuffer overflow (it occurs in the BSS section!), and there are several additional factors that made exploiting this bug difficult.
Debugging sudo sucks
Sudo is a very annoying binary to debug. The main factors there are that it requires setuid to run, and that running sudo as root bypasses the password prompt. So basically, we needed to run sudo in userland, but debug it as root. It would be relatively trivial to attach a debugger to this, however the bug occurs at the first (and last) prompt the program displays, and requires input be sent via a non-writeable source (aka not user input).
This lead to a very hacky solution. Basically, a small script waits for a file to be created, then cats it. This was piped into sudo, then a debugger was attached, then a file with the payload was created, which was then piped into sudo automatically. In the end, we had to use an alternative source for input (for reasons I’ll go into later), but the concept was the same. First, running sudo, then attaching a debugger, then sending input into the data source.
BSS-based buffer overflows
I am relatively new to binary bugs in general. In fact, in the scope of this project, I learned to use two new debugging tools, as well wrote my first r2pipe script. For someone who had relatively limited knowledge of buffer overflows, the concept of a buffer overflow in a BSS buffer was new to me. The fact the original bug description, along with several articles on the topic later incorrectly described the bug as a stack-based buffer overflow is interesting, and illustrates an understandable confusion.
Signo
One of the challenges with this bug was finding addresses referencing the space we can overflow. We found some probable candidates initially. This was the rough layout of the space:
variable | Size |
---|---|
buffer | 256 |
askpass | 32 |
signo | 260 (4x 65 byte entries) |
tgetpass_flags | 28 |
user_details | 104 |
list_user | 24 |
policy_plugin | 72 |
Most notably, we identified askpass (the password prompt program), user_details(gid, uid, name, etc), and policy_plugin(used to load a shared library) as possible options. However, an additional factor that initially made this incredibly challenging was that we couldn’t write zeros due to the nature of the bug. As soon as we tried to write outside past askpass
, we hit a nasty problem caused by this code in sudo:
restore:
...
for (i = 0; i < NSIG; i++) {
if (signo[i]) {
switch (i) {
case SIGTSTP:
case SIGTTIN:
case SIGTTOU:
if (suspend(i, callback) == 0)
need_restart = 1;
break;
default:
kill(getpid(), i);
break;
}
}
}
Essentially, they iterate through signo
, and check if any of the entries are non-zero. If they are, it chooses one of two paths to kill the program. This almost stopped us dead in our tracks, but not quite!
The missing piece
After staring at this for hours, I realized if we could avoid hitting this block entirely, it wouldn’t matter if we set entries in signo. I began following the different paths to this code. I came up with several theories, like building a custom pipe that only rejected writes on “\n”s, which would prevent one path. After some more looking around, I came across this check in one of the paths:
if (neednl || pass == NULL) {
if (write(output, "\n", 1) == -1)
goto restore;
}
Essentially, on this path to restore
, it will not continue if neednl
is false. I remembered seeing this flag set (see the first block) when a non-echo input was set. So basically, we needed an input that was NOT user input, but was also NOT stdin. Luckily, this left us only with tty as an option.
We began messing around with tty a little bit, but didn’t have much luck. It was around this time that we noticed a new post had been published on the openwall thread:
https://www.openwall.com/lists/oss-security/2020/02/05/2
This post provided the missing piece we needed. Creating a pty and using it to write to sudo, bypassing the need to keep errno empty. After this, we simply needed to modify the user id, which was then used to execute askpass, providing privesc. This is the important part of the user struct for a user with gid=1002
, and uid=1001
:
<user_details+8>: 104501 -1 104501 1001
<user_details+24>: 0 1002 1002 0
The hex version of that looks something like this (with some excess trimmed off). I’ve wrapped the uid in triangle brackets:
<user_details+8>: 0x35 0x98 0x01 0x00 0xff 0xff 0xff 0xff
<user_details+16>: 0x35 0x98 0x01 0x00 >0xe9 0x03< 0x00 0x00
We wanted to overwrite the uid, so we took the hex characters for 1001 (0xe9 0x03
), and overwrote them with zeros, resulting in the new start of our struct setting uid to zero:
<user_details+8>: 0x35 0x98 0x01 0x00 0xff 0xff 0xff 0xff
<user_details+16>: 0x35 0x98 0x01 0x00 >0x00 0x00< 0x00 0x00
This worked, and the program specified in SUDO_ASKPASS
was executed with uid=0
. In order to make exploitation easier, we built a simple c program that gives itself setuid 0
when executed as root, then spawns a shell.
Here’s a short video of what that looks like:
Conclusion and Thanks
I just want to take a moment to again say thanks to Yuu and Anonymous_ for giving me guidance and assistance in exploitation. We all put a lot of time and thought into this bug. I learned a lot in this process, and got to explore the mechanics of several really interesting pieces of sudo code.
Also, since you’ve read this far, I suppose it’s only fair I provide a PoC. While this bug does provide privesc, it has been publicly known for a few days already, and requires local access to a relatively uncommon configuration where pwfeedback is enabled, so I feel comfortable releasing this exploit to the public.
You can find the PoC exploit and code here:
https://github.com/Plazmaz/CVE-2019-18634